注:本系列为《深入理解Java虚拟机》的读书笔记。
少量来源于网络,视频教材。

如何设计一个虚拟机

假设自己要写一个虚拟机,应该考虑什么?

1
2
3
4
5
虚拟机要运行什么?
如何把规范的文件内容读取到内存中?
读取到内存中的数据如何存储?
如何执行这些规范文件中的数据翻译成操作系统认识的指令?
执行完成后如何清理?

虚拟机的主要功能以及组成

虚拟机要运行代码,这些代码要定制一个规范,这样虚拟机才可以根据制定好的规范去解析和执行。对于Java虚拟机来讲,它要读取的文件就是Class类文件。
我们需要一段代码把Class类文件内容读取到内存中,同时要注意判断,读取的文件内容是否符合Class类文件的规范,同时文件内容是否会危害到虚拟机。
把文件内容读取到内存中后,如何存放、方便管理和使用,同时要保证性能、安全性。
把这些文件内容“翻译”为操作系统认识的指令,并且操控这些指令按照预定的“规则”运行,达到可预期的结果。为了提升效率需要考虑多线程和并发。
执行完成后,清理不需要的内存空间,以便其他程序使用(JVM规范中并没有规定垃圾回收部分)。

JVM虚拟机不包含ClassLoader部分

jvm

虚拟机相关知识传送门

扩展阅读:深入理解Java虚拟机到底是什么

Java虚拟机是一种抽象的计算机,它模拟了一套自己的硬件体系结构(处理器、堆栈、指令集和寄存器)。
不同的平台有着不同的实现版本,屏蔽了平台的差异性。使得不同平台上的虚拟机可以运行被javac编译的同一份.class文件。

关于JVM的面试题:
24个Jvm面试题总结及答案
常见JVM面试题及答案整理
2020年JVM面试题吐血整理【过年必看】
总结的JVM面试题
面试必问的JVM应该怎么学(面试题含答案)
大厂面试经:高频率JVM面试问题整理!
2019年JVM面试都问了什么?快看看这22道面试题!(附答案解析)
JVM 面试题 68 问,面试又可以多扯一个小时了!
【搞定Jvm面试】 Java 内存区域揭秘附常见面试题解析

Java虚拟机规范规定Class文件格式统一采用一种类似于C语言结构体的伪结构体来存储数据,
这种伪结构体存储两种数据类型:无符号数和表。

无符号数:属于基本的数据类型,以u1、u2、u4、u8分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码结构构成的字符串值。

表:由多个无符号数或者其他表作为数据项构成的符合数据类型,所以表都习惯性地以_info结尾。
表用于描述有层次关系的符合结构的数据,整个Class文件就是一张表,由下表中数据项构成。

类型 名称 个数
u4 Magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count - 1
u2 access_flag 1
u2 this_class 1
u2 super_class 1
u2 interface_count 1
u2 interfaces interface_count
u2 fields_count 1
field_info fields fields_count
u2 method_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

魔数

每个Class文件的头四个字节称为魔数,其值的16进制表示为0xCAFEBABE(换算为二进制为1100 1010 1111 1110 1011 1010 1011 1110),虚拟机在加载类时分析该文件是否为Class文件。

版本号

第5、6个字节表示次版本号。第7、8个字节表示主版本号。
高版本的JDK能够向下兼容低版本的Class文件,虚拟机会拒绝执行超过其版本的Class文件。

常量池

常量池中存放两大类常量:

  • 字面量常量:
    比较接近Java语言层面的常量概念,比如字符串和被声明为final的常量值。对于范围在-127~128之间的包装类型也是常量。
  • 符号引用常量:属于编译原理方面的概念,包含
    1. 类和接口的全限定名
    2. 字段的名称和描述符
    3. 方法的名称和描述符

常量池的大小不固定,用两个字节表示,第零项空出来,为了满足某些指向常量池的索引值的数据“不引用任何一个常量池项目”。

常量池的每一项常量都是一个表,这个表的第一位都是一个u1类型的标识位。

下面是常量池中的14种项目类型:
常量池中的14种项目类型

下面是常量池中的14种项目类型的结构表:

常量池中的表类型

常量池中的表类型

