一. Java内存区域
1. JVM的内存区域划分,以及各部分的作用
可分为运行时数据区域和本地内存,按照线程私有和线程共享分类:
线程私有:程序计数器、虚拟机栈、本地方法栈。
线程共享:堆、方法区、直接内存。
JDK1.7与1.8版本略有不同。
1.8中,方法区被划分到了本地内存,并以元空间的形式存在。
(1)程序计数器
主要用来依次读取指令,实现代码的流程控制;同时还可以记录当前线程的位置,使线程切换后能够恢复到正确的位置。
(2)虚拟机栈
主要用来实现Java方法的调用与执行。栈由多个栈帧组成, 每个栈帧由局部变量表、操作数栈、动态链接、方法返回地址构成。
(3)本地方法栈
主要用来实现本地方法的调用与执行。
(4)堆
线程共享的一块内存区域,主要用于存放新创建的对象实例,几乎所有对象都在这里分配内存;也是垃圾回收的主要区域,也可称为GC堆。
从垃圾回收的角度来说,堆还可细分为新生代和老年代。再细分的话,新生代有Eden区、Survivor区、Old区。
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK1.8版本后,永久代被元空间取代。
对象的存活的区域大致可以描述为,新创建的对象在Eden区,当进行一次新生代垃圾回收后,如果对象还存活,就会进入Survivor区(S0、S1),如果年龄继续增加,就会进入老年代。
(5)方法区
JVM运行时数据区的一块逻辑区域,被线程共享。主要用来存放类信息、方法信息、常量、静态变量等。
永久代和元空间是方法区的具体实现。
为什么要将永久代替换为元空间?
- 永久代有一个JVM本身设置的固定大小上限,无法调整,元空间使用的本地内存,虽然也可能会发生内存溢出,但概论相对较小。
- 元空间里存放的是类的元数据,由系统实际可用的空间来控制,能够加载更多的类。
(6)运行时常量池
用于存放编译期生成的各种字面量和符号引用的常量池表,常量池表会在类加载后存到方法区的运行时常量池中,它的功能类似于符号表。
(7)字符串常量池
为了提升性能与减少内存消耗,避免字符串的重复创建。
JDK1.7之前, 字符串常量池存放在永久代中, 为什么1.7后移动到堆中?
主要是因为永久代(方法区的具体实现)的垃圾回收效率太低,只有在Full GC时才会被GC。Java中有大量的字符串等待回收,放到堆中能够及时有效的回收字符串内存。
(8)直接内存
位于本地内存中,并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。
2. Java对象的创建过程
(1)类加载检查:
new指令——检查指令参数是否能在常量池中定位到符号引用——检查符号引用代表的类是否被加载过——若没有——执行类加载过程。
(2)分配内存:
类加载通过后进行内存分配,两种方式,指针碰撞和空闲列表。选择哪种方式由Java堆是否完整决定,Java堆是否完整由采用的GC收集器是否具有压缩整理功能决定。
- 指针碰撞
适用于堆完整。原理是用过的内存整合到一边,没用过的整合到另一边,中间有个分界指针,向着没用过的方向移动指针即可。
- 空闲列表
适用于堆不完整。虚拟机会维护一个列表,列表中会记录哪些内存块可用,分配时,会找一个足够大的内存块分配给对象,然后更新列表。
内存分配并发问题:
- CAS+失败重试:乐观锁。失败就重试,直到成功。这种方式保证了操作的原子性。
- TLAB:在Eden区预留一块内存。分配内存时,先从TLAB中分配,如果不够,再用上述CAS进行分配。
(3)初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,保证了对象的实例字段在代码中可以不被赋值就直接使用。
(4)设置对象头
虚拟机对对象进行必要的设置,如对象是哪个类的实例、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象头中。
(5)执行init方法
从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,init方法还没执行,所有字段都还为零。执行new指令后接着执行init方法,按照开发者的意愿进行初始化。
3. 什么是JVM堆溢出和栈溢出?
堆溢出指的是JVM的堆内存不足以分配新的对象时发生的溢出。
栈溢出指的是JVM虚拟栈空间不足以支持新的方法调用时发生的溢出。
二. JVM垃圾回收机制
1. 堆内存的常见分配策略
- 对象优先在在Eden区分配。
- 大对象直接进入老年代。
- 长期存活的对象将进入老年代。
2. 死亡对象判断方法(是否可以被GC回收)
(1)引用计数法
给对象中添加一个引用计数器。
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
实现简单,效率高,但使用较少,因为无法解决对象间的循环引用问题(两个对象互相引用导致计数器不为0)。
(2)可达性分析法
以 "GC Roots" 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
哪些对象可以作为GC Roots呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象。
- 本地方法栈(Native方法)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
3. Java中四种引用类型
(1)强引用:大部分引用都是强引用,最普遍的引用。new出来的对象都是强引用,即使内存空间不足,也不会被GC回收。
(2)软引用:SoftReference修饰。比强引用弱一些。内存空间不足时才会回收它。
(3)弱引用:WeakReference修饰。比软引用弱。无论内存空间充足与否,只要发现了弱引用就会被GC回收。
(4)虚引用:PhantomReference修饰。最弱的引用。没有实际作用,任何时候都能被回收,主要用来跟踪对象被垃圾回收的活动。
实际中使用软引用较多,软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出等问题的产生。
4. 如何判断一个常量是废弃常量?
运行时常量池主要被回收的是废弃常量。
假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量。
5. 如何判断一个类是无用类?
方法区主要被回收的是无用的类。
需同时满足3个条件:
- 该类所有的实例都已被回收。
- 加载该类的ClassLoader已被回收。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
6. 4种垃圾收集算法
(1)标记—清除算法
标记和清除两个阶段。首先标记出所有不需要回收的对象,然后标记完成后对未标记的对象统一回收。这是基础算法,后续算法都是对其的改进。
但存在两个明显问题:效率问题(两阶段效率都不高)和空间问题(清除后存在大量不连续的内存碎片)。
(2)复制算法
为了解决效率和内存碎片问题,复制算法将内存分为大小相同的两块,每次使用其中的一块,其中一块使用完成后,将还存活的对象复制到另一块中去,然后再把使用的空间进行清理。保证每次的内存回收都是对一半区域的回收。
但依然存在问题:可用内存缩小为原来的一半。
(3)标记—整理算法
标记过程与标记—清除算法一样,但在标记完成后,让所有存活的对象向一端移动,然后清理掉端边界以为的内存。
(4)分代收集算法
主流算法。根据对象存活周期的不同将内存分为几块,一般将Java堆分为新生代和老年代,根据各个年代的特点选择合适的回收算法。
比如新生代,对象创建的多,但回收时死去的对象也很多,因此可以选择复制算法。老年代中的对象存活比较多,所以选择标记—清除或标记—整理算法。
7. 8个垃圾收集器
(1)Serial收集器
最基本、最悠久。单线程收集器,在进行垃圾收集工作时会暂停其它所有工作线程(Stop The World),直到结束。
简单而高效。
(2)ParNew收集器
Serial收集器的多线程版本,其余都和Serial一样。
(3)Parallel Scavenge收集器
几乎和ParNew收集器一样,但更关注吞吐量,高效利用CPU。
(4)Serial Old收集器
Serial收集器的老年代版本。
(5)Parallel Old收集器
Parallel Scavenge收集器的老年代版本。
(6)CMS收集器
CMS(Concurrent Mark Sweep ),以获取最短回收停顿时间为目标的收集器,注重用户体验。第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程基本上同时工作。
标记清除算法的实现,运作过程大致分为四个步骤:
- 初始标记:暂停其它所有线程,记录下与GC Root相连的对象。
- 并发标记:同时开启GC和用户线程,用一个闭包结构记录可达对象。但GC线程无法保证可达性分析的实时性,会有引用更新的地方。
- 重新标记:修正并发标记期间因为用户程序继续运行而导致标记产生变动的标记记录。
- 并发清除:开启用户线程,同时GC线程对未标记的区域清除。
并发收集,低停顿。
(7)G1收集器
面向服务器的垃圾收集器,主要针对配备了多颗处理器及大容量内存的机器。既能满足低停顿还可以做到高吞吐量。
特点:
- 并行与并发:充分利用多核的硬件优势来缩短停顿时间,也能做到和用户线程的并发执行。
- 分代收集:不需要其它收集器配合就能独立管理整个GC堆。
- 空间整合:整体标记—整理,局部标记—复制。
- 可预测的停顿:除了降低停顿外,还能够建立可预测的停顿时间模型,让用户指定停顿时间。
步骤:
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region,这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率。
JDK9之后,G1变成了默认的GC。
(8)ZGC收集器
采用复制算法,将暂停时间控制在几毫米以内,且暂停时间不受内存堆大小的影响,代价是牺牲了一些吞吐量。
8. Minor GC和Full GC
Minor GC主要发生在新生代,频繁且速度快;Full GC主要发生在老年代,回收速度较慢。一般来说,对象在新生代的Eden区分配。当Eden区没有足够空间分配时,虚拟机会进行⼀次Minor GC,Minor GC之后survivor放不下,要放到老年代,此时发现老年代也放不下,就会触发Full Gc。
三. 类加载器
1. 类加载器有哪些?
类加载过程:加载—连接—初始化。
连接又分为三步:验证—准备—解析。
类加载器作用于第一步加载。
类加载器的主要作用就是加载 Java 类的字节码(. class文件)到 JVM 中(在内存中生成一个代表该类的Class对象)。
JVM中内置了三个重要的ClassLoader:
- BootstrapClassLoader(启动类加载器):最顶层的加载类,加载核心库:JAVA_HOME/jre/lib目录下的库。
- ExtensionClassLoader(扩展类加载器) :加载扩展类的类库;JAVA_HOME/jre/lib/ext。
- AppClassLoader(应用程序类加载器) :面向用户的加载器,加载classpath下的类。
- 自己编写的类加载器。
除了 BootstrapClassLoader,其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader。如果要自定义自己的类加载器,也需要继承 ClassLoader 抽象类。
2. 双亲委派模型
类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?
双亲委派模型和上面提到的类加载器层次关系图一致。
大致流程:加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,继续向上委托,如果这个类委托的上级没有被加载,子加载器会尝试加载这个类。
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
- 加载的时候,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父类加载器的loadClass()方法来加载类),所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader中。
- 当父类加载器无法处理这个加载请求(它的搜索范围中没有找到所需的类),子加载器才会尝试自己加载(调用自己findClass()方法来加载类)。
- 子加载器也无法加载,抛出ClassNotFoundException异常。
双亲委派模型的好处?
- 保证Java程序的稳定运行,避免重复加载类(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类)。
- 保证Java的核心API不被篡改。比如自己编写了一个java.lang.Object类,程序运行时就会有两个不同的Object类。双亲委派模型可以保证加载的时JRE里的Object类,而不是自己写的。因为AppClassLoader加载自己写的Object类时,会先委派给它的父类,即ExtClassLoader,而Ext又会委派给Boot,Boot发现自己加载过Object类了,会直接返回,而不是加载自己写的。
如何打破双亲委派模型?
继承ClassLoader类,自定义一个加载器,然后重写 loadClass() 方法。
之所以重写loadClass方法是因为加载类时,类加载器会先委托父类加载器去完成,即调用父类的loadClass()方法。