JVM知识图谱 GC知识图谱 美团面试题JVM知识图谱
1 怎么解决OOM?/ 怎么排查OOM?/ JVM调优
参考:https://blog.csdn.net/BigData_Mining/article/details/80874549
1.1 JDK自带工具
- jps:打印Java进程
- jstack:打印堆栈日志,jstack pid, jstack -l pid
- jinfo:JVM参数, jinfo pid
- jstat -gc pid:gc统计信息
- jmap -histo pid | head -20,堆转储文件
- arthas只有jmap这个命令没有封装实现,jmap执行会对进程产生很大影响,甚至卡顿
- headdump,堆转储文件,导入到visualVM或者MAT、jprofiler收费
1.2 阿里开源JVM调优工具arthas
- help
- dashboard
- jvm,比jinfo更详细
- thread,比jstack好用
- thread -b,直接定位产生死锁的线程
- sc,search class,打印出加载的所有类名称
- sm,search method
- trace,跟踪方法执行时间
- monitor,跟踪方法入参和返回等信息
- jad:反编译
- redefine:内存中改代码,应急操作
1.3 经验排查
-
修改JVM启动参数,直接增加内存。(-Xms,-Xmx参数一定不要忘记加)
-
检查错误日志,查看“OutOfMemory”错误前是否有其 它异常或错误。
-
对代码进行走查和分析,找出可能发生内存溢出的位置。
- 检查对数据库查询中,是否有一次获得全部数据的查询。一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。因此对于数据库查询尽量采用分页的方式查询。
- 检查是否有大循环重复产生新对象实体。
- 检查List、MAP等集合对象是否有使用完后,未清除的问题。List、MAP等集合对象会始终存有对对象的引用,使得这些对象不能被GC回收。
- 第三方jar包要慎重引入,坚决去掉没有用的jar包,提高编译的速度和系统的占用内存。
- 对于大的对象或者大量的内存申请,要进行优化,大的对象要分片处理,提高处理性能,减少对象生命周期。
- 尽量固定线程的数量,保证线程占用内存可控,同时需要大量线程时,要优化好操作系统的最大可打开的连接数。
- 检查代码中是否有死循环或递归调用。对于递归调用,也要控制好递归的层级,不要太高,超过栈的深度。
- 分配给栈的内存并不是越大越好,因为栈内存越大,线程多,留给堆的空间就不多了,容易抛出OOM。JVM的默认参数一般情况没有问题(包括递归)。
-
使用内存查看工具动态查看内存使用情况。
- 利用jmap和MAT等工具查看JVM运行时堆内存;以及Linux下/proc/meminfo、free -m、top等查看内存命令。
2 JVM参数详解
2.1 JVM参数分类
- 标准参数-:所有JVM必须实现,并且向后兼容
- -verbose:class
- -verbose:gc
- -verbose:jni
- 非标准参数-X:不保证所有JVM必须实现,并且不保证向后兼容
- Xms20m,-Xmx20m,-Xmn20m,-Xss128k
- 非Stable参数-XX:JVM实现有差异,将来可能随时取消
- -XX:+PrintGCDetails,-XX:-UseParallelGC,-XX:+PrintGCTimeStamps
2.2 关键JVM参数
- -Xoss:本地方法栈(HotSpot中不会使用)
- -Xss:虚拟机栈的大小,JDK1.4默认256K,JDK1.5+默认1M
- -Xmx:Java堆最大内存,默认机器内存的1/4
- 操作系统位数限制:32 bit还是64 bit
- 32 bit Windows一般1.5~2G, Linux一般2~3G
- 64 bit不限制
- 系统可用物理内存限制
- 系统可用虚拟内存限制
- 操作系统位数限制:32 bit还是64 bit
- -Xms:初始内存大小,默认机器内存的1/64,一般与-Xmx相同避免GC完毕后JVM重新分配
- -Xmn:新生代内存
- -XX:MaxPermSize:永久代/方法区最大内存
- -XX:PermSize:永久代/方法区初始内存
2.3 常见JVM配置
堆设置
- -Xms 初始堆大小
- -Xmx 最大堆大小,默认机器内存的1/4
- -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数。并行收集线程数。
3 内存溢出有多少种?
调整JVM参数测试OOM3.1 Java堆内存溢出Java heap space
什么场景发生堆内存溢出?
- 设置的jvm内存太小,对象所需内存太大,创建对象时分配空间,超过-Xmx,就会抛出这个异常。
- 流量/数据峰值,应用程序自身的处理存在一定的限额,比如一定数量的用户或一定数量的数据。而当用户数量或数据量突然激增并超过预期的阈值时,那么就会峰值停止前正常运行的操作将停止并触发java . lang.OutOfMemoryError:Java堆空间错误
代码示例
package cn.homecredit.jvm;
import java.util.ArrayList;
import java.util.List;
/**
*VM Args:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError
*
*/
public class TestOOMHeap {
static class OOMObject{
}
public static void main(String[]args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while(true) {
list.add(new OOMObject());
}
}
}
C:\ProgramFiles\Java\jdk1.8.0_144\bin\java.exe "
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at cn.homecredit.jvm.TestOOMHeap.main(TestOOMHeap.java:17)
解决方案
- 如果代码没有什么问题的情况下,可以适当调整-Xms和-Xmx两个jvm参数,使用压力测试来调整这两个参数达到最优值。
- 通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。
- 一般的手段是先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)。
- 尽量避免大的对象的申请,像文件上传,大批量从数据库中获取,这是需要避免的,尽量分块或者分批处理,有助于系统的正常稳定的执行。
- 尽量提高一次请求的执行速度,垃圾回收越早越好,否则,大量的并发来了的时候,再来新的请求就无法分配内存了,就容易造成系统的雪崩。
3.2 栈内存溢出
发生场景
- 递归
代码示例
package cn.homecredit.jvm;
/**
*VM Args:-Xss128k
*
*/
public class TestOOMStack {
private int stackLength=1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[]args) throws Throwable {
TestOOMStack oom=new TestOOMStack();
try{
oom.stackLeak();
}catch(Throwable e) {
System.out.println("stack length:"+oom.stackLength);
throw e;
}
}
}
C:\ProgramFiles\Java\jdk1.8.0_144\bin\java.exe "
stack length:19417
Exception in thread "main" java.lang.StackOverflowError
at cn.homecredit.jvm.TestOOMStack.stackLeak(TestOOMStack.java:11)
at cn.homecredit.jvm.TestOOMStack.stackLeak(TestOOMStack.java:11)
at cn.homecredit.jvm.TestOOMStack.stackLeak(TestOOMStack.java:11)
at cn.homecredit.jvm.TestOOMStack.stackLeak(TestOOMStack.java:11)
Process finished with exit code 1
解决方案
- 由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。
- 关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
- 如果程序中确实有递归调用,出现栈溢出时,可以调高-Xss大小,就可以解决栈内存溢出的问题了。递归调用防止形成死循环,否则就会出现栈内存溢出。但是栈内存也不能调太大会压缩堆空间内存,进而引发OOM
3.3 方法区内存溢出PermGen space
出现场景
String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
代码示例
package cn.homecredit.jvm;
import java.util.ArrayList;
import java.util.List;
public class TestOOMPerm {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
int i = 0;
while(true) {
i++;
list.add(String.valueOf(i++).intern());
}
}
}
解决方案
- 在JDK 1.6及之前的版本中,由于常量池分配在永久代内,我们可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。
- 方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了CGLib字节码增强和动态语言之外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
3.4 Metaspace内存溢出java.lang.OutOfMemoryError: Metaspace
出现场景
元空间的溢出,系统会抛出java.lang.OutOfMemoryError: Metaspace。出现这个异常的问题的原因是系统的代码非常多或引用的第三方包非常多或者通过动态代码生成类加载等方法,导致元空间的内存占用很大。
OOM-Metaspace代码示例
解决方案
默认情况下,元空间的大小仅受本地内存限制。但是为了整机的性能,尽量还是要对该项进行设置,以免造成整机的服务停机。
-
优化参数配置,避免影响其他JVM进程
- -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
- -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
- 除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:
- -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集 。
- -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集。
-
慎重引用第三方包
- 对第三方包,一定要慎重选择,不需要的包就去掉。这样既有助于提高编译打包的速度,也有助于提高远程部署的速度。
-
关注动态生成类的框架
- 对于使用大量动态生成类的框架,要做好压力测试,验证动态生成的类是否超出内存的需求会抛出异常。
3.5 直接内存溢出java.lang.OutOfMemoryError: Direct buffer memory
出现场景
越过了DirectByteBuffer类,直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有rt.jar中的类才能使用Unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。
代码示例
解决方案
- DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。
- 由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,那就可以考虑检查一下是不是这方面的原因。
3.6 数组超限内存溢出Requested array size exceeds VM limit
出现场景
给数组分配了很大的capacity
OOM-数组超限示例代码
解决方案
- int类型最长不要超过Integer.MAX_VALUE-1
3.7 创建本地线程内存溢出
出现场景
剩余内存不足以创建本地线程需要的内存空间。
OOM-创建本地线程示例代码
解决方案
- 最大线程数要可控,不能无限制增长。
3.8 超出交换区内存溢出Out of swap space
出现场景
JVM请求总内存大于可用物理内存。
解决方案
- 增加系统交换区的大小,我个人认为,如果使用了交换区,性能会大大降低,不建议采用这种方式,生产环境尽量避免最大内存超过系统的物理内存。
- 其次,去掉系统交换区,只使用系统的内存,保证应用的性能。
3.9 GC超时内存溢出
出现场景
默认的jvm配置GC的时间超过98%,回收堆内存低于2%。
OOM-GC超时示例代码
解决方案
要减少对象生命周期,尽量能快速的进行垃圾回收。
3.10 系统杀死进程内存溢出Kill process or sacrifice child
出现场景
当内核检测到系统内存不足时,OOM killer被激活,检查当前谁占用内存最多然后将该进程杀掉。
OOM-killer示例代码
解决方案
虽然增加交换空间的方式可以缓解Java heap space异常,还是建议最好的方案就是升级系统内存,让java应用有足够的内存可用,就不会出现这种问题。
3.11 堆内存泄露导致OOM
- 一次次堆内存泄露不断堆积导致OOM
4 内存泄漏(Memory Leak)和内存溢出(Memory Overflow)有什么区别?
- 内存泄露(Memory Leak)
申请了内存无法释放 - 内存溢出(Memory Overflow)
申请内存但是没有足够内存空间供使用 - 一次内存泄露可能没啥大问题,但是多次内存泄露堆积可能会导致内存溢出
5 java内存划分?静态类放在哪里?
Java内存划分 java内存划分-new JVM内存划分线程共享
-
堆heap
- Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有(所有类的Class对象都分配在方法区)的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
- 新生代
- eden
- from survivor
- to survivor
- 老年代
- 永久代(jdk1.8之前,JDK1.8之后是元空间)
- 异常:OutOfMemoryError
- -Xmx:堆最大内存
- -Xms:堆初始内存
- -Xmn:新生代内存
-
方法区(永久代)
- 方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
- 运行时常量池(字面量和符号引用)
- 静态变量
- GC对方法区的回收效果很低,主要是废弃常量和无用的类,同时满足如下3个条件才能算作是无用的类:
- 该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
- 异常:OutOfMemoryError
- 方法区JVM参数:
- -XX:PermSize (jdk1.7前)
- -XX:MaxPermSize (jdk1.7前)
- -XX:MetaspaceSize (JDK1.7后)
- -XX:MaxMetaspaceSize (JDK1.7后)
操作数栈线程私有
-
栈/java栈/虚拟机栈
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用至结束就对应于一个栈桢在虚拟机栈中的入栈和出栈过程。- 栈桢
- 局部变量表
- 操作数栈
- 对运行时常量池的引用
- 动态链接
- 方法出口
- 异常
- 无法申请到更多JVM内存,OutOfMemoryError
- 线程请求的栈的深度大于JVM允许的深度,StackOverFlowError
- -Xoss:本地方法栈(HotSpot不会使用,只是用-Xss)
- -Xss:虚拟机栈
- 栈桢
-
本地方法栈
本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。这里之所以简要说明这部分内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同层次的概念。 -
程序计数器
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址;如果线程执行的是native方法,则程序计数器中的值是undefined。由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。 -
直接内存Direct Memory
- 直接内存不是Java虚拟机运行时数据区的一部分,也不是Java虚拟规范中定义的内存区域,但是它也受机器内存限制,也会产生OutOfMemoryError。
- -XX:MaxDirectMemorySize
6 java内存模型(JMM)
Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
java内存模型
JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开的(稍后会分析)。JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下:
- 主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
- 工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
java内存模型与硬件内存架构主内存与工作内存8种交互操作
- lock锁定:作用于主内存的变量,标识线程独占
- unlock解锁:作用于主内存的变量,解锁
- read读取:作用于主内存的变量,把变量的值从主内存读到工作内存
- load载入:作用于工作内存的变量,将主内存的值装入工作内存的变量副本中
- use使用:工作内存
- assign赋值:工作内存
- store存储:工作内存,工作内存的值传送到主内存
- write写入:主内存,工作内存的值装入主内存的变量中
JMM的三大特性:
-
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。 -
可见性
理解了指令重排现象后,可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。 -
有序性
有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。
7 volatile关键字
-
volatile:轻量级同步机制
-
synchronized:重量级同步机制
-
volatile的特性:
(1)保证可见性,不保证原子性
a.当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;
b.这个写回操作会导致其他线程中的缓存无效。
(2)禁止指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:
a.重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运
行时这两个操作不会被重排序。
b.重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
volatile禁止指令重排序也有一些规则:
a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
b.在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。
volatile不能解决原子性,解决办法:
-
synchronized
synchronized解决i++问题 -
Lock
Lock解决i++问题 -
CAS
Compare and Swap,采用Java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的。 CAS解决i++问题
volatile原理:
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
I. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内
存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
II. 它会强制将对缓存的修改操作立即写入主存;
III. 如果是写操作,它会导致其他CPU中对应的缓存行无效。
CAS导致的ABA问题:
CAS可以有效的提升并发的效率,但同时也会引入ABA问题。
如线程1从内存X中取出A,这时候另一个线程2也从内存X中取出A,并且线程2进行了一些操作将内存X中的值变成了B,然后线程2又将内存X中的数据变成A,这时候线程1进行CAS操作发现内存X中仍然是A,然后线程1操作成功。虽然线程1的CAS操作成功,但是整个过程就是有问题的。比如链表的头在变化了两次后恢复了原值,但是不代表链表就没有变化。
所以JAVA中提供了AtomicStampedReference/AtomicMarkableReference来处理会发生ABA问题的场景,主要是在对象中额外再增加一个标记来标识对象是否有过变更。
8 GC需要完成哪3件事?
8.1 哪些内存需要回收?/ GC作用的对象是什么?/ 怎么确定垃圾?
-
引用计数器法
- 有引用计数器+1,失去引用计数器-1,计数器为0的对象就是待垃圾回收的
- 缺陷:相互循环引用,引用计数器不为0,但是两个对象都是垃圾却不会被回收
-
可达性分析
- 从GC Roots开始搜索,搜索不到的对象称为不可达对象,不可达对象不等于可回收对象,不可达对象变成可回收对象至少要经过两次标记过程,两次标记后,仍然没有复活的对象将面临回收。
- 第一次标记并进行一次筛选:对象是否有必要执行finalize()
- 对象没有覆盖finalize()或者finalize()已经被JVM调用过,都视为没有必要执行
- 第二次小规模标记
- 对象要在finalize()中存活,只要重新与引用链上的任何一个对象建立关联即可,如果没有意味着第二次标记后该对象基本上就真的被回收了。
- 对象的自救机会只有一次,因为一个对象的finalize()最多只会被系统自动调用一次
- finalize()运行代价高昂,不确定性大,无法保证各个对象的调用顺序
- finalize()能做的所有工作,使用try-finally或者其他方式都可以做的更好更及时
- 第一次标记并进行一次筛选:对象是否有必要执行finalize()
- Java语言中可作为GC Roots的对象包括如下四种:
- 虚拟机栈(栈桢中的本地变量表)中引用的对象
- 本地方法栈JNI(即一般说的Native方法)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 从GC Roots开始搜索,搜索不到的对象称为不可达对象,不可达对象不等于可回收对象,不可达对象变成可回收对象至少要经过两次标记过程,两次标记后,仍然没有复活的对象将面临回收。
8.2 什么时候回收?/ GC在什么时候工作?
- 系统空闲的时候
这种回答大约占30%,遇到的话一般我就会准备转向别的话题,譬如算法、譬如SSH看看能否发掘一些他擅长的其他方面。
- 系统自身决定,不可预测的时间/调用System.gc()的时候。
这种回答大约占55%,大部分应届生都能回答到这个答案,起码不能算错误是吧,后续应当细分一下到底是语言表述导致答案太笼统,还是本身就只有这样一个模糊的认识。
- 能说出新生代、老年代结构,能提出minor gc/full gc
到了这个层次,基本上能说对GC运作有概念上的了解,譬如看过《深入JVM虚拟机》之类的。这部分不足10%。
- 能说明minor gc/full gc的触发条件、OOM的触发条件,降低GC的调优的策略。
列举一些我期望的回答:eden满了minor gc,升到老年代的对象大于老年代剩余空间full gc,或者小于时被HandlePromotionFailure参数强制full gc;gc与非gc时间耗时超过了GCTimeRatio的限制引发OOM,调优诸如通过NewRatio控制新生代老年代比例,通过MaxTenuringThreshold控制进入老年前生存次数等……能回答道这个阶段就会给我带来比较高的期望了,当然面试的时候正常人都不会记得每个参数的拼写,我自己写这段话的时候也是翻过手册的。回答道这部分的小于2%。
总结:程序员不能具体控制时间,系统在不可预测的时间调用System.gc()函数的时候;当然可以通过调优,用NewRatio控制newObject和oldObject的比例,用MaxTenuringThreshold 控制进入oldObject的次数,使得oldObject 存储空间延迟达到full gc,从而使得计时器引发gc时间延迟OOM的时间延迟,以延长对象生存期。
8.3 GC如何回收内存?/ GC做了些什么?
- 删除不使用的对象,腾出内存空间。
分析:同问题2第一点。40%。
- 补充一些诸如停止其他线程执行、运行finalize等的说明。
起码把问题具体化了一些,如果像答案1那样我很难在回答中找到话题继续展开,大约占40%的人。
- 能说出诸如新生代做的是复制清理、from survivor、to survivor是干啥用的、老年代做的是标记清理、标记清理后碎片要不要整理、复制清理和标记清理有有什么优劣势等。
也是看过《深入JVM虚拟机》的基本都能回答道这个程度,其实到这个程度我已经比较期待了。同样小于10%。
- 还能讲清楚串行、并行(整理/不整理碎片)、CMS等搜集器可作用的年代、特点、优劣势,并且能说明控制/调整收集器选择的方式。
总结:删除不使用的对象,回收内存空间;运行默认的finalize,当然程序员想立刻调用就用dipose调用以释放资源如文件句柄,JVM用from survivor、to survivor对它进行标记清理,对象序列化后也可以使它复活。
9 Java四种引用类型
-
强引用
Java中最常见的引用类型,当一个对象被强引用变量引用时,它处于可达状态,不会被垃圾回收,是造成Java内存泄漏的主要原因之一。 -
软引用
软引用要用SoftReference类实现,当系统内存不够时,它不会被回收,软引用通常用在对内存敏感的程序中。 -
弱引用
弱引用要用WeakReference类实现,它比软引用的生命周期更短,对于只有弱引用的对象来说,只要垃圾回收器一运行,就会回收该对象占用的内存。 -
虚引用/幽灵引用/幻影引用
虚引用需要用PhantomReference类来实现,它不能单独使用,必须和引用队列联合使用,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
10 为什么分代?GC分代/堆(内存)分代/JVM分代管理
GC为什么分代?
堆分代或者GC分代回收的唯一理由就是优化GC性能,提高GC效率。
- 首先就是分代回收可以对堆中对象采用不同的gc策略。在实际程序中,对象的生命周期有长有短。进行分代垃圾回收,能针对这个特点做很好的优化。
- 分代以后,gc时进行可达性分析的范围能大大降低。在分代回收中,新生代的规模比老年代小,回收频率也要高,显然新生代gc的时候不能去遍历老年代。而如果不进行分代,由于老年代对象长期存活,所以总的gc频率应该和分代以后的young gc差不多,但是每次gc都需要从gc roots进行完整的堆遍历,无疑大大增加了开销。
新生代:young区或者年轻代
- 新生代分为三部分:eden区、s0区/from区,s1区/to区,三块区域默认比例是8:1:1,新生代和老年代1:2
- eden区又叫伊甸园,新创建的对象分配在Eden区(一些大对象特殊处理),这些对象经历过第一次minor gc后如果仍然存活,将会被移到survivor区。
- 年轻代的GC算法是复制-清理算法,当eden区满了触发gc,JVM把eden区和survivor区的的有效对象都复制到临时survivor区,然后清理掉除临时survivor区外的区域,清理完毕把临时survivor区的对象移动到survivor区
- 年轻代的GC复制-清理算法,缺点是浪费内存空间,优点是不会有内存碎片空间
老年代:old区
-
老年代的内存大小和新生代大致相同,老年代对象来自新生代或者直接放进去的。
-
新生代对象有三种途径会进入老年代:
- 新创建对象分配在新生代的eden区,对象经历第一次minor gc后如果仍然存活就会被移动到survivor区,这些对象在survivor区每熬过一次minor gc,年龄就加1(有个属性记录经历minor gc次数),经历15次后,就会进入老年代
- 并不是所有对象只有经历15次minor gc才会进入老年代,当同一gc年龄的对象所占内存大小超过了survivor区的50%,就会直接进入老年代
- 对于新创建的大对象大到eden区不足以存放,也会进入老年代
-
当老年代内存不够用时会触发major gc,即full gc
-
老年代的GC算法主要有两种:
- 标记-清理:直接清理掉引用不可达对象,清理后会有内存不连续问题,例如:cms垃圾收集器采用标记-清理算法
- 标记-整理:清理后内存空间是连续的,例如:G1垃圾收集器采用标记-整理算法
永久代:permanent区
- 永久代存放常量、类,在JDK1.6后,取消永久代,改为元空间(Meta Space)
11 GC触发条件
GC分类
- young gc / minor gc:清理新生代
- full gc / major gc:清理老年代,一般清理老年代同时会触发新生代清理
young GC / Minor GC触发条件
- 当Eden区满时,触发Minor GC。
Full GC / Major GC触发条件
- 老年代空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
- 方法区空间不足
- 调用System.gc时,系统建议执行Full GC,但是不必然执行
12 垃圾回收算法
12.1 复制-清理算法-Copy Sweep
- Hotspot JVM新生代采用的GC算法
- 复制-清理算法是将内存划分为两等份,当其中一块内存使用完了,就将有效对象复制到另一块内存,然后将使用掉的那块内存清理掉,清理完毕将对象拷贝过去。
- 优点是不会有内存碎片;实现简单、运行高效
- 缺点是需要较多的内存空间
12.2 标记-清理算法-Mark Sweep
- Hotspot JVM老年代采用的GC算法之一
- 标记-清理算法先是标记需要清理的对象,标记完成后统一清理所有标记对象
- 优点是相比较复制-清理算法而言,不会浪费太多的内存空间
- 缺点是产生了太多内存碎片;标记和清理过程中的效率都不高
12.3 标记-整理算法 / 标记-压缩算法-Mark Compact
- Hotspot JVM老年代采用的GC算法之一
- 标记-整理算法的标记阶段跟标记-清理算法一样,不同的是清理前会将存活的对象放到一端,然后清理掉端边界外的内存
- 优点是改进了复制算法浪费内存和标记-清理算法产生内存碎片的缺点
- 缺点是标记、整理、清理的效率不高
12.4 HotSpot的算法实现
- 枚举根节点
- 类加载时候将GC Roots等信息存储在OopMap的数据结构中
- 安全点(Safepoint)
- 在安全点才能停顿下来开始GC,如果在GC发生时让所有线程都在最近的安全点停顿下来,主要有两种方式:
- 抢先式中断(Preemptive Suspension):发生GC时,首先中断所有线程,发现有不在安全点上,就恢复该线程,让它跑到安全点上;
- 主动式中断(Voluntary Suspension):GC时候设置一个标志,各个线程轮询这个标志,发现中断标志为真时就将自己中断挂起。
- 在安全点才能停顿下来开始GC,如果在GC发生时让所有线程都在最近的安全点停顿下来,主要有两种方式:
- 安全区域(Safe Region)
- Safe Region看作是被扩展了的Safepoint
- Safepoint解决的是程序运行时如何进入GC,Safe Region解决的是程序不执行的时候(没有分配CPU时间)怎么进入GC。
13 垃圾回收器
十种垃圾回收器13.0 三色标记
- 白色:还没有标记
- 灰色:自己标记完成,成员变量没有标记
- 黑色:自己和成员变量都标记完成
13.1 串行垃圾回收器Serial GC(几十M甚至200M内存,STW在几十最多100多毫秒)
- 最古老的垃圾收集器,采用单线程进行垃圾回收,分为新生代串行回收器和老年代串行回收器
- 缺点是stop the world,串行垃圾收集器工作时必须暂停用户的所有进程
- 参数控制:-XX:+UseSerialGC,启动串行垃圾收集器
13.2 并行垃圾回收器Parallel GC(几个G内存)
1 并行垃圾回收器ParNew GC
- 新生代垃圾回收器,简单地将串行收集器多线程化,回收策略和算法和串行收集器一样
- 新生代并行,老年代串行;新生代复制算法,老年代标记-整理算法
- -XX:+UseParNewGC,启用ParNew收集器
- -XX:ParallelGCThreads,限制线程数量
- ParNew GC可以跟CMS一起使用,Parallel Scavenge GC不能配合CMS使用
2 并行垃圾回收器Parallel Scavenge GC
- 新生代垃圾回收器,采用复制算法,关注吞吐量
- 所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。
- -XX:MaxGCPauseMillis 设置最大垃圾收集停顿时间
- -XX:GCTimeRatio 设置吞吐量的大小(默认是99,允许最大1%的垃圾收集时间)
- -XX:+UseAdaptiveSeizPolicy 打开自适应模式
3 并行垃圾回收器Parallel Old GC
- 老年代垃圾回收器,采用多线程和标记-整理算法,关注吞吐量
- -XX:+UseParallelOldGC 使用ParallelOld收集器
- -XX:ParallelGCThreads 限制线程数量
- JDK1.8默认使用的是垃圾回收器组合是Parallel Scavenge + Parallel Old(PS+PO)
13.3 CMS垃圾回收器(几十个G内存)
CMS特点
-
CMS,Concurrent Mark Sweep,并发标记清除,工作在老年代,关注系统的停顿时间
-
CMS不是独占的回收器,CMS并发标记和并发清理时候不会stop the world,CMS在回收过程中应用程序仍然在不停工作,当老年代内存空间使用率达到一定比例(JDK1.5是68%,JDK1.6是92%)的默认阈值时候CMS就会工作。
-
如果内存使用率增长过快,在CMS执行过程中出现了内存不足的情况,CMS回收就会执行失败,此时JVM会启动串行垃圾回收器继续回收,这个过程会导致应用程序中断,直到垃圾回收完成后才能正常工作,这个过程中GC停顿时间可能过长,所以68%的阈值要根据实际情况设置
-
标记-清理算法的缺陷是内存碎片问题,CMS做了优化,可以设置在进行多少次CMS后进行内存碎片整理
-
CMS垃圾回收器参数控制:
- -XX:CMSInitatingPermOccupancyFraction 设置阀值
- -XX:+UserConcMarkSweepGC 使用cms垃圾清理器
- -XX:ConcGCThreads 限制线程数量
- -XX:+UseCMSCompactAtFullCollection 设置完成CMS之后进行一次碎片整理
- -XX:CMSFullGCsBeforeCompaction 设置进行多少次CMS回收后进行碎片整理
-
三色标记+写屏障,错标,Incremental Update,remark
CMS工作流程
- 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark):可达性分析,时间最长
- 重新标记(CMS remark):修正浮动标记,STW比初始标记长
- 并发清理(CMS concurrent sweep)
CMS三大缺陷
- 标记-清除算法(Mark-Sweep)会产生大量内存碎片;
- CMS对CPU资源非常敏感;
- CMS默认企鹅东的回收线程数是(CPU核数+3)/4;
- CMS无法处理浮动垃圾,可能出现Concurrent Mode Failure
- CMS对浮动垃圾留待下一次GC时再清理掉,无法在当次收集中处理掉。
- CMS在老年代使用了一定比例(JDK1.5是68%,JDK1.6是92%)时候就会被激活开始工作,可以通过参数-XX:CMSInitiatingOccupancyFraction设置,参数设置太高容易导致大量Concurrent Mode Failure失败,性能反而降低。
CMS:Incremental Update,解决三色标记的问题,将节点置为灰色
三色标记-CMS-Incremental Update13.4 G1垃圾回收器Garbage First GC(上百G内存)
G1特点
-
G1是里程牌的垃圾回收期,从分代到分Region,逻辑上分代、物理上不分代。
-
G1是当今技术最好的垃圾回收器,在JDK7中加入G1,关注最小时延,也就是停顿时间,G1的停顿时间达到几十到几百毫秒,官方推荐用G1代替CMS
-
G1的优点很多:
- 并行与并发:G1充分利用CPU降低了stop-the-world的停顿时间
- 分代收集:分代概念在G1中得以保留,逻辑上分代、物理上不分代
- 空间整合:CMS采用的是标记-清理算法,G1从整体上看是用的标记-整理算法,从局部(两个Region之间)上看是用的复制-清理算法
- 可预测的停顿:G1较CMS最大的区别是能建立可预测的停顿时间模型,让使用者能够明确指定在一个长度M毫秒的时间片段内,消耗在垃圾收集的时间不超过N毫秒
- G1避免全区域垃圾回收,将堆内存划分为大小固定的几个区域,优先回收垃圾最多的区域。
-
G1的控制参数:
- -XX:+UseG1GC 使用G1垃圾收集器
- -XX:ParallelGCThreads 限制线程数量
- -XX:MaxGCPauseMillis 指定最大停顿时间
-
三色标记+SATB+写屏障
G1跨Region回收策略
- G1将内存划分为很多Region,G1按照回收带来的空间大小和回收所需要时间的经验值对垃圾划分不同的价值,G1跟踪各个Region的垃圾回收价值大小,在后台维护了一个优先列表,每次优先回收垃圾价值最大的Region。
- JVM通过Remembered Set + 写屏障(Write Barrier)来避免全堆扫描的。
- G1中每个Region都有一个与之对应的Remembered Set;
- JVM发现程序在对Reference类型的数据进行写操作时,会产生一个写屏障(Write Barrier)暂时中断写操作,检查Reference引用的对象是否处在跨Region中;
- 如果存在跨Region,JVM通过CardTable把相关的引用信息记录到所属的Region的Remembered Set中;
- 当开始GC时,在GC根节点枚举范围中加入Remembered Set以保证不全堆扫描也不会遗漏。
G1垃圾回收流程
-
初始标记(Initial Mark):标记GC Roots能直接关联到的对象,STW时间很短。
-
并发标记(Concurrent Mark):可达性分析,时间最长,并发标记线程和用户线程可并发进行
- 并发标记的时机是在YGC后,只有内存消耗达到一定的阈值后才会触发。在G1中,这个阈值通过参数InitiatingHeapOccupancyPercent控制(默认值是45,表示的是当已经分配的内存加上本次将分配的内存超过内存总容量的45%时就可以开始并发标记)。
- 多个并发标记线程启动,每个线程每次只扫描一个分区,从而标记出存活对象。在标记的时候还会计算存活对象的数量,同时会计算存活对象所占用的内存大小,并计入分区空间。
- 并发标记子阶段会对所有分区的对象进行标记。这个阶段并不需要STW,故标记线程和应用程序线程并发运行。使用Snapshot-At-The-Beginning(SATB)算法进行并发标记。
-
最终标记(Final Mark)
- 浮动垃圾标记,STW较初始标记长;
- JVM将变动记录到Remembered Set Logs中,然后将Remembered Set Logs中的数据合并到Remembered Set中
- 最终标记是要结束标记过程,需要满足3个条件:
- 从根(survivor)出发并发标记子阶段已经标记出所有的存活对象。
- 标记栈是空的。
- 所有的引用变更对象都被处理了。这里的引用变更对象包括新增空间分配的对象和引用变更对象,新增空间所有对象被认为都是活跃的(即使对象已经“死亡”也没有关系,在这种情况下只是增加了一些浮动垃圾),引用变更处理的对象通过一个队列记录,在该子阶段会处理这个队列中所有的对象。(前两个条件是很容易满足的,但是满足最后一个条件是很困难的。如果不引入一个STW的再标记过程,那么应用会不断地更新引用,也就是说,会不断地产生新的引用变更,因而永远无法达成完成标记的条件。)
-
筛选回收(Live Data Counting and Evacuation):需要一个STW的时间段,并不会清理垃圾对象,也不会执行存活对象的复制。
- 将各个Region的回收价值和回收成本排序;
- 根据用户期望的GC停顿时间来制定回收计划,并行执行的,未来可并发执行
再标记子阶段之后是清理子阶段,该子阶段也需要一个STW的时间段。清理子阶段主要执行以下操作:
G1的缺陷
- 停顿时间过长,通常G1的停顿时间要达到几十到几百毫秒;这个数字其实已经非常小了,但是垃圾回收发生导致应用程序在这几十或者几百毫秒中不能提供服务,在某些场景中,特别是对用户体验有较高要求的情况下不能满足实际需求。
- 内存利用率不高,通常引用关系的处理需要额外消耗内存,一般占整个内存的1%~20%左右。
- 支持的内存空间有限,不适用于超大内存的系统,特别是在内存容量高于100GB的系统中,会因内存过大而导致停顿时间增长。
G1中的屏障
- 写屏障。写屏障为了处理RSet引入的。
- 读屏障。读屏障是为了处理SATB并发标记引入的。
所以一句简单的Java赋值语句,例如Object.Field=other_object,实际上被JVM处理成3条伪代码,如下所示:
- JVM ----> Insert Pre-write barrier,处理SATB,保证标记的正确性
- Object.Field = other_object; 真正的代码
- JVM ----> Insert Post-write Barrier,处理RSet,即产生对象到DCQ中
RSet原理
RSet是一个抽象概念,记录对象在不同代际之间的引用关系,目的是加速垃圾回收的速度。JVM使用的是根对象引用的收集算法,即从根集合出发,标记所有存活的对象,然后遍历对象的每一个成员变量并继续标记,直到所有的对象标记完毕。在分代垃圾回收中,我们知道新生代和老生代处于不同的回收阶段,如果还是采用这样的标记方法,不合理也没必要。假设我们只回收新生代,如果标记时把老生代中的活跃对象全部标记,但回收时并没有回收老生代,则浪费了时间。同理,在回收老生代时有同样的问题。当且仅当我们要进行FGC时,才需要对内存做全部标记。所以算法设计者做了这样的设计——用一个RSet记录从非收集部分指向收集部分的指针的集合,而这个集合描述就是对象的引用关系。通常有两种方法记录引用关系,第一种为Point Out,第二种为Point In。假设有这样的引用关系,对象A的成员变量指向对象B(伪代码为:ObjA.Field = ObjB),对于Point Out的记录方式来说,会在对象A(ObjA)的RSet中记录对象B(ObjB)的地址;对于Point In的记录方式来说,会在对象B(ObjB)的RSet中记录对象A(ObjA)的地址,这相当于一种反向引用。这二者的区别在于处理时有所不同:Point Out记录简单,但是需要对RSet做全部扫描;Point In记录操作复杂,但是在标记扫描时可以直接找到有用和无用的对象,不需要进行额外的扫描,因为RSet里面的对象可以看作根对象。G1中使用的是Point In的方式,为了提高RSet的存储效率,使用了3种数据结构:
- 稀疏表,通过哈希表方式(哈希表底层使用数组)来存储。
- 细粒度表,通过数组来存储,每个数组元素指向引用者分区中512字节内存块对本分区的引用情况。
- 粗粒度位图,通过位图来指示,每1位表示对应的分区有引用到本分区。
G1新引入了Refine线程,它实际上是一个线程池,有两大功能:
- 用于处理新生代分区的抽样,并且在满足响应时间这个指标的情况下,更新新生代分区的数目,通常由一个单独的线程来处理。
- 更新RSet(也是最重要的功能)。对于RSet的更新并不是同步完成的,G1会把所有引用关系都先放入一个队列中,称为Dirty Card Queue(DCQ),然后使用Refine线程来消费这个队列完成引用关系的记录。正常来说有G1ConcRefinementThreads个线程处理;实际上除了Refine线程更新RSet之外,GC工作线程或者应用程序线程也可能会更新RSet;DCQ通过Dirty Card Queue Set(DCQS)来管理;为了能够快速、并发地处理,每个Refine线程只负责DCQS中的某几个DCQ。
虽然RSet是为了记录对象在代际之间的引用,但是并不是所有代际之间的引用都需要记录。我们简单地分析一下哪些情况需要使用RSet进行记录。分区之间的引用关系可以归纳为:
-
分区内部有引用关系。
-
新生代分区到新生代分区之间有引用关系。
-
新生代分区到老生代分区之间有引用关系。
-
老生代分区到新生代分区之间有引用关系。
-
老生代分区到老生代分区之间有引用关系。
这里的引用关系指的是分区里面有一个对象存在一个指针指向另一个分区的对象。针对这5种情况,最简单的方式就是在RSet中记录所有的引用关系,但这并不是最优的设计方案,因为使用RSet进行回收实际上有两个重大的缺点: -
需要额外的内存空间;这一部分通常是G1最大的额外开销,一般会达到1%~20%。
-
可能导致浮动垃圾;由于根据RSet回收,而RSet里面的对象可能已经死亡,这个时候被引用对象会被认为是活跃对象,实质上它是浮动垃圾。
所以有必要对RSet进行优化,根据垃圾回收的原理,我们来逐一分析哪些引用关系需要记录在RSet中:
- 分区内部有引用关系,无论是新生代分区还是老生代分区内部的引用,都无须记录引用关系,因为回收的时候是针对一个分区而言,即这个分区要么被回收,要么不回收。如果分区回收,则会遍历整个分区,所以无须记录这种额外的引用关系。
- 新生代分区到新生代分区之间有引用关系,这无须记录,原因在于G1的YGC/Mixed GC/FGC回收算法都会全量处理新生代分区,所以它们都会被遍历,所以无须记录新生代到新生代之间的引用。
- 新生代分区到老生代分区之间有引用关系,这无须记录,对于G1中YGC针对的新生代分区,无须知道这个引用关系,混合回收发生时,G1会使用新生代分区作为根,那么遍历新生代分区时自然能找到新生代分区到老生代分区的引用,所以也无须记录这个引用关系,对于FGC来说更是如此,所有的分区都会被处理。
- 老生代分区到新生代分区之间有引用关系,这需要记录,在YGC的时候有两种根:一个就是栈空间/全局空间变量的引用,另外一个就是老生代分区到新生代分区的引用。
- 老生代分区到老生代分区之间有引用关系,这需要记录,在混合回收的时候可能只有部分分区被回收,所以必须记录引用关系,快速找到哪些对象是活的。
SATB算法介绍
并发标记指的是标记线程和应用程序线程并发运行。那么标记线程如何并发地进行标记?并发标记时,一边标记垃圾对象,一边还在生成垃圾对象,如何能正确标记对象?为了解决这个问题,以前的垃圾回收算法采用串行执行方式,这里的串行指的是标记工作和对象生成工作不同时进行。而G1中引入了新的算法SATB,在介绍算法之前,我们先回顾一下对象分配。
在堆分区中分配对象时,对象都是连续分配的,所以可以设计几个指针,分别是Bottom、Prev、Next和Top。用Bottom指向堆分区的起始地址,用Prev指针指向上一次并发处理后的地址,用Next指向并发标记开始之前内存已经分配成功的地址,当并发标记开始之后,如果有新的对象分配,可以移动Top指针,使用Top指针指向当前内存分配成功的地址。Next指针和Top指针之间的地址就是应用程序线程新增对象使用的内存空间。如果假设Prev指针之前的对象已经标记成功,在并发标记的时候从根出发,不仅仅标记Prev和Next之间的对象,还标记了Prev指针之前活跃的对象。当并发标记结束之后,只需要把Prev指针设置为Next指针即可开始新一轮的标记处理。
Prev和Next指针解决了并发标记工作内存区域的问题,还需要引入两个额外的数据结构来记录内存标记的状态,典型的是使用位图(BitMap)来指示哪块内存已经使用,哪块内存还未使用,所以并发标记引入两个位图PrevBitmap和NextBitmap,用PrevBitmap记录Prev指针之前内存的标记状况,用NextBitmap表示整个内存从Bottom到Next指针之前的标记状态。
也许你会奇怪,NextBitmap包含了整个使用内存的标记状态,那为什么要引入PrevBitmap这个数据结构?这个数据结构在什么时候使用?我们可以想象,如果并发标记每次都成功,我们确实不需要用到PrevBitmap,只需要根据NextBitmap这个位图对对象进行清除即可。但是如果标记失败将会发生什么?我们将丢失上一次对Prev指针之前所有内存的标记状况,也就是说当不能完成并发标记时,将需要重新标记整个内存,这显然是不对的。我们通过示意图来演示一下并发标记的过程。
假定初始情况如图1-8所示。
image.png
图1-8并发标记开始之前
这里用Bottom表示分区的底部,Top表示分区空间使用的顶部,TAMS指的是Top-At-Mark-Start,Prev就是前一次标记的地址,即Prev TAMS,Next指向的是当前开始标记时最新的地址,即Next TAMS。并发标记开始是从根对象出发开始并发的标记。在第一次标记时PrevBitmap为空,NextBitmap待标记。开始进行并发标记,结束后如图1-9所示。
image.png
图1-9并发标记结束后的状态
并发标记结束后,NextBitmap记录了分区对象存活的情况,假定上述位图中黑色区域表示堆分区中对应的对象还活着。在并发标记的同时应用程序继续运行,所以Top指针发生了变化,继续增长。
这个时候,可以认为NextBitmap中活跃对象以及Next和Top之间的对象都是活跃的。在进行垃圾回收的时候,如果分区需要被回收,则会把这些对象都进行复制;如果分区可用空间比较多,那么分区不需要回收。当应用程序继续执行,新一轮的并发标记启动时,初始状态如图1-10所示。
在新一轮的并发标记开始时,交换Bitmap,重置指针。根据根对象对Bottom和Next TAMS之间的内存对象进行标记,标记结束后,状态如图1-11所示。
image.png
图1-10第二次并发标记开始之前的状态
image.png图1-11第二次并发标记结束后的状态
当标记完成时,如果分区垃圾对象满足一定条件(如分区的垃圾对象占用的内存空间达到一定的数值),分区就可以被回收。
这里演示的仅仅是并发标记的SATB算法,但是还有一个主要的问题没有解决,那就是应用程序和并发标记工作线程对同一个对象进行修改,如何保证标记的正确性?
13.5 ZGC(4T内存)
ZGC特点
- 最开始由Oracle开发的,2017年捐献给OpenJDK,JDK11开始引入,逻辑上物理上都不分代。
- 内存分区管理,且支持不同的分区粒度,在ZGC中分区称为页面(page),有小页面、中页面、大页面3种。
- 仅支持Linux 64位系统,不支持32位系统。
- 不支持使用压缩指针。
- 具有颜色指针(color pointer),通过设计不同的标记位区分不同的虚拟空间,而这些不同标记位指示的不同虚拟空间通过mmap映射在同一物理地址;颜色指针能够快速实现并发标记、转移和重定位。
- 设计了读屏障,实现了并发标记和并发转移的处理。
- 支持NUMA,尽量把对象分配在访问速度比较快的地方。
- ZGC设计之初的三大目标都已经实现了:
- 支持TB级内存,最大支持4TB堆内存
- STW控制在10ms之内
- 对程序吞吐量影响小于15%
ZGC缺陷
- 仅实现了单代内存管理,也就是说没有考虑热点数据与冷数据,分代内存管理在C4中已经得到支持。据Azul官网文章介绍,所实现的分代的内存管理器比没有分代的内存管理器效率高10倍,也就是说ZGC还有巨大的进步空间。
- C2的支持还不够完善。
- 不支持Graal、HDSB等功能。
- 一些功能尚待完善,比如尚不支持类回收。
- 稳定性尚需提高。
ZGC的内部逻辑(ZGC为什么能控制STW在10ms内)
- ZGC设计思路借鉴了一款商业垃圾回收器——Azul的C4,号称无停顿时间,其实是停顿时间非常短。
- ZGC最大的优化是把一切能并发处理的工作都并发执行。
- ZGC为了保证进入安全点的时间足够短,会把这一部分工作优化成并发处理。比如我们知道在JVM中进入安全点时会进行字符串回收(这里字符串回收指的是回收因使用String类中的intern方法而产生的垃圾)。
- 在G1中对象的转移都是在STW中并行执行的,而ZGC就是把对象的转移也并发执行,通常转移时间占比在80%左右。G1中的停顿时间主要来自垃圾回收(YGC和混合回收)阶段中的复制算法,在复制算法中,需要把对象转移到新的空间中,并且更新其他对象到这个对象的引用。实际中对象的转移涉及内存的分配和对象成员变量的复制,而对象成员变量的复制是非常耗时的。,从而满足停顿时间在10ms以下。
- ZGC除了并发转移,还对整个垃圾回收进入STW的过程做了改进,把原来串行执行的动作也并发执行。
- 在G1中可能存在FGC,如果发生了FGC,也可能导致停顿时间不可控。在目前的ZGC中,垃圾回收就是全量回收,也就是每发生一次垃圾回收就是一次FGC,而每次垃圾回收的停顿时间在10ms以下,所以FGC导致停顿时间不可控这一存在于G1中的问题也解决了。因为ZGC中每次垃圾回收都是全量回收(即每次都是FGC)。
13.6 shenandoah(几T内存)
- 最早由Red Hat发起的后捐献给OpenJDK,JDK12开始,逻辑上物理上都不分代,最初的目标是利用多核CPU特点,把STW降到毫秒级,同时对内存的支持扩大到TB级别。
- 因为Shenandoah立项比较早,所以实现的功能也更多更全。到目前为止,Shenandoah已经实现了很多特性,包括解释器、C1屏障、C2屏障、对引用的支持、对JNI临界区域的支持、对System.gc()的支持等。
- Shenandoah目前还算稳定,它的平均性能能够达到G1的90%,有时会差一些,比如只有G1的70%,不过有时候会超过G1的性能,比如达到G1的150%。
- ColoredPointers + 写屏障+读屏障+比较屏障
- Shenandoah不像ZGC仅支持Linux 64位系统,它是在原来的对象头上增加一个额外的指针(颜色指针),通过这个指针可以实现读屏障、写屏障和比较屏障,从而实现并发标记和并发转移时的并发处理。
- 虽然Shenandoah和ZGC都加入OpenJDK中,就目前的结果来说,Shenandoah功能实现得更为齐全,但Shenandoah在进行并发处理时需要3种屏障,而ZGC在进行并发处理时仅需要读屏障,且不需要访问内存对象,所以效率更高。
13.7 Epsilon
- 啥也不干,调试,确认不用GC参与就能干完活
14 JVM类加载机制(7个阶段)
JVM类加载机制虚拟机把描述类的数据文件(字节码)加载到内存,并对数据进行验证、准备、解析以及类初始化,最终形成可以被虚拟机直接使用的java类型(java.lang.Class对象)。
-
加载过程
通过一个类的全限定名来获取定义此类的二进制字节流,将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。在内存中(方法区)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口,Class对象获取方式有:- class文件
- zip包、jar包、war包
- 运行时计算生成(动态代理)
- 其他文件生成,如JSP文件
-
验证过程
为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,文件格式验证、元数据验证、字节码验证、符号引用验证。 -
准备过程
正式为类属性分配内存并设置类属性初始值的阶段,这些内存都将在方法区中进行分配。 -
解析阶段
虚拟机将常量池内的符号引用替换为直接引用的过程。 -
初始化阶段
类初始化阶段是类加载过程的最后一步。初始化阶段就是执行类构造器<client>()方法的过程。 -
使用阶段
执行类构造器<client>方法的过程。 -
卸载阶段
GC释放内存空间
15 JVM类加载器
Java类加载器从Java虚拟机的角度来说,只存在两种不同的类加载器:
- 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(HotSpot虚拟机中,Oracle还收购了另外一家JRokit虚拟机),是虚拟机自身的一部分;
- 另一种就是所有其他的类加载器,这些类加载器都有Java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader。
从开发者的角度,类加载器可以细分为:
-
启动(Bootstrap)类加载器:负责将 JAVA_HOME/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
-
标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将JAVA_HOME/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
-
应用程序(Application)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统(System)加载器。
16 双亲委派模型
双亲委派模型- 该模型要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,而这种父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)。
- 某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
- 使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在ClassPath中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。
- 在java.lang.ClassLoader的loadClass()方法中,先检查是否已经被加载过,若没有加载则调用父类加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父加载失败,则抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
17 如何打破双亲委派模型?
重写ClassLoader类的loadClass(),一般是重写findClass()。
- 双亲委派模型的第一次“被破坏”
其实发生在双亲委派模型出现之前--即JDK1.2发布之前。由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在 的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。 - 双亲委派模型的第二次“被破坏”
是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被调用代码调用的API。但是,如果基础类又要调用用户的代码,那该怎么办呢。
这并非是不可能的事情,一个典型的例子便是JNDI服务,它的代码由启动类加载器去加载(在JDK1.3时放进rt.jar),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?
为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。了有线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。 - 双亲委派模型的第三次“被破坏”
- 是由于用户对程序的动态性的追求导致的,例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。
- OSGI,Open Service Gateway Initiative,面向Java的动态模型系统,是Java动态化模块化系统的一系列规范。
- OSGI实现的是模块化的热插拔功能,也有成熟的框架支持,并非所有架构都适合用OSGI作为基础架构,它在提供强大功能的同时,也引入了额外的复杂度,因为它不遵守类加载的双亲委派模型。
18 Java对象创建时机
- 使用new关键字创建对象
- 使用Class或Constructor类的newInstance方法(反射机制)
- 使用Clone方法创建对象
- 使用(反)序列化机制创建对象
19 引起类加载的五个行为
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令
- 反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
- 子类初始化的时候,如果其父类还没初始化,则需先触发其父类的初始化
- 虚拟机执行主类的时候(有 main(string[] args))
- JDK1.7 动态语言支持
20 stackoverflow和outofmerroy区别
-
stackoverflow:
每当java程序启动一个新的线程时,java虚拟机会为他分配一个栈,java栈以帧为单位保持线程运行状态;当线程调用一个方法是,jvm压入一个新的栈帧到这个线程的栈中,只要这个方法还没返回,这个栈帧就存在。
如果方法的嵌套调用层次太多(如递归调用),随着java栈中的帧的增多,最终导致这个线程的栈中的所有栈帧的大小的总和大于-Xss设置的值,而产生生StackOverflowError溢出异常。 -
outofmemory:
-
栈内存溢出
java程序启动一个新线程时,没有足够的空间为改线程分配java栈,一个线程java栈的大小由-Xss设置决定;JVM则抛出OutOfMemoryError异常。 -
堆内存溢出
java堆用于存放对象的实例,当需要为对象的实例分配内存时,而堆的占用已经达到了设置的最大值(通过-Xmx)设置最大值,则抛出OutOfMemoryError异常。 -
方法区内存溢出
方法区用于存放java类的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。在类加载器加载class文件到内存中的时候,JVM会提取其中的类信息,并将这些类信息放到方法区中。
当需要存储这些类信息,而方法区的内存占用又已经达到最大值(通过-XX:MaxPermSize);将会抛出OutOfMemoryError异常对于这种情况的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。这里需要借助CGLib直接操作字节码运行时,生成了大量的动态类
-
21 内存溢出进程还在吗?其他线程还能正常运行吗?程序还能正常访问吗?
- 当一个线程抛出OOM异常后,它所占据的内存资源会全部被释放掉,但是其他线程还在继续运行,进程无法恢复。
22 静态类放在哪里?静态类会被垃圾回收吗?
23 Java堆的-Xms和-Xmx设置成一样有什么好处?
把xmx和xms设置一致可以让JVM在启动时就直接向OS申请xmx的commited内存,好处是:
- 避免JVM在运行过程中向OS申请内存
- 延后启动后首次GC的发生时机
- 减少启动初期的GC次数
- 尽可能避免使用swap space
24 -verbose:class和-verbose:gc分别代表什么意思?
- -verbose:gc
监视虚拟机内存回收情况 - -verbose:class
查看程序运行时有多少类被加载
25 -verbose:gc和-XX:+PrintGC有什么区别?
- -XX:+PrintGC 与 -verbose:gc 功能是一样的;
- -verbose:gc是稳定版本,-XX:+PrintGC是非稳定版本,未来可能删除。
26 Java产生dump日志的方式?
-
获取内存详情:jmap -dump:format=b,file=e.bin pid
这种方式可以用 jvisualvm.exe 进行内存分析,或者采用 Eclipse Memory Analysis Tools (MAT)这个工具 -
获取内存dump: jmap -histo:live pid
这种方式会先触发fullgc,所有如果不希望触发fullgc,可以使用jmap -histo pid -
jdk启动加参数:
- -XX:+HeapDumpBeforeFullGC
- -XX:HeapDumpPath=/httx/logs/dump
这种方式会产生dump日志,再通过jvisualvm.exe 或者Eclipse Memory Analysis Tools 工具进行分析。
27 Eden区快满了,再实例化一个对象会发生什么?
28 HotSpot虚拟机对象详解
28.1 对象的创建
类加载过程
- new指令
- JVM检查是否能在常量池中定位到一个类的符号引用
- JVM检查这个符号引用代表的类是否已被加载、解析和初始化过
- 如果没有,先执行相应的类加载过程
- 类加载检查通过后,JVM开始为新生对象分配内存。
对象的内存分配方式
- 指针碰撞(Bump the Pointer)
- 适用于内存比较规整
- Serial、ParNew等带压缩整理功能的垃圾收集器采用的是指针碰撞
- 空闲列表(Free List)
- 适用于内存相互交错,JVM维护一个列表记录哪些内存块是可用的
- CMS这种不带压缩整理功能的垃圾收集器采用的是空闲列表
解决内存分配多线程竞争方式
- 同步控制
- CAS+失败重试
- TLAB(本地线程分配缓冲,Thread Local Allocation Buffer)
- 每个线程在堆中预先分配一小块内存区域,哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
- JVM是否开启TLAB,可用-XX:+/-UseTLAB决定。
半初始化
- 内存分配完成后,JVM将分配到的内存空间都初始化为零值(不包括对象头)。
- 如果使用TLAB,半初始化工作可以提前到TLAB分配时进行。
对象头设置
- JVM对对象头进行必要的设置。
- 对象的哈希码、GC分代年龄、类的元数据信息、偏向锁等
初始化
- 执行对象头设置后,从JVM视角,一个新的对象已经产生了。
- 从Java程序视角,还需要执行<init>方法按照程序意愿进行初始化,一个真正可用的对象才算完全产生出来。
28.2 对象的内存分配(栈-TLAB-Eden-Old)
对象分配流程栈上分配
- 优点
- JVM一项优化技术,将线程私有的对象打散分配到栈上
- 不需要GC的介入,方法调用结束自动销毁对象
- 栈上分配速度快,提高系统性能
- 缺点
- 栈空间小,大对象无法在实现栈上分配
- 栈上分配依赖于逃逸分析和标量替换
堆上分配
-
TLAB分配
- TLAB(本地线程分配缓冲,Thread Local Allocation Buffer),TLAB默认占用Eden区的1%大小,每个线程在堆中预先分配一小块内存区域,哪个线程需要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。
- JVM是否开启TLAB,可用-XX:+/-UseTLAB决定。
-
对象优先在Eden分配
-
大对象直接进入老年代
- 大对象的阈值参数:-XX:PretenureSizeThreshold
- 比如说很长的字符串或者数组
-
长期存活的对象进入老年代
- 对象在Eden经历一次Minor GC仍然存活并且Survivor区能够容纳,就会被移动到Survivor区,对象年龄设为1;
- 对象在Survivor区每熬过一次Minor GC,年龄就+1,年龄增加到一定年龄(CMS默认6次,G1默认是15)就会被晋升到老年代。
- 年龄的阈值参数:-XX:MaxTenuringThreshold
-
动态对象年龄判定
- JVM并不是永远要求对象年龄必须要MaxTenureThreshold才晋升到老年代
- 如果Survivor区中相同年龄所有对象的大小的总和大于Survivor区空间的一半,年龄大于等于该年龄的对象可以直接进入老年代。
-
空间分配担保
- 当发生YGC前,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,
- 如果大于,那么这次YGC是安全的;
- 如果不大于的话,JVM就需要判断HandlePromotionFailure是否允许空间分配担保。
- JVM继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小,
- 如果大于,则正常进行一次YGC,尽管有风险(因为判断的是平均大小,有可能这次的晋升对象比平均值大很多);
- 如果小于,或者HandlePromotionFailure设置不允许空间分配担保,这时要进行一次FGC。
- 当发生YGC前,JVM会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,
28.3 对象的内存布局
- 对象头(Header,12字节)
- Mark Word(存储对象自身的运行时数据,8字节)
- hashcode
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
- 类型指针(默认有压缩是4字节,没有压缩是8字节):用来确定这个对象是哪个类的实例
- 数组长度(默认4字节,普通对象没有)
- Mark Word(存储对象自身的运行时数据,8字节)
- 实例数据(Instance Data):对象真正存储的有效信息
- 对其填充(Padding)
- 不是必须存在的,起到占位符的作用
- HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍,当对象实例数据部分没有对齐时,需要对齐填充来补全。
28.4 对象的访问定位
- 句柄
- reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改;
- 直接指针
- 速度更快,节省了一次指针定位的时间开销;
- HotSpot采用的直接指针访问方式。
29 为什么压缩指针超过32G失效?
29.1 查看JDK是否开启压缩指针?
- CMD下输入命令:java -XX:+PrintCommandLineFlags -version
- 命令行出现-XX:+UseCompressedClassPointers -XX:+UseCompressedOops,说明JDK启用了压缩指针。
29.2 为什么需要压缩指针?
-
32位操作系统能够寻址的最大内存位4g(2^32=410241024=4g)
- 32位操作系统花费的内存空间为16个字节:对象头-8字节 + 实例数据 int类型-4字节 + 引用类型-4字节+补充0字节(16是8的倍数) 16个字节
-
64位操作系统能够寻址的最大内存空间近似无穷大,但是同一个对象在64位操作系统中存储会花费更多的空间,寻址也变得更加复杂。
- 64位操作系统花费的内存空间为32个字节:对象头-16字节 + 实例数据 int类型-4字节 + 引用类型-8字节+补充4字节(28不是8的倍数补充4字节到达32字节) 32个字节
-
开启压缩指针后可以减缓堆空间的压力(同样的内存更不容易发生oom)
- 64位开启压缩指针花费内存空间为24个字节: 对象头-12字节 + 实例数据 int类型-4字节 + 引用类型-4字节+补充0字节=24个字节
-
在JVM中(不管是32位还是64位),对象已经按8字节边界对齐了。对于大部分处理器,这种对齐方案都是最优的。所以,使用压缩的oop并不会带来什么损失,反而提升了性能。
29.3 JVM怎么实现压缩指针?
- 不再保存所有引用,而是每隔8个字节保存一个引用。
- 例如,原来保存每个引用0、1、2…,现在只保存0、8、16…。因此,指针压缩后,并不是所有引用都保存在堆中,而是以8个字节为间隔保存引用。
- 在实现上,堆中的引用其实还是按照0x0、0x1、0x2…进行存储。只不过当引用被存入64位的寄存器时,JVM将其左移3位(相当于末尾添加3个0),例如0x0、0x1、0x2…分别被转换为0x0、0x8、0x10。而当从寄存器读出时,JVM又可以右移3位,丢弃末尾的0。
- oop在堆中是32位,在寄存器中是35位,2的35次方=32G。也就是说,使用32位,来达到35位oop所能引用的堆内存空间。
29.4 哪些信息会被压缩?
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
- 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
29.5 哪些信息不会被压缩?
- 指向非Heap的对象指针
- 局部变量、传参、返回值、NULL指针
29.6 压缩指针32g指针失效问题
- 因为寄存器中2的35次方只能寻址到32g左右(JDK8前提下,32760m的堆是开启压缩指针的,32770m的堆压缩指针已经关闭:32G=32*1024=32768M,刚好在范围[32760, 32770]中。),所以当你的内存超过32g时,jvm就默认停用压缩指针,用64位寻址来操作,这样可以保证能寻址到你的所有内存,但这样所有的对象都会变大;
- JVM大堆的缺陷:
- 内存超过32G压缩指针失效
- 堆越来浪费内存,降低CPU性能,GC表现越差,dump分析也麻烦。
30 阅读GC日志
-
最前面数字,如33.125,代表GC发生的时间,从JVM启动以来经历的秒数;
-
垃圾收集停顿类型
- [GC
- [Full GC ,一般发生了STW
- [Full GC(System),调用System.gc()触发的垃圾收集
-
GC发生的区域
- [DefNew,Default New Generation,Serial收集器的新生代
- [ParNew,Parallel New Generation,ParNew收集器的新生代
- [PSYoungGen,Parallel Scavenge Young Generation,Parallel Scavenge收集器的新生代
- [Tenured,老年代
- [Perm,永久代
-
3324K->152K(3712K):GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)
-
3324K->152K(11904K):GC前java堆已使用容量->GC后java堆已使用容量(java堆总容量)
-
0.0032596 secs:该内存区域GC所占用的时间,单位是秒
-
[Times:user=0.01 sys=0.00,real=0.02 secs]:用户态消耗的CPU时间,内核态消耗的CPU时间,操作从开始到结束所经历的墙钟时间
- 墙钟时间(Wall Clock Time):包含各种非运算的等待时间,如等待线程阻塞、等待磁盘I/O等
- CPU时间:不包含等待消耗,如果是多核CPU,多线程操作会叠加CPU时间,user或sys时间超过real也是正常的。
网友评论