当我们开始编写Java程序,很可能写出如下的代码:
public class Main {
public String getMsg(){
return "Hello world!";
}
public static void main(String[] args){
Main main = new Main();
System.out.println(main.getMsg());
}
}
那么,在这个程序的执行过程中,Java到底做了什么呢?下面我们就来详细解释。如下图展示了Java的使用。首先我们将介绍Java中内存区域是如何划分的,以及内存区域中各个部分的内容,这是Java程序的基础。对于程序的运行过程,我们将首先介绍创建对象,对象的使用,以及最后将无用的对象清除。
内存区域
如上图所示,Java的内存区域,通常会划分为线程私有的程序计数器、Java栈、本地方法栈,以及线程共享的堆和方法区,这里我们还额外画出了直接内存等区域。下面我们一一对其进行介绍:
- 程序计数器:程序计数器是当前线程执行的字节码的行号指示器。在JVM的概念模型中,字节码解释器工作时,就是通过解析并改变这个计数器的值来选取下一条需要执行的字节码指令。各个线程的执行位置不同且互补干扰,因此程序计数器是独立存储的。当执行Native方法时,程序计数器值为空。特别注意,程序计数器是JVM中唯一没有OutOfMemoryError的区域。
- Java栈:Java方法在执行时,会自动创建一个栈帧,用于存储信息,方法从调用直到执行完成的过程中,就是栈帧在虚拟机中入栈到出栈的过程。虚拟机栈中还放有局部变量表,局部变量表存放了编译期可知的基本数据类型、对象引用和returenAddress类型地址等。在JVM中,规定了两种异常状况:如果线程请求的栈深度大于最大允许的深度,抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
- 本地方法栈:本地方法栈用于执行Native方法时使用,会抛出StackOverflowError和OutOfMemoryError异常。
- 堆:堆是最大的一块内存,是所有线程共享的,唯一目的就是存放对象实例。Java堆是垃圾收集器管理的主要区域,通常会根据对象的存活周期细分为新生代和老年代。更细致的划分是Eden空间、From Survivor空间、To Survivor空间。从内存分配的角度而言,堆中还可能分配出线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。如果堆中没有内存完成实例分配,且堆无法再扩展时,会抛出OutOfMemoryError。
- 方法区:方法区和Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用。这部分内容在类加载后将存放在运行时常量池。这个区域也可能抛出OutOfMemoryError。
- 直接内存:直接内存并不是虚拟机运行时数据区的一部分,也不是JVM定义的内存区域。直接内存是在Java中的NIO(New Input/Output)类引入的,基于通道(Channel)与缓冲区(Buffer)的I/O方式,可以直接分配堆外内存,然后通过堆中的DirectByteBuffer对象作为内存的引用。
创建对象
了解了Java的内存区域后,下面将介绍如何在Java的内存区域中创建对象、分配内存等。
类的符号引用查找
当JVM遇到一条new指令时,JVM将首先检查是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,将首先执行类加载过程。
类加载过程
下面介绍一下类加载的全过程。
加载
加载是类加载过程的一个阶段,主要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
对于数组而言,数组本身不通过类加载器创建,它是由JVM直接创建的,但数组类的元素类型最终是要靠类加载器创建的。
验证
验证是连接阶段的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全。验证阶段大致上会完成以下4个阶段的校验动作:
- 文件格式验证:验证字节流是否符合Class文件格式规范,并且能够被当前版本的虚拟机处理。主要包括:是否以魔术0xCAFEBABE开头;主、次版本号是否在虚拟机处理范围之内;常量池的产量中是否有不被支持的产量类型;指向产量的各种索引值中是否有指向不存在的常量或者不符合类型的产量……
- 元数据验证:主要对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范:这个类是否有父类(除java.lang.Object外的类都有夫类);父类是否设置了不允许被继承;如果不是抽象类,是否实现了父类或接口中要求的所有方法;类中的字段、方法是否与父类产生矛盾……
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的,也是最复杂的阶段。
- 符号引用验证:确保解析动作能正常执行:符号引用中通过字符串描述的全限定名能找到对应的类;指定类中存在符合方法的字段描述符以及简单名称所描述的方法和字段;符号引用中的类、字段、方法 的访问性是否可被 当前类访问……
准备
正式为类变量分配内存并设置类变量初始值的阶段,这些变量使用的内存都将在方法区中进行分配。这里分配的仅包括类变量 (被static修饰的变量)而不包括实例变量,同时,这里分配的初始值通常是零值。但是,如果设置了final值,则会初始化为final指定的值。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用和直接引用的异同如下:
- 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。虚拟机实现的内存布局可以不同,但能接受的符号引用必须是一致的。
- 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能直接定位到目标的句柄。直接引用是与虚拟机内存布局相关的。如果有了直接引用,那引用的目标必定已经在内存中存在。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
初始化
初始化是类加载过程的最后一步。在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其他资源,也就是执行类构造器<clinit>方法的过程。<clinit>介绍如下:
- 由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,顺序取决于源文件中的顺序。
- 与构造函数不同,不需要显式的调用父类的构造函数,虚拟机会保证在子类
<clinit>执行前,调用父类的<clinit>方法并执行完毕。 - 父类中的静态语句块先于子类中的。
<clinit>对于类或接口来说并不是必需的,如果类中没有静态语句块,也没有对变量的赋值操作,那么可以不生成<clinit>方法。- 接口中不能使用静态语句块,但仍然由变量初始化的赋值操作,因此接口也会生成
<clinit>,但接口不需要先执行父接口的<clinit>方法,只有当父接口中定义的变量使用时,父接口才会初始化。 - 虚拟机会保证一个类的
<clinit>方法在多线程环境中被正确地枷锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>方法,其他线程都阻塞等待。
类加载器
类加载器实现类的加载动作,同时类加载器和类本身同时确定类在Java虚拟机中的唯一性。
双亲委派模型
对于JVM而言,只存在两种不同的类加载器:启动类加载器和所有其他的加载器。
对于Java开发人员而言,类加载器还可以划分得更细致一些:
- 启动类加载器:负责加载
<JAVA_HOME>\lib或者-Xbootclasspath参数指定的路径中的,并且是虚拟机识别的类库。 - 扩展类加载器:负责加载
<JAVA_HOME>\lib\ext目录中的或java.ext.dirs系统变量指定的路径中的所有类库。 - 应用程序类加载器:由ClassLoader中的给SystemClassLoader()方法的返回值确定,一般称为系统类加载器,加载用户类路径上所指定的类库。
下图展示了类加载器的层次关系,称为双亲委派模型:
双亲委派模型的工作过程是:如果类加载器收到了类加载的请求,首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,直到达到顶层的启动类加载器中,只有父类反馈无法加载时,子加载器才会尝试自己加载。
优点是,Java类随着类加载器具备了带有优先级的层次关系。这样,系统类不会被自定义类所加载,保证了安全和有序。
破坏双亲委派模型
举例说明,典型的如SPI代码:
- 我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml解析模块、jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。
- Java SPI则是提供这种能力的:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。
因此,java中引入了线程上下文类加载器,这种类加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置。
内存分配
Java对象需要的内存,在类加载完成后便可完全确定。下图是假设Java堆中内存是绝对规整的,所有用过的内存放在一边,空闲内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存就是把指针向空闲内存挪动一段与对象大小相等的距离。这种分配方式称为指针碰撞。
但如果Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,那么虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。这种分配方式称为空闲列表。
Java堆是否规整,通常与垃圾收集器有关,如果垃圾收集器带有压缩整理功能,那么Java堆就是规整的。否则就不是。
内存分配还有一个很关键的事情不得不考虑,那就是内存分配是一个非常频繁的事情,因此在并发情况下,需要保证线程安全。解决问题通常有如下两种方案:
- 对分配内存空间的动作进行同步处理:虚拟机采用CAS和失败重试的方式保证更新操作的原子性;
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个内存的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域。当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。如果证明一个对象不会逃逸到方法或线程之外,即其他方法或线程无法访问到这个对象,则可能为这个变量进行一些高效的优化:
- 栈上分配:在栈上分配对象的内存空间,随着方法调用结束,对象也被销毁,降低堆的内存占用及垃圾收集器的工作。
- 同步消除:该变量由于不会被其他线程所访问,可以消除其所有的同步措施。
- 标量替换:标量是指一个数据已经无法再分解成更小的数据来表示。Java中的原始数据类型都是标量。否则,如果数据可以分解,则称为聚合量。将一个Java对象拆散,其使用到的成员变量恢复原始类型访问就称为标量替换。如果对象不会被外部访问,且对象可以被拆解,那么可能不创建对象,改为直接创建它的成员变量。这样,成员变量可以再栈上分配和读写,还可以为后续进一步的优化手段创造条件。
内存分配规则
在这一系列文章的后面,将介绍Java中通常将堆划分为Eden区和两个Survivor区。这里我们介绍下Java是如何分配内存的。
1. 对象优先在Eden分配
一般而言,对象在新生代Eden区中分配,但Eden区没有足够空间时,虚拟机将发起一次Minor GC。
2. 大对象直接进入老年代
大对象指的是需要大量连续内存空间的Java对象。虚拟机可以通过-XX:PretenureSizeThreshold参数设置大于这个值的对象直接在老年代分配,避免在Eden区和两个Survivor区之间发生大量的内存复制。
3. 长期存活的对象将进入老年代
虚拟机是如何识别哪些对象应放在老年代,哪些对象应放在新生代呢:虚拟机给每个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象每熬过一次Minor GC,年龄就增加1,当年龄增加到一定程度(默认15),就被晋升到老年代中。
4. 动态对象年龄判定
如果在Survivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
内存初始化
内存分配后,虚拟机需要将分配到的内存空间都初始化为0值(不包括对象头)。如果使用TLAB,这一工作过程可以提前到TLAB分配时进行。
对象设置
接下来,JVM要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向所等,对象头会有不同的设置方式。
调用构造函数
完成以上工作后,新的对象就产生了,接下来Java将调用对象的<init>方法,将对象按照程序员的意愿进行初始化。
对象的内存布局
上面我们介绍了Java中是如何创建对象的,但我们对创建的对象还不了解,这里我们将介绍Java中对象的内存布局。如下图所示,对象在内存中可以分为3块区域:对象头、实例数据和对齐填充。
对象头包括两部分信息,第一部分是对象自身的运行数据,第二部分是类型指针。
- 对象运行时数据:此部分用于存储哈希码、GC分代年龄、锁标志等,在32位和64位虚拟机中分别位32bit和64bit。官方称为Mark Word。实际上,对象需要存储的运行时数据很多,远超出能记录的限度,但Mark Word的设计会根据对象的状态复用自己的存储空间,存储的内容如表所示:
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象哈希码、对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
- 类型指针:对象指向类元数据的指针,虚拟机通过这个指针确定对象是哪个类的实例。并不是所有的虚拟机实现都必须保留类型指针。如果是Java数组,那么还需记录数组长度。
接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。包括从父类集成下来的,还是在子类中定义的,都需要记录起来。这部分包括从父类集成下来的以及子类中定义的。存储顺序受到虚拟机分配参数和字段在源码中定义顺序的影响。通常相同宽度的字段总是被分配到一起,例如HotSpot虚拟机中默认的分配策略为longs/doubles、ints、shorts/charts、bytes/booleans、oops(Ordinary Object Pointers)。
第三部分的对齐填充并不是必然存在的,也没什么含义,仅仅起着占位符的作用。
使用对象
对象的访问定位
在创建了对象后,程序就要开始使用对象了。Java中式通过栈上的reference数据来操作堆上的对象的。通常这种引用有两种实现方式:使用句柄和直接指针两种。上图展示了两种方式的例子。
- 句柄:Java堆中划分一块内存作为句柄池,reference中保存的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优点在于reference中存储的是稳定的句柄地址,当对象移动时,只会改变句柄中的实例数据指针,而reference本身不需要修改。
- 直接指针:直接指针的方式中,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象地址。优点是访问更快,节省了一次指针定位的时间开销。
对象的方法
数据结构——栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里从入栈到出栈的过程。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅取决于具体的虚拟机实现。下图展示了栈帧的例子。
下面介绍一下栈帧中的各个部分的作用和数据结构:
- 局部变量表:一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范中指明每个Slot应该能存放一个boolean、byte、char、short、int、float、reference或returenAddress。另外,局部变量表是在线程的堆栈上,是线程私有的,因此是线程安全的。虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。
- 操作数栈:后入先出栈。
- 动态连接:栈帧中包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用中的动态连接。
- 方法返回地址:当方法开始执行后,只有两种方式可以退出。第一种方法是return,第二种方法是未捕获异常。在方法退出后,需要返回到方法被调用的位置,程序才能继续执行。方法退出的过程实际上等同于栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用
方法调用并非方法执行,方法调用的唯一任务就是确定被调用方法的版本。由于Class文件的编译过程中不包含连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。也因此需要在类加载期间,甚至是运行期间才能确定目标方法的直接引用。
解析
所有方法调用中的目标方法在Class文件里都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
Java中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
分派
Java是一门面向对象的程序语言,具备面向对象的3个基本特征:继承、封装、多态。分派调用过程则展示了多态性特征的一些基本的体现。
- 静态分派:虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据的,也就是对象指定的类型,如父类、接口等,而不是具体实现的类型如子类。因此在方法重载时,会根据参数类型查找方法并调用。静态分派只发生在编译阶段,因此这个动作不是由虚拟机执行的。
- 动态分派:与多态性的另外一个重要的体现——重写(Override)有着很密切的关联。动态分派时需要确定调用的实际类型。
- 单分派与多分派:方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则根据多于一个宗量对目标方法进行选择。
- 虚拟机种的实现;虚拟机中具体实现分派是不同的。常见的手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
多线程
线程基础
线程实现
线程实现的三种方式:
- 使用内核线程实现:内核线程(Kernel-Level Thread,KLT)直接由操作系统内核(Kernel)支持。内核通过操纵调度器对线程进行调度,并复杂将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多监视器,支持多线程的内核就叫做多线程内核。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程。由于每个轻量级线程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作。轻量级进程的局限在于:由于时基于内核线程实现,各种线程操作需要进行系统调用,系统调用代价高,需要在用户态和内核态中来回切换;其次,每个轻量级进程需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此系统支持轻量级进程的数量是有限的。下图展示了轻量级进程与内核线程之间1:1的关系:
- 使用用户线程实现:广义上而言,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此轻量级进程也可以属于用户线程,但轻量级进程的实现始终是建立在内核之上。狭义上而言,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到线程存在,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。优势在于一般不需要切换到内核态,操作快速且低消耗。这种进程与用户线程之间1:N的关系称为一对多的线程模型。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。同时还导致了如“阻塞”等问题的处理非常困难。下图展示了进程与用户线程之间1:N的关系:
- 使用用户线程加轻量级进程混合实现:除了上面两种方式外,还有一种将内核线程与用户线程一起使用的实现方式。在混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作仍然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式下,用户线程与轻量级进程的数量比是不定的,即为N:M的关系。下图展示了N:M的关系:
线程调度
1. 调度方式
线程调度指的是系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度、抢占式线程调度:
- 协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。协同式的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程调度,切换操作对线程自己是可知的。但是,协同式调度下线程执行时间不可控制,可能发生阻塞。
- 抢占式调度:线程由系统来分配执行时间,线程的切换不由线程本身决定。系统可以控制线程的执行时间,也不会由于线程导致进程阻塞的问题。Java使用的线程调度方式就是抢占式调度。
2. 状态转换
Java语言定义了5种线程状态,在任意一个时间点,有且只有其中一种状态:
- 新建
- 运行
- 无限期等待
- 限期等待
- 阻塞
- 结束
下图展示了线程状态转换关系:
线程同步与并发
Java内存模型
1. 主内存和工作内存
内存模型指的是在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。事实上,在物理计算机中,也存在并发的问题,即主内存中元素与各个CPU中的高速缓存之间的“数据一致性”问题。
JVM中试图定义一种Java内存模型,以屏蔽掉各种硬件和操作系统的内存访问差异,以实现在各种平台下一致的内存访问效果。
上图是Java内存模型的图,展示了线程、主内存、工作内存三者的交互关系,图中还展示了内存间交互操作的位置。Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(实例字段、静态字段和构成数组的元素,不包括局部变量与方法参数等线程私有的)存储到内存中和从内存中取出变量这样的底层细节。
Java规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存。线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
2. 内存间交互操作
主内存与工作内存之间具体的交互协议也就是一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。Java的内存模型定义了8种原子操作:
- lock锁定:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock解锁:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read读取:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load载入:作用于主内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use使用:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
- assign赋值:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store存储:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write写入:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。Java内存模型还规定了在执行上必须满足如下规则:
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或从工作内存发起了写入但主内存不接受。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无原因地把数据从线程的工作内存同步回主内存中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,换句话说,就是对一个变量实时use、store操作之前,必须先执行过了assign和load操作。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
- 如果一个变量没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许unlock一个被其他线程锁定住的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。
3. volatile变量的特殊规则
volatile变量的两种特性:
- 保证此变量对所有线程的可见性。也就是说,当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。volatile只能保证可见性,因此仅在两种情况下可以不用加锁以保证原子性:运算结果不依赖变量的当前值,或者能够确保只有单一线程修改变量的值;变量不需要与其他的状态变量共同参与不变约束。
- 禁止指令重排序优化。添加了内存屏障,使得重排序时不能把后面的指令重排序到内存屏障之前的位置。
下面介绍volatile变量的特殊规则,假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
- 只有当线程T对变量V执行的前一个动作时load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联,必须连续一起出现。(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)
- 只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作,并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联,必须连续一起出现。(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)
- 假定动作A是线程T对变量V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相对应的对变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G适合动作B相关联的load或store动作,假定动作Q是和动作G相应的对变量W的read或write动作。如果A先于B,那么P先于Q。(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)
4. 对于long或double型变量的特殊规则
对于64位的数据类型long和double,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择不保证64位数据类型的load、store、read和write这4个操作的原子性,也就是所谓的long和double的非原子性协定。一般的商用虚拟机都选择把64位数据的读写操作作为原子操作来对待。
5. 原子性、可见性与有序性
- 原子性:由Java内存模型来直接保证原子性变量操作,大致可以认为基本数据类型的访问读写是具备原子性的。如果需要更大范围的原子性保证,可以使用lock和unlock操作来满足这种需求,也就是synchronized。
- 可见性:可见性是当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了volatile外,Java还有两个关键字能实现可见性:synchronized和final。
- 有序性:Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
6. 先行发生原则:happen-before
Java中规定了一个先行发生原则,该原则是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突地所有问题。首先,先行发生原则指的是Java内存模型中定义地两项操作之间地偏序关系,如果说操作A先行发生于操作B,也就是说在发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是Java内存模型中一些“天然的”先行发生关系:
- 程序次序原则:在一个线程内,按照程序代码顺序,书写在前面的操作(在控制流中的顺序)先行发生于书写在后面的操作。
- 管程锁定规则:一个unlock操作先行发生于后面(时间顺序)对同一个锁的lock操作。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
- 线程启动规则:Thread对象的start方法先行发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程interrupt()的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成先行于它的finalize()方法的开始。
- 传递性:如果A先行于B,B先行于C,那么A先行于C。
Java锁
这一章主要介绍Java多线程中用于保证线程安全的锁机制,我们将Java中的锁机制划分为两类,分别是互斥锁和共享锁。互斥锁也就相当于写锁,当数据加了互斥锁后,只有持有锁的线程能够进入,其他线程只能阻塞等待。共享锁则相当于读锁,当数据加了共享锁后,只有同样申请或持有共享锁的线程能够进入,其他线程无法进入。本章的主要内容与Java的线程安全相关,Java的线程安全实现的手段较多,包括利用互斥锁的互斥同步、乐观的非阻塞同步、以及没有同步的方案。
1. 互斥同步
互斥同步是常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是因,同步是果;互斥是方法,同步是目的。
synchronized的原理是在同步块的前后加入monitorenter和monitorexit字节码指令,两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。
特别地,synchronize同步块对同一条线程来说是可重入地,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。由于Java线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,需要由操作系统执行,导致线程从用户态转换到核心态中,耗费处理器时间。
另外还可以使用java.util.concurrent(J.U.C)包中的重入锁(ReentrantLock)来实现同步。ReentrantLock和synchronized都具有线程重入特性,区别在于一个是API层面的互斥锁,另一个是原生语法层面的互斥锁。相比于synchronized,ReentrantLock增加了一些高级功能,主要是等待可中断、可实现公平锁以及锁可以绑定多个条件:
- 等待可中断:当持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
- 锁绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件。
2. 非阻塞同步
互斥同步是悲观的,而阻塞同步是乐观的,也就是基于冲突检测的乐观并发策略。通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的步长措施,这种乐观的并发策略方式可以不把线程挂起,因此称为非阻塞同步。
执行非阻塞同步操作通常只需要一条处理器指令就能完成,这类指令常用的有:
- 测试并设置(Test-and-Set)
- 获取并增加(Fetch-and-Increment)
- 交换(Swap)
- 比较并交换(Compare-and-Swap,也就是CAS)
- 加载链接/条件存储(Load-Linked/Store-Conditional,也就是LL/SC)
CAS指令需要有3个操作数,分别是内存位置(用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。
Java通过提供sun.misc.Unsafe类的compareAndSwapInt()和compareAndSwapLong()等几个方法提供了CAS操作功能。但Unsafe类不是提供给用户程序调用的类,Unsafe.getUnsafe()代码中限制了只有启动类加载器加载的类才能反问它,因此常使用反射手段或其他Java API来间接使用它,如J.U.C包里面的整数原子类等。
CAS的缺点在于存在ABA问题,也就是如果一个变量V首次被读取的时候是A值,并且在准备赋值的时候检查到它仍为A值,那么能说明变量V没有被修改过吗?其实不行,因此变量可能被修改为其他值后又被改回A值,解决这个问题的关键是提供一个版本号的机制。
3. 无同步方案
要保证线程安全,并不是一定就要进行同步。有些代码天然是线程安全的,因为不涉及共享数据:
- 可重入代码:纯代码,可重入代码的特征是不依赖堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。
- 线程本地存储。
锁优化
Java中为了高效并发,实现了许多锁优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这里我们将对各种锁进行介绍。
1. 自旋锁
当多个线程竞争锁且有多个处理器时,让后面请求的线程稍等一下,但不放弃处理器执行时间,看看持有锁的线程是否很快就会释放锁。
自旋等待本身不能代替阻塞,它不仅要求处理器数量,同时还占用处理器时间。如果锁被长期占用,那么自旋线程就会消耗处理器资源。
Java中的自适应的自旋锁可以根据前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
2. 锁消除
锁消除指的是在虚拟机即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
3. 锁粗化
我们在编写代码时,总是推荐将同步块的作用范围限制得很小。而如果一系列得操作都对同一个对象反复加锁、解锁,甚至在循环体中出现了加锁操作,那即使没有线程竞争,频繁的互斥同步操作也会导致不必要的性能损耗。当虚拟机检测到这样一串零碎的操作都对同一个对象加锁,就会进行锁粗化,将加锁同步的范围扩展到整个序列的外部。
4. 轻量级锁
轻量级锁中的轻量级时相对于使用系统互斥量的传统锁而言的,传统的锁机制就称为重量级锁。轻量级锁和重量级锁并非替代关系。轻量级锁的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
在轻量级锁的执行过程中,当代码进入同步块的时候,如果同步对象没有被锁定,虚拟机将首先在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝(即为Displaced Mark Word)。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁状态。这个过程如下图所示:
如果这个更新动作失败了,虚拟机会首先检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁不再有效,要膨胀成重量级锁,锁标志的状态值变成“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就完成额里。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。如果存在竞争,那么轻量级锁的开销比传统的重量级锁更慢。
5. 偏向锁
偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做。
偏向锁的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
在偏向锁中,当锁对象第一次被线程获取的时候,虚拟机会将对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。
当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁的状态。
清除对象
可达性分析
Java中是通过可达性分析算法来判定对象是否存活的,对于已经死亡的或者说没有引用可以访问到的对象,就要将其占用的空间进行清理回收供其他使用。算法的基本思想是通过一系列的“GC Roots”的对象作为起始点,从这些对象向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI引用的对象。
Java中还扩充了引用的概念,因为对于一些已分配的空间,当内存足够时,这些空间还保存在内存之中,如果内存空间在垃圾收集后还是很紧张,则可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景。Java中将引用的概念按照从强到弱进行了扩充:
- 强引用:在程序代码中普遍存在的,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用:用来描述一些还有用但非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用:描述非必需对象,强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用:幽灵引用/幻影引用,是最弱的引用关系。对象的虚引用不会影响其生存时间,也无法通过虚引用来取得一个对象实例。对象虚引用的唯一目的就是在对象被垃圾收集器回收时收到一个系统通知。
对象的死亡过程要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象有无必要执行finalize方法。当对象没有覆盖finalize方法或finalize方法已经被虚拟机调用过,都视为没有必要执行。
如果对象被判定为有必要执行,那么这个对象将会放置在一个叫做F-Queu的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行。执行指的是虚拟机会触发这个方法,但并不承诺会等待它运行结束。原因是如果对象在finanlize中执行缓慢甚至死循环,可能导致内存回收系统崩溃。finanlize是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可。注意任何一个对象的finalize方法只会被系统执行一次。
回收方法区:方法区的垃圾收集效率较低,但不代表完全不在方法区中回收。永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似。然而,判断一个是否是“无用的类”的条件就相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
- 加载该类的ClassLoader已经被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
标记清除算法
最基础的收集算法是“标记-清除”算法,算法的阶段分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后同一个回收所有被标记的对象。
缺点在于:
- 效率较低,标记和清除效率都不高;
- 空间问题,标记清除之后会产生大量不连续的内存碎片。
复制算法
为了解决效率问题,“复制”算法将可用内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上,然后将已使用的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片的问题。
现在的虚拟机很少将内存一分为二,否则就未免太过浪费了。通常是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor上,最后清理掉Eden和刚使用过的Survivor空间。
分配担保:当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
标记整理算法
标记整理算法的过程类似“标记清除”,只不过不是直接对可回收对象进行清理,而是让存活的对象都往一端移动,然后直接清理掉边界以外的内存。
垃圾收集器
垃圾收集器是内存回收的具体体现。下面我们介绍几种不同的垃圾收集器。
Serial收集器
- 单线程的收集器,收集时必须暂停所有其他的工作线程,直到它收集阶数,因此导致应用会停机。
- 优点在于简单而高效,具有最高的单线程收集效率。
- 新生代采用复制算法,老年代采用标记整理算法。
ParNew收集器
- Serial收集器的多线程版本,效率不一定优于Serial收集器。
- 新生代采用复制算法,老年代采用标记整理算法。
Parallel Scavenge收集器
- 新生代收集器
- 复制算法
- 多线程收集器
- 目标是达到可控制的吞吐量(CPU用于运行用户代码的时间与CPU总消耗时间的比值,即
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))。高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。 - 精确控制吞吐量的参数:控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis参数、直接设置吞吐量大小的-XX:GCTimeRatio参数(垃圾收集时间占总时间的比率,也就是吞吐量的倒数)、-XX:+UseAdaptiveSizePolicy,虚拟机根据系统运行情况收集性能信息,动态调控新生代(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节,称为GC自适应调节策略。
Serial Old收集器
- Serial收集器的老年代版本,采用标记整理算法。
Parallel Old收集器
- Parallel Scavenge收集器的老年代版本,多线程,标记整理算法。
CMS收集器
- 以获取最短回收停顿时间为目标的收集器
- 过程如下:
- 初始标记:标记GC Roots能直接关联的对象
- 并发标记:进行GC Tracing的过程
- 重新标记:修正因并发标记期间用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
- 并发清除:并发清除
- 缺点如下:
- CMS收集器对CPU资源非常敏感。并发程序都对CPU资源比较敏感。
- 无法处理浮动垃圾,可能出现“Concurrent Mode Failure”导致另一次Full GC的产生。由于并发清理阶段用户线程还在运行并产生垃圾,CMS收集器无法收集这些浮动垃圾。如果在CMS运行期间预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”失败,这时虚拟机将临时启用Serial Old收集器来重新进行老年代的垃圾收集,导致停顿时间变长。
- CMS基于标记清除,收集结束时会有大量空间碎片产生,导致无法进行大对象分配以至于不得不触发Full GC。为了解决这个问题,CMS提供了-XX:+UseCMSCompactAtFullCollection开关参数,用于在CMS收集器顶不住进行FullGC开启内存碎片的合并整理过程,尽管空间碎片问题没有了,但由于无法并发,停顿时间也会变长。
G1收集器
- 特点
- 并行与并发:使用多个CPU缩短Stop-The-World停顿的时间
- 分代收集:采用不同的方式处理新建的对象和已经存活了一段时间,熬过多次GC的旧对象
- 空间整合:整体而言是基于标记整理算法的,从Region的角度而言是基于复制算法的,垃圾清理后不会产生内存空间碎片,收集后能提供规整的可用内存
- 可预测的停顿:建立可预测的停顿时间模型
- G1收集器对Java堆的内存布局管理与其他收集器不同,G1收集器将Java堆划分为多个大小相等的独立区域,新生代和老年代不再是物理隔离的,它们都是一部分Region(不需要连续)的集合。G1跟踪各个Region里堆积垃圾的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
- G1收集器中,Region之间的对象应用以及其他收集器中的新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,判断Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关的引用信息记录到被引用对象所属的Region的Remembered Set之中。但进行内存回收时,在GC根节点的枚举范围中加入Remember Set即可保证不对全堆扫描也不会有遗漏。
- 忽略维护Remembered Set的操作,G1收集器的运作大致分为如下几个阶段:
- 初始标记:标记GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。在这个阶段需要停顿线程,但耗时很短。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户线程并发执行。
- 最终标记:修正在并发标记期间因用户线程继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set中,这阶段需要停顿线程,但可并行执行。
- 筛选回收:根据每个Region的回收价值和成本进行排序,按照用户期望的GC停顿时间执行回收计划。