JVM相关合集整理
本章主要针对JVM基础知识的整理以及拓展
JVM内存区域
JVM在执行Java程序的过程中会把管理的内存分为若干个不同的数据区域。
JDK1.8前后分区略有不同
根据上述两图,运行时数据区域按照线程是否私有
分为两部分:
线程私有
:程序计数器、虚拟机栈、本地方法栈线程共享
:堆、方法区
程序计数器
线程私有,当前线程所执行的字节码的
行号指示器
,记录当前线程执行的位置。
程序计数器主要有两个作用:
- 字节码解释器通过改变
程序计数器
来依次读取指令,从而实现代码的流程控制 - 在多线程的情况下,
程序计数器
用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到何处。- 线程执行Java方法时,计数器记录了
当前正在执行的字节码指令地址
。 - 线程执行Native方法时,计数器值为
Undefined
。
- 线程执行Java方法时,计数器记录了
程序计数器
是唯一一个不会出现OutOfMemory
的内存区域,它的生命周期随着线程的创建而创建,随线程的结束而死亡。
虚拟机栈
线程私有,描述
Java方法执行的内存模型
,每次方法调用的数据都是通过栈传递的。
Java内存可以粗糙的分为堆内存(Heap)和栈内存(Stack),栈内存位于虚拟机栈上。
栈内存:存储局部变量
和方法调用
堆内存:存储Java中的对象(无论成员变量、局部变量、类变量)
如果局部变量是基本数据类型
,那局部变量的值存储于栈上;若局部变量是对象
,该变量的引用存在于栈上,但是对象存储于堆中。
基本数据类型:
boolean、byte、char、short、int、float、long、double
栈帧
虚拟机栈
由一个个栈帧组成,栈帧
也叫过程活动记录
,是用于支持虚拟机调用/执行程序方法的数据结构,记录了每一个方法从调用直至执行完成的过程。栈帧随着方法的调用而创建,执行完成而销毁。
栈帧
主要由以下四部分组成:
局部变量表
用于存储方法参数和定义在方法体的局部变量,包含了编译器可知的各种基本数据类型、对象引用、returnAddress类型。
局部变量表的大小在编译期就已经确定了,对应了字节码中Code属性表中的max_locals
。
操作数栈
通过入栈、出栈操作来完成一次数据访问,本质是一个临时数据存储区域。
是一个后入先出栈(LIFO)
操作数栈的大小在编译期已经确定,对应字节码中的Code属性表中的max_stacks
动态链接
为了支持方法调用过程中的动态连接,调用编译期无法被确定的方法。
在运行期将符号引用转换为所在内存地址的直接引用。
静态链接:被调用的目标方法在编译期可知且运行期保持不变时,那么这种情况下调用方法的符号引用可以转换为直接引用。
返回地址
记录方法被调用的位置,可以在方法执行结束后回到被调用处继续向下执行程序。
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 正常退出:方法中的代码正常执行完成,或者遇到任意一个方法返回的字节码指令(
return
)并退出,将返回值传递给上层的方法调用者,没有抛出任何异常。 - 异常退出:执行方法过程中出现异常,并且没有处理该异常,导致方法退出。
一般方法退出正常值为调用者的PC计数器数值
。
在虚拟机栈
会出现两种异常情况:
StackOverflowError
:请求栈深度超出虚拟机栈说允许的深度时抛出OutOfMemoryError
:无法申请到足够的内存时抛出
本地方法栈
线程私有,虚拟机执行Native方法的服务,和
虚拟机栈
功能类似。
在本地方法栈
会出现两种异常情况:
StackOverflowError
:请求栈深度超出虚拟机栈说允许的深度时抛出OutOfMemoryError
:无法申请到足够的内存时抛出
Java堆
线程共享
JVM所管理内存中的最大一块,该区域唯一目的是存放对象实例,几乎所有对象实例都在这里分配内存。
因此他也是垃圾收集管理的主要区域,因此也被称作
GC堆
。
由于现在基本都采用分代垃圾回收算法,按照对象存储时间的不同,还可以细分为新生代(分为Eden和Survivor,大致比例为8:1:1)
和老年代
。
在Java堆
中会出现以下异常情况:
OutOfMemoryError
:无法申请到足够的内存时抛出
Tips
JVM堆内存溢出后,其他线程是否继续正常工作?
发生OOM之后会不会影响其他线程正常工作需要具体的场景分析。一般情况下,发生OOM的现场都会被终结,然后该线程持有的对象占用就会被GC,释放内存。
方法区(版本区别较大)
线程共享
用于存储
已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码数据
。
方法区
还有一个别名叫做Non-Heap
,可以与上述的Java堆
进行区分。
JDK 1.8前
那时方法区
也被称为永久代
,GC在该区域是比较少出现的,但是不代表不进行GC操作。常见的异常为java.lang.OutOfMemoryError:PermGen space
表示了永久代异常信息
。
JDK 1.8
这时永久代
已被移除,代替它的是元空间(meta space)
,元空间
位于直接内存
中,因此元空间
的最大占用就是系统的内存空间,用户可通过-XX:MetaspaceSize
设置元空间
最大占用,避免占用过量内存。
Why
- 由于
永久代
内存经常会溢出,导致OOM发生,因此JVM开发者希望这块内存可以被更灵活的管理,减少OOM错误的出现。 永久代
会为GC带来不必要的复杂度,并且回收效率偏低。永久代
的大小难以确定,太小容易造成方法区
发生OOM
在方法区
会出现两种异常情况:
StackOverflowError
:请求栈深度超出虚拟机栈说允许的深度时抛出OutOfMemoryError
:无法申请到足够的内存时抛出
Tips
如何使方法区发生OOM?
借助CGLib
这类字节码技术,不断动态生成新类,新方法。或者使用不同的ClassLoader去加载同一个类(不同的ClassLoader加载的同一个类也是不同的
)
JDK1.8之前
可以通过配置
-XX:Maxpermsize
设置一个较小的值JDK1.8
上述方法由于移除了
永久代
无法生效,可以通过配置-XX:MetaspaceSize
一个较小的值,也可以模拟这个异常。
常量池
Java中常量池的概念主要有三个:
字符串常量池
Class文件常量池
运行时常量池
其中
Class文件常量池
存在于class文件中,不受JDK版本影响。
字符串常量池
在JDK1.6前位于方法区
中,之后的版本存在于Java堆
上
运行时常量池
在JDk1.7前位于方法区
中,之后的版本存在于元空间
上
Class文件常量池(Class Constant Pool
)
class文件除了包含
类的版本、字段、方法、接口等描述信息
外,还有一项信息就是Class文件常量池
,用于存放编译器生成的各种字面量和符号引用
。
字面量
接近Java语言层面的常量
文本字符串
1
2
3
4public String s = "abc";//其中abc为字面量
对应字节码常量池数据为
#31 = Utf8 abc8种基本类型的值
1
2
3
4
5public int value = 1;
对应字节码常量池数据为
#7 = Utf8 value
#8 = Utf8 I常量池只保留了字段描述符(I)和字段名称(value),字面量不存在于常量池中。
用
final
修饰的成员变量,包括静态变量、实例变量,局部变量
1
2
3
4public final static int f = 2;//其中2为字面量
对应字节码常量池数据为
#11 = Integer 2
符号引用
用一组符号描述所引用的目标,符号可以是任何形式的字面量。
类和接口的全限定名
1
2
3
4
5public String s = "abc";
对应字节码常量池数据为
#5 = Class #10 // java/lang/String
#10 = Utf8 Ljava/lang/String;其中
String
对应全限定名为java/lang/String
存储于常量池中主要用于在运行时解析得到类的直接引用
字段的名称和描述符
字段:类或接口中声明的变量,包括类级别变量和实例级的变量
1
2
3
4
5
6
7
8
9public int value = 1;
对应字节码常量池数据为
#4 = Fieldref #5.#32 // JavaBasicKnowledge/JavaBean.value:I
#5 = Class #33 // JavaBasicKnowledge/JavaBean
#32 = NameAndType #7:#8 // value:I
#7 = Utf8 value
#8 = Utf8 I对于方法中的局部变量名,class常量池中仅仅保存字段名
1
2
3
4
5
6
7public void XX(int v){
int temp = 3;
}
对应字节码常量池数据为
#23 = Utf8 v
#24 = Utf8 temp
方法的名称和描述符
保存的是
方法名、参数类型+返回值
1
2
3
4
5
6
7public void XX(int v){
...
}
对应字节码常量池数据为
#21 = Utf8 XX //方法名
#22 = Utf8 (I)V //参数类型+返回值
字符串常量池(String Constant Pool
)
在JDK1.7及之后版本中,
字符串常量池
被移动到Java堆
中(可能是因为方法区的内存空间太小)。
JDK1.7之前
字符串常量池
的位置在方法区
,此时存储的是字符串对象。JDK1.7及之后
字符串常量池中的内容是在类加载完成,经过
验证、准备
阶段之后在Java堆
中生成字符串对象实例,然后将该对象实例引用值存在字符串常量池
中。字符串常量池中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间进行存放的。
在HotSpot VM里实现的String Pool
对应一个StringTable
类,实际是一个Hash表,默认值大小长度为1009
(如果放入过多,导致Hash冲突使链表变长,导致查询性能大幅下降)。该StringTable
在每个VM的实例只有一份,被所有的类共享。
在JDK1.7版本中,StringTable
长度可以通过配置参数指定——-XX:StringTableSize=${num}
指定长度。
创建字符串对象
1 |
|
字面量何时进入常量池
- 加载类的时候,那些字面量会进入到当前类的
运行时常量池
,不会进入全局的字符串常量池
中 - 当字面量赋值的时候,会翻译成字节码中的
ldc
指令,将常量从常量池中推送至栈顶。
运行时常量池
在JDK1.7及之后的版本已将
运行时常量池
从方法区
移了出来,在Java堆
中开辟一块区域存放运行时常量池。为了存储class文件常量池中的符号信息,在
解析
的时候会去查询字符串常量池
,以保证运行时常量池所引用的字符串与字符串常量池
中是一致的。
JVM在执行某个类的时候,必须经过加载、链接(验证,准备,解析)、初始化过程。
当类执行加载
过程后,JVM将class常量池
中的内容存放到运行时常量池
中,已知class文件常量池
中存储的内容是字面量与符号引用
。
准备
阶段在Java堆
中生成字符串的实例对象,将生成的实例对象引用放置于字符串常量池
。
解析
阶段将class文件常量池
中的符号引用
翻译成直接引用
也是存储于运行时常量池
中。
动态性
Java规范并不要求常量只在运行时才能产生,也就是表示运行时常量池的内容不一定都来自于class文件常量池,在运行时可以通过代码生成常量放置于运行时常量池
中,例如String.intern()
String.intern()
JDK 1.7之前
intern
的处理是:先判断字符串是否存在于字符串常量池
中,如果存在直接返回该常量;如果没有找到,则将字符串常量加入到字符串常量池
中。JDK 1.7及之后
intern
的处理是:先判断字符串是否存在于字符串常量池
中,如果存在直接返回该常量;如果没找到,表示该字符串常量在堆中,然后把Java堆
该对象的引用加入到字符串常量池
中,以后别人拿到的就是该字符串常量的引用,实际字符串存在于堆中。
直接内存
直接内存并不是JVM的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,可能导致OOM的出现。
在JDK1.4新加入了NIO
类,引入一种基于通道(Channel)
和缓冲区(Buffer)
的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存在Java堆
的对象作为这块内存的应用进行操作。
Java对象创建过程以及访问方式
Java对象创建过程
在Java语言层面上,创建对象只需要调用new
关键字。
在JVM中,实际需要执行以下几步:
类加载检查
遇到一条new
指令时,先检查指令对应的参数是否在常量池
中可以定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,需要执行相应的类加载过程
。
分配内存
类加载检查
通过后,JVM将为新生对象分配内存
,对象所需大小在类加载完成后便可以确定。
这块内存由Java堆
划分出来。内存的分配方式由Java堆中内存是否规整决定,而内存是否规整
由采用的垃圾收集器是否带有压缩整理功能决定。
指针碰撞
Java堆内存规整
,把指针向空闲空间挪动对象大小的距离对应GC收集器:Serial、ParNew
关键看GC收集器采用了
标记-整理、标记-压缩、复制
算法进行回收空闲列表
Java堆内存不规整
,虚拟机维护一个列表记录内存块中的可用区域,在分配内存的时候,找到一块儿足够大的空间划分给对象实例对应GC收集器:CMS
关键看GC收集器采用了
标记-清除
算法进行回收
内存分配并发问题
创建对象是一个很频繁的事情,就会涉及一个很重要的问题——线程安全。作为虚拟机来讲,必须要保证线程安全,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS+失败重试
CAS是乐观锁的一种实现方式
乐观锁:假设没有冲突而去完成某项操作,若发生冲突就重试直到成功为止。
采用这种方式可以保证更新操作的原子性。
TLAB(本地线程分配缓存)
每个线程预先在
Java堆
中分配一块内存,JVM在给对象分配内存时,首先在TLAB
分配。如果分配的对象大于TLAB
的剩余内存或TLAB
内存已用尽时,再采用上述CAS方式进行内存分配。
初始化零值
内存分配完成时,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)。这一步操作可以保证对象的实例字段在代码中可以不赋值就直接使用,程序也可以访问到这些字段的数据类型所对应的零值。
设置对象头
初始化零值
完成后,虚拟机要对对象进行必要的设置。将类的元数据信息、对象的哈希码、对象的GC分代年龄
等信息,存放到对象头
中。另外根据虚拟机运行状态的不同,如是否启用偏向锁等,对象头都会进行存储。
可以在对象内存布局
这节看到对象头相关内容。
执行<init>
方法
从虚拟机角度来说,一个新的对象已经产生了。从代码角度来说,对象才刚开始创建,在执行<init>
方法之前,所有的字段都还为零。一般执行完new
指令后会接着执行<init>
方法,把对象按照意愿进行初始化,这时就产生了一个真正可用的对象。
对象内存布局
对象内存布局分为以下三块区域:
对象头(Header)
必须先了解 HotSpot虚拟机的对象(对象头部分)的内存布局:分为两部分
Mark Word
存储自身的运行时数据,如:HashCode、GC分代年龄和锁信息
,这部分数据的长度在32和64位中的JVM中分别为32bit和64bit。它是实现轻量级锁和偏向锁的关键。
类型指针
存储指向方法区对象类型数据的指针,如果是数组对象的话,额外会存储数据的长度。JVM通过这个指针来确定该对象是哪个类的实例。
实例数据(Instance Data)
对象真正存储的有效信息,即在代码里面所定义的各种类型的字段内容。
对齐填充(Padding)
并非必然存在的,也没有特别的含义,仅仅起着占位符的作用。
Java对象访问方式
Java程序通过栈上的refrence数据来操作堆上的具体对象。
句柄访问
Java堆
可能会划分一块内存作为句柄池,refrence存储的就是对象的句柄地址
,句柄中包含了对象的实例数据与类型数据的各自具体地址信息。
refrence中存储的稳定句柄地址,在对象被移动时(例如GC时)只会改变句柄中的实例数据指针,refrence本身不需要修改。
直接访问
Java堆
中对象的内存布局就必须考虑如何设置访问类型数据的相关信息,refrence直接存储的就是对象地址
。
最大好处就是速度快,节省了一次指针定位的时间开销。在HotSpot虚拟机中很常用。
类加载机制
JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
类的生命周期
一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中准备、解析、初始化
称为连接。
类的卸载
由JVM自带的类加载器(
BootstrapClassLoader根加载器、ExtensionClassLoader拓展加载器、ApplicationClassLoader应用加载器
)所加载的类,在虚拟机的生命周期中,都不会被卸载。只有由用户自定义的类加载器所加载的类是可以被卸载的。
类卸载的触发条件:
- 该类所有的实例都已被GC,在JVM中不存在任何该类的实例
- 加载该类的ClassLoader也被GC
- 该类的Class对象没有被任何地方调用,反射也无法访问该类
执行类卸载后,在方法区的二进制数据会被卸载。
类加载过程
类加载过程包括上述的五步:
加载、验证、准备、解析、初始化
。
加载
JVM找到class文件问生成字节流,然后根据字节流创建java.lang.class对象的过程。
JVM在此过程需要完成三件事:
- 通过一个类的
全限定名(包名+类名)
来查找.class文件,并生成二进制字节流(使用ClassLoader进行加载)。其中字节码来源不一定是.class文件,也可以是jar包、zip包,甚至是来源于网络的字节流。 - 将字节流所代表的静态存储结构转化为JVM的特定的数据结构,并存储在
方法区
- 在内存中创建一个
java.lang.Class
类型的对象,作为方法区这个类的各种数据的访问入口。
一个非数组类的加载阶段(加载阶段获取二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写类加载器的findClass()
或loadClass()
)。
对于数组类而言,数组类本身不通过类加载器创建,由JVM直接在内存中动态创建。
加载时机
一个项目经过编译之后,往往会产生大量.class文件,程序运行时,JVM不会一次性将这些文件全部加载到内存中,而是有一定的加载时机去进行加载操作。
隐式装载
在程序运行过程中,当碰到通过new
生成对象时,系统会隐式调用ClassLoader装载对应class到内存中(loadClass()
)
1 |
|
显示装载
在编写源代码时,主动调用Class.forName()
也会进行class装载操作。执行时会默认调用静态代码块static{...}
以及分配静态变量存储空间
1 |
|
验证
确保.class文件的字节流中包含的信息符合虚拟机规范的全部要求,并且不会危及虚拟机本身的安全。
若代码被反复验证和使用过,可以通过配置-XVerify:none
关闭大部分的验证措施,缩短加载时间
主要包含以下四个方面的验证:
文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
这一阶段可能包含以下验证点:
- 是否以魔数
0xCAFEBABE
开头 - 主次Java版本号是否在当前JVM接受范围内
- …
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java语言规范的要求
这一阶段可能包含以下验证点:
- 这个类是否有父类(除了 java.lang.Object外,都应该有父类)
- 这个类是否继承了不允许被继承的类(被final修饰的类)
- …
字节码验证
通过数据流分析和控制流分析,确定程序语义是合法、符合逻辑的
这一阶段可能包含以下验证点:
- 任意时刻操作数栈的数据类型与指令代码序列都配合工作
- 任何跳转指令都不会跳到方法体以外的的字节码指令中
- …
符号引用验证
发生于JVM将
符号引用
转换直接引用
的时候。对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验。通俗来说就是,该类是否缺少或者被禁止访问她依赖的某些外部类、方法、字段等资源。
这一阶段可能包含以下验证点:
- 符号引中通过字符串描述的全限定名能否找到对应的类
- 在指定类是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- …
准备
正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置为变量初始值(
零值
)的阶段,不包括实例变量。
1 |
|
在准备阶段,JVM会为value分配内存,并将其设置为0。真正的100需要在初始化阶段进行设置。
数据类型 | 零值 |
---|---|
Int | 0 |
long | 0L |
short | (short)0 |
char | ‘\u0000’ |
byte | (byte)0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | Null |
以上是通常情况下初始值是零值
,还是会存在一些特殊情况——静态常量。
1 |
|
此时value的初始值就为100。
解析
把常量池中的符号引用转换为直接引用,也就是具体的内存地址。JVM会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。
符号引用
以一组符号描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
直接引用
直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。对象真正的内存地址
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行。
初始化
类加载的最后一个步骤,直到这一步,JVM才真正开始执行类中编写的Java代码。
执行类构造器
()的过程,并真正初始化类变量(前面准备过程的零值在此时被赋予真正的值)
初始化执行时机
JVM规范严格规定类class初始化的时机,主要有以下几种情况:
虚拟机启动时,初始化包含
main()
的主类遇到
new(创建对象实例)、getstatic(读取类静态字段)、putstatic(设置类静态字段)、invokestatic(调用类的静态方法)
这四条字节码指令时,如果目标对象没有经过初始化,需要执行初始化操作当需要对类进行反射调用时,如果类型没有进行初始化,需要执行初始化操作
当初始化子类的时候,发现父类还没有进行初始化,需要执行父类的初始化操作
在第一次调用
java.lang.invoke.MethodHandle
实例时,需要初始化MethodHandle指向方法所在的类。JDK7之后当一个接口中定义了JDK8新加入的默认方法(
default
关键字修饰),如果实现了这个接口的类进行初始化,那么接口需要执行初始化操作1
2
3
4
5
6
7
8public interface DefaultInterface {
//默认接口方法
default void test(){
System.err.println("Default Interface Method");
}
void test1();
}
以上6种情况在JVM中被称为主动引用,除此之外的其他应用方式都被称为被动引用,不会出发Class的初始化操作。
例如以下几种情况:
通过子类调用父类的静态变量,不会导致子类初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class Parent{
public static int value = 1;
static {
System.out.println("Parent");
}
}
public class Child extends Parent{
static {
System.out.println("Child");
}
}
public class Test{
public static void main(String[] args){
Child.value = 2;
}
}
日志输出
java NonInitTest
Parent只有直接定义这个字段的类才会被初始化,所以子类不会进行初始化。
静态常量引用时,不会触发定义常量类的初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class ConstClass{
static {
System.out.println("Const");
}
public static final String value ="Value";
}
public class Test{
public static void main(String[] args){
System.out.println(ConstClass.value);
}
}
日志输出
Value常量实际在编译阶段直接存储在Test类的常量池中,已于ConstClass无关,所以不会导致初始化。
Class初始化和对象的创建顺序
在代码中使用new
创建对象实例时,类中静态代码块、非静态代码块、构造函数
之间的执行顺序是如何的?
1 |
|
输出内容为
1 |
|
按照上述输出内容,可以总结初始化顺序为:
1 |
|
基础规则:静态变量/静态代码块 -> 普通变量/普通代码块 -> 构造函数。
特殊情况
1 |
|
TODO:需要好好分析流程。
类加载器
在Java程序启动的时候,并不会一次性加载程序中所有的.class文件,而是在程序运行的过程中,动态加载相应的类到内存中。
同一个类使用不同的类加载器,得到的类也是不一样的。
Java类加载器
启动类加载器(BootstrapClassLoader)
由C/C++语言编写的,本身属于虚拟机的一部分,无法在Java代码获取他的引用。可以以
null
代表引导类加载器。负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定路径的类库
拓展类加载器(ExtensionClassLoader)/PlatformClassLoader(JDK9后改名)
由Java语言编写,可以直接在程序中使用
负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs所指定的路径中的所有类库
应用程序/系统类加载器(ApplicationClassLoader)
负责加载用户类路径(
java.class.path
)上所有的类库,我们自己编写的代码以及使用的第三方jar通常由他进行加载。若没有自定义类加载器,默认由他进行类加载。
自定义类加载器(CustomClassLoader)
上述三种加载器只能加载特定目录下的class文件,如果需要加载特殊位置下的jar包或类时(磁盘上的class),就需要继承
java.lang.ClassLoader
去实现功能。自定义ClassLoader步骤如下:
- 自定义一个类继承
ClassLoader
- 重写
findClass()
- 在
findClass()
中,调用defineClass()
将字节码转换成Class对象并返回
伪代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14Class CustomClassLoader extends ClassLoader{
@Override
Class findClass(String name){
//获得字节码
byte[] code = loadClassData(name);
//根据字节码获得Class对象
return defineClass(name,code);
}
//这里是获得Class的字节码数组
byte[] loadClassData(String name){
}
}- 自定义一个类继承
双亲委托模型
当类加载器收到类加载请求时,通常都是先委托给父类加载器进行加载,因此所有的类加载请求最终都会传送到最顶层的
启动类加载器
中,只有当父加载器无法完成这个加载请求时,子加载器才会去进行类加载过程。
1 |
|
按照源码分析,双亲委托模型总共4步:
- 判断class是否被加载,已加载直接返回class
- class未被加载且
parent(父加载器)
不为空,父加载器进行加载class parent(父加载器)
为空,直接调用BootstrapClassLoader
加载class- 如果
parent
或Bootstrap
都未加载成功,则调用当前classLoader继续尝试加载class
双亲委托模型好处
- 避免类的重复加载,若class已被加载直接从缓存读取
- 保证类加载的安全,避免核心API被篡改,无论哪一个类加载去加载核心类(例
java.lang.Object
),最终都会由BootstrapClassLoader
进行加载。
破坏模型
双亲委托机制
只是Java推荐的机制,并不是强制的机制,可以通过一些手段破坏该模型
可以通过继承java.lang.ClassLoader
实现自己的类加载器
- 保持
双亲委托模型
,只要重写findClass()
- 破坏
双亲委托模型
,需要重写loadClass()
Android类加载器
本质上,Android和传统的JVM是一样,也要通过
ClassLoader
加载目标类到内存,但是加载细节略有差别。基本运行方式:
传入dex文件,然后进行优化,保存优化后的dex文件(odex)到optimizedDirectory目录
Android无法直接运行.class文件
,会将所有的.class文件
转换.dex
文件,Android通过自定义的BaseDexClassLoader
加载dex文件
,也会通过继承BaseDexClassLoader
实现特定功能的子类。
BaseDexClassLoader
1 |
|
dexPath
包含目标类或资源的
apk,dex,jar文件的路径,也可以是SD卡的路径
,存在多个路径时使用;
分割optimizedDirectory
优化后dex文件(
odex
)存在的目录,可以为null,Android8.0之后,该参数被废弃librarySearchPath
存放目标文件使用的native库,存在多个路径使用
;
分割parent
父加载器
PathClassLoader
加载Android系统类和应用程序的类,在
Dalvik
只能加载已安装的apk的dex文件(/data/app
),在ART没有这个限制。支持加载外部的dex/apk文件
1 |
|
PathClassLoader
传入的optimizedDictory
为空,表示只能加载系统默认位置(/data/dalvik-cache/
)的odex
文件。
DexClassLoader
支持加载外部的dex/apk文件,但是可以配置
optimizedDirectory
指定odex
存放位置。
1 |
|
可以支持BaseDexClassLoader
配置的所有参数。
Android类加载过程
Android-Art类加载过程JVM垃圾回收机制(GC)
GC:自动管理回收不再引用的内存数据
JVM内存运行时区域分为5部分,其中
程序计数器、虚拟机栈、本地方法栈
3个区域随线程生而生,随线程灭而灭,是不需要考虑内存回收的问题,内存自然会回收。Java堆和方法区
不一样,只有在程序运行期间才知道创建哪些对象,这部分内存的分配和回收是动态的,主要在这两部分触发GC。
对象是否已死(什么是垃圾
)
堆中几乎存放所有的对象实例,垃圾回收(GC)前的第一步是判断哪些对象已经死亡(不再被任何途径引用的对象)。
引用计数法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器加1;引用失效,计数器减1;任何时候计数器为0的对象就是不可能在被使用的。
引用计数法
虽然需要占用额外的内存空间来进行计数,但是原理简单,效率也高。
但是主流的Java虚拟机里面都没有使用该方法,主要原因是必须配合大量额外处理才能保证正确的工作,例如无法解决对象之间相互循环引用的问题。
可达性分析算法
通过一系列称为
GC Roots
的根对象作为起始点,从这些节点开始搜索,搜索过程走过的路径称为引用链,如果一个对象到GC Roots
没有任何引用链相连的话,则证明此对象是不可用的。
可以作为GC Roots
的对象类型:
- 虚拟机栈中的引用对象(局部变量表)
- 方法区中静态属性引用的对象(字符串常量池中的引用),常量引用的对象
- 本地方法栈中JNI所引用的对象
什么时候回收
一般会在以下两种情况下触发GC
Allocation Failure
:如果内存剩余可用空间不足导致对象内存分配失败,系统会触发一次GCSystem.gc()
:开发者可主动调用该API触发一次GC
四大引用类型(Java堆)
引用类型 | GC时机 | 用途 |
---|---|---|
强引用 | 不会被回收 | 对象一般状态 |
软引用 | 内存不足时(即将OOM时) | 内存敏感的高速缓存 |
弱引用 | 触发GC时 | 对象缓存 |
虚引用 |
强引用Strong Reference
在程序代码间普遍存在的引用赋值。无论何种情况,只要存在强引用关系,就永远不会被垃圾回收器回收。即使发生OOM。
强引用也是造成Java内存泄露的原因之一。
对于一个普通的对象,如果没有其他的引用关系,若显式的将对象赋值为null,就可以认为该对象可以被回收。
设置对象为null,不代表对象会被立即回收,具体回收时机需要看垃圾收集策略。
1 |
|
软引用Soft Reference
描述一些还有用,但并未必需的对象。对于
软引用
关联的对象,在系统即将发生OOM
之前,会把这些对象进行GC,如果GC完毕还没有充足空间,就抛出OOM异常。实现内存敏感的高速缓存。
只有系统内存不足时才会被回收,其他情况下等效强引用。
1 |
|
弱引用Weak Reference
弱引用也是描述非必须对象,但强度比
软引用
更弱一些,被弱引用关联的对象只能生存在下一次GC前。无论内存是否足够,弱引用关联的对象都会被回收。
1 |
|
WeakHashMap
就是弱引用
的一个使用实例
其中key
为弱引用类型,当key不在引用时,对应的key/value也会被移除
1 |
|
当使用String key = “111”
时,本质引用的对象已经变成字符串常量池
中的对象,这部分的回收无法被GC处理。也导致了weakHashMap
对象不为空。
虚引用Phantom Reference
最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对生存时间构成影响,也无法通过
虚引用
来取得一个对象实例。主要用于
跟踪对象垃圾回收的状态,在这个对象被回收时可以收到一个系统通知或者后续添加进一步的处理。
虚引用
必须与引用队列
联合使用,当准备回收一个对象时,发现对象存在虚引用
,就会在回收对象之前把虚引用
加入关联的引用队列
中,可以根据引用队列
是否已加入虚引用
来判断被引用的对象是否要被回收。
1 |
|
引用队列Reference Queue
配合引用工作的,当GC准备回收一个对象时,如果发现对象被
软引用或弱引用或虚引用
包装,就会在回收对象前将引用加入到引用队列
中。如果一个引用(软引用、弱引用、虚引用)存在引用队列中,则表示该引用指向的对象已被回收。
1 |
|
ReferenceQueue
是一个先进先出的队列。
引用 Reference
上面的弱引用、软引用、虚引用
都是java.lang.ref.Reference
的直接子类。
Reference
主要存在四种状态
Active
新创建的实例为
Active
状态Pending
当实例等待进入
引用队列
时,处于Pending
状态。未注册引用队列
时永远不会处于此状态Enqueued
当实例进入
引用队列
时,处于Enqueued
状态。未注册引用队列
时永远不会处于此状态Inactive
该引用实例指向的实际对象一定已被回收。引用实例未注册
引用队列
直接从Active
状态进入到Inactive
状态。
使用实例
例如Leakcanary
,内部主要原理就是:弱引用+引用队列
。
在一个Activity执行完onDestroy()
后,用WeakReference
引用Activity
,再将引用对象与ReferenceQueue
关联。这时再从ReferenceQueue
中查看是否存在该弱引用对象
如果存在,执行一次手动GC,再次移除引用,如果弱引用不存在,则这次执行结束。
如果不存在,执行一次手动GC,再次查看是否存在弱引用对象,如果不存在则表示已发生内存泄露。
回收方法区
方法区的GC性价比比较低,方法区的回收条件比较苛刻,比较少用。
方法区的垃圾收集主要回收两部分内容:
废弃常量
如果常量池中存在字符串wxy
,但是当前没有任何String对象引用该字符串常量。就表示了当前这个常量处于废弃状态,当发生内存回收的时候而且有必要进行方法区回收,就会清理wxy
出常量池。
无用类
需要同时满足以下三个条件:
- 该类所有的实例都已被回收,Java堆中已不存在该类的任何实例
- 加载该类的类加载器已被回收,所以必须是自定义加载器去加载
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,也无法在任何地方被反射访问。
GC可以对满足上述三个条件的无用类进行回收,但不是必然会进行回收。
垃圾收集算法
标记-清除算法
最基础的收集算法
算法分为两个阶段:
标记(Mark)
标记所有需要回收的对象
。找到内存中所有的GC Root对象,然后找到与上述对象没有关联的对象(需要回收的垃圾
)。清除(Sweep)
回收掉所有被标记的对象
。
优点:
- 实现简单,不需要移动对象
缺点:
执行效率不稳定
。如果存在大部分需要回收的对象,导致标记、清除两个动作执行效率降低。内存碎片问题
。清除过程后会产生大量不连续的内存碎片,导致下次分配大对象时没有连续内存导致再次触发GC。
复制算法
将可用内存分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,将剩下的对象复制到另一块内存上。然后再清理已使用过的另一块内存,完成GC。
优点:
- 按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片
缺点:
可用内存减少一半
。对象存活率较高时会频繁进行复制。
标记-整理算法
标记-清除算法
的升级版
算法分为两个阶段:
标记(Mark)
标记所有需要回收的对象
。找到内存中所有的GC Root对象,然后找到与上述对象没有关联的对象(需要回收的垃圾
)。整理(Compact)
移动剩余存活对象到内存的某一端
。然后直接清理边界外的内存对象。
优点:
- 避免内存碎片的产生 相比于标记-清除算法
- 高效利用内存空间 相比于复制算法
缺点:
- 移动对象的过程必须全程暂停用户应用程序(STW-Stop The World),降低了效率。
*分代收集理论
主流JVM使用的垃圾收集算法
根据对象存活的周期不同,把堆内存划分几块,一般分为新生代和老年代。根据不同年代的特点使用不同的垃圾收集算法。
对于新创建的对象会在新生代
分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将他们转移到老年代
中。
新生代
新生成的对象优先存放
新生代
,新生代对象朝生夕死,存活率很低,所以回收效率很高。新生代经常要进行一些复制操作,所以一般采用复制算法进行回收。
新生代继续分为3部分:Eden、From Survivor、To Survivor
。这三部分并非均分,而是按照8:1:1的比例进行划分。
新生代GC过程如下:
- 绝大多数新创建对象都会先存放在
Eden
区 - 当
Eden
区满时,会执行一次GC(Minor GC),清除Eden
区的垃圾对象,将存活的对象复制到From Survivor
区 - 当
From Survivor
区满时,会执行一次Minor GC
,将存活的对象复制到To Survivor
区。如果存在可以晋升
的对象会直接放到老年代
中。 - 将
From Survivor
与To Survivor
区域进行切换。每次切换过程中即GC过后,对象的年龄+1,直到达到晋升年龄阈值(一般为15)
之后,对象被放到老年代。——长期存活的对象直接进入老年代
晋升年龄阈值
:该值的大小影响着对象在新生代
中的停留时间,可以通过-XX:MaxTenuringThreshold
配置数值。
老年代
在新生代经历了N次(
晋升年龄阈值
)回收之后仍然存活的对象,就会放入老年代
。
老年代
的内存一般比新生代
大(大概比例为2:1),可以存放更多的对象。如果对象比较大(
升入老年代对象大小
),并且新生代无法存放,则这个大对象会被直接分配老年代
上。——大对象直接进入老年代
。
老年代
通常使用标记-清除、标记-整理
算法进行GC。
升入老年代对象大小
:如果新生代的对象需要分配一块较大连续内存空间才可以存放,且该大小大于该值,则直接在老年代
进行内存分配,可以通过-XX:PretenureSizeThreshold
配置数值。
老年代
发生的GC称为Major GC
,针对老年代
的GC操作,通常伴随一次Minor GC
在某些虚拟机中,还有Full GC
,针对新生代与老年代
的GC,回收整个堆的内存。发生时,会导致长时间停顿。
老年代
有时候会引用新生代
对象,当执行Minor GC
时可能就需要查询老年代
的引用情况。导致GC过程低效。
所以老年代会维护一个Card table
,记录老年代所引用的新生代对象信息,在发生Minor GC
时,只要检查Card table
即可。
垃圾收集器
Serial收集器
最基本,发展历史最悠久的收集器。
是一个
单线程
工作的收集器,只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是进行垃圾回收时,必须停止其他所有工作线程(Stop The World
),直到收集结束。
新生代采用复制算法
,老年代采用标记-整理算法
。
优点:
- 简单而高效
- 没有线程交互的开销,可以获得最高的单线程收集效率
缺点:
- Stop The World可能时间过长
ParNew收集器
其实就是
Serial收集器
的多线程版本,不同的就是使用多线程进行垃圾收集
新生代采用复制算法
,老年代采用标记-整理算法
。
除了Serial收集器
以外,只有它可以和CMS收集器
配合工作。
默认开启的收集线程数与CPU数一致。
*CMS收集器
以
获取最短回收停顿时间
为目标的收集器。基于
标记-清除
算法实现。整体上来说是内存回收线程
与用户线程
并发执行。应用于
老年代
的垃圾收集器。
运作过程比较复杂,分为以下4步:
初始标记
暂停所有其他线程,并记录下与GC Roots关联的对象。触发Stop-The-World
并发标记
从GC Roots直接关联对象开始遍历整个对象图的过程(
GC Roots Tracing
)。这个过程耗时较长但不用停顿用户线程,主要跟踪记录发生引用更新的地方。重新标记
为了修正
并发标记
期间,因用户线程继续运行而导致标记产生变动的对象的标记记录。触发Stop-The-World并发清除
清理删除掉在标记阶段判断的垃圾对象,可以与用户线程一起工作。
优点:
- 并发收集
- 低停顿
缺点:
- 对CPU资源非常敏感
- 无法处理
浮动垃圾
- 产生大量内存碎片(由于
标记-清除算法
的实现)
*G1收集器
主要面向服务器的垃圾收集器,以极高概率满足GC、停顿时间要求的同时,还具备高吞吐量性能特征。
具备如下特点:
并行与并发
使用多个CPU缩短
STW
时间,还可以通过并发的方式让Java程序继续运行。分代收集
分代概念在
G1收集器
中进行了保留,但G1
可以直接管理新生代和老年代,然后采用不同的方式去管理。空间整合
整体基于
标记-整理算法
,局部(两个Region之间)采用了复制算法
可预测的停顿
除了追求低停顿外,还可以建立可预测的时间模型,用户去指定期望停顿时间。
运作过程分为以下4步:
初始标记
标记一下与GC Roots直接关联的对象。需要停顿线程
并发标记
从GC Roots对象开始对堆中对象进行可达性分析,找出需要回收的对象。
最终标记
修正
并发标记
期间因为用户线程继续运行导致标记发生变动的对象。需要停顿线程筛选回收
对各个Region的回收价值和成本进行排序,根据用户指定
期望停顿时间
制定回收计划。然后把决定回收的那部分Region存活对象复制到空Region中,再清理旧Region空间。必须暂停用户线程,因为涉及到对象的移动。
Region
Java堆的内存布局被划分为多个大小相等的区域(Region
),虽然保留了分代概念,但新生代
与老年代
都变成了Region
的集合。
G1收集器
认为大小超过Region
容量一半的对象判定为大对象
,存放于Humongous
区域。
可停顿的时间模型
在后台维护了一个优先列表,每次根据用户设置的期望停顿时间
,优先选择回收价值(回收获得的空间大小以及回收所需时间的经验值
)最大的Region。
记忆集(Remembered Set)
每个Region
都会存在一个记忆集
,里面记录下别的Region
指向自己的指针并标记这些指针分别在哪些页卡的范围之内。
通常约占Heap大小的20%或者更高。
ZGC收集器
在JDK 11中加入的低延迟垃圾收集器。
主要新增了两项新技术
着色指针
将少量额外的信息存储在指针上,在对象的内存发生变化的时候,指针颜色就会发生变化。就能知道当前对象状态
读屏障
由于
着色指针
的存在,程序访问对象的时候可以轻易知道对象的存储状态,若发现指针变色,则会触发读屏障
,会更新指针并重新返回结果,虽然存在一定的耗费但是可以达到与用户线程并发的效果。
与标记对象的传统算法相比。ZGC在指针上做标记,并在访问指针时加入
读屏障
,比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就先把指针更新为有效地址再返回,永远只会有单个对象读取时有概率被减速(需要更新指针地址),而不会再发生Stop-The-World。
JVM内存分配策略
对象优先在
Eden区
分配大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次
Minor GC
。每次GC后,对象依然存在就会进入
两块Survivor区内
。大对象直接进入
老年代
大对象:那种很长的字符串或者元素数量很多的数据。需要连续内存空间的Java对象。
长期存活的对象直接进入
老年代
每经过一次
Minor GC
仍然存活的对象,并且能被Survivor
容纳,其对象年龄就会+1,当达到晋升年龄阈值
对象就会晋升到老年代
。晋升年龄阈值
:默认为15,通过-XX:MaxTenuringThreshold
进行配置。动态对象年龄判定
为了更好适应不同的内存情况,不一定对象达到年龄阈值才能晋升老年代。
如果在
survivor区
相同年龄的对象大小总和超过Survivor
空间的一半,所有年龄大于或等于该年龄的对象都可以直接晋升老年代。空间分配担保
如果
survivor
没有足够空间存放在Eden区
存活对象,这些对象将通过分配担保机制
直接进入老年代。
Java内存模型(JMM)
CPU缓存一致性
缓存一致性问题
线程是CPU调度的最小单位。
由于CPU的发展,执行速度越来越快,内存与CPU的执行差距会越来越大,导致数据的交互需要等待较长时间。
因此,为了提升CPU的使用效率,在CPU中添加了高速缓存(cache)
作为内存与CPU之间的缓冲:将运算需要的数据复制到Cache中,让运算能快速进行,当运算完成之后,将运算结果刷回主内存,这样CPU就无需等待内存读写完毕。
由于每个CPU都有自己的cache
,当多个CPU共同操作一块主内存时,可能导致各自cache
中的数据不一致,发生缓存一致性问题。
为了解决缓存一致性
的问题,需要各个处理器访问缓存时遵循一些协议,在读写时要根据协议来进行操作,这类协议有MESI、MSI
、MOSI
等。
MESI(缓存一致性协议)
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态。
因此当其他CPU需要读取这个变量时,发现自己缓存变量的缓存行无效时,就需要从主内存中重新获取变量值。
上面介绍到每个处理器都会去检测自己的缓存变量是否有效?,这个检测机制就是通过嗅探来实现的。
嗅探
:每个处理器都会去检测主内存
上的数据来判断自己的缓存数据是否有效,当CPU发现缓存数据对应的内存地址发生修改时,就意味着缓存数据已经无效,需要做以下几步:
- 将当前CPU的缓存行设置无效
- 当CPU需要操作该数据时,就需要重新从主内存中读取数据
- 读取完毕后更新自己的缓存对象
嗅探
需要持续的从主内存检测数据并且通过CAS
进行循环获取,导致占用的总线带宽较高。这也被称之为总线风暴。
指令重排
为了使CPU的运算单元能够尽量被充分利用,CPU会对输入的代码进行重排序处理,也就是处理器优化
。
as-if-serial
不管怎么重排序,都不允许单线程下的程序执行结果发生改变。
编译器、runtime和CPU都必须遵守as-if-serial
协议。
一般重排序分为以下三种:
- 编译器优化的重排序
- 指令级并行的重排序
- 内存系统的重排序
概念
描述了Java程序中各种变量(线程共享变量
)的访问规则,屏蔽了各种硬件和操作系统的内存访问差异,实现让Java程序在各种平台都能达到一致的内存访问效果。
主要目的
定义程序中各种变量的访问规则,关注的是虚拟机中把变量值存储到内存中和从内存中取出变量值这样的底层细节
。
此处的变量
指的是实例字段、静态字段和构成诉诸对象的元素
,不包括局部变量
。
主内存与工作内存
主内存:所有的变量都存储于此
工作内存:每条线程都存在自己的工作内存
,保存了该线程使用变量的主内存副本
。线程对变量所有的操作都必须在工作内存
中进行,无法直接操作主内存
数据。不同线程之间也不能互相访问工作内存
中的变量,线程间传值都需要通过主内存
中转完成。
原子性、可见性和有序性
JMM模型具有以下特征:原子性、可见性、有序性。
原子性
对基本数据类型的变量读取和赋值操作都是
原子性
操作,这些操作不可被中断,要么执行,要么不执行。
可以通过synchronized
和Lock
实现原子性
,因为两者能够保证同一时刻只有一个线程访问该代码块。
原子性
操作包括:
- 除
long
和double
之外的基本数据类型赋值和读取操作,如果需要保证原子性需要加上volatile
关键字修饰 - 所有引用
refrence
的赋值操作 java.util.concurrent.atomic.*
包下的操作,例如AtomicInteger
可见性
当一个线程修改了共享变量的值,其他线程能够立即得知这个修改,修改后的值立即更新到主内存中,在其他线程读取时,会重新从主内存获取值。
volatile
可以保证可见性
,具体参考volatile
synchronized
和Lock
也可以保证可见性
,可以保证同一时刻只有一个线程访问共享资源,并在其释放锁之前将修改变量更新到主内存中。
final
也可以实现可见性
,对象一旦初始化完成,其他线程都可以该值。
有序性
如果在本线程内观察,所有的操作都是有序的;——
as-if-serial
如果其他线程观察,所有操作都是无序的。——
指令重排序
程序代码按照先后顺序执行。
volatile
可以保证有序性
,具体参考volatile
synchronized
和Lock
也可以保证有序性
,可以保证同一时刻只有一个线程能执行同步代码,线程可以顺序执行代码。
Happens-Before(先行发生)原则
JMM天生具有一定的有序性
,不需要任何手段保证有序性,通常这个称为happens-before(先行发生)原则。
用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。
如果一个操作的执行结果需要另一操作可见,那么这俩操作必须存在Happen-before关系。
主要有以下几条规则:
程序次序规则
按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。需要考虑分支、循环等结构。
管程锁定规则
一个锁的unlock
操作先行发生于lock
操作
volatile变量规则
对volatile
修饰的变量的写操作先行发生于读操作
线程启动规则
Thread
的start()
先行发生于此线程的每一个动作。
线程中断规则
Thread
的interrupt()
先行发生于线程中断检测代码Thread.interrupted()
线程终止规则
Thread
的所有操作都先行发生于此线程的终止检测,例如Thread.join(),Thread.isAlive()
对象终结规则
对象的初始化完成先行发生于finalize()
执行
finalize
:垃圾回收器准备释放内存的时候,会先调用finalize()
,可以在执行的时候做点工作。
传递性
如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
volatile
线程
Java线程相关合集整理Class文件结构(字节码)
DVM&ART(Android虚拟机)
参考链接
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!