常量池中数据项类型 类型标志 类型描述
CONSTANT_Utf8 1 UTF-8编码的Unicode字符串
CONSTANT_Integer 3 int类型字面值
CONSTANT_Float 4 float类型字面值
CONSTANT_Long 5 long类型字面值
CONSTANT_Double 6 double类型字面值
CONSTANT_Class 7 对一个类或接口的符号引用
CONSTANT_String 8 String类型字面值
CONSTANT_Fieldref 9 对一个字段的符号引用
CONSTANT_Methodref 10 对一个类中声明的方法的符号引用
CONSTANT_InterfaceMethodref 11 对一个接口中声明的方法的符号引用
CONSTANT_NameAndType 12 对一个字段或方法的部分符号引用

访问标志

访问标志标示了当前类的修饰符,比如 public / final / super / interface / enum / abstract / annotation / synthetic

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 是否为Public类型
ACC_FINAL 0x00 10 是否被声明为final,只有类可以设置
ACC_SUPER 0x00 20 是否允许使用invokespecial字节码指令的新语义.
ACC_INTERFACE 0x02 00 标志这是一个接口
ACC_ABSTRACT 0x04 00 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假
ACC_SYNTHETIC 0x10 00 标志这个类并非由用户代码产生
ACC_ANNOTATION 0x20 00 标志这是一个注解
ACC_ENUM 0x40 00 标志这是一个枚举

类索引、父类索引、接口索引集合

  • 类索引:this_class, 用于确定这个类的全限定名
  • 父类索引:super_class, 父类索引用于确定这个类的父类的全限定名
  • 接口索引集合:interfaces, 用于描述这个类实现了哪些接口

字段表集合

字段表集合(field_info)用于描述接口或者类中声明的变量,包含类变量和实例变量,但不包含方法内部声明的局部变量。

|类型|名称|数量|
|—-|—-|—-|—-|
|u2 |access_flag|1|访问修饰符|
|u2 |name_index | 1| 字段的简单名称,如String str = “123”, str就是简单名称|
|u2 |descritpor_index|1| 字段或者方法的描述符|
|u2 |attributes_count |1 | |
|attribute_info | attributes | attributes_count| |

访问修饰符

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否为public
ACC_PRIVATE 0x0002 字段是否为private
ACC_PROTECTED 0x0004 字段是否为protected
ACC_STATIC 0x0008 字段是否为static
ACC_FINAL 0x0010 字段是否为final
ACC_VOLATILE 0x0040 字段是否为volatile
ACC_TRANSTENT 0x0080 字段是否为transient
ACC_SYNCHETIC 0x1000 字段是否为由编译器自动产生
ACC_ENUM 0x4000 字段是否为enum

描述符:
描述符的作用是用来描述字段的数据类型、方法的参数列表(数量、类型、顺序)和返回值。
根据描述符的规则:基本数据类型以及代表无返回值的void类型都用一个大写的字符来表示;
而对象类型则用字符L加对象的全限定名来描述;

