内存模型
运行时数据区域
- 程序计数器:当前线程所执行的字节码的行号指示器,因为各个线程所执行到的位置不同,所以线程计数器是线程私有的。
- Java虚拟机栈:虚拟机栈描述的是Java方法执行的线程内模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。线程私有,生命周期与线程相同。
- 局部变量表
- 本地方法栈:与Java虚拟机栈相似,Java虚拟机栈为Java方法服务,本地方法栈为本地方法(Native)服务
- Java堆:存放对象实例。空间最大,在虚拟器启动时创建,线程共享。
- -Xmx最大,-Xms最小
- 方法区:线程共享
- 运行时常量池:存放已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
- 常量池表:用于存放编译期生成的各种字面量与符号引用
- 运行时常量池:存放已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
作用 | 线程私有? | 生命周期 | |
---|---|---|---|
程序计数器 | 当前线程所执行字节码的行号指示器 | 私有 | 与线程同步 |
Java虚拟机栈 | 存放栈帧,栈帧存储方法局部变量,栈帧方法开始时生成,方法结束时销毁 | 私有 | 与线程同步 |
本地方法栈 | 与 Java虚拟机栈 类似,但是存储的是,本地方法的栈帧 | 私有 | 与线程同步 |
Java堆 | 存放变量 | 共享 | 虚拟机启东时创建 |
方法区 | 逻辑区域,存放运行时常量池 | 共享 | |
运行时常量池 | 位于方法区,存放编译期生成的各种字面量与符号引用 | 共享 |
对象的创建
对象创建过程:
对象所需内存在类加载完成后便可以确定
为对象分配空间的任务实际上等同于把一块确定大小的内存块从Java堆中划分出来
内存(Java堆)是否规整
- 规整:使用中的内存放在一边,空闲的内存放在另一边
- 不规整:已使用和空闲的内存相互交错在一起
内存的分配方式
内存的分配方式由Java堆是否规整决定
- 指针碰撞:规整|中间放着一个指针作为分界点的指示器,分配内存时将指针向空闲方向移动与对象大小相等的距离即可
- 空闲列表:不规整|虚拟机需要维护一个列表,记录下哪些内存块是可用的,并在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
内存分配的线程安全问题
对象创建在虚拟机中是非常频繁的行为,及时仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
- CAS配上失败重试的方式保证更新操作的原子性
- 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),本地线程缓冲用完,分配新的缓存区时才需要同步锁定。使用
-XX:+/-UseTLAB
参数设置
对象的内存布局
HotSpot中对象在堆内存中的存储布局可以划分为三个部分:对象头、实例数据、对齐填充
- 对象头(Header、Mark Word)
- 对象自身的运行时数据:哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等
- 类型指针(Java虚拟机通过这个指针来确定该对象是哪个类的实例)
- 如果该对象是数组:数组长度
- 实例数据(Instance Data):对象真正存储的有效信息,即我们在代码中定义的各种类型的字段,包括父类字段。
- 对齐填充(Padding):占位符,HotSpot中要求对象的起始必须是8字节的整数倍 → 对象的大小必须是8字节的整数倍。
对象的访问定位
- 句柄
- 直接访问(开销较小)
GC
判断对象是否需要被回收的方法
- 引用计数器:有循环引用问题,主流Java虚拟机没有采用这种方式
- 可达性分析:通过一系列称为GC Roots的根对象作为起始节点集,根据引用关系向下搜索,搜索过程所走过的路程称为引用链。如果一个对象从GC Roots到这个对象不可达,那么这个对象是不可能再被使用的。
四种引用
- 强引用(不会死):最传统的“引用”的定义,如引用赋值。只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
- 软引用(溢出前死):在系统将要发生内存溢出前,会吧这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会OOM
- 弱引用(每次GC死):被弱引用关联的对象只能生存到下一次垃圾收集发生为止
- 虚引用(幽灵引用、幻影引用):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用还获得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
可达性分析的“两次标记”
![JVM 垃圾回收](https://i-blog.csdnimg.cn/blog_migrate/013dd21029bb060fe26ffbfa46f9eb1b.jpeg)
垃圾收集算法
理论/假说
-
分代收集理论:
- 分代收集名为理论,实质是一套大部分情况下正确的经验法则
- 弱分代假说:绝大部分对象都是朝生夕灭的
- 强分代假说:挨过越多次垃圾收集的对象就越难以消防
分代收集假说奠定了垃圾收集器的设计原则:收集器应该讲Java堆划分出不同的区域
-
跨代引用假说:跨代引用相对于同代引用来说仅占极少数
- 根据这一假说,只需在新生代上建立一个全局数据结构:记忆集,记忆集吧老年代划分成若干小块,标记处老年代的哪一块内存会存在跨代引用,只有这些内存会在Minor GC时被放入带GC Roots
分区
- 新生代
- 老年代
收集类型
- 部分收集(partial GC)
- 新生代收集(Minor/Young GC)
- 老年代收集(Major/Old GC):CMS
- 混合收集(Mixed GC):对整个新生代 和 部分老年代进行垃圾收集,目前只有G1会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾
算法
标记-清除算法
- 先标记出所有需要回收的对象,然后统一回收所有被标记的对象(也可以反过来,标记不需要回收的,回收没被标记的)
- 缺点:
- 执行效率不稳定:执行时间随着数量增长而降低
- 内存碎片化问题:在给大对象分配内存时,可能因为无法找到连续的内存,而提前触发下一次GC
标记-复制算法
- 最开始(半区复制)
- 将内存划分成大小相等的两块(1:1),每次只使用其中的一块,GC时将还活着的对象复制到另一块上(半区复制)。使用率50%
- 优点:实现简单,运行高效,没有碎片化
- 缺点:
- 如果大部分对象还都存活,这种算法将产生大量的内存复制开销(反之也可以成为优点)
- 内存缩小为原来的一半
- *改进:
- 根据新生代朝生夕灭的特点:不需要按照1:1来划分新生代内存空间
- 把新生代分为一块较大的**
Eden空间
(8),和两块较小的Survivor空间
(1*2)** - 每次分配内存只使用
Eden
和其中一块Survivor
;GC时将Eden和Survivor中仍然存活的对象,复制到另一块Survivor上。使用率90% - 存活的对象超过10%怎么办?
- 使用其他内存区域(大多数是老年代)进行分配担保
标记-整理算法
- 先标记,然后让所有存活的对象都向内存空间的一段移动(整理),然后直接清理掉边界以外的内存
- 缺点
- 移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,移动对象需要暂停用户线程
总结
优点 | 缺点 | 适用 | |
---|---|---|---|
标记-清除 | 不停顿 | 碎片化 | 老年代 (大部分都活着,不需要清除很多) |
标记-复制 | 无碎片化 | 内存浪费 需要其他内存担保 | 新生代 (大部分都死了,只需要复制一小部分) |
标记-整理 | 无碎片化 | 移动对象暂停用户线程 | 老年代 |
移动式/非移动式回收算法
- 非移动式:
- 标记清除、标记复制
- 停顿小,但因在内存分配时需要额外的时间,整体效率低
- 移动式:
- 标记整理
- 停顿大(STW),整体效率高
实现细节
-
根节点枚举:OopMap,需要暂停用户线程 StopTheWorld
-
安全点
如何在垃圾收集是让所有线程都跑到最近的安全点,然后停顿下来
- 抢先式中断:几乎不用
- 主动式中断:当垃圾收集需要中断线程的时候,不直接对线程操作,仅简单地设置一个标志位。在安全点和所有需要在Java堆上分配内存的地方查看标志位是否为真,为真就在最近的安全点上主动中断挂起
-
安全区域:安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,因此在这个区域中任意地方开始垃圾收集都是安全的,可以把安全区域看做被扩展拉伸了的安全点
-
记忆集:与部分收集有关
三种不同的精度:
- 卡精度:用卡表(card table)的方式实现记忆集,是目前最新常用的一种记忆集实现方式。()
- 字长精度:每个记录精确到一个机器字长(也就是处理器的寻址位数,如常见的32位或64位)
- 对象精度:每个记录精确到一个对象
-
写屏障
- 使用AOP,在赋值后对卡表进行更新
- 伪共享问题
-XX:+UseCondCardMark
-
并发的可达性分析
- How?:三色标记 + 破坏两个条件中的一个解决“对象消失”问题
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 增量更新:将新插入的引用记录下来,在并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
- 可以简化理解为:对象一但新插入了指向白色对象的引用之后,他就变回灰色对象了。
- CMS
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
- 原始快照:将要删除的引用记录下来,在并发扫描结束后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
- 可以简化理解为:无论引用关系是否被删除,都会按照刚开始扫描那一刻 的对象图快照来进行搜索
- G1,Shenandoah
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- How?:三色标记 + 破坏两个条件中的一个解决“对象消失”问题
经典垃圾收集器
单线程/多线程 | 是否需要暂停用户线程(STW) | 回收分代 | 基于算法 | 备注 | |
---|---|---|---|---|---|
Serial | 单 | 是 | 新生代 | ||
ParNew | 多 | 是 | 新生代 | Serial的多线程版本 | |
Parallel Scavenge | 多 | 是 | 新生代 | 标记-复制 | |
Serial Old | 单 | 是 | 老年代 | Serial的老年代版本 | |
Parallel Old | 多 | 是 | 老年代 | Parallel Scavenge的老年代版本 | |
CMS | 多 | 部分(初始标记、重新标记时需要STW) | 老年代 | 标记-清除 | |
G1 | 多 | 部分(初始标记、最终标记、筛选回收时需要STW) | Mixed | 标记-复制 |
Serial
-
古老、基础、简单、高效
-
单线程、StopTheWorld
-
客户端模式下默认的新生代收集器
-
额外内存消耗最小
ParNew
- Serial的多线程版本(除了多线程外其他与Serial完全一致)
Parallel Scavenge
-
新生代收集器
-
多线程
-
更关注程序的吞吐量(其他收集器更加注重垃圾收集时,用户线程的停顿时间)
-
标记复制方法实现
Serial Old
- Serial的老年代版本
- 单线程
Parallel Old
- Parallel Scavenge的老年代版本
- 多线程
CMS(Concurrent Mark Sweep)
-
以最短回收停顿时间为目标的回收器
-
老年代收集器
-
C——并发
-
S——标记-清除方法实现
-
运行步骤
- 初始标记(STW)—— 枚举根节点
- 并发标记 —— 三色标记
- 重新标记(STW)—— 增量更新
- 并发清除 —— 清除
-
默认回收线程数 = (处理器核心数量 + 3)/ 4
- 当处理器核心数较少时,CMS会对用户线程造成较大影响(慢)
- i-CMS并发标记、并发清除时,让收集器线程、用户线程交替执行,尽量减少垃圾收集器线程独占资源的时间。
- 整个垃圾收集时间边长,对用户程序影响变小
- 就好像全速迅雷时,网页打不开;限速后下载总时间边长,同时也能满足基本上网需求
- JDK7时i-CMS被声明为“deprecated”,JDK9时该模式被完全废弃
-
浮动垃圾
- 在CMS清理时,用户线程还在运行,新出现的垃圾是无法在这次收集中清理他们,这就是浮动垃圾
- 在CMS清理时,用户线程还在运行,不能等到老年代满了再进行GC,需要预留一部分空间供并发收集时的程序运行使用
- -XX:CMSInitiatingOccu-pancyFraction来设置当老年代的百分之多少被占用时,进行GC
- 如果设置的过大,会导致“并发失败”:冻结用户线程,临时启用Serial Old来进行GC
- 反之会增加GC的频率
Garbage First(G1)
- 停顿时间模型
虚拟机执行子系统
虚拟机类加载机制
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制
七个阶段
加载、验证、准备、解析、初始化、使用、卸载
其中验证、准备、解析
这三个阶段被统称为 连接
加载、连接、初始化都是在程序运行期间完成的
加载 -> 验证 -> 准备 -> 初始化 -> 卸载,这五个阶段的顺序是确定的
加载
- 通过一个类的完全限定名来获取定义类的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
《Java虚拟机规范》没有强制约束何时“加载”,可以由虚拟机的具体实现来决定(但是规定了什么时候必须初始化,而初始化前必须加载)
验证
这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部要求,保证这些信息被当做代码运行后不会危害虚拟机的安全
准备
对静态变量进行内存分配和设置初始值(没有实例变量的事)
- 对于大部分这里的初始值指的是默认
- 对于被final修饰的常量,这里就是实际值
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
执行类变量(static
)的赋值 和 静态语句块(static{}
)
主动引用——什么时候进行初始化
《Java虚拟机规范》严格规定有且只有6中情况必须立即对类进行“初始化”
- new、getstatic、putstatic、invokestatic
- 反射
- 初始化类
A1
时发现其父类A
没有被初始化,需要先初始化A(A1接口是接口时,不需要先初始化他的父接口) - main方法包含所在的类
- 初始化A时发现其实现的接口InterfaceA含有默认方法,需要先初始化InterfaceA
这六种场景中的行为被称为主动引用
除这六种以为的引用类型方式都不会触发初始化,被称为被动引用
被动引用举例
-
定义三个类用于后续的举例
package com.kelvin.JVM.load_calss_test; class SuperClass { public static int value = 123; static { System.out.println("Super Class init"); } } class SubClass extends SuperClass{ static { System.out.println("Sub Class init"); } } class ConstClass { public static final String HELLO_WORLD = "Hello World"; static { System.out.println("Const Class init"); } }
-
对于静态字段,只有直接定义这个字段的类才会被初始化
package com.kelvin.JVM.load_calss_test; public class Test1 { public static void main(String[] args) { System.out.println(SubClass.value); } } // Super Class init // 123
-
通过数组定义来引用类,不会触发此类的初始化
package com.kelvin.JVM.load_calss_test; public class Test2 { public static void main(String[] args) { SuperClass[] a1 = new SuperClass[10]; SubClass[] a2 = new SubClass[10]; } } // 没有输出
-
引用类的静态常量,不会触发此类的初始化(原因:常量在编译阶段通过常量传播优化,将此类常量的值“Hello World”直接存储在Test3的常量池中,此后Test3对常量ConstClass.HELLO_WORLD的引用,实际都被转化为Test3对自身常量池的引用了)
public class Test3 { public static void main(String[] args) { System.out.println(ConstClass.HELLO_WORLD); } } // Hello World
其他例子链接:Java 中静态代码块初始化问题测试
类加载器
双亲委派模型
如果一个类加载器收到了类加载的请求,他首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完场,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要的类)时,子加载器才会尝试自己去完成加载
![img](https://i-blog.csdnimg.cn/blog_migrate/d906050759e965963044c2e6addab619.png)
-
双亲委派模型要求除了顶层的启动类加载器外,其他的类加载器都应有自己的父类加载器,不过这里类加载直接的父子关系一般不是以继承来实现的,而是通常使用组合关系来复用父类加载器的代码
-
双亲委派模型不是一个具有强制性约束的模型,而是Java设计者们推荐给开发者的一种类加载器实现的最佳实践
-
作用:保证通用类在程序的各种类加载器环境中都能保证是同一个类
-
绝大多数Java程序都会使用到已下3个系统提供的类加载器来进行加载
- 启动类加载器
- 扩展类加载器
- 应用程序类加载器
- (自定义类加载器)