(图片来源网络)
1、JVM为什么要分代?
因为不同对象的生命周期是不一样的,因此对于不同的对象可以采用不同的垃圾回收方式进行收集处理。JVM进行分代之后,新创建的对象在新生代分配,经过多次(默认15)YGC后仍然存活的对象转入老年代,新生代对象基本属于朝生夕死的短生命周期,可进行YGC对其回收。分代回收提升了GC的效率。
2、为什么有survivor区,为什么有2个survivor?
如果不存在survivor区,Eden区满后对象直接进入老年代,这样会增加fullGC的频率,影响性能。survivor区具有缓冲、筛选的作用,是将达到一定"寿命"的老对象转移到老年代,减少了被送到老年代中的对象,从而减少了fullgc的频次。
划分2个survivor区的目的是为了提高内存的利用率,如果只有一个survivor区,存入survivor区的对象在YGC后的内存会变得支离破碎(不连续、内存碎片),如果有个较大的对象再进入survivor区后发现没有内存分配,就会直接进入老年代。因此划分2个survivor区,方便对象在2个survivor之间复制、整理内存,从而保证一个survivor为空,另一个使用且无内存碎片。
3、什么是指令重排序?
Java内存模型允许编译器和处理器对指令进行重排序来提升运行性能(只对不存在数据依赖的指令间进行重排序),这种方式只对单线程有效,多线程环境重排序后运行的结果并不是我们所预期的那样,这就是指令重排序。
例:
public int add() {
int x = 1; //(1)
int y = 2; //(2)
int z = x + y; //(3)
return z;
}
上面的代码中,(1)和(2)没有依赖关系,它们之间可以重排序,(3)依赖了(1)和(2),所以(3)不会被重排序到(1)或(2)之前,这个栗子中,(1)(2)即使发生了重排序,也不会影响程序运行结果。
多线程情况下,结果可能与预期不一致。
Thread readThread = new Thread(() -> {
if (ready) { //(1)
System.out.println(num + num); //(2)
}
});
Thread writeThread = new Thread(() -> {
num = 2; //(3)
ready = true; //(4)
});
当对(3)和(4)的指令进行重排序的时候,(2)输出的结果可能是0或者4.(1)(2)(3)(4)之间也是没有依赖关系,但是在多线程环境下,就有歧义的结果。如何避免指令重排序,使用volatile关键字修饰。
4、什么是内存屏障?
它是为了防止编译器和硬件的不正确优化,使得对存储器的访问顺序(其实就是变量)和书写程序时的访问顺序不一致而提出的一种解决办法。
编译器的内存屏障只是为了告诉编译器不要对指令进行重排序。当编译完了以后,这种内存屏障就消失了,CPU并不会感知到编译器层面的内存屏障。CPU的内存屏障则是CPU提供的指令,可供开发者调用。
从JDK8开始,Java在Unsafe类中提供了三个内存屏障函数。
public final class Unsafe{
//...
public native void loadFence();
public native void storeFence();
public native void fullFence();
//...
}
这三个屏障并不是最基本的内存屏障。在理论层面,可以把基本的CPU内存屏障分为四种:
LoadLoad:禁止读和读的重排序
StoreStore:禁止写与写的重排序
LoadStore:禁止读和写的重排序
StoreLoad:禁止写和读的重排序
Volatile如何实现的禁止指令重排序?
①在volatile写操作的前面插入一个StoreStore屏障。保证了volatile写操作不会和之前的写操作重排序
②在volatile写操作后面插入一个StoreLoad屏障。保证了volatile的写操作不会和之后的读操作重排序
③在volatile读操作后面插入一个LoadLoad屏障+LoadStore屏障。保证volatile的读操作不会和后面的读操作和写操作重排序
5、Happens-before原则
JMM(Java内存模型:线程工作内存和主内存)通过Happen-before原则保证多线程执行过程中的内存可见性,即A线程的写操作a与B线程的读操作b之间存在happens-before关系,JMM保证a操作将对b操作可见。
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
(7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
happens-before并不保证程序的执行顺序!!!
利用程序顺序规则(规则1)存在三个happens-before关系:
A happens-before B;
B happens-before C;
A happens-before C。
6、JMM(Java内存模型)
(图片来源网络)
JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
7、JVM安全点、安全区域?
JVM采用的可达性分析法从GC Roots查找引用链耗时。GC进行扫描时,需要查看每个位置存储的是不是引用类型,每次GC都要去扫描找出不可达对象非常浪费时间的,而且垃圾回收器在这一步需要STW。针对这个问题,Hotspot使用一种存储引用类型的数据结构叫OopMap(Ordinary Object Pointer Map 普通对象指针映射表),把栈上的引用类型位置记录下来,GC的时候直接读取。
安全点
OopMap中数据的更新需要在对象引用关系发生变化的时候进行,但导致引用关系变化的指令非常多,如果对每条指令都记录OopMap的话 ,那将会需要大量的额外存储空间,空间成本就会变得无法忍受的高昂。选用一些特定的点来记录就能有效的缩小需要记录的数据量,这些特定的点就称为安全点(Safepoint)。
有了安全点,当GC回收需要停止用户线程的时候,将设置某个中断标志位,各个线程不断轮询这个标志位,发现需要挂起时,找到离自己最近的安全点,更新完OopMap 才能挂起。这主动式中断的方式是绝大部分现代虚拟机选择的方案(被动式就是发个信号,例如关机、Control+C,带来的问题就是不可控,发信号的时候不知道线程处于什么状态)。
安全点不是任意的选择,既不能太少以至于让收集器等待时间过长,也不能过多以至于过分增大运行时的内存负荷。通常选择一些执行时间较长的指令作为安全点,如:
1)循环的末尾
2)方法返回前
3)调用方法的call之后
4)抛出异常的位置
安全区域
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了。但是,如果此时线程正处于 Sleep 或者 Blocked 状态,这些线程他不会自己走到安全点停不下来了。针对这种情况引入安全区域 (Safe Region)。
安全区域指的是,在某段代码中,引用关系不会发生变化,线程执行到这个区域是可以安全停下进行 GC 的。因此,我们也可以把 安全区域 看做是扩展的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域。那样当这段时间里虚拟机要发起 GC 时,就不必去管这些在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否处于 STW 状态,如果是,则需要等待直到恢复。
8、类加载器有哪些?
(图片来源网络)
1)启动类加载器:这个类加载器负责放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库。用户无法直接使用。
2)扩展类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader实现。它负责<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。用户可以直接使用。
3)应用程序类加载器:这个类由sun.misc.Launcher$AppClassLoader实现。是ClassLoader中getSystemClassLoader()方法的返回值。它负责用户路径(ClassPath)所指定的类库。用户可以直接使用。如果用户没有自己定义类加载器,默认使用这个。
4)自定义加载器:用户自己定义的类加载器。
9、Java类加载过程描述
类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中类加载过程包括加载、验证、准备、解析和初始化五个阶段。
1)加载阶段,虚拟机需要完成三件事:
根据类的全限定名获取定义此类的二进制字节流;
将这个字节流代表的静态存储结构转换为方法区的运行时数据结构;
在方法区中为这个类生成一个java.lang.Class对象,作为方法区这个类的访问入口。
2)验证包括:文件格式验证、元数据验证、字节码验证、符号引用验证
3)准备阶段为类变量分配内存并且设置初始化值的阶段,这些变量所使用的内存都在方法区分配。这个阶段进行初始化的数据只有静态字段,并且是赋值初始化值(final修饰的字段除外,被final修饰这一阶段赋实际值),不是代码中定义的值。
4)解析是将符号引用转为直接引用的过程
5)初始化这个阶段才开始真正的执行用户定义的Java程序。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则需要为类变量(非final修饰的类变量)和其他变量赋值,其实就是执行类的<clinit>()方法。<clinit>()方法与类的构造方法不同,它不需要用户显示的调用,虚拟机会保证父类的<clinit>()方法先于子类的<clinit>()执行,java.lang.Object的<clinit>()方法是最先执行的。
10、JVM符号引用和直接引用是什么?
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。
例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。
直接引用可以是直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)、可以是相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)、也可以是一个能间接定位到目标的句柄。直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
11、什么是双亲委派模型?为什么需要?如何破坏?
JVM 并不是在启动时就把所有的.class文件都加载一遍,而是程序在运行过程中用到了这个类才去加载。除了启动类加载器外,其他所有类加载器都需要继承抽象类ClassLoader,这个抽象类中定义了三个关键方法。loadClass方法的主要职责就是实现双亲委派机制:首先检查这个类是不是已经被加载过了,如果加载过了直接返回,否则委派给父加载器加载,这是一个递归调用,一层一层向上委派,最顶层的类加载器(启动类加载器)无法加载该类时,再一层一层向下委派给子类加载器加载。
public abstract class ClassLoader {
//每个类加载器都有个父加载器
private final ClassLoader parent;
public Class<?> loadClass(String name) {
//查找一下这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
//如果没有加载过
if (c == null) {
//先委派给父加载器去加载,注意这是个递归调用
if (parent != null) {
c = parent.loadClass(name);
} else {
// 如果父加载器为空,查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果父加载器没加载成功,调用自己的findClass去加载
if (c == null) {
c = findClass(name);
}
return c;
}
protected Class<?> findClass(String name) {
//1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
//...
//2. 调用defineClass将字节数组转成Class对象
return defineClass(buf, off, len);
}
// 将字节码数组解析成一个Class对象,用native方法实现
protected final Class<?> defineClass(byte[] b, int off, int len) {
//...
}
}
为什么需要双亲委派?
双亲委派保证类加载器,自下而上的委派,又自上而下的加载,保证每一个类在各个类加载器中都是同一个类。一个非常明显的目的就是保证java官方的类库<JAVA_HOME>\lib和扩展类库<JAVA_HOME>\lib\ext的加载安全性,不会被开发者覆盖。例如类java.lang.Object,它存放在rt.jar之中,无论哪个类加载器要加载这个类,最终都是委派给启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。
怎么破坏双亲委派模型?
如果想自定义类加载器,就需要继承ClassLoader,并重写findClass,如果想不遵循双亲委派的类加载顺序,还需要重写loadClass。注意破坏双亲委派的位置,自定义类加载机制先委派给ExtClassLoader加载,ExtClassLoader再委派给BootstrapClassLoader,如果都加载不了,然后自定义类加载器加载,自定义类加载器加载不了才交给AppClassLoader。为什么不能直接让自定义类加载器加载呢?不能!双亲委派的破坏只能发生在AppClassLoader及其以下的加载委派顺序,ExtClassLoader上面的双亲委派是不能破坏的!
Tomcat为了实现web应用间加载隔离,自定义了类加载器;Class.forName默认使用的类加载器;线程上下文类加载器。
破坏双亲委派有两种方式:第一种,自定义类加载器,必须重写findClass和loadClass;第二种是通过线程上下文类加载器的传递性,让父类加载器中调用子类加载器的加载动作。
12、Java四种引用
1)强引用:正常创建的对象,只要引用存在,永远不会被GC回收,即使OOM
Object obj = new Object();
2)软引用,内存溢出之前进行回收,GC时如果内存足够,就不回收。使用场景:在内存足够的情况下进行缓存,提升速度
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;
sf.get();//有时候会返回null
3)弱引用:每次GC时回收,无论内存是否足够。使用场景:1. ThreadLocalMap防止内存泄漏 2. 监控对象是否将要被回收
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
wf.get();//有时候会返回null
wf.isEnQueued();//返回是否被垃圾回收器标记为即将回收的垃圾
4)虚引用,每次垃圾回收时都会被回收,主要用于监测对象是否已经从内存中删除
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj=null;
pf.get();//永远返回null
pf.isEnQueued();//返回是否从内存中已经删除
引用强度排序:强引用>软引用>弱引用>虚引用