标志符号 含义
B byte
C char
D double
F float
I int
J long
S short
Z boolean
V void 按照先参数列表后返回值的顺序描述,参数列表按照顺序放在”()”内部,String toString()描述为V()java.lang.String
L 对象类型
[ 数组类型 [I: 代表int[], [[I:代表int[][]

方法表集合

Class 文件中对方法的描述和对字段的描述完全是一致的, 方法表中的结构和字段表中的接口一样。

因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有ACC_VOLATILE和ACC_TRANSIENT。 但是增加了synchronized,native,abstract,strictfp(strict float point, 精确的浮点数)关键字的修饰符。
对于方法里的代码,经过编译器变异常字节码指令后,存放在方法属性表中一个名为code的属性里。

标志名称 标志值 含义
ACC_PUBLIC 0x00 01 方法是否为public
ACC_PRIVATE 0x00 02 方法是否为private
ACC_PROTECTED 0x00 04 方法是否为protected
ACC_STATIC 0x00 08 方法是否为static
ACC_FINAL 0x00 10 方法是否为final
ACC_SYHCHRONRIZED 0x00 20 方法是否为synchronized
ACC_BRIDGE 0x00 40 方法是否是有编译器产生的方法
ACC_VARARGS 0x00 80 方法是否接受参数
ACC_NATIVE 0x01 00 方法是否为native
ACC_ABSTRACT 0x04 00 方法是否为abstract
ACC_STRICTFP 0x08 00 方法是否为strictfp (strict-float-point,精准的浮点数)
ACC_SYNTHETIC 0x10 00 方法是否是有编译器自动产生的

属性表集合

在Class文件、字段表、方法表中都可以携带自己的属性表集合(attribute_info),用于描述某些场景的专有信息。

不强制要求各属性表的顺序,只要不与已有属性表重名即可。
任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机在运行时会忽略不认识的属性。

属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量池
Deprecated 类,方法,字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClass 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配
Signature 类,方法表,字段表 用于支持泛型情况下的方法签名
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 用于存储额外的调试信息
Synthetic 类,方法表,字段表 标志方法或字段为编译器自动生成的
LocalVariableTypeTable 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类,方法表,字段表 为动态注解提供支持
RuntimeInvisibleAnnotations 表,方法表,字段表 用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotation 方法表 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法
RuntimeInvisibleParameterAnnotation 方法表 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数
AnnotationDefault 方法表 用于记录注解类元素的默认值
BootstrapMethods 类文件 用于保存invokeddynamic指令引用的引导方式限定符

对于看了文章还有点懵的同学,看这里的视频讲解,忽略广告。《全网最牛JVM字节码结构分析、Class类文件核心结构》。

Java代码要在JVM中正常运行,首先要先编译成.class文件,由类加载器将其加载到内存中。之后由类编译器来编译执行。
在加载的过程中需要注意什么呢?
1)什么时候需要加载一个类呢?
2)随便任何一个文件都是否可以被加载呢,它的加载过程是什么样的?
3)类加载器需要对内存中的内容做什么工作才可以被JVM直接使用?

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Class类型,这就是虚拟机的类加载机制。—《深入理解Java虚拟机》

1
2
类型的加载、链接和初始化过程都是在程序运行期间完成的。
Java可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类的加载时机

Java虚拟机规范并没有规定什么时候加载类,但是规定了什么时候初始化一个类。

虚拟机规范严格规定了有且只有5中情况必须立即对类进行“初始化”。
1)遇到new、getstatic、putstatic、invokestatic;
2)初始化类的时候,对父类进行初始化;
3)JVM执行的时候,对主类进行初始化;
4)使用java.lang.reflect包的方法对类进行反射调用时;
5)使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后解析结果是REF_getstatic, REF_putstatic, REF_invokestatic时,对该类进行初始化;
6) jdk1.8中子类实现的接口中定义了default方法时;

类的加载过程

类的加载过程总共分为:Loading(加载)、Linking(连接)、Initializing(初始化)、Using(使用)、Unloading(卸载)。
其中 Linking阶段 分为 Verification(验证)、Preparation(准备)、Resolution(解析)。

ClassLoader

加载

加载阶段的成果是生成Class类对象,并将该对象存储在堆中。Class对象作为访问该对象数据的访问入口。
加载的步骤:
1) 根据类的全限定名读取类的二进制字节流;
2) 对二进制字节流进行解析转换,并将类信息存储在方法区中;
3) 堆中生成Class类对象,作为程序访问方法区中的类型数据的外部接口;

注意:
数组类不通过类加载器创建,由java虚拟机直接在内存中动态构造出来的。
数组的元素类型(去掉所有维度)要靠类加载器加载完成。
数组的组件类型(去掉一个维度)是引用类型,数组将被标识在加载该组建类型的类加载器的类名称空间上。
如果数组的组件类型不是引用类型,Java虚拟机会把数组标记为与引导类加载器关联。

验证

验证加载的类信息是否符合Java虚拟机规范,类的实现和继承、方法的执行、对象和方法的可访问性等等,主要分为四个阶段:

1)文件格式的验证
主要对Class文件中内容进行验证,是否符合Java虚拟机规范。
比如魔数、常量池的类型、指向常量的索引值是否存在或者类型是否正确等等;

这个阶段的验证是基于二进制字节流进行的,只有通过了验证才会被允许进入Java虚拟机的内存方法区存储。

2)元数据验证
对字节码描述信息进行语义分析,保证描述信息符合《Java语言规范》比如:

  • 这个类是否有父类、父类是否允许继承(被final修饰);
  • 这个类是否实现了父类所要求实现的所有方法;
  • 类中的字段、方法是否与父类产生矛盾;

3)字节码验证
对类的方法体进行验证,保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
保证类型转化都是有效的.

4)符号引用验证

虚拟机在解析阶段将符号引用转化为直接引用的时候,对符号引用进行验证;

  • 符号引用中通过字符串描述的全限定名能否找到对应的类;
  • 在指定类中是否存在符合方法的字段描述以及简单名称所描述的方法和字段;
  • 符号引用中的类、字段、方法的可访问性是否可以被当前类访问;

