第1章 Java内存区域的划分与各个区域可能存在的异常
1.1 Java内存区域的划分与内存溢出
JVM会在执行Java程序的过程中把它管理的内存划分为若干个不同的数据区域、这些区域各司其职,各有各的创建与销毁时间、有的区域随着JVM的启动而存在,有的区域则依赖用户线程的启动和结束而创建与销毁。
Java虚拟机规范将JVM所管理的内存分为以下几个运行时数据区:
程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区
1.1.1 程序计数器
程序计数器是一块比较小的内存空间,可以看做是当前线程所执行字节码的行号指示器,也称作为PC寄存器
如果当前线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是一个native本地方法,这个计数器的值为空。
那么什么叫做线程私有呢?
由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令,因此为了切换程序后能恢复到正确的执行位置,每条线程都需要有独立的程序计数器,各条线程之间计数器互不影响,独立存储。我们把类似的这类区域称之为线程私有的内存。
程序计数器内存区域是唯一一个在JVM规范中没有规定任何内存溢出(00M out of memory)情况的区域
1.1.2 Java虚拟机栈
Java栈又称为虚拟机栈描述的是Java方法执行的内存模型。
Java栈中实际存放的是一个一个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表、操作数栈、指向当前方法所属类的运行时常量池的引用、方法返回地址和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。该区域也是线程私有的,它的生命周期与线程相同。
局部变量表: 用于存储方法中的局部变量。对于基本数据类型的变量,直接存储它的值。对于引用类型的变量,则存的是指向对象的引用。局部变量表的大小在编译时就确定好了,在执行期间,局部变量表的大小是不会改变的。
此区域一共会产生以下两种异常:
1.线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常 例如:递归无出口
2.虚拟机在动态扩展时无法申请到足够的内存空间,将会抛出OOM(out of memory)异常 例如:死循环创建对象
1.1.3 本地方法栈
本地方法栈和虚拟机栈的作用完全一样,区别只是本地方法栈为native方法服务,而虚拟机栈为JVM执行的java方法服务。
在Hotspot虚拟机中,本地方法栈与虚拟机栈是同一块内存区域
1.1.4 Java堆
Java堆是JVM所管理的最大的内存区域,Java堆是所有线程共享的一块区域,在JVM启动时建立,此内存区域存放的是对象实例。即所有的对象实例以及数组都要在堆上分配
Java堆是垃圾回收器管理的主要区域,因此很多时候可以称之为“GC堆”,java堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可。
如果在堆中没有内存可分配时,并且堆也无法扩展时,将会抛出OutOfMemoryError异常。
1.1.5 方法区
方法区和Java堆一样,是多个线程共享的内存区域。它用于存储已被虚拟机加载的类信息,常量(static final),静态变量(static),及时编译器编译后的代码等数据。
在JDK8以前的HotSpot虚拟机中,方法区也被称为永久代(JDK8已经废弃永久代,被元空间取代)。移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制
永久代并不意味着数据进入方法区就永久存在,此区域的内存回收主要是针对常量池的回收以及对类型的卸载。
当方法区无法满足内存分配需求时,将抛出OOM异常
1.1.6 运行时的常量池
运行时的常量池是方法区的一部分,存放字面量与符号引用 例如:String直接赋值法定义的字符串直接入池
字面量:字符串JDK1.7后移动到堆中、final常量、基本数据类型的值。
符号引用:类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
1.1.7 内存区域分布图
第2章 垃圾回收器 如何判断对象已”死’’-引用计数法、可达性分析法详解
2.1 垃圾回收器
对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭,这三个区域的内存分配与回收具有确定性,当方法结束或者线程结束时,内存就自然而然跟着回收了。
所以我们谈的垃圾回收关注的是Java堆和方法区这两个区域
2.1.1如何判断对象已“死”
Java堆中存放着几乎所有的对象实例【如果虚拟机通过分析得出对象不会存在方法逃逸或者线程逃逸、考虑直接在栈帧中为该对象分配空间】,垃圾回收器在对堆进行垃圾回收前,首先判断这些对象哪些还活着,哪些已经死去。
2.1.1.1引用计数法
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已“死”。
引用计数器实现方法简单,效率也比较高,但是主流的JVM并没有选用引用计数法来管理内存,最主要的原因是引用计数器无法解决对象的循环引用问题。
2.1.1.2可达性分析算法
Java中采用可达性分析算法来判断对象是否存活。
此方法的核心思想是:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为”引用链”,当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达),证明此对象是不可用的。
对象Object5-Object7之间虽然彼此还有关联,但是它们到GC Roots是不可达的,因此他们会被判定为可回收对象。
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量(final static)引用的对象
- 本地方法栈中引用的对象
在JDK1.2之前,Java中引用的定义很传统:如果引用数据类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。但是在这种定义下只有被引用或者没有被引用两种状态
我们希望描述对象时:当内存空间还足够时,能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减
强引用:指的是在程序代码之中普遍存在的,类似于”Object obj = new Object()”这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象实例。
软引用:软引用是用来描述一些还有用但是不是必须的对象,对于软引用关联着的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行第二次回收(先标记后回收–二次回收),如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现弱引用
弱引用:弱引用也是用来描述非必须要对象的。但是它的强度弱于软引用,被弱引用关联的对象只能生存到下一次垃圾回收发生之前(一旦GC就回收–一次回收),当垃圾回收器开始进行工作时,无论当前内容是否够用,都会回收掉被弱引用关联的对象
虚引用:也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为一个对象设置虚引用的唯一目的就是能在这个对象被垃圾回收时收到一个系统通知。(监听到垃圾回收机制)
生存还是死亡?
可达性分析算法中的不可达对象,也并不是”非死不可”的,这时候他们暂时处在”缓刑阶段”。要宣告一个对象的真正死亡,至少要经历两次标记过程:如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行**finalize()**方法,当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过了,虚拟机将这两种情况都视为”没有必要执行”,此时的对象才是真正’死’的对象。
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(即就是虚拟机触发finalize()方法),finalize()方法是对象逃脱死亡的最后一次机会,如果对象在finalize()中成功拯救自己 (只需要与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被收回(finalize()方法只能自救一次哦);如果对象在finalize()中没有成功拯救自己,就会被立刻被收回。
范例:对象的自我拯救过程
代码执行结果如下:
- finalize方法执行
- 线程活着
- 线程死亡
若上述代码中没有finalize()方法或者finalize()方法中的test没有与引用链中的任何一个对象建立联系,对象就会在第一个次gc的时候被回收。/** * 示例: * 垃圾回收器在回收的过程中 * 对象的自我拯救过程 * * @author parkour * @date 2022/10/25 */ public class SaveYourselfTest { public static SaveYourselfTest test; public void isAlive() { System.out.println("线程活着"); } @Override public void finalize() throws Throwable { super.finalize(); System.out.println("finalize方法执行"); //与引用链上的任何一个对象建立起关联关系 test = this; } public static void main(String[] args) throws InterruptedException { //1.在堆上创建对象 test = new SaveYourselfTest(); //2.test置空 堆上的对象没有任何栈内存指向 test = null; //3.调用垃圾回收机制 但是由于此对象覆写了finalize方法 可以缓刑 System.gc(); //4.垃圾回收需要时间 Thread.sleep(500); if (test != null) { test.isAlive(); } else { System.out.println("线程死亡"); } //------------------------------------------ //下面的代码与上面完全一致,但是此时自救失败 test = null; System.gc(); Thread.sleep(500); if (test != null) { test.isAlive(); } else { System.out.println("线程死亡"); } } }
任何一个对象的finalize()方法都只会被系统调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的fianlize()方法不会被再次执行。
2.1.2 回收方法区
方法区的垃圾回收主要收集两部分内容:废弃常量和无用的类
回收废弃常量和回收Java堆中的对象十分类似,以常量池的回收为例,假如一个字符串”abc”已经进入了常量池,但是当前系统中没有任何一个String对象引用常量池中的abc常量,也没有在其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个“abc”常量会被系统清理出常量池,常量池中的其他类、接口、方法、字段的符号引用也与此类似。
判定一个类是否是”无用类”则相对复杂的多,需要同时满足以下三个条件:
- 该类的所有实例都已经被回收了(java堆中不存在该类的实例)
- 加载该类的ClassLoader已经被回收
- 该类对应的Class对象没有在任何其他地方被引用,无法在任何地方通过反射访问该类的方法
JVM可以对满足上述3个条件的无用类进行回收,也仅仅是”可以”而不是必然。在大量使用反射、动态代理等场景都需要JVM具备类卸载的功能来放置永久代的溢出。
第3章 垃圾回收算法
3.1 垃圾收集算法
3.1.1 标记-清除算法
“标记-清除”算法是最基础的收集算法。算法分为”标记”和”清除”两个阶段
- 标记出所有需要回收的对象(遍历堆标记)
- 标记完成后统一回收所有被标记的对象(遍历堆删除)
“标记-清除”算法的不足之处:
- 效率问题:标记 和 清除这两个过程效率都不高。
- 空间问题:标记和清除后会产生大量 不连续的 内存碎片,空间碎片太多 可能会导致 以后在程序运行中 需要分配较大对象时,无法找到足够连续内存而不得不触发一次垃圾回收,触发一次之后要是内存还不够,就会连续触发,导致OOM
3.1.2 复制算法
“复制”算法是为了解决”标记-清理”的效率问题。它将可用内存 按容量划分为大小相等的两块,每次只使用其中的一块,当这块内存需要垃圾回收时,会将此区域还活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等复杂情况,只需要按序分配即可。此方法实现简单,运行高效。
新生代中98%的对象都是 ‘‘朝生夕死’’ 的,所以并不需要按照1:1的比例来划分空间,而是将内存(新生代内存)分为一块**比较大的Eden(伊甸园)和两块较小的Survivor(幸存者)**空间,每次使用Eden和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区)。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间
HotSpot默认Eden:Survivor From:Survivor To=8:1:1,所以每次新生代可用内存空间为整个新生代容量的90%,而剩下的10%用来存放回收后存活的对象。
当Survivor空间不够用时,需要依赖其它内存(老年代进行分配担保)
HotSpot实现复制算法的流程:
当Eden区满的时候,会触发第一次Minor gc,把还活着的对象拷贝到Survivor From区;当Eden区再次触发Minor gc的时候,会扫描Eden和From区域,对两个区域进行垃圾回收,经过这次回收后还存活的对象,直接赋值到To区域,并将Eden和From区域清空。
当后续Eden又发生Minor gc的时候,会对Eden和To区域进行垃圾回收,存活的对象复制到From区域,并将Eden和To区域清空。
部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终还是存活,就存入老年代。
3.1.3 标记-整理算法(老年代回收算法)
复制算法 在对象存活率较高时 会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。针对老年代的特点,提出了一种称之为”标记-整理”算法。标记过程仍与”标记-清除”中标记的过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界(除存活对象)以外的内存。
3.1.4 分代收集算法
当前JVM垃圾收集都采用的是”分代收集”算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。
一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批的对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象的存活率高,没有额外空间对他进行分配担保,就必须采用”标记-清理”或者”标记-整理”算法。
3.2 垃圾回收的操作过程分类
Minor GC 清理新生代的垃圾回收操作(包括Eden区和Survivor区)。当JVM无法为一个新的对象分配空间时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,因此Minor GC(采用复制算法)非常频繁,虽然它会触发stop-the-world,但是回收速度也比较快。
Major GC清理老年代,出现Major GC通常会出现至少一次Minor GC即大多数Major GC是由Minor GC触发的
Full GC是针对整个堆空间包括新生代、老年代、元空间GC,Full GC不等于Major GC,也不等于Minor GC+Major GC。
第4章垃圾收集器
4.1 并行和并发
4.1.1 并行-Parallel
并行:多条垃圾收集线程并行工作,用户线程处于等待状态
如:ParNew、Parallel Scavenge、Parallel Old
4.1.2 并发-Concurrent
并发:指用户线程与垃圾线程同时执行(但不一定是并行的,可能会交替执行)
4.2 吞吐量
吞吐量就是CPU 运行用户代码的时间 与 CPU总消耗时间 的比值。
吞吐量 = 运行用户代码的时间 / (运行用户代码的时间+垃圾收集的时间)
假设虚拟机总共运行了100分钟,其中垃圾收集花了一分钟 吞吐量就是99%
在正式了解虚拟机之前,我们先看一下JVM包含的收集器(以HotSpot为例)
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连接,就说明他们之间可以搭配使用,所处的区域,表示它是属于新生代收集器还是老年代收集器。
4.2 新生代收集器
4.2.1 Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器,在JDK1.3.1之前,是虚拟机新生代收集的唯一选择
4.2.1.1 特性
这个收集器是一个单线程的收集器,但是它的”单线程”的意义并不仅仅说明它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是它进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束
4.2.1.2 优势
Serial收集器是虚拟机运行在Client模式下的默认的新生代收集器
Client模式:之前的版本有参数-client,客户端程序可以加-client,但是JDK1.8之后被取消了
java开发桌面程序,虚拟机就分为Client模式和Server模式,Server模式没有必要加载可视化界面,所以就有之前的Client。
简单、高效(与其它收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率
4.2.2 ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余与Serial收集器完全一样。
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器
4.2.2.1 Serial和ParNew的对比分析
与Serial收集器相比,ParNew在单CPU的环境下绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU环境中都不能百分之百的保证可以超过Serial收集器。
然而随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。
4.2.3 Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,使用复制算法的并行收集器
Parallel Scavenge 收集器使用两个参数控制吞吐量
MaxGCPauseMillis:控制最大的垃圾收集停顿时间
GCRatio:直接设置吞吐量的大小
直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。比如原来10秒收集一次,每次停顿100毫秒。但是线程编程每5秒收集一次,每次停顿70毫秒,停顿时间下降的同时,吞吐量也下降了。
4.2.3.1 Parallel Scavenge和ParNew的对比分析
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器与ParNew收集器的一个重要区别是它具有自适应调节策略
4.2.3.2 Parallel Scavenge的自适应调节策略
Parallel Scavenge收集器有一个参数- XX:+UseAdaptiveSizePolicy当这个参数打开之后,就不需要手动指定新生代的大小,Eden和Survivor区的比例,晋升老年代对象等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量,这种调节方式成为GC自适应的调节策略。
4.3 老年代收集器
4.3.1 CMS收集器
4.3.1.1 特性
由于垃圾回收时,都需要暂停用户线程,CMS(Concurrent Mark Sweep)收集器是一种以 获取最短停顿时间 为目标的收集器,重视服务的响应速度,希望系统停顿时间最短,能给用户带来良好的体验。
CMS收集器是基于”标记-清除”算法实现的,它的运作过程比较复杂,整个过程分为四个步骤:
初始标记 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要Stop The World(暂停所有的用户线程)
并发标记 并发标记阶段就是进行GC Roots Tracing的过程 (用户不暂停)—用户不暂停就还可能产生一些对象与GC Roots不可达
重新标记 重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动 的那一部分对象的标记记录,这个阶段的停顿时间会比初始阶段稍长一些,但是远比并发标记的时间短,仍然需要”Stop The World”
并发清除 并发清除阶段会清除对象(用户不暂停)
整个过程中耗时最长的并发表及和并发清除过程收集线程可以与用户线程一起工作,所以整体上来说,CMS收集器的内存回收过程与用户线程一起并发执行。
4.3.1.2 优点
CMS是一款优秀的收集器,主要优点:并发、低停顿
4.3.1.3 缺点
CMS收集器对CPU的资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但是会因为占了一部分CPU资源,而导致应用程序变慢,总吞吐量会降低。
CMS无法处理浮动垃圾,由于CMS 并发清理阶段用户线程还在运行着,用户线程在运行自然就还会有新的垃圾产生,CMS无法在当次收集中处理掉它们,只好留到下一次GC再清理掉,这一部分垃圾叫做”浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS不能等到老年代几乎满了才开始收集,需要预留一部分空间提供给并发时的程序运作使用。要是CMS在运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure”,这时虚拟机将启动后备方案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样下来停顿时间就长了
4.CMS收集器会产生大量的空间碎片,CMS是一款基于”标记-清除” 算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,就会给大对象的分配带来很多麻烦,往往会出现还有很大的空间剩余,但是无法找到足够大连续的空间来分配当前对象,不得不提前触发一次Full GC
4.3.1.4 Parallel Scavenge收集器 VS CMS收集器
CMS关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,由于与吞吐量密切相关,Parallel Scavenge收集器也经常称为”吞吐量优先”收集器
4.3.2 Serial Old收集器
4.3.2.1 特性
Serial Old是Serial收集器的老年代版本,同样是单线程收集器,使用标记-整理算法。
4.3.3 Parallel Old收集器
4.3.3.1 特性
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法
4.3.3.2 应用场景
在注意吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加 Parallel Old收集器
这个收集器是在JDK1.6中才开始提供的,在此之前Parallel Scavenge一直处于尴尬的状态。原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old别无选择,由于老年代Serial Old性能上的拖累,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量的最大化效果,直到Parallel Old收集器出现后,”吞吐量优先”收集器终于有了名副其实的应用组合
JDK1.6之前 Parallel Scavenge + Serial Old
JDK1.6以及之后 Parallel Scavenge + Parallel Old
4.4 全区域的垃圾回收器
4.4.1 G1收集器
G1垃圾回收器是用在heap memory很大的情况下,把heap划分为很多很多的region块,然后并行的对其进行垃圾回收。
G1垃圾回收器在清除实例所占用的内存后,还会做内存压缩。
G1垃圾回收器回收region的时候基本不会Stop The World,从整体来看是基于标记-整理算法,从局部(两个region之间)来看基于复制算法。
一个region有可能属于Eden、Survivor或者Tenured内存,图中的E表示Eden区,S表示Survivor区、T表示Tenured区、空白就是未使用的空间。G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。这种内存区域主要用于存储大对象-即大小超出一个region大小的50%的对象
4.4.1.1 G1针对年轻代的收集过程
在G1垃圾收集器中,年轻代的垃圾回收过程使用复制算法,把Eden区和Survivor区的对象复制到新的Survivor区域
4.4.1.2 G1针对老年代的收集过程
对于老年代的垃圾收集,G1(Garbage First)也分为四个阶段,基本与CMS垃圾收集器一样,但是略有不同
初始标记(Initial Mark) 同CMS垃圾收集器初始标记阶段一样,G1也需要暂停应用程序的执行,它会标记从跟对象出发,在根对象的第一层孩子结点中标记所有可达对象。但是G1的垃圾收集器的初始标记结点是跟Minor gc一起发生的。也就是说,在G1中,不用像CMS那样,单独暂停应用程序的执行来运行初始标记阶段,而是在G1出发Minor gc的时候一并将老年代上的初始标记给做了。
并发标记(Concurrent Mark) 同CMS垃圾收集器并发标记阶段一样,但G1还多做了一件事件,就是如果在并发标记阶段,发现哪些Tenured region中对象的存活率很小或者基本没有对象存活,那么G1就会在这个阶段将其回收掉,而不用等到后面的清除阶段,这也是Garbage First名字的由来,同时在该阶段,G1会计算每个region的存活率,方便后面的清除阶段使用。
最终标记(CMS中的remark阶段) 同CMS垃圾收集器重新标记阶段一样,但是采用的算法不一样,G1采用了一种叫做STAB(snapshot-at-the-begining)的算法能够在Remark阶段更快的标记可达对象。
筛选回收(clean up/Copy) 在G1中,没有CMS对于的Sweep阶段。相反,它有一个Clean up/Copy阶段,在这个阶段中,G1会挑选出那么对象存活率低的region进行回收,这个阶段也是和minor gc一同完成的。
G1是一款面向服务端应用的垃圾收集器,Hotspot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器
你想追求低停顿、想让用户有更好的体验用G1
如果你的应用追求吞吐量,G1并不能带来很明显的好处。
第5章 Java内存模型(Java内存模型的三大特性、happens-before)、volatile型变量的特殊规则(单例模式中Double check的优化)
5.1 Java内存模型
JVM定义了一种Java内存模型来 屏蔽掉各种硬件和操作系统的之间的交互,不像c++中的内存模型,不同的数据类型在不同的平台上长度不一样,Java中不管哪个基本数据类型,在不同的平台上长度都是一模一样的。使得Java程序在各种平台下都能达到一致的内存访问效果。
5.1.1 主内存 和 工作内存
JVM内存模型的主要目标是定义程序中各个变量的访问规则,即JVM将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量包含实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后两者是私有的,不会被线程共享。
JVM内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了 被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如下:
5.1.2 内存间交互操作
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存中拷贝到工作内存、如何从工作内存同步回到内存之类的实现细节。Java内存模型中定义了以下8种操作来完成。JVM实现时必须保证下面提及的每一种操作是原子的、不可再分的
lock(锁定):作用于主内存中的变量,它把一个变量标识为一条线程独占的状态
unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存中的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎
assign(赋值):作用于工作内存中的变量,它把一个执行引擎接收到的值赋给工作内存中的变量
store(存储):作用于工作内存中的变量,它把工作内存中一个变量的值送到主内存中,以便后续的write操作使用
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
5.1.2.1 Java内存模型的三大特性
原子性: 由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store、write。大致可以认为基本数据类型的访问读写是具备原子性的,如若需要更大范围的原子性,需要synchronized关键字约束。(即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行)
可见性:指当一个线程修改了共享变量的值,其他线程能够立即知道这个修改。volatile、synchronized、final三个关键字可以实现原子性
有序性:如果在本线程内观察,所有的操作都是有序的,如果在线程中观察另外一个线程,所有的操作都是无序的,前半句是指”线程内表现为串行”,后半句是指”指令重排序”和”工作内存与主内存同步延迟”现象 (有序性即禁止指令重排序)
Java内存模型具备一些先天的”有序性”,即不需要通过任何的手段就能够得到保证的有序性,这个通常也称为happens-before原则,如果两个操作的执行次序无法从happens-before原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意的对它们进行重排序。
5.1.2.2 happens-before 先行发生原则
程序次序原则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
锁定规则:unlock解锁操作先行发生于后面对同一个锁的lock加锁操作
volatile变量规则:对一个变量的写操作先行发生于后面对这个线程的读操作
线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终结规则:线程中所有的操作都在线程终止前发生
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
也就是说,要想并发程序的正确执行,必须要保证原子性、可见性以及有序性,只要有一个没有被保证,就会导致程序运行不正确。
5.2 volatile型变量的特殊规则
关键字volatile可以说是JVM提供的最轻量级的同步机制,JVM内存模型读volatile专门定义了一些特殊的访问规则。
当一个变量定义为volatile之后,它将具备两种特性:
5.2.1 变量对所有线程可见
保证此变量对所有线程的可见性,这里的”可见性”是指:当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量做不到这一点,普通变量的值在线程间传递均需要荣光主内存来完成。例如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成之后再从主内存进行读取操作,新值才会对线程B可见。
volatile变量在各个线程中是一致的,但是volatile变量的运算在并发下一样是不安全的
// 如下程序运行多次结果不一致
/**
* 测试volatile变量的运算在并发下一样是不安全的 (不保证原子性)
*
* @author parkour
* @date 2022/10/25
*/
public class VolatileAtomicTest{
public static volatile int num = 0;
public static void increase(){
num++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
for(int i = 0; i < 10;i++){
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0 ; i < 100;i++){
increase();
}
}
});
threads[i].start();
}
System.out.println(num);
}
}
问题在于num++之中,实际上num++等同于num = num+1,volatile关键字保证了num的值在取值时是正确的,但是在执行num+1的时候,其他线程可能已经把num的值增大了,这样+1之后会把较小的数值同步回主存之中。
由于volatile关键字只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁(synchronized或者lock)来保证原子性
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
- 变量不需要与其它的状态变量共同参与不变约束
5.2.2 禁止指令重排序
使用volatile变量的语义是禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序和程序代码中执行的顺序一样
volatile关键字禁止指令重排序有两层意思
- 当程序执行volatile变量的读操作或者写操作时,在其前面的操作肯定全部已经进行完毕,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行
- 在进行指令优化时,不能将volatile变量前面的语句放到其后面执行,也不能把volatile变量后面的语句放到其前面执行。
由于flag变量为volatile变量,那么在进行指令重排序的过程中,不会把语句2 放在语句1,语句2 前面,也不会将语句3 放到语句4,语句5后面。// x,y为非valatile变量 //flag为volatile变量 x = 2; 语句1 y = 0; 语句2 flag = true; x = 4; 语句3 y = -1; 语句4
并且volatile关键字能保证,执行到语句3 ,语句1和语句2一定执行完毕,且结果对语句3,语句4,语句5是可见的
volatile禁止指令重排序的应用
Map configOptions;
char[] configText;
volatile boolean initialized = false;
//假设以下代码在线程A执行
//模拟读取配置文件信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
initialized = true;
//假设以下代码在线程B执行
//等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized) {
sleep();
}
//使用线程A初始化好的配置信息
doSomethingWithConfig();
initialized设置为volatile保证在initialized变为true之后,所有的配置文件信息已经读取成功
5.2.3 单例模式中的Double check
双重检验锁模式,是一种使用同步块加锁的方法,会有两次检查instance == null,一次是在同步块外,一次是在同步块内。为什么同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的if,如果同步块内不进行二次检验的话就会产生多个实例
public static Singleton getSingleton(){
if(instance==null){ //Single Checked
synchronized (Singleton.class){
if(instance==null){ //Double Checked
instance=new Singleton();
}
}
}
return instance;
}
这段代码实际上也是有很大的问题,主要在于instance=new Singleton()这句,这并非是一个原子操作,在JVM中这句话做了三件事
- 给instance分配内存
- 调用Singleton的构造函数来初始化成员变量
- 将instance对象指向分配的内存空间
执行完这三步instance就为非null的了。但是JVM存在指令重排序的优化,也就是说上面的步骤是不能保证的,最终执行的顺序可能是1-2-3也可能是1-3-2.如果是后者,在3执行完毕2执行之前被线程二抢占了,这时instance已经是非null的了(但却没有初始化),线程2会直接返回instance,后面自然而言就会出错。
为避免上述错误,我们只需要将instance变量声明成volatile就可以了。
class Singleton{
//禁止指令重排序
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
第6章Java虚拟机可以配置的JVM&GC参数
堆设置
- -Xms:初始堆大小
- -Xmx:最大堆大小
- -XX:NewSize=n:设置年轻代大小
- -XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4
- -XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5
- -XX:MaxPermSize=n:设置持久代大小
收集器设置
- -XX:+UseSerialGC:设置串行收集器
- -XX:+UseParallelGC:设置并行收集器
- -XX:+UseParalledlOldGC:设置并行年老代收集器
- -XX:+UseConcMarkSweepGC:设置并发收集器
垃圾回收统计信息
- -XX:+PrintGC
- -XX:+PrintGCDetails
- -XX:+PrintGCTimeStamps
- -Xloggc:filename
并行收集器设置
- -XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。
- -XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
- -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n)
并发收集器设置
- -XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。
- -XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。