还在为面试被问JVM发愁?来看看阿里P7大佬的JVM笔记
1、知识点汇总
JVM是Java的运行的基础,也基本是互联网公司以及一线大厂必问的一个知识点。下面先用思维导图画一个整体概念。然后,在进行拓展说明。
先上图,如下:
图上都是重点,都要理解和记。如果非要说重点,内存模式、类加载、GC被问的频率比较高。性能调优偏向应用实践。场景应用被问的几率也很高。总之,都是重点。
2、知识点详解
2-1 类加载
2-1-1类的加载过程
加载:通过类的全限定名(包路径+类名),查找该类的字节码文件,利用字节码创建Class对象。
验证:确保字节码是正确安全可被虚拟机执行,不会威胁虚拟机自身的安全。
准备:进行内存分配,为static修饰的类变量分配内存,并初始化值。(比如int a=3,此时,a=0。a被初始为0,初始化按照jvm规则。)不包括final修饰的静态变量。(final修饰的不会被初始化。因为在在编译的时候已经被赋值了)
解析:将常量池中的符号替换为直接引用的过程,直接引用直接指向目标或相对偏移量等。
初始化:主要完成静态块执行以及静态变量赋值。先初始化父类,在初始化当前类,只有对类的主动使用才会被初始化。(触发条件有,创建类实例时,访问类的静态方法或静态变量时。使用Class.froName()反射类时,某个子类初始化时,java自带类加载器加载类时,在虚拟机生命周期中是不会被卸载的,只有用户定义的类加载器加载类时,才会被卸载)
使用:实例化
卸载:GC的过程,垃圾回收。在垃圾回收拓展说明。
2-1-2类加载机制
java类加载机制采用双亲委派机制,会做2次遍历。
1、第一次从当前类,找是否被加载。没有,委托父类加载,没有继续委托父类加载,知道根节点。如果根节点也没有,向下委托子类加载依次到当前类加载器。这个时候当前类加载器尝试加载。
2、第二次,当前类加载。有,执行下一步。
优点:
避免重复加载;
沙箱安全机制;(防止api被篡改)
1、能不能自己写个类叫java.lang.System?
不可以,因为java加载是双亲委派机制。父类已经有了。子类不会被加载。
2、如何打破双亲委派机制?
jvm系统类(如rt.jar、ext.jar),不能被打破。因为这是jvm系统的规则(java.lang.System)。其他,如spring可以打破双亲委派机制;
static class MyClassLoad extends ClassLoader {
private String classPath;
private MyClassLoad(String classPath) {
this.classPath = classPath;
}
private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read();
fis.close();
return data;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
/**
* 重写类加载方法,打破委派给双亲加载
*
* @param name
* @param resolve
* @return
* @throws ClassNotFoundException
*/
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//非自定义的类还是走双亲委派加载
if (!name.startsWith("com.lzc.jvm")) {
c = this.getParent().loadClass(name);
} else {
c = findClass(name);
}
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
}
public static void main(String args[]) throws Exception {
MyClassLoad classLoader = new MyClassLoad("D:/test");
Class clazz = classLoader.loadClass("com.lzc.jvm.wzp");
Object obj = clazz.newInstance();
Method method = clazz.getDeclaredMethod("sout", null);
method.invoke(obj, null);
System.out.println(clazz.getClassLoader());
}
2-2 内存模式
方法区:也称非堆区。用于存储已经被虚拟器加载的信息。常量、静态变量、、类信息、及时编译器优化后的代码等数据。jdk1.7的永久代、jdk1.8的元空间都是方法区实现的一种方式。
栈:又称方法栈。线程是方法私有的。线程在执行方法的时候,都会创建一个栈帧,用来存储静局部变量表、操作数栈、动态链接和方法出口等信息。执行方法时入栈才做,方法返回执行出栈。
本地方法栈:与栈类似,也是用来保存执行方法的信息。执行Java方法是使用栈,执行Native方法时使用本地方法栈。
程序计数器:保存着当前线程执行的字节码位置,每个线程工作都有独立的计数器,只为执行Java方法服务,执行Native方法时,程序计数器为空。
堆:JVM内存管理最大的一块,对被线程共享,目的是存放对象的实例,几乎所欲的对象实例都会放在这里,当堆没有可用空间时,会抛出OOM异常.根据对象的存活周期不同,JVM把对象进行分代管理,由垃圾回收器进行垃圾的回收管理。
2-3 执行模式
java通常java分为编译期和运行期。编译期就是通过javac编译器将java源代码文件生成.class文件,.class文件里面实际上是字节码,而不是可以执行的机器码。运行时,JVM 会通过类加载器(Class-Loader)加载字节码,解释或者编译执行。启动JVM时加上-XX:PrintCompilation参数能看到相关的信息。
编译模式
JVM启动时,指定-Xcomp参数,就是告诉JVM关闭解释器,使用编译模式(或者叫最大优化级别),不进行解释执行。这种模式并不表示执行效率最高,它会导致JVM启动变得非常慢,同时有些JIT编译器的优化操作(如分支预测)并不能进行有效的优化。
注释模式
JVM启动时,指定-Xint参数,就是告诉JVM只进行解释执行,不对代码进行编译。这种模式抛弃了JIT可能带来的性能优势。毕竟解释器是逐条读入,逐条解释执行的。
混合模式
混合模式,就是解释和编译混合的一种模式,新版本的JDK(例如JDK8)默认采用的是混合模式(JVM参数为-Xmixed)。通常运行在server模式的JVM,会进行上万次调用以收集足够的信息进行高效的编译,client模式这个限制是1500次。Hotspot JVM内置了两个不同的JIT编译器,C1对应client模式,适用于对于启动速度敏感的应用(如java桌面应用);C2对应server模式,它的优化是为长时间运行的服务器端应用设计的。
2-4 编译器优化
公共子表达式消除:
如果表达式A已经计算过了。而且从开始的计算到现在的A中,所有变量的值没有发生变化。那么A这次出现就是公共表达式。对于这种表达式,没必要花时间在去计算。只需要用前面的计算结果代替A即可。
int a = b * c + (2 + b * c)
//优化
int a = A + ( 2 + A)
//优化
int a = 2 * A +2
同步消除
同步消除是jvm的一种优化策略,通过逃逸分析。可以知道一个线程是否被其他线程访问。
如果没有线程逃逸,对象的读写就不会有资源竞争。那就可以对该对象进行同步锁。
可以用-XX:+EliminateLocks开启同步消除
内联
就是将一些没用的代码剔除,或者对于没有必要的方法跳转,将目标方法中的代码 “复制”到发起调用的方法之中,避免真实的方法调用。因为 Java 中的多态性。因此,内联有时不能确定目标方法,对应的情况如下:
如果是非虚方法,直接进行内联。
如果是虚方法,只有一个版本,进行守护内联属于激进优化,要设置”逃生门“,没有变化时继续内联,若是加载了一个有变化的新类就直接抛弃退回解释执行。如果有多个版本,就做内联缓存,在未发生内联的时候,缓存为空,第一次调用方法时,记录方法的调用者,后面每次调用就进行判断,一致就继续进行,不一致就取消内联。
逃逸分析
逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后可能被外部方法所引用,例如传参,称为方法逃逸。还有被外部线程访问到,例如赋值给类变量,称为线程逃逸。如果一个对象不会逃逸那么可以进行如下优化:
(1)栈上分配:在栈上分配内存,对象所占的内存空间随栈帧出栈而销毁。
(2)同步消除:消除没有必要的线程同步。
(3)标量替换:标量是指一个数据不能再分解,比如原始数据类型(int、long等)。相对的,一个数据可以继续分解,那称它为聚合量,例如对象。如果把一个Java对象拆散,根据程序访问情况,将其使用到的成员变量恢复原始类型来访问,称为标量替换。如果一个对象可以被分解,且不会逃逸,就直接使用标量代替对对象的成员变量,而不直接创建这个对象。
2-5 GC
分代回收基于两个事实:大部分对象很快就不使用了,还有一部分不会立即无用,但也不会持续很长时间。
jdk1.8没有老年代(元空间)。下图我是用jdk自带的jvisualvm.exe软件。安装了一个插件。
年轻代:
所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到第二个Survivor区,当第二个Survivor区也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor区过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
老年代:
当对象在新生代躲过一次Minor GC的话,其对象年龄便会加1,默认情况下,如果对象年龄达到15岁(对象年龄最大15岁,可以调整区域为<=15岁),就会移动到老年代中;一般来说,大对象会被直接分配到老年代,所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组或者长字符串。
永久代:
用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。
2-5-1回收算法
垃圾回收的几种常用算法:
标记-清除、复制算法、标记-整理算法、分代收集算法
Jvm的垃圾收集器(serial收集器、parnew收集器、parallel scavenge收集器、serial old 收集器、parallel old收集器、cms收集器、g1收集器)
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:CMS、Serial Old、Parallel Old
整堆收集器:G1
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
下面挑2个说下
cms收集器
一种以获取最短回收停顿时间为目标的收集器。
特点:基于标记-清除算法实现。并发收集、低停顿。
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
CMS收集器的运行过程分为下列4步:
初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题。
并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。
重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题。
并发清除:对标记的对象进行清除回收。
CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的缺点:
对CPU资源非常敏感。
无法处理浮动垃圾,可能出现Concurrent Model Failure失败而导致另一次Full GC的产生。
因为采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
G1收集器(jdk默认回收器)
特点如下:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。
可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。
初始标记:仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。(需要线程停顿,但耗时很短。)
并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)
最终标记:为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)
筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)
G1为什么能建立可预测的停顿时间模型?
因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。
G1与其他收集器的区别:
其他收集器的工作范围是整个新生代或者老年代、G1收集器的工作范围是整个Java堆。在使用G1收集器时,它将整个Java堆划分为多个大小相等的独立区域(Region)。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region(不需要连续)的集合。
G1收集器存在的问题:
Region不可能是孤立的,分配在Region中的对象可以与Java堆中的任意对象发生引用关系。在采用可达性分析算法来判断对象是否存活时,得扫描整个Java堆才能保证准确性。其他收集器也存在这种问题(G1更加突出而已)。会导致Minor GC效率下降。