符号引用阶段抛出的异常有:IllegalAccessError、NoSuchFieldError、NoSuchMethodError;

准备

准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段;

对于类变量(静态属性),设置的是“零值”,真正赋值是在初始化阶段;
对于常量(final static),会被设置为ConstantValue属性所指定的初始值。

对于常量,Javac时会为value生成ConstantValue属性。

解析

解析阶段是Java虚拟机将常量池的符号引用替换为直接引用的过程。《Java虚拟机规范》并没有规定何时进行解析。

  • 符号引用:可以无歧义的定位到目标即可;与虚拟机的内存布局无关;引用的目标不一定已经加载到虚拟机中;必须遵循《Java虚拟机规范》;
  • 直接引用:直接可以指向目标的指针、偏移量,或者可以间接定位到目标的句柄;与虚拟机的内存布局直接相关;直接引用的目标一定已经加载到虚拟机内存中;

解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符号引用进行。

类、接口解析:

虚拟机的完整解析过程:
1)如果不是一个数组类型,虚拟机将会把全限定名传给当前类的加载器去加载,在加载的过程中,由于元数据验证、字节码验证的需要,又可能出发其他相关类的加载动作。
2)如果是一个数组类型,并且数组的元素类型为对象,那么会按照第一点的规则加载数组元素类型。如果是基础数据类型包装类,那么由虚拟机生成一个代表该数组唯独和元素的数组对象;
3)前面两部解析OK后进行符号引用验证,确定当前类是否有加载类的访问权限。

如果一个类拥有另外一个类的访问权限,那么至少有一条是成立的:
1)被访问类是public的,并且与访问类处于同一个模块;
2)被访问类是public的,不与访问类处于同一个模块,但是被访问类的模块允许访问类访问;
3)被访问类不是public的,但是与访问类处于同一个包中

字段解析

对字段表内class_index项中索引的CONSTANT_Class_Info符号引用进行解析(即字段所属类)

1)先查找当前类是否包含了简单名称与描述符与目标相匹配
2)从下往上的顺序查找实现的接口类
3)从下往上的顺序查找父类
4)都无法查找到,抛出java.lang.NoSuchFieldError.

如果查找成果返回了引用,将会对这个字段进行权限验证,对于不具备权限的访问会抛出IllegalAccessError;

方法解析

解析出方法表class_index项中索引的方法所属的类或者接口的符号引用。
具体的解析过程与字段解析类似。

接口方法解析

与方法解析类似。

初始化

这个阶段Java虚拟机才真正开始执行类中编写的程序代码。程序在这个阶段初始化类变量和其他资源。

初始化阶段也是执行<clinit>()的过程。
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的;
编译器收集顺序是由语句在源文件中出现的顺序决定的;
静态语句块中智能访问到定义在静态语句块之前的变量;
定义在静态语句块后的变量,静态语句块可以赋值,但是不能访问;
Java虚拟机保证父类的<clinit>() 总是优先于子类的<clinit>()执行;

如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器可以不为这个类生成clinit()方法。

类加载器

类加载器主要通过一个类的全限定名来获取描述该类的二进制字节流,它处于Java虚拟机的外部,方便让应用程序自己决定如何获取所需的类。

对于任意一个类,必须由加载它的类加载器与类本身来确定在Java虚拟机中的唯一性。
每个类加载器都拥有一个独立的类名称空间。

类加载器的双亲委派模型

双亲委派模型

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应有自己的父“类加载器”。
当类加载器需要加载一个类的时候,首先看该类是否已经被加载,如果没有被加载则尝试着让父“类加载器”去加载,如果没有父“类加载器”则让bootstrapClassLoader作为父“类加载器”,如果父“类加载器”加载失败,则使用自己的find_class方法去加载。

  • 启动类加载器(Bootstrap Class Loader)
    启动类加载器负责加载<JAVA_HOME>\lib 目录下的jar包(按名字识别,名字不符合的类库存放在lib目录下也不会被加载),也可以指定 -Xbootclasspath参数指定加载目录。启动类加载器无法被java程序直接引用。
  • 扩展类加载器(Extension Class Loader)
    扩展类加载器负责加载<JAVA_HOME>\lib\ext目录下的jar包,或者被java.ext.dirs指定的路径这种的所有类库。这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码实现的。
  • 应用程序类加载器(Application Class Loader)
    负责加载用户类路径上的所有类库。这个类加载器是在sun.misc.Launcher$AppClassLoader实现。
