JVM
JVM内存区域划分

内存区域一共分为5个区域,其中方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁,而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程都有一个自己的区域,并且线程启动时会自动创建,结束后会自动销毁。内存划分完成之后,我们的JVM执行引擎和本地库接口,也就是Java程序开始运行之后就会根据分区合理利用对应区域的内存。
运行时数据区
程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程毁灭
程序计数器
定义:JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就是值得是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一跳即将执行的指令。
- 程序计数器的两个特点
- 现成私有
- 不会内存溢出
虚拟机栈
定义:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverFlowError异常;
如果Java虚拟机栈容量可以动态扩展,当栈扩展到无法申请到足够的内存会抛出OutOfMemoryError异常。
问题辨析
- 垃圾回收是否涉及栈内存? 不涉及
- 栈内存分配越大越好吗? 不是,栈内存越大,线程数越少
- 方法内的局部变量是否线程安全?
- 局部变量互不影响,线程安全;全局变量(static),需要考虑线程安全问题
- 还要看变量是否逃离方法作用范围,如果没有逃离,那么是线程安全
- 如果局部变量引用了对象并逃离了方法的作用范围,需要考虑线程安全
栈内存溢出–StackOverFlow
- 栈帧过多
- 栈帧过大–不太容易出现
线程运行诊断
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
堆
Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例和数组,Java世界里“几乎”所有的对象实例都在这里分配内存。
Java堆是垃圾回收器管理的内存区域,称为GC堆。
如果在Java堆中没有内存完成实例分配,并且堆也无法扩展时,Java虚拟机会抛出OutOfMemoryError异常。
Heap 堆
- 通过new关键字,创建对象都会使用堆内存
特点
- 线程共享,堆中对象都需要考虑线程安全问题
- 垃圾回收机制
堆内存溢出
OutOfMemoryError: Java heap space
堆内存诊断
方法区
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码缓存等数据。可以大致分为两个部分,一个是类信息表,一个是运行时常量池。
方法区内存溢出
- 1.8以前会导致永久代内存溢出
- 1.8之后会导致元空间内存溢出
运行时常量池
attention❤️:String的intern()方法作用是将堆中的字符串加载到常量池中,第二次调用此方法时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址。
定义:运行时常量池,常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
字符串常量池 StringTable
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
特性
- 常量池中的字符串仅仅是符号,第一次用到时才变为对象
- 利用串池的机制来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译期优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池
- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回
- 1.6 区别在于:如果没有的话会把此对象复制一份,放入串池,会把串池中的对象返回
1 | // 面试题 |
StringTable垃圾回收
StringTable性能调优
- 调整-XX:StringTableSize=桶个数
- 考虑将字符对象是否入池
直接内存
直接内存是一种特殊的内存缓冲区,并不在Java堆或方法区中分配的,而是在JNI的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,但是有可能产生OutOfMemoryError异常。
Direct Memory
- 常见于NIO操作时,用于数据缓冲区
- 分配成本较高,但是读写性能高
- 不受JVM内存回收管理
不使用直接内存读取文件形式:有两个缓冲区,两次复制操作
使用直接内存:一个缓存区,减少一次文件复制操作
- 直接内存分配和回收原理
垃圾回收(GC)
1. 如何判断对象可以回收
1. 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
java未采用
2. 可达性分析算法
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
Java中固定作为GC Roots的对象包括以下几种:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 在本地方法栈中JNI(即通常说的Native方法)引用的对象
- Java虚拟机内部的引用
- 所有被同步锁持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
!比较重要
minor GC过程:
3. 四种引用
以下四种引用强度逐渐减弱
- 强引用
指在程序代码之间普遍存在的引用赋值,只要强引用关系还存在,GC就永远不会回收掉被引用的对象
- 软引用
一些还有用,但非必须的对象
- 弱引用
- 虚引用
- 终结器引用
2. 垃圾回收算法
2.1 标记清除
首先标记处所有需要回收的对象,然后再一次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。
优点:速度快
缺点:空间不连续,产生内存碎片
2.2 标记-整理算法–老年代使用
标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,然后直接清理掉排序之后的待回收对象。
标记之后让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
优点:内存碎片少
缺点:整理文件花费时间,效率较低
Stop the world:移动存活对象导致的停顿
2.3 标记-复制–新生代使用
标记复制算法,将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束之后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。
分成FROM和TO两个区域
优点:不会产生碎片
缺点:占用双倍内存空间
2.4 分代收集
原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。
3. 分代垃圾回收
一般把Java堆划分为新生代和老年代
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾回收,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾回收
- 触发条件:新生代的Eden区容量已满。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾回收
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾回收。
- 触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间。
- 触发条件2:Minor GC后存活的对象超过了老年代剩余空间
- 触发条件3:永久代内存不足(JDK8之前)
- 触发条件4:手动调用System.gc()方法
4. 垃圾回收器
4.1 串行
- 单线程
- 堆内存较小,适合个人电脑
4.2 吞吐量优先
- 多线程
- 堆内存较大,多核cpu支持
- 让单位时间内,STW的时间最短
4.3 响应时间优先
- 多线程
- 堆内存较大,多核cpu
- 尽可能让单次STW的时间最短
P68
HotSpot虚拟机的垃圾回收器
- Serial收集器
这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。
优点:简单高效,额外内存消耗最小
缺点:单线程,在垃圾回收阶段,所有的用户线程必须等待GC线程完成工作。
- ParNew收集器
这款垃圾收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集:
Serial收集器的多线程并行版本
- Parallel Scavenge收集器/Parallel Old收集器
Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现,这两个都支持多线程并发收集。
PS收集器的目标是达到一个可控制的吞吐量。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。
PS收集器有着自适应策略
- Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法
- CMS收集器
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。
CMS基于标记清除算法实现,过程分为四步:
1. 初始标记(需要暂停用户线程):标记出GC Roots能直接关联的对象
2. 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程
3. 重新标记(需要暂停用户线程):
4. 并发清除

