1> JVM内存模型
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK. 1.8 和之前的版本略有不同,下面会介绍到。
JDK1.8之前:
JDK1.8:
2.1程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
从上面的介绍中我们知道程序计数器主要有两个作用:
•字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。•在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
2.2Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存(Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。
Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
•StackOverFlowError: 若Java虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前Java虚拟机栈的最大深度的时候,就抛出StackOverFlowError异常。•OutOfMemoryError: 若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出OutOfMemoryError异常。
Java 虚拟机栈也是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
扩展:那么方法/函数如何调用?
Java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入Java栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java方法有两种返回方式:
•return 语句。•抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
2.3本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
2.4Java堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
上图所示的 eden区、s0区、s1区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden区->Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
2.5方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
JDK 1.8 的时候,方法区被彻底移除了(JDK1.7就已经开始了),取而代之是元空间,元空间使用的是直接内存。
我们可以使用参数: -XX:MetaspaceSize
来指定元数据区的大小。与永久区很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。
2.6运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)。(比如spring 使用IOC或者AOP创建bean时,或者使用cglib,反射的形式动态生成class信息等)
既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。
2.7直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
2>对象创建的过程
下图便是 Java 对象的创建过程,我建议最好是能默写出来,并且要掌握每一步在做什么。
①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
②分配内存: 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式:(补充内容,需要掌握)
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
•CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。•TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配
③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
④设置对象头: 初始化零值完成i之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
3>对象访问定位的两种方式
①对象内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:对象头、实例数据和对齐填充。
Hotspot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希码、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
②对象访问定位
建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
•句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
4>判断对象存活的方法
①引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。
引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法。但是Java语言中没有选用引用计数算法来管理内存,其中最主要的一个原因是它很难解决对象之间相互循环引用的问题。
②可达性分析
在主流的商用程序语言中(Java和C#),都是使用可达性分析算法判断对象是否存活的。这个算法的基本思路就是通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,下图对象object5, object6, object7虽然有互相判断,但它们到GC Roots是不可达的,所以它们将会判定为是可回收对象。
在Java语言里,可作为GC Roots对象的包括如下几种:
a.虚拟机栈(栈桢中的本地变量表)中的引用的对象
b.方法区中的类静态属性引用的对象
c.方法区中的常量引用的对象
d.本地方法栈中JNI的引用的对象
③finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1).第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
2).第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象finalize()方法中执行缓慢,或者发生死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。
Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。
流程图如下:
补充:四种引用类型
a.强引用
Java中默认声明的就是强引用,类似“Object obj = new Object()”这类的引用,只要强引用存在,垃圾回收器将永远不会回收被引用的对象。
b.软引用
软引用是用来描述一些非必需但仍有用的对象。在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。在 JDK1.2 之后,用java.lang.ref.SoftReference类来表示软引用。
c.弱引用
弱引用的引用强度比软引用要更弱一些,无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。在 JDK1.2 之后,用 java.lang.ref.WeakReference 来表示弱引用。
d.虚引用
虚引用是最弱的一种引用关系,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,它随时可能会被回收,在 JDK1.2 之后,用 PhantomReference 类来表示,唯一目的能在这个对象被收集器回收的时候收到一个系统通知。
5>垃圾收集算法
①标记清除算法
标记清除算法分为“标记”和“清除”两个阶段,首先先标记出那些对象需要被回收,在标记完成后会对这些被标记了的对象进行回收;如下图:
这种算法的优点在于不需要对对象进行移动操作,仅对不存活的对象进行操作,所以在对象存活率较高的情况下效率非常高,但是从上图模拟的结果来看对象被回收后,可用的内存并不是连续的,而是断断续续,造成大量的内存碎片。 存储对象时要求内存空间时连续的,所以虚拟机在给新的内存较大的对象分配空间时,有可能找不到足够大的连续的空闲的空间来存放,从而引发一次垃圾回收动作,实际上里面是有大量的空闲空间的,只是不连续而已。
②复制算法
复制算法是将内存分为两块大小一样的区域,每次是使用其中的一块。当这块内存块用完了,就将这块内存中还存活的对象复制到另一块内存中,然后清空这块内存。这种算法在对象存活率较低的场景下效率很高,比如说新生代,只对整块内存区域的一半进行垃圾回收,在垃圾回收的过程也不会出现内存碎片的情况,不需要移动对象,只需要移动指针即可,实现简单,所以运行效率很高。运行效率是在建立在浪费空间的基础上的,这是典型的已空间换时间的方法,因为每次只能是使用北村的一半。算法示意图如下:
现在商用的jvm中都采用了这种算法来回收新生代,因为新生代的对象基本上都是朝生夕死的,存活下来的对象约占10%左右,所以需要复制的对象比较少,采用这种算法效率比较高。hotspot版本的虚拟机将堆(heap)内存分为了新生代和老年代,其中新生代又分为内存较大的Eden区和两个较小的survivor区。当进行内存回收时,将eden区和survivor区的还存活的对象一次性地复制到另一个survivor空间上,最后将eden区和刚才使用过的survivor空间清理掉。hotspot虚拟机默认eden和survivor空间的大小比例为8:1,也就是每次新生代中可用内存空间为整个新生代空间的90%(80%+10%),只会浪费掉10%的空间。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当survivor空间不够用时,需要依赖于其他内存(这里指的是老年代)进行分配的担保。
③标记整理算法
复制算法在对象存活率较高的情况下就要进行较多的对象复制操作,效率将会变低。更关键的是,如果你不需要浪费50%的空间,就需要有额外的空间进行分配担保,用以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种办法。
根据老年代的特点,有人提出了标记-整理的算法,标记过程仍然与标记-清楚算法一样,但后续步骤不是直接将可回收对象清理掉,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,算法示意图如下:
④分代收集算法
分代收集算法将heap区域划分为新生代和老年代,新生代的空间比老年代的空间要小。新生代又分为了Eden和两个survivor空间,它们的比例为8:1:1。对象被创建时,内存的分配是在新生代的Eden区发生的,大对象直接在老年代分配内存,IBM的研究表明,Eden区98%的对象都是很快消亡的。
为了提高gc效率,分代收集算法中新生代和老年代的gc是分开的,新生代发生的gc动作叫做minor gc 或 young gc,老年代发生的叫做major gc 或 full gc。
minor gc 的触发条件:当创建新对象时Eden区剩余空间小于对象的内存大小时发生minor gc;
major gc 触发条件:
1、显式调用System.gc()方法;
2、老年代空间不足;
3、方法区空间不足;
4、从新生代进入老年代的空间大于老年代空闲空间;
Eden区对象的特点是生命周期短,存活率低,因此Eden区使用了复制算法来回收对象,上面也提到复制算法的特点是在存活率较低的情况下效率会高很多,因为需要复制的对象少。与一般的复制算法不同的是,一般的复制算法每次只能使用一半的空间,另一半则浪费掉了,Eden区的回收算法也叫做"停止-复制"算法,当Eden区空间已满时,触发Minor GC,清理掉无用的对象,然后将存活的对象复制到survivor1区(此时survivor0有存活对象,survivor1为空的),清理完成后survivor0为空白空间,survivor1有存活对象,然后将survivor0和survivor1空间的角色对象,下次触发Minor gc时重复上述过程。如果survivor1区剩余空间小于复制对象所需空间时,将对象分配到老年代中。每发生一次Minor gc时,存活下来的对象的年龄则会加1,达到一定的年龄后(默认为15)该对象就会进入到老年代中。
老年代的对象基本是经过多次Minor gc后存活下来的,因此他们都是比较稳定的,存活率高,如果还是用复制算法显然是行不通的。所以老年代使用“标记-整理”算法来回收对象的,从而提高老年代回收效率。
总的来说,分代收集算法并不是一种具体的算法,而是根据每个年龄代的特点,多种算法结合使用来提高垃圾回收效率。
6>垃圾收集器分类
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。
①Serial收集器
Serial收集器是最基本、发展历史最悠久的收集器。是单线程的收集器。它在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。
Serial收集器依然是虚拟机运行在Client模式下默认新生代收集器,对于运行在Client模式下的虚拟机来说是一个很好的选择。
②ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial 收集器完全一样。
ParNew收集器是许多运行在Server模式下的虚拟机中首选新生代收集器,其中有一个与性能无关但很重要的原因是,除Serial收集器之外,目前只有ParNew它能与CMS收集器配合工作。
③Parallel Scavenge(并行回收)收集器
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器
该收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可用高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量,分别是控制最大垃圾收起停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。Parallel Scavenge收集器还有一个参数:-XX:+UseAdaptiveSizePolicy。这是一个开关参数,当这个参数打开后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGVPauseMillis参数或GCTimeRation参数给虚拟机设立一个优化目标。
自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别
④Serial Old 收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。
如果在Server模式下,主要两大用途:
(1)在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
(2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
Serial Old收集器的工作工程
⑤Parallel Old 收集器
Parallel Old 是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在1.6中才开始提供。
⑥CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。
CMS收集器是基于“标记-清除”算法实现的。它的运作过程相对前面几种收集器来说更复杂一些,整个过程分为4个步骤:
(1)初始标记
(2)并发标记
(3)重新标记
(4)并发清除
其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”.
CMS收集器主要优点:并发收集,低停顿。
CMS三个明显的缺点:
(1)CMS收集器对CPU资源非常敏感。CPU个数少于4个时,CMS对于用户程序的影响就可能变得很大,为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种。所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想
(2)CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中蓝年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK1.6中,CMS收集器的启动阀值已经提升至92%。
(3)CMS是基于“标记-清除”算法实现的收集器,手机结束时会有大量空间碎片产生。空间碎片过多,可能会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前出发FullGC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间变长了。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,标识每次进入Full GC时都进行碎片整理)
⑦ G1收集器
G1收集器的优势:
(1)并行与并发
(2)分代收集
(3)空间整理 (标记整理算法,复制算法)
(4)可预测的停顿(G1处处理追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征)
使用G1收集器时,Java堆的内存布局是整个规划为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的又来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽量可能高的灰机效率
G1 内存“化整为零”的思路
在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为一下步骤:
(1)初始标记
(2)并发标记
(3)最终标记
(4)筛选回收
补充一:收集器选择
注重吞吐量以及CPU资源敏感的场合,优先考虑Parallel Scavenge加Parallel Old收集器。
互联网站或者B/S系统的服务端上,尤其注重服务响应速度,希望系统停顿时间短,优先考虑ParNew加CMS收集器。
补充二:常用参数
-Xmx4000m -Xms4000m -Xss256k -Xmn1g -XX:NewRatio=4 -XX:SurvivorRatio=8 -XX:+UseConcMarkSweepGC -XX:ParallelGCThreads=2 -XX:CMSFullGCsBeforeCompaction=3 -XX:+UseCMSCompactAtFullCollection
-Xmx -Xms -Xss -Xmn:最大堆内存,最小堆内存,栈内存,新生代内存
-XX:NewRatio=4:新生代和老年代比例1:4 ,注意同时设置了大小和比例,Xmn优先级比较高
-XX:SurvivorRatio=8: survivor和eden比例2:8
-XX:+UseConcMarkSweepGC:老年代使用cms收集器,未设置默认新生代使用parNew收集器
-XX:ParallelGCThreads=2:垃圾回收并发线程数2
-XX:CMSFullGCsBeforeCompaction=3:执行了3次不压缩的Full GC后,来一次带压缩的,默认0,每次都碎片整理
-XX:+UseCMSCompactAtFullCollection:顶不住要进行FullGC时开启内存碎片的合并整理过程
-XX:CMSInitiatingOccupancyFraction=80 超过百分之80做GC
-XX:+CMSClassUnloadingEnabled 是否启用类卸载功能(清理持久代)
-XX:MaxTenuringThreshold=7 设置进入老年代的年龄阈值
补充三:docker容器
博主实际应用中发现了一个有趣的问题:首先Xms默认为物理内存的64分之一,Xmx默认为物理内存的4分之一;如果我们在docker容器中启动Java服务,使用docker run -m 2G参数设置了容器的内存上限,但是如果你用的JVM版本低于1.8u131,它是无法感知容器的资源上限,物理内存加入有64G,那么Xms为1G,Xmx为4G,当jvm使用内存超过2G上限时,容器会被OOMKilled。
如果你习惯较好,设置了JVM参数Xms和Xmx,也请一定注意,docker容器-m设置的内存上限,应该大于最大堆内存200m,因为栈内存,metaspace(方法区)都不是包含在堆里面的。
7>内存分配和回收策略
①对象优先分配在Eden 代
对象在新生代Eden区分配,当Eden 区没有足够的空间进行分配时,虚拟机将会发起一次Minor GC
②大对象直接进入老年代
大对象:需要连续内存空间的Java对象。大对象直接在老年代分配是为了避免在Eden 区 以及两个 Survivor 区之间发生大量的内存复制(新生代回收算法采用 复制算法)
③长期存活的对象将进入老年代
判定长期存活的对象:虚拟机给每个对象定义一个对象年龄计数器,如果对象在Eden代出生并经过第一次Minor GC 后依然存活,并且能够被survivor容纳的话,将被移动到 survivor空间中,并且对象年龄设为1,对象在survivor区中每熬过一次Minor GC,年龄就增加了1岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中
④动态对象年龄判定
虚拟机并不是永远的要求对象的年龄必须达到年龄才能晋升老年代,如果在survivor区相同年龄所有对象大小的总和大于survivor空间一半,年龄大于等于该年龄的对象直接进入老年代
⑤空间分配担保
在Minor GC前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,确保Minor GC 是安全的。否则虚拟机会查看是否允许担保失败;如果允许,继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行Minor GC;如果小于,或者不允许冒险,要进行 一次Full GC
补充问题1:Minor GC 和 Full GC 的区别
Minor GC:又称新生代GC,发生在新生代的垃圾收集动作,Java对象大多数都是昭生夕灭,Minor GC 非常频繁,回收速度也快
Major GC/Full GC:又称老年代GC,发生在老年代的GC,出现了Major GC,经常伴随至少一次Minor GC,Major GC的速度一般比Minor GC慢10倍以上
补充问题2:空间分配担保中的冒险是指什么
新生代使用复制收集算法,但是为了内存利用率,只使用一个survivor空间来轮换备份,因此当出现大量对象在Minor GC 后依然存活的情况们就需要老年代进行分配担保,把survivor无法容纳的对象直接进入老年代。进行担保,前提是老年代知道本身的剩余空间,有多少对象会活下来是不知道的,取之前每次回收晋升到老年代对象的平均大小作为经验值,与剩余空间作比较,看是都进行Full GC 来腾出更多空间
8>内存溢出和内存泄漏?如何解决?
Java堆内存溢出时,异常栈信息“OutOfMemoryError”,重点是确认内存中的对象是否是必要的,分清楚是内存溢出还是内存泄漏。
内存泄漏,可以通过工具查看泄漏对象到GC Roots的引用链。于是就能找到泄露对象是通过怎样的路径与GC Roots相关联并导致垃圾收集器无法自动回收他们的。
内存溢出就应当检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存中某些对象生命周期过长,持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
9>自旋锁
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。
本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。
基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。
自适应自旋锁
所谓自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数
10>GC日志详解
2019-10-16T16:15:58.141+0800: 512068.623: [GC (Allocation Failure) 2019-10-16T16:15:58.141+0800: 512068.623: [ParNew: 1164245K->6467K(1258304K), 0.0051555 secs] 3360634K->2242221K(4054528K), 0.0052838 secs] [Times: user=0.11 sys=0.01, real=0.00 secs]
2019-10-16T16:16:05.477+0800: 512075.960: [GC (Allocation Failure) 2019-10-16T16:16:05.477+0800: 512075.960: [ParNew: 1124995K->4272K(1258304K), 0.0042682 secs] 3360749K->2246244K(4054528K), 0.0044153 secs] [Times: user=0.06 sys=0.03, real=0.01 secs]
2019-10-16T16:16:05.482+0800: 512075.965: [GC (CMS Initial Mark) [1 CMS-initial-mark: 2241971K(2796224K)] 2268563K(4054528K), 0.0010449 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2019-10-16T16:16:05.483+0800: 512075.966: [CMS-concurrent-mark-start]
2019-10-16T16:16:05.618+0800: 512076.100: [CMS-concurrent-mark: 0.134/0.134 secs] [Times: user=1.22 sys=0.01, real=0.14 secs]
2019-10-16T16:16:05.618+0800: 512076.100: [CMS-concurrent-preclean-start]
2019-10-16T16:16:05.628+0800: 512076.110: [CMS-concurrent-preclean: 0.010/0.010 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
2019-10-16T16:16:05.628+0800: 512076.110: [CMS-concurrent-abortable-preclean-start]
2019-10-16T16:16:08.541+0800: 512079.023: [CMS-concurrent-abortable-preclean: 0.307/2.913 secs] [Times: user=1.42 sys=0.05, real=2.91 secs]
2019-10-16T16:16:08.541+0800: 512079.024: [GC (CMS Final Remark) [YG occupancy: 581082 K (1258304 K)]
2019-10-16T16:16:08.541+0800: 512079.024: [Rescan (non-parallel)
2019-10-16T16:16:08.541+0800: 512079.024: [grey object rescan, 0.0086268 secs]
2019-10-16T16:16:08.550+0800: 512079.033: [root rescan, 0.3317517 secs]
2019-10-16T16:16:08.882+0800: 512079.364: [visit unhandled CLDs, 0.0000158 secs]
2019-10-16T16:16:08.882+0800: 512079.364: [dirty klass scan, 0.0002344 secs], 0.3407053 secs]
2019-10-16T16:16:08.882+0800: 512079.365: [weak refs processing, 0.0004095 secs]
2019-10-16T16:16:08.883+0800: 512079.365: [class unloading, 0.0478774 secs]
2019-10-16T16:16:08.930+0800: 512079.413: [scrub symbol table, 0.0096733 secs]
2019-10-16T16:16:08.940+0800: 512079.423: [scrub string table, 0.0011914 secs][1 CMS-remark: 2241971K(2796224K)] 2823054K(4054528K), 0.4148583 secs] [Times: user=0.41 sys=0.01, real=0.41 secs]
2019-10-16T16:16:08.957+0800: 512079.439: [CMS-concurrent-sweep-start]
2019-10-16T16:16:09.083+0800: 512079.565: [CMS-concurrent-sweep: 0.126/0.126 secs] [Times: user=0.15 sys=0.00, real=0.13 secs]
2019-10-16T16:16:09.083+0800: 512079.565: [CMS-concurrent-reset-start]
2019-10-16T16:16:09.088+0800: 512079.571: [CMS-concurrent-reset: 0.005/0.005 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
2019-10-16T16:16:12.524+0800: 512083.007: [GC (Allocation Failure) 2019-10-16T16:16:12.524+0800: 512083.007: [ParNew: 1122800K->4231K(1258304K), 0.0030867 secs] 1600337K->481787K(4054528K), 0.0032259 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
2019-10-16T16:16:19.854+0800: 512090.336: [GC (Allocation Failure) 2019-10-16T16:16:19.854+0800: 512090.336: [ParNew: 1122759K->4229K(1258304K), 0.0030972 secs] 1600315K->481787K(4054528K), 0.0031861 secs] [Times: user=0.07 sys=0.00, real=0.00 secs]
①ParNew收集器
以第一条日志为例解读一下日志:
2019-10-16T16:15:58.141+0800: 512068.623: [GC (Allocation Failure) 2019-10-16T16:15:58.141+0800: 512068.623: [ParNew: 1164245K->6467K(1258304K), 0.0051555 secs] 3360634K->2242221K(4054528K), 0.0052838 secs] [Times: user=0.11 sys=0.01, real=0.00 secs]
GC:表明进行了一次垃圾回收,前面没有Full修饰,表明这是一次Minor GC ,注意它不表示只GC新生代,并且现有的不管是新生代还是老年代都会STW。
Allocation Failure:表明本次引起GC的原因是因为在年轻代中没有足够的空间能够存储新的数据了。
ParNew:表明本次GC发生在年轻代并且使用的是ParNew垃圾收集器。ParNew是一个Serial收集器的多线程版本,会使用多个CPU和线程完成垃圾收集工作(默认使用的线程数和CPU数相同,可以使用-XX:ParallelGCThreads参数限制)。该收集器采用复制算法回收内存,期间会停止其他工作线程,即Stop The World。
1164245K->6467K(1258304K):单位是KB,三个参数分别为:GC前该内存区域(这里是年轻代)使用容量,GC后该内存区域使用容量,该内存区域总容量。
0.0051555 secs: 该内存区域GC耗时,单位是秒
3360634K->2242221K(4054528K):三个参数分别为:堆区垃圾回收前的大小,堆区垃圾回收后的大小,堆区总大小。0.0052838 secs:该内存区域GC耗时,单位是秒
[Times: user=0.11 sys=0.01, real=0.00 secs]: 分别表示用户态耗时,内核态耗时和总耗时,user+sys>=real 因为开启多线后(user+sys)/n=real,n是并发线程数
②CMS收集器
同样以上图中日志6-32行为例解读一下日志
2019-10-16T16:16:05.482+0800: 512075.965: [GC (CMS Initial Mark) [1 CMS-initial-mark: 2241971K(2796224K)] 2268563K(4054528K), 0.0010449 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
这阶段是CMS初始化标记的阶段,从垃圾回收的“根对象”开始,且只扫描直接与“根对象”直接关联的对象,并做标记,在此期间,其他线程都会停止。这时候的老年代容量为 2796224K, 在使用到 2241971K 时开始初始化标记。耗时短。
2019-10-16T16:16:05.483+0800: 512075.966: [CMS-concurrent-mark-start]
并发标记阶段,与用户线程并发执行,过程耗时很长。目的:从GC Root 开始对堆中对象进行可达性分析,找出存活的对象。
2019-10-16T16:16:05.618+0800: 512076.100: [CMS-concurrent-mark: 0.134/0.134 secs] [Times: user=1.22 sys=0.01, real=0.14 secs]
并发标记阶段花费了0.134s cpu 时间 和0.134s 系统时间(包括其他线程占用cpu导致标记线程挂起的时间)
2019-10-16T16:16:05.618+0800: 512076.100: [CMS-concurrent-preclean-start]
并发预清理阶段,也是与用户线程并发执行。虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代, 或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段”重新标记”的工作,因为下一个阶段会Stop The World。
2019-10-16T16:16:05.628+0800: 512076.110: [CMS-concurrent-preclean: 0.010/0.010 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
该阶段花费了花费了 0.010s cpu 时间 和 0.010s 系统时间
2019-10-16T16:16:05.628+0800: 512076.110: [CMS-concurrent-abortable-preclean-start]
2019-10-16T16:16:08.541+0800: 512079.023: [CMS-concurrent-abortable-preclean: 0.307/2.913 secs] [Times: user=1.42 sys=0.05, real=2.91 secs]
并发可中止预清理阶段,运行在并行预清理和重新标记之间,直到获得所期望的eden空间占用率。增加这个阶段是为了避免在重新标记阶段后紧跟着发生一次垃圾清除。为了尽可能区分开垃圾清除和重新标记 ,我们尽量安排在两次垃圾清除之间运行重新标记阶段。
2019-10-16T16:16:08.541+0800: 512079.024: [GC (CMS Final Remark) [YG occupancy: 581082 K (1258304 K)]
2019-10-16T16:16:08.541+0800: 512079.024: [Rescan (non-parallel)
2019-10-16T16:16:08.541+0800: 512079.024: [grey object rescan, 0.0086268 secs]
2019-10-16T16:16:08.550+0800: 512079.033: [root rescan, 0.3317517 secs]
2019-10-16T16:16:08.882+0800: 512079.364: [visit unhandled CLDs, 0.0000158 secs]
2019-10-16T16:16:08.882+0800: 512079.364: [dirty klass scan, 0.0002344 secs], 0.3407053 secs]
2019-10-16T16:16:08.882+0800: 512079.365: [weak refs processing, 0.0004095 secs]
2019-10-16T16:16:08.883+0800: 512079.365: [class unloading, 0.0478774 secs]
2019-10-16T16:16:08.930+0800: 512079.413: [scrub symbol table, 0.0096733 secs]
2019-10-16T16:16:08.940+0800: 512079.423: [scrub string table, 0.0011914 secs][1 CMS-remark: 2241971K(2796224K)] 2823054K(4054528K), 0.4148583 secs] [Times: user=0.41 sys=0.01, real=0.41 secs]
重新标记阶段,会暂停所有用户线程。该阶段的任务是完成标记整个年老代的所有的存活对象。
详解:
(1)2019-10-16T16:16:08.541+0800: 512079.024: – 时间
(2) CMS Final Remark – 收集阶段,这个阶段会标记老年代全部的存活对象,包括那些在并发标记阶段更改的或者新创建的引用对象
(3) YG occupancy: 581082 K (1258304 K) – 年轻代当前占用情况和容量
(4) [Rescan (non-parallel) [grey object rescan, 0.0086268 secs] [root rescan, 0.3317517 secs] – 重新标记所花的时间
(5) weak refs processing, 0.0004095 secs –处理弱引用所花的时间
(6) class unloading, 0.0478774 secs] – 卸载无用的class所花的时间
(7) scrub string table, 0.0096733 secs – 暂时不清楚什么意思,网上找到一段英文解释(that is cleaning up symbol and string tables which hold class-level metadata and internalized string respectively)
(8) 2241971K(2796224K)] – 在这个阶段之后老年代占有的内存大小和老年代的容量
(9) 2823054K(4054528K) – 在这个阶段之后整个堆的内存大小和整个堆的容量
(10) 0.4148583 secs – 这个阶段的持续时间
2019-10-16T16:16:08.957+0800: 512079.439: [CMS-concurrent-sweep-start]
并发清理阶段开始,与用户线程并发执行。
2019-10-16T16:16:09.083+0800: 512079.565: [CMS-concurrent-sweep: 0.126/0.126 secs] [Times: user=0.15 sys=0.00, real=0.13 secs]
并发清理阶段结束,所用的时间。
2019-10-16T16:16:09.083+0800: 512079.565: [CMS-concurrent-reset-start]
2019-10-16T16:16:09.088+0800: 512079.571: [CMS-concurrent-reset: 0.005/0.005 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
并发重置阶段开始。在这个阶段,与CMS相关数据结构被重新初始化,这样下一个周期可以正常进行。以上过程是一个正常的CMS GC循环周期
PS:以上标红为CMS回收工作的七个阶段,是上文提的四个阶段更为详细的划分。
参考文档:
1)JVM内存模型
2)Java垃圾回收算法
3)垃圾收集器