定制自己的类加载器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CustomClassLoader extends ClassLoader {

public CustomClassLoader(ClassLoader parent) {
super(parent);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {

String path = "/classes/";
try {
InputStream ins = new FileInputStream(path + name.replace(".", File.separator) + ".class");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
byte[] classData = baos.toByteArray();
return defineClass(name, classData, 0, classData.length);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}

public static void main(String[] args) throws Exception {
CustomClassLoader classLoader = new CustomClassLoader(null);
Class clazz = classLoader.loadClass("com.*.ClassLoader");
System.out.println(clazz.getClassLoader());
System.out.println(clazz.getClass());
}

计算机为了提升效率,增加了多内核,并且为每个内核增加了自己的高速缓存,而他们又共享同一个主内存。

计算机处理器、高速缓存、主内存之间的交互关系

除了增加高速缓存外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,但保证结果与顺序执行的结果一致。

与处理器的乱序执行优化类似,Java虚拟机的即时编译器也有指令重排序优化。

Java内存模型

《Java虚拟机规范》中曾试图定义一种Java内存模型来屏蔽各种硬件和操作系统的内存访问差异。

主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则(关注虚拟机中把变量存储到内存和从内存中取出变量的底层细节。)
Java内存模型规定了所有的变量都存储在主内存(除线程私有的以外)。
线程的工作内存保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的数据。不同的线程之间也无法直接访问对方工作内存的变量,线程之间变量值的传递均需要通过主内存来完成。

内存间交互操作

关于主内存与工作内存之间的交互协议,Java内存模型中定义了8种的操作。

Java虚拟机实现时必须保证每种操作都是原子的,不可再分的(对于double和long来讲,load、store、、read、write允许有例外)。

  • lock : 锁定,作用于主内存的变量,把一个变量标识为一条线程独占的状态。

  • unlock: 解锁,作用于主内存的变量,把一个处于锁定状体的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read: 读取,作用于主内存的变量,把一个变量的值从主内存传输到工作内存,以便随后的load动作使用。

  • write: 写入,作用于主内存的变量,把store操作从工作内存中得到的变量值存放到主内存变量中。

  • load: 载入,作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use:使用,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign: 赋值,作用于工作变量,把一个从执行引擎接收的值赋给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store: 存储,把工作内存中一个变量的值传送到内存中,以便随后的write操作使用。

Java内存模型规定必须满足如下规则:

1) 不允许read 和 load、store和write操作单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写单主内存不接受
2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
3)不允许一个线程在没有发生assign操作就把数据从线程的工作内存同步回主内存中。
4) 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load\assign)的变量,即对一个变量进行use之前必须使用load, 使用store之前必须使用assign。。
5)一个变量在同一个时刻内只允许一个线程对其进行lock操作, 但lock操作可以被同一个线程重复执行多次, 多次执行lock后, 只有执行相同次数的unlock操作,变量才会被解锁。
6)对一个变量执行unlock之前, 必须先把此变量同步回主内存中(执行store、write操作)

volatile型变量的特殊规则

volatile是Java虚拟机最轻量级的同步机制。它有两个特性:
1)保证此变量对于所有线程的可见性(一个线程修改了这个变量的值,其他线程立即可见,也就是每个线程在使用被volatile修饰的变量时,都会强制从主内存中同步该变量的值到工作内存中)
在不符合以下两条规则的场景中,仍然需要通过加锁来保证原子性(synchronized、java.util.concurrent中的锁或者原子类):

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值;
  • 变量不需要与其他状态变量共同参与不变约束;

2) 禁止指令重排序优化

volatile变量读操作的性能与普通变量几乎没有差别,但是写操作会慢些, 因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

Singleton DoubleCheck

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private Singleton() {}

private volatile static Singleton instance = null;

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); //warning
}
}
}
return instance;
}
}

在上面的代码中,instance = new Singleton()最终会被编译成多条汇编指令。
(1)为Singleton的实例分配内存。
(2)调用Singleton的构造函数,初始化成员变量
(3)将instance对象指向分配的内存空间。

或者可能被重排序为(1)、(3)、(2), 也就是当分配内存后,如果没有对instance 加volatile,将instance对象指向分配的内存空间后,另外一个线程可以读到未被初始化的对象。

针对long和double类型变量的特殊规则