CMS收集器的内存回收过程是与用户线程一起并发执行的。
缺点:标记清楚算法会产生大量的内存碎片,导致可用连续空间组件变少,长期这样下来,会有更高的概率触发Full GC,并且在与用户并发执行的情况下,也会占用一部分系统资源,导致用户线程的运行速度一定程度减慢。
- Garbage Fisrt收集器
我们知道,我们的垃圾回收分为Minor GC、Major GC和Full GC,它们分别对应的是新生代,老年代和整个堆内存的垃圾回收,而G1收集器巧妙地绕过了这些约定,它将整个Java堆划分成2048个大小相同的独立Region块,每个Region块的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region大小相同,且在JVM的整个生命周期内不会发生改变。每个Redion都可以根据需要,自由决定扮演那个角色(Eden、Suvivor和老年代),收集器会根据对应的角色采用不同的回收策略。
- 初始标记(需要暂停用户线程)
- 并发标记
- 最终标记(需要暂停用户线程)
- 筛选回收

5. 垃圾回收调优
元空间

HotSpot虚拟机对象
1. 对象的创建
- 类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
- 指针碰撞 :
- 适用场合 :堆内存规整(即没有内存碎片)的情况下。
- 原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
- 使用该分配方式的 GC 收集器:Serial, ParNew
- 空闲列表 :
- 适用场合 : 堆内存不规整的情况下。
- 原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
- 使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。
内存分配并发问题
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- 本地线程分配缓冲TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
- 初始化零值
- 设置对象头
- 执行init方法
2. 对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
3. 对象访问定位
- 句柄访问:Java堆中将可能会划分为一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
- 直接指针访问:Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
类加载过程
类从北加载到虚拟机内存开始到卸载出内存为止,生命周期有以下7个阶段:加载、验证、准备、解析、初始化、使用和卸载。
- 加载
- 通过类的完全限定名称获取该类的二进制字节流
- 将该字节流生成的静态存储结构转化为方法区的运行时存储结构
- 在内存中生成一个代表该类的Class对象,作为方法区中该类各种数据的访问入口
- 验证
确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
- 准备
类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
初始值一般为0值,例如下面的类变量value被初始化为0而不是123
1 | public static int value = 123; |
如果类变量是常量,那么它将初始化为表达式所定义的值而不是0.例如下面的常量value被初始化为123而不是0.
1 | public static final int value = 123; |
- 解析
将常量池的符号引用替换为直接引用的过程。
其中解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的动态绑定。
- 初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。
类加载器
即使两个类是同一个Class文件加载的,只要类加载器不同,那么这两个类就是不同的两个类
类加载器分类
从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:
- 启动类加载器(Bootstrap ClassLoader),使用 C++ 实现,是虚拟机自身的一部分;
- 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。
从 Java 开发人员的角度看,类加载器可以划分得更细致一些:
- 启动类加载器(Bootstrap ClassLoader)此类加载器负责将存放在
\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。 - 扩展类加载器(Extension ClassLoader)这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将
/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。 - 应用程序类加载器(Application ClassLoader)这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型
双亲委派机制过程:如果一个类加载器收到了类加载请求,它首先不会去自动去尝试加载这个类,而是把这个类委托给父类加载器去完成,每一层依次这样,因此所有的加载请求都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成该加载请求(找不到所需的类)时,这个时候子加载器才会尝试自己去加载,这个过程就是双亲委派机制!
优点:
- 避免了类的重复加载
- 保护了程序的安全性,防止核心的API被修改