Java内存模型要求lock、unlock、read、write、 load、assign、use、store这8种操作都具有原子性,但是对于64位数据类型的long 和double, 在模型中定义了一条宽松的规定, 允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32为的操作来执行,也就是虚拟机自己选择是否要保证64位数据类型的load、store、read、write这四个操作的原子性。

经过实际的测试,在目前的主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的Java虚拟机(如32位的X86Hotspot)存在非原子性访问的风险。

因此,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码的时候一般不需要因为这个原因刻意把用到的long和double变量专门声明位volatile。

原子性、可见性与有序性

原子性(Atomicity)

有Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write。因此基本数据类型的访问都是具备原子性的。

如果应用场景需要一个更大范围的保证,需要使用锁、或者synchronized关键字来保证原子性。
(lock、unlock并没有直接开放给用户使用)。

可见性(visibility)

可见性就是当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

volatile

volatile的特殊规则保证了新值立即能同步到主内存,以及每次使用前都立即从主内存刷新。而普通变量则不能保证“立即”。

synchronized

同步块的可见性时由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得。

final

被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。

this引用逃逸,其他线程有可能通过这个引用访问到“初始化了一般”的对象。

有序性

如果在本线程中观察,所有的操作都是有序的(线程内似表现为串行的语义);
如果在一个线程中观察另一个线程,所有的操作都是无序的(指令重排序和工作内存与主内存同步延迟现象);

Java语言提供了volatile和sychronized两个关键字来保证线程之间的有序性。
volatile:本身包含了禁用指令重排序的语义;
synchronized:一个变量在同一时刻只允许一个线程对其进行lock操作;

先行发生原则

先行发生原则:判断数据是否存在竞争,线程是否安全的重要手段。

程序次序规则:在一个线程内,按照控制流顺序(分支、循环等),书写在前面的操作先行发生在书写在后面的操作;

管程锁定规则:一个unlock的操作先行发生在后面对同一个锁的操作。

volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作;

线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;

线程的中止规则:线程中所有的操作都先行发生于此线程的终止检测,可以通过thread.join()方法是否结束、thread.isAlive()返回值来检测线程是否已经停止;

线程的终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过thread.interrupted()检测是否有中断发生;

对象终结规则:一个对象初始化完成(构造函数执行结束)先行发生于他的finalize()方法的开始;

传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出,操作A先行发生于操作C。

当一个类加载到内存后,JVM运行时数据区域都需要存储什么东西呢?

1)类的格式信息、主次版本号、 常量池、字段、方法、属性信息需要存储,这部分数据需要每个线程共享;
当类加载器将静态的二进制字节码从类文件加载到内存后,对内容进行验证、解析转换为符合JVM存储的数据格式。

2)生成的Class类对象和代码运行时动态创建的对象和数组需要存储;
通过loading、linking、initializing后生成的Class对象,做为程序访问这个类的入口,
以及程序运行时产生的对象和数组,都有可能会被不同的线程引用和访问,因此这部分区域的数据也需要线程共享。
3)每个线程的线程数据需要存储和每个线程下方法的方法数据需要存储;
当线程创建的时候需要有一个线程栈来存放线程信息,以及PC计数器记录当前线程正在执行的代码行号。
还有JVM本地线程执行时需要的本地线程栈存放本地线程的执行情况。

《Java虚拟机规范》规定了如下内存区域来存储数据

  • 共享区域
  • 堆区
  • 方法区
    • 运行时常量池
    • 线程私有区域
  • JVM线程栈
  • 本地方法栈
  • PC计数器

Runtime DataArea

堆区

几乎所有的对象以及数组都存放在堆区。

方法区

用于存储已经被虚拟机加载的类型信息、常量、静态变量、及时编译器变异后的代码缓存等数据。

运行时常量池

运行时常量池是方法区的一部分,用于存放编译期或者运行期生成的各种字面量和符号引用。

Java虚拟机栈

每个线程会生成一个线程栈,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口信息。

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、long 、float、double)、引用类型、returnAddress类型。
这些数据类型在局部变量表中的存储空间以局部变量槽来表示(64位的long和double占用2个变量槽,其余数据类型占用1个变量槽)。局部变量表所需的内存空间在编译期完成分配,因此当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是确定的。在运行期间是不会改变局部变量表的大小(槽的多少)。

本地方法栈

与虚拟机栈作用类似,为虚拟机使用到的本地方法服务。

PC计数器

当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

对于JDK1.8以后HotSpot虚拟机改用与Jrokit、J9一样与本地内存中实现的元空间(Meta-space)来实现方法区。无论是原空间还是永久代,他们只是方法区的实现,字符串常量池和静态变量的存储逻辑上仍然属于方法区。元空间使用的是物理内存、永久代使用的是JVM内存。因此元空间的大小只受限于物理内存。

HotSpot 虚拟机实现

HotSpot 虚拟机对象的创建与引用

对象的创建

1) 在new一个对象的时候,先需要通过类加载检查,然后虚拟机为新生对象分配内存。

对象所需要的内存大小在类加载完成后便可以完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

  • 指针碰撞
    假设内存规整,将使用过的在一边,空闲的在一边,中间放一个指针作为分界点的指示器,所分配内存就是把指针向空闲空间方向挪动一段与对象相等的距离。
    使用Serial、ParNew带压缩整理过程的GC收集器时,采用指针碰撞,简单高效;
  • 空闲列表
    假设内存不规整,是一块块不连续的,那么就需要维护一个列表记录哪块内存可用,哪块内存不可用,在分配的时候在列表中找一块足够大的空间划分给对象实例,并更新列表上的记录。
    使用CMS基于清除算法的收集器时,理论上只能采用复杂的空闲列表来分配内存。
Thread Local Allocation Buffer

在堆中创建对象是特别频繁的事情,并且涉及到多线程的访问。如何保证高效且安全的申请空间?

  • 同步或者CAS+失败重试的方式保证更新操作的原子性;
  • 为每个线程在堆中预先分配一小块内存,通过-XX:+/-UseTLAB 设定;

2)分配完内存后,为内存空间初始化零值以及必要的设置。
初始化零值可以保证对象实例字段在Java对象中可以不赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。
之后设置这个对象的类型、关联类的元数据信息、GC分代年龄信息等。

3)执行 <init>() 方法,将对象进行初始化。

对象的内存布局

在Hotspot虚拟机中,对象在内存中的存储布局划分为三个部分

  • 对象头(Header)
    • 一部分是对象自身运行时数据,HashCode, GC分代年龄,锁状态标志,线程持有锁,偏向线程ID,偏向时间戳(数组长度)。
    • 一部分是类型指针,指向它的类型元数据的指针,用来确定是哪个类的实例。
  • 实例数据(Instance Data)
    字段的存储顺序受到虚拟机分配策略(-XX:FieldsAllocationStyle)和字段在源码中的顺序影响。
    默认相同宽度的字段被分配到一起存放,父类中定义的变量会出现在子类之前。
    使用+XX:CompactFields=true字类中较窄的变量也会插入到父类变量的空隙之中。
  • 填充(Padding)
    为了满足硬件的性能需求和提高垃圾回收时指针扫描的方便性。HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8个字节的整数倍(任何对象的大小必须是8个字节的整数倍)。

对象的访问定位

对象在堆中创建好了,Java程序会通过栈上局部变量表中的reference字段来引用堆上的对象。

  • 句柄访问
    Java堆中划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,句柄中包含了对象实例数据与类型数据各自具体的地址信息。
    好处:reference中存储的事稳定的句柄地址,对象被移动时只会改变句柄中的实例数据指针。
    句柄访问
  • 直接指针
    reference中存储的是对象的地址。好处是速度快,节约了指针定位的时间开销。
    直接指针

CPU

为了支持更多的内存支持,CPU必须是64位来支持64位操作系统。

OLTP

  • 用户操作并发量大
  • 事务处理的时间比较短
  • 查询语句简单,走索引
  • 复杂查询少

OLTP对CPU要求不是很高,但是并发量高,是IO密集型操作。

OLAP

  • 用户操作并发量小
  • 一般只用于查询
  • 查询语句复杂,需要优化分析,比较、排序、连接等耗费CPU
  • 复杂查询多,耗费时间长

OLAP对CPU要求较高,是CPU密集型操作。

从InnoDB存储引擎的设计架构上来看,主要后台操作都是在一个单独的master thread中完成的,因此并不能更好的支持多核应用。InnoDB1.2支持多个purge线程,以及将刷新操作从master thread中分离出来。虽然一条SQL查询语句只能在一个CPU总工作,但是可以有效的提升大并发量的请求处理。

可以通过修改以下两个参数来增加IO线程。
innodb_read_io_threads
innodb_write_io_threads

内存

内存的大小直接反映了数据库的性能(InnoDB缓存数据、索引都缓存在内存中时性能最优。)
通常InnoDB存储引擎的缓冲池的命中率不应该小雨99%。

1
show global status like 'innodb%read%';

innodb_buffer_pool_reads: 表示从物理磁盘读取页的次数;
innodb_buffer_pool_read_ahead: 表示预读的次数;
innodb_buffer_pool_read_ahead_evicted: 预读的页,但是没有被读取就从缓冲池中被替换的页的数量,一般用来判断预读的效率;
innodb_buffer_pool_read_requests: 从缓冲池中读取页的次数;
innodb_data_read: 总共读入的字节数;
innodb_data_reads: 总共读取请求的次数,每次读取可能需要读取多个页;

缓冲池命中率 = (innodb_buffer_pool_read_requests) / (innodb_buffer_pool_read_requests + innodb_buffer_pool_read_ahead + innodb_buffer_pool_reads);

平均每次读取字节数 = innodb_data_read / innodb_data_reads;

当缓冲池的大小已经大于数据库文件的大小时,磁盘操作依旧会进行。
数据库的缓冲池只是用来存放热点的区域, 后台线程还负责将脏页异步写入到磁盘。
每次事务提交还要将日志写入重做日志文件。

磁盘

机械硬盘

机械硬盘的访问需要耗费长时间的磁头旋转和定位来查找,因此顺序访问的速度远高于随机访问。
同时用多块磁盘组成RAID来提高数据库的性能,也将数据分布在不同的硬盘上来达到负载均衡。

固态硬盘

基于闪存的固态硬盘,内部由山村组成。企业级应用一般使用固态硬盘,通过并联多块闪存来进一步提高数据传输的吞吐量。
固态硬盘没有传统的机械磁盘的读写磁头,不需要耗费大量的时间用于磁盘旋转和定位来查找数据,所以固态硬盘可以提供一致的随机访问时间。

闪存中的数据时不可以更新的,智能通过扇区的覆盖重写。在覆盖重写之前需要执行非常耗时的擦除工作。擦除操作不能再所含数据的扇区上完成,而需要在删除整个被称为擦出块的基础上完成,这个擦除块的尺寸大于山区大小,通常是128KB或者256KB。每个擦除块有擦写次数的限制。

因为有擦除写入的问题,因此需要考虑如何利用齐读取性能,避免过多次的写入操作。

增加 innodb_io_capacity 变量的值达到充分利用固态硬盘带来的高IOPS特性。
关闭临接页的刷新,可以可以带来一定效果的提升。

RAID

RAID(Redundant Array of Independent Disks,独立磁盘冗余数组)基本思想是吧多个相对便宜的硬盘组合起来,成为一个磁盘数组,使得性能达到甚至超过一个价格昂贵、容量巨大的硬盘。由于将多个硬盘组合成一个逻辑山区,RAID看起来就像一个单独的硬盘或者逻辑存储单元,因此操作系统会把它当作一个硬盘。

RAID的作用:

  • 增强数据集成度
  • 增强容错功能
  • 增加处理量或者容量
RAID0

将多个磁盘并列起来合并成一个大磁盘,存放数据时将数据按照磁盘个数分段,同时将这些数据写入磁盘,不会有冗余,并行IO速度最快,但是没有冗余如果一个磁盘损坏,则所有数据都会丢失。

RAID1

两组以上的N个磁盘相互作为镜像,在一些多线程操作系统中能有很好的读取速度,但是写入速度略有降低。
除非拥有相同数据的主磁盘与镜像同时损坏,否则只要一个磁盘正常即可用维持运作,可靠性最高,磁盘利用率最低。

RAID5

一种存储性能、数据安全和存储成本兼顾的解决方案。是一种硬盘分区技术,RAID5至少需要三个硬盘,RAID5部队存储的数据进行备份,而是把数据和相对应的奇偶校验信息存储到组成?RAID5的各个磁盘上,并且奇偶校验信息和相对应的数据分别存储与不同的磁盘上。

RAID10

RAID1和RAID0的组合体

RAID50

RAID5和RAID0的组合体

操作系统

Linux
FreeBSD

Windows操作系统下,表名不区分大小写;Linux操作系统大小写敏感。

测试工具

sysbench

模块化的,跨平台的多线程基准测试工具,用于测试各种不同系统参数下的数据库负载情况。

  • CPU性能
  • 磁盘IO性能
  • 调度程序性能
  • 内存分配以及传输速度
  • POSIX线程性能
  • 数据库OLTP基准测试

tpcc-mysql

开源的TPC-C测试工具,遵循tpcc标准。