个人Blog地址: www.re1ife.top/index.php/a…
欢迎大家光临
1. 运行时数据区域
Jvm 在执行Java程序时,会把它管理的内存划分为若干不同的区域。各有分工、
1.1 程序计数器
程序计数器(PCR) : 一块较小的内存空间,可以看作当前线程所执行字节码的行号指示器。
JVM概念模型中,字节码解释器会改变计数器选区下条需要执行的字节码指令。
JVM的多线程是由线程轮流切换、分配处理器执行时间实现的,一个处理器(一个内核)在同一时刻只执行一条指令。
切换后为了恢复到原来的位置,因此每个线程都有一个独立的PCR. 线程私有内存
计数器实际记录的是正在执行的虚拟机字节码指令的地址。若执行本地方法, 计数器为空。
本地方法: 用非Java语言(如C、C++等)实现的方法,也称为本地代码(Native Code)
1.2 Java 虚拟机栈
VM Stack:也是线程私有的。生命周期与线程相同。
虚拟机栈描述了Java方法执行的线程内存模型:
每个方法被执行时,Java 虚拟机会同步创建一个栈帧(Stack Frame)存储局部变量表、操作栈、动态连接、方法出口等。每方法执行完毕对应一个栈帧在VM Stack
从入栈到出栈的过程。
-
栈帧: 方法运行时期重要的数据结构
-
局部变量表:存放编译器各种Java 基本数据类型、对象引用(Reference) .
-
数据类型以局部变量槽(Slot)存储在局部变量表中
-
long 与 double 占据两个变量槽,其他的类型占用一个
-
局部变量表的内存空间在编译期间就分配好了
-
大小是指slot的数量的多少,具体 64bit 或 32bit由虚拟机实现
-
注:基本类型的数组不再局部变量表,在Java堆中。
-
两类异常:
- StackOverflowError: 线程请求栈深度大于虚拟机允许深度
- OutOfMemeoryError: 若JVM Stack 容量可动态扩展,而栈扩展无法申请到足够内存
-
HotSpot 虚拟机栈容量不可变,Classic 虚拟机可扩展
1.3 本地方法栈
本地方法栈与虚拟机栈作用相似。
区别:
-
VM Stack:为虚拟机执行Java方法服务
-
NativeMethod Stack:为虚拟机使用的本地方法服务。
有些VM 如 HotSpot将本地方法栈和VM栈合二为一。
1.4 Java Heap
Java Heap: 虚拟机管理的内存最大的一块,用来存放对象实例,“几乎”所有对象实例在这里分配内存。
在《Java 虚拟机规范》描述了:所有的的对象实例及数组都应该在堆上分配。
-
但是随着JIT技术进步以及逃逸分析技术的日益强大,栈上分配、标量替换导致也不是这么绝对了。
-
栈上分配:一种内存分配技术,它将对象的内存分配在线程栈上,而不是在堆上。这种技术可以减轻Java堆的负担,同时也可以提高程序的性能。栈上分配的前提条件是对象的生命周期非常短暂,即对象在方法内被创建并在方法结束时被销毁。由于这种技术可以避免频繁地访问堆,从而减少了内存访问的开销,因此可以提高程序的性能。
-
标量替换:一种优化技术,它将一个对象拆分成多个基本类型的属性,并将这些属性分配在栈上或寄存器中,而不是在堆上。
-
逃逸分析:一种静态分析技术,它可以分析程序中对象的生命周期,判断对象是否会被外部引用,即“逃逸”出当前方法或线程。如果对象不会逃逸,那么可以将对象分配在栈上或寄存器中。
Java 堆是被所有线程共享的一块内存区域,虚拟机启动时创建。也是垃圾收集器
管理的内存区域,因此也叫GC堆
。
Tips:现代垃圾收集器大部分基于分代收集理论,会出现
新生代
、老年代
、永久代
、Eden空间
等,仅仅是部分垃圾收集器的共同特性与设计风格。
从内存分配角度看,所有线程共享的Java堆中可以划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer)来提升效率。
但是不会改变堆中存储的内容---对象实例。
Java 堆逻辑上是连续而物理上不连续的。同时还可以被实现为固定大小、可扩展的。 通过 -Xmx
与 -Xms
设定。
异常: 内存中没有空间来进行对象实例的分配会抛出OutOfMemeoryError.
1.5 方法区
方法区:线程共享区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
《Java虚拟机规范》将其描述为堆一个了了逻辑部分。
JDK 8 以前,Java 程序员习惯在HotSpot上开发,很多人都更愿意把方法区称呼为“永久代”,但其实并不等价。
当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已
1.6 运行时常量池
运行时常量池: 方法区的一部分。
常量池表: 位于Class文件信息里,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
2. HotSpot 虚拟机对象
Java 是一门面向对象的语言,时时刻刻都会有对象被创建。
2.1 对象创建
第一步
当 Java 虚拟机遇到一条字节码 new
:
- 如何分配?
- 指针碰撞(BumpThePointer):堆中的内存都是绝对规整,所有被用过的内存放在一边,用一个指针用来作为分界指示点。分配多少空间,移动相同纪律。
- 空闲列表(FreeList):堆中内存不规整,在分配的时候从列表中找一块足够的空间,然后更新列表。
JVM 分配方式由Java堆是否规整决定 -> 由采用的垃圾收集器是否有空间压缩整理(Compact) 能力决定。
- Serial、ParNew等带压缩整理过程收集器使用指针碰撞。
- CMS这种基于清除算法收集器,采用空闲列表的算法。
除了如何分配内存之外,如何在频繁创建对象下,保证指针的的移动的并发安全?
内存分配完成之后,虚拟机会将分配的内存初始化为0。
第二步
JVM会对对象进行一些设置比如:对象HashCode(调用时生成)、实例属于哪一个类、如何才能找到类的元数据信息以及对象GC分代年龄等信息。
以上的信息都存储在对象头中,从之前的 JUC学习中我们知道,对于锁的状态(偏向锁、重向锁)等。
完成以上步骤,开始进入到构造函数。所有字段此时都默认为0,new 执行之后执行 ()
方法,按照程序编写的逻辑初始化。
2.2 对象内存布局
对象在堆内存中有三个部分: 对象头(Header)、实例数据(InstanceData)、对齐填充(Padding).
Java 中的对象头可以分为两类:普通对象与数组。
普通对象的对象头:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组的对象头:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
两者相同的部分是:Mark Workd, Klass Word。
Klass Word 更多是与类相关的数据,类是静态的。而MarkWord则是对象运行时动态的数据,并且在32Bit与64Bit下的是不同的。
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
里面存放了 identity_hashcode: 就是对象hash值、age对象的年龄,biased_lock 与 lock共同组成了对于这个对象的锁的状态的描述。
下面这张图是上面的翻译版本。由于可用的空间比较细,但是需要表示的内容变多了。因此MarkWord会在复用空间。
具体锁相关的只是可以去看看JUC部分的知识。
- 相关字段的解释:
lock
:2
位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock
标记。biased_lock
:对象是否启用偏向锁标记,只占1
个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。age
:4
位的Java对象年龄。在GC
中,如果对象在Survivor
区复制一次,年龄增加1
。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC
的年龄阈值为15
,并发GC
的年龄阈值为6
。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold
选项最大值为15
的原因。identity_hashcode
:25
位的对象标识Hash
码,采用延迟加载技术。调用方法System.identityHashCode()
计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor
中。thread
:持有偏向锁的线程ID
。epoch
:偏向时间戳。ptr_to_lock_record
:指向栈中锁记录的指针ptr_to_heavyweight_monitor
:指向管程Monitor
的指针。
然后的实例数据部分,才是真正对于我们来说有效的数据,所有字段都记录了起来,存储顺序首到虚拟机分配策略和Java内部的设定有关。
分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机自动内存管理系统要求对象的其实地址必须是8字节。
学过《计算机系统基础》的,是比较明白我说的意思的。
2.3 对象的访问定位
创建了对象之后如何使用?
Java 程序会通过栈上的 reference
数据来操作堆上的对象。
- refernce: 一个指向对象的应用。
目前主流的访问方式:
-
句柄方式:Java堆可能会划分一块内存作为句柄池,refernce中存储的是句柄地址,句柄中包含对象实例数据、类型数据各自的地址
- 如图
- 如图
-
直接指针方式:只需要访问一次,不需要间接访问一次句柄池,直接根据reference中的内存地址访问对象。但是放置对象更加困难。
快是真的快!因为Java中会经常访问到对象,多一次访问综合起来还是会有不小的开销的。
3. GC与内存分配策略
垃圾回收器(Garbage Collection): 用来对内存动态分配和回收的。
Java 程序会频繁创建对象,有些对象生命周期结束之后,GC为了复用空间会♻️回收对象。
因此我们应该如何判断对象已经死亡了呢?
3.1 对象的去世流程
首先死亡第一步要在医学上检验这个是不是真的去世了。
去世检验
目前主流的去世判断方法主要有下面两种:
- 在对象中添加引用计数器,有人引用就+1,失效-1. 当计数器为0时就可以回收了。
- 优点: 原理简单、判定效率高(只需判断是否为0)
- 缺点:
- 占用额外空间
- 其他情况待考虑
- ...
[!INFO]
引用计数法不能很好的解决,对象之间的相互依赖。
我们举个小例子:
class A{
private class B;
}
class B{
private class A;
}
如果存在 a、b对象,两者会相互依赖,引用计数器就无法为0。
是常用的方法
把所有对象建成一个树的结构,根对象为
GC Root
. 根据之间引用的关系从上往下遍历,如果不能访问到的对象则可以被回收。如下图所示:
可作为根节点的条件:
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)同样也包括本地方法栈中JNI引用的对象。
- 类的静态成员变量引用的对象。
- 方法区中,常量池里面引用的对象,比如我们之前提到的
String
类型对象。 - 被添加了锁的对象(比如synchronized关键字)
- 虚拟机内部需要用到的对象。
示意图如下
一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。就是GC Roots和对象1之间断开。
也能较好的解决相互依赖的问题:
最终核验
死亡是一件大事,上面的检验完成之后。并不是立刻马上就入殡了。
JVM规定了至少检验两次之后,才能入殡防止诈尸。
第一次被标记之后,会进行一次筛选是否有必要执行 finalize()
方法。
- 对象没有覆盖 finalize()
- finalize() 已经被虚拟机调用
上述两种情况都没必要执行。
如果有必要执行,JVM会将其放到一个 F-Queue ,在一个虚拟机建立的、低调度优先级的Finalizer线程执行finalize()。
对象可以覆盖finalize方法来抢救一下,但是只能抢救一次。因为finalize()
不会执行第二遍。
以下行为不推荐
拯救对象:
public class test{
public static test SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
test.SAVE_HOOK = this;
}
public static void main(String []args) throws Throwable {
SAVE_HOOK = new test();
//自救
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
执行结果:
finalize method executed!
yes, i am still alive :)
有关方法区的回收
部分人认为方法区没有垃圾收集行为,实际上Java虚拟机规范也不要求虚拟机在方法区实现。
通常来说,对于方法区的回收其实收益是比较小的相较于堆中的回收。
[!INFO]
堆中的新生代回收率可以达到 70%-99%
所以方法区的回收有点吃力不讨好。
3.2 垃圾收集算法
各个平台虚拟机内存操作方法各异。
分代收集理论
上面两个假说奠定多款常用GC的设计原则:将Java堆的划分出不同的区域,然后将回收对象熬过GC收集次数(Age) 分配到不同的区域。
比如这个区域都是朝生夕灭,可以较低代价回收大量空间。若是难以消亡的对象,那把它们集中放在一块, 虚拟机用较低的频率来回收这个区域,同时兼顾了垃圾收集的时间开销和内存的空间。
根据这个由此发展出了:标记——复制算法,标记——清楚算法,标记——整理算法等。
上面两种假说可以分别对应:新生代(Young Generation)、老年代(Old Generation)。但是后面的GC并没有遵循这一设计,因为对象之间可能跨代引用。
例如新生代对象被老年代引用,若要找出存活对象,不得不固定的GC Roots之外,还要遍历整个老年代中所有对象来保证准确,会增加负担。
因此增加了一个假说:
因此若存在跨代引用,应该是同时消亡或生存。
如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
因此不需要去扫整个老年代,只需在新生代上建立一个记忆集,将老年代划分为若干块,表示哪儿存在跨代,只有包含跨代引用的小块里会被加入GC Roots扫描。
- Partial GC:不完整Java堆垃圾收集
- 新生代收集(Minor GC/Youn GC): 新生代垃圾收集
- 老年代收集(Major GC/Old Gc)
- 混合收集(Mixed GC)
- Full GC
具体收集算法
这是最基础的垃圾收集算法。
首先表基础所有需要回收的对象,标记完成之后统一回收对象。或者相反,统一收回未标记对象。
但是这个缺点有点明显:
a. 效率不稳定,大量的对象需要回收的话,会频繁标记和清除
b. 内存空间碎片话,标记清除之后产生大量不连续内存碎片
Fenichel提出了半区复制
的垃圾收集算法。
将内存按容量分为大小相等的两块,只使用其中一块。若一块用完了,将还存活的对象复制到另外一块,然后将已使用的一次清楚。
如果都是存活的话,会有很大的内存复制开销,但是多数对象是可回收的。只需按顺序移动堆顶指针来分配内存。
目前大多数JVM优先使用此收集算法,IBM研究发现 新生代中98%对象熬不过第一次收集,因此没必要1:1划分新生代内存空间。
因此针对此现象,将新生代分为一块较大的Eden空间与两块较小Survivor空间。
每次分配内存时只使用Eden和其中一块Survivor.
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1
在老年代,对象存活率高,因此标记复制方法,会进行较多次复制操作。
若不想浪费50%空间,就要有额外空间担保。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
经典垃圾收集器
上图中收集器处于不同分代,若俩之间有连线则可以搭配使用。
Serial收集器是最基础、历史最悠久的收集器。
这是一个单线程收集器,在收集时会暂停其他所有工作线程直到结束。
这其实体验很不好,就好比没过几分钟你电脑就卡死一下然后又正常。
虽然这很不好,并且HotSpot虚拟机开发团队一直在努力构思优秀的垃圾收集器,但是它仍然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。
因为它非常的简单和高效。
其实就是Seriral收集器的多线程版本,但是在收集时仍然要停止所有的用户线程。
在JDK7 之前的遗留系统是首选的新生代收集器。
在JDK5 时发布了CMS 收集器,这是第一款真正意义上并发的垃圾收集器,支持GC线程与用户线程同时工作。但是却无法与 JDK1.4中的Parallel Scavenge收集器配合。因此只能选择ParNew
与 Serial
。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果。
一款基于标记-复制支持并行收集的新生代收集器。
PS收集器目标是达到一个可控制吞吐量。
$$吞吐量 = frac{运行用户代码时间}{运行用户代码时间 + 运行垃圾收集时间}$$
而 CMS等收集器是注重尽可能缩短垃圾收集时用户线程停顿时间。
在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现:
与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。
目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。
4. Serial Old收集器
这是Serial的老年代版本,同样也是标记-整理算法。
主要意义也是供客户端模式下的HotSpot虚拟机使用。
在服务端下也可以作为CMS失败后的后备收集器。
CMS收集器一种以获取最短回收停顿时间为目标的收集器。
大量的Java应用集中在互联网网站服务端上,因此希望系统停顿尽量的短。
CMS收集过程有四个步骤:
- 标记 GC Roots 可以直接关联到的对象,速度较快
- 从GC Roots直接关联的对象遍历整个对象图
- 耗时较长无需暂停用户线程
- 为了修正并发标记期间,因为用户线程继续运作的导致的标记改动部分
- 比初始标记时间长,比并发标记时间短
- 清除标记的死亡对象,不需要移动存活的对象
运行示意图:
CMS的缺点:
- 对处理器资源非常敏感,并发阶段虽然不会暂停用户线程,但是会占用部分线程导致应用变慢。
- 默认启动的回收线程数:(处理器核心数量+3)/4
- 当核心数 < 4时影响较大
- 无法处理“浮动垃圾”,标记中用户线程仍然会产生新的垃圾对象, 只能等待下次标记。
- 会产生大量的碎片空间,需要
-XX:+UseCMS-CompactAtFullCollection
来触发Full GC时合并碎片空间。
[!INFO]
垃圾收集器技术发展历史上的里程碑式的成果
JDK7 Update 4 是G1第一个商用版本,到了JDK8 Update 40 G1 提供了并发的类卸载支持。
成为了Oracle官方称为的 全功能垃圾收集器。
HotSpot开发团队对于G1目标是替换CMS.
身为CMS接班人,Developers 希望那个有一款能建立 Pause Prediction Model的收集器。
- 持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标
G1 面向的并不是新生代、老年代或者Java堆。G1 面向堆内任何部分组成回收集(Collection Set)进行回收。
因此它的回收标准不是xx代,而是哪儿的垃圾数量最多。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
除此之外,Region中有一个Humongous特殊区域,来存储大对象(大小超过一个Region容量一半的对象)。
Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB。
它的回收过程与CMS大体类似:
分为以下四个步骤:
- 初始标记(暂停用户线程):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
Other: 引用
JDK 1.2 之后Java将引用分为了:强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。
- 强引用: Object obj = new Object()
- 这种就是强引用,只要引用关系还在就不会被回收。
- 软引用:描述一些有用但非必须的对象
- 只有即将发生内存溢出异常时才会列进二次回收。
- 弱引用:描述非必须对象
- 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。
1. 运行时数据区域
Jvm 在执行Java程序时,会把它管理的内存划分为若干不同的区域。各有分工、
1.1 程序计数器
程序计数器(PCR) : 一块较小的内存空间,可以看作当前线程所执行字节码的行号指示器。
JVM概念模型中,字节码解释器会改变计数器选区下条需要执行的字节码指令。
JVM的多线程是由线程轮流切换、分配处理器执行时间实现的,一个处理器(一个内核)在同一时刻只执行一条指令。
切换后为了恢复到原来的位置,因此每个线程都有一个独立的PCR. 线程私有内存
计数器实际记录的是正在执行的虚拟机字节码指令的地址。若执行本地方法, 计数器为空。
本地方法: 用非Java语言(如C、C++等)实现的方法,也称为本地代码(Native Code)
1.2 Java 虚拟机栈
VM Stack:也是线程私有的。生命周期与线程相同。
虚拟机栈描述了Java方法执行的线程内存模型:
每个方法被执行时,Java 虚拟机会同步创建一个栈帧(Stack Frame)存储局部变量表、操作栈、动态连接、方法出口等。每方法执行完毕对应一个栈帧在VM Stack
从入栈到出栈的过程。
-
栈帧: 方法运行时期重要的数据结构
-
局部变量表:存放编译器各种Java 基本数据类型、对象引用(Reference) .
-
数据类型以局部变量槽(Slot)存储在局部变量表中
-
long 与 double 占据两个变量槽,其他的类型占用一个
-
局部变量表的内存空间在编译期间就分配好了
-
大小是指slot的数量的多少,具体 64bit 或 32bit由虚拟机实现
-
注:基本类型的数组不再局部变量表,在Java堆中。
-
两类异常:
- StackOverflowError: 线程请求栈深度大于虚拟机允许深度
- OutOfMemeoryError: 若JVM Stack 容量可动态扩展,而栈扩展无法申请到足够内存
-
HotSpot 虚拟机栈容量不可变,Classic 虚拟机可扩展
1.3 本地方法栈
本地方法栈与虚拟机栈作用相似。
区别:
-
VM Stack:为虚拟机执行Java方法服务
-
NativeMethod Stack:为虚拟机使用的本地方法服务。
有些VM 如 HotSpot将本地方法栈和VM栈合二为一。
1.4 Java Heap
Java Heap: 虚拟机管理的内存最大的一块,用来存放对象实例,“几乎”所有对象实例在这里分配内存。
在《Java 虚拟机规范》描述了:所有的的对象实例及数组都应该在堆上分配。
-
但是随着JIT技术进步以及逃逸分析技术的日益强大,栈上分配、标量替换导致也不是这么绝对了。
-
栈上分配:一种内存分配技术,它将对象的内存分配在线程栈上,而不是在堆上。这种技术可以减轻Java堆的负担,同时也可以提高程序的性能。栈上分配的前提条件是对象的生命周期非常短暂,即对象在方法内被创建并在方法结束时被销毁。由于这种技术可以避免频繁地访问堆,从而减少了内存访问的开销,因此可以提高程序的性能。
-
标量替换:一种优化技术,它将一个对象拆分成多个基本类型的属性,并将这些属性分配在栈上或寄存器中,而不是在堆上。
-
逃逸分析:一种静态分析技术,它可以分析程序中对象的生命周期,判断对象是否会被外部引用,即“逃逸”出当前方法或线程。如果对象不会逃逸,那么可以将对象分配在栈上或寄存器中。
Java 堆是被所有线程共享的一块内存区域,虚拟机启动时创建。也是垃圾收集器
管理的内存区域,因此也叫GC堆
。
Tips:现代垃圾收集器大部分基于分代收集理论,会出现
新生代
、老年代
、永久代
、Eden空间
等,仅仅是部分垃圾收集器的共同特性与设计风格。
从内存分配角度看,所有线程共享的Java堆中可以划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer)来提升效率。
但是不会改变堆中存储的内容---对象实例。
Java 堆逻辑上是连续而物理上不连续的。同时还可以被实现为固定大小、可扩展的。 通过 -Xmx
与 -Xms
设定。
异常: 内存中没有空间来进行对象实例的分配会抛出OutOfMemeoryError.
1.5 方法区
方法区:线程共享区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
《Java虚拟机规范》将其描述为堆一个了了逻辑部分。
JDK 8 以前,Java 程序员习惯在HotSpot上开发,很多人都更愿意把方法区称呼为“永久代”,但其实并不等价。
当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已
1.6 运行时常量池
运行时常量池: 方法区的一部分。
常量池表: 位于Class文件信息里,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
2. HotSpot 虚拟机对象
Java 是一门面向对象的语言,时时刻刻都会有对象被创建。
2.1 对象创建
第一步
当 Java 虚拟机遇到一条字节码 new
:
- 如何分配?
- 指针碰撞(BumpThePointer):堆中的内存都是绝对规整,所有被用过的内存放在一边,用一个指针用来作为分界指示点。分配多少空间,移动相同纪律。
- 空闲列表(FreeList):堆中内存不规整,在分配的时候从列表中找一块足够的空间,然后更新列表。
JVM 分配方式由Java堆是否规整决定 -> 由采用的垃圾收集器是否有空间压缩整理(Compact) 能力决定。
- Serial、ParNew等带压缩整理过程收集器使用指针碰撞。
- CMS这种基于清除算法收集器,采用空闲列表的算法。
除了如何分配内存之外,如何在频繁创建对象下,保证指针的的移动的并发安全?
内存分配完成之后,虚拟机会将分配的内存初始化为0。
第二步
JVM会对对象进行一些设置比如:对象HashCode(调用时生成)、实例属于哪一个类、如何才能找到类的元数据信息以及对象GC分代年龄等信息。
以上的信息都存储在对象头中,从之前的 JUC学习中我们知道,对于锁的状态(偏向锁、重向锁)等。
完成以上步骤,开始进入到构造函数。所有字段此时都默认为0,new 执行之后执行 ()
方法,按照程序编写的逻辑初始化。
2.2 对象内存布局
对象在堆内存中有三个部分: 对象头(Header)、实例数据(InstanceData)、对齐填充(Padding).
Java 中的对象头可以分为两类:普通对象与数组。
普通对象的对象头:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
数组的对象头:
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Word(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
两者相同的部分是:Mark Workd, Klass Word。
Klass Word 更多是与类相关的数据,类是静态的。而MarkWord则是对象运行时动态的数据,并且在32Bit与64Bit下的是不同的。
|-------------------------------------------------------|--------------------|
| Mark Word (32 bits) | State |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal |
|-------------------------------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased |
|-------------------------------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|-------------------------------------------------------|--------------------|
|------------------------------------------------------------------------------|--------------------|
| Mark Word (64 bits) | State |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | Normal |
|------------------------------------------------------------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | Biased |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | Lightweight Locked |
|------------------------------------------------------------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | Heavyweight Locked |
|------------------------------------------------------------------------------|--------------------|
| | lock:2 | Marked for GC |
|------------------------------------------------------------------------------|--------------------|
里面存放了 identity_hashcode: 就是对象hash值、age对象的年龄,biased_lock 与 lock共同组成了对于这个对象的锁的状态的描述。
下面这张图是上面的翻译版本。由于可用的空间比较细,但是需要表示的内容变多了。因此MarkWord会在复用空间。
具体锁相关的只是可以去看看JUC部分的知识。
- 相关字段的解释:
lock
:2
位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock
标记。biased_lock
:对象是否启用偏向锁标记,只占1
个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。age
:4
位的Java对象年龄。在GC
中,如果对象在Survivor
区复制一次,年龄增加1
。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC
的年龄阈值为15
,并发GC
的年龄阈值为6
。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold
选项最大值为15
的原因。identity_hashcode
:25
位的对象标识Hash
码,采用延迟加载技术。调用方法System.identityHashCode()
计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor
中。thread
:持有偏向锁的线程ID
。epoch
:偏向时间戳。ptr_to_lock_record
:指向栈中锁记录的指针ptr_to_heavyweight_monitor
:指向管程Monitor
的指针。
然后的实例数据部分,才是真正对于我们来说有效的数据,所有字段都记录了起来,存储顺序首到虚拟机分配策略和Java内部的设定有关。
分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
第三部分是对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机自动内存管理系统要求对象的其实地址必须是8字节。
学过《计算机系统基础》的,是比较明白我说的意思的。
2.3 对象的访问定位
创建了对象之后如何使用?
Java 程序会通过栈上的 reference
数据来操作堆上的对象。
- refernce: 一个指向对象的应用。
目前主流的访问方式:
-
句柄方式:Java堆可能会划分一块内存作为句柄池,refernce中存储的是句柄地址,句柄中包含对象实例数据、类型数据各自的地址
- 如图
- 如图
-
直接指针方式:只需要访问一次,不需要间接访问一次句柄池,直接根据reference中的内存地址访问对象。但是放置对象更加困难。
快是真的快!因为Java中会经常访问到对象,多一次访问综合起来还是会有不小的开销的。
3. GC与内存分配策略
垃圾回收器(Garbage Collection): 用来对内存动态分配和回收的。
Java 程序会频繁创建对象,有些对象生命周期结束之后,GC为了复用空间会♻️回收对象。
因此我们应该如何判断对象已经死亡了呢?
3.1 对象的去世流程
首先死亡第一步要在医学上检验这个是不是真的去世了。
去世检验
目前主流的去世判断方法主要有下面两种:
- 在对象中添加引用计数器,有人引用就+1,失效-1. 当计数器为0时就可以回收了。
- 优点: 原理简单、判定效率高(只需判断是否为0)
- 缺点:
- 占用额外空间
- 其他情况待考虑
- ...
[!INFO]
引用计数法不能很好的解决,对象之间的相互依赖。
我们举个小例子:
class A{
private class B;
}
class B{
private class A;
}
如果存在 a、b对象,两者会相互依赖,引用计数器就无法为0。
是常用的方法
把所有对象建成一个树的结构,根对象为
GC Root
. 根据之间引用的关系从上往下遍历,如果不能访问到的对象则可以被回收。如下图所示:
可作为根节点的条件:
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)同样也包括本地方法栈中JNI引用的对象。
- 类的静态成员变量引用的对象。
- 方法区中,常量池里面引用的对象,比如我们之前提到的
String
类型对象。 - 被添加了锁的对象(比如synchronized关键字)
- 虚拟机内部需要用到的对象。
示意图如下
一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。就是GC Roots和对象1之间断开。
也能较好的解决相互依赖的问题:
最终核验
死亡是一件大事,上面的检验完成之后。并不是立刻马上就入殡了。
JVM规定了至少检验两次之后,才能入殡防止诈尸。
第一次被标记之后,会进行一次筛选是否有必要执行 finalize()
方法。
- 对象没有覆盖 finalize()
- finalize() 已经被虚拟机调用
上述两种情况都没必要执行。
如果有必要执行,JVM会将其放到一个 F-Queue ,在一个虚拟机建立的、低调度优先级的Finalizer线程执行finalize()。
对象可以覆盖finalize方法来抢救一下,但是只能抢救一次。因为finalize()
不会执行第二遍。
以下行为不推荐
拯救对象:
public class test{
public static test SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
test.SAVE_HOOK = this;
}
public static void main(String []args) throws Throwable {
SAVE_HOOK = new test();
//自救
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它 Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
执行结果:
finalize method executed!
yes, i am still alive :)
有关方法区的回收
部分人认为方法区没有垃圾收集行为,实际上Java虚拟机规范也不要求虚拟机在方法区实现。
通常来说,对于方法区的回收其实收益是比较小的相较于堆中的回收。
[!INFO]
堆中的新生代回收率可以达到 70%-99%
所以方法区的回收有点吃力不讨好。
3.2 垃圾收集算法
各个平台虚拟机内存操作方法各异。
分代收集理论
上面两个假说奠定多款常用GC的设计原则:将Java堆的划分出不同的区域,然后将回收对象熬过GC收集次数(Age) 分配到不同的区域。
比如这个区域都是朝生夕灭,可以较低代价回收大量空间。若是难以消亡的对象,那把它们集中放在一块, 虚拟机用较低的频率来回收这个区域,同时兼顾了垃圾收集的时间开销和内存的空间。
根据这个由此发展出了:标记——复制算法,标记——清楚算法,标记——整理算法等。
上面两种假说可以分别对应:新生代(Young Generation)、老年代(Old Generation)。但是后面的GC并没有遵循这一设计,因为对象之间可能跨代引用。
例如新生代对象被老年代引用,若要找出存活对象,不得不固定的GC Roots之外,还要遍历整个老年代中所有对象来保证准确,会增加负担。
因此增加了一个假说:
因此若存在跨代引用,应该是同时消亡或生存。
如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
因此不需要去扫整个老年代,只需在新生代上建立一个记忆集,将老年代划分为若干块,表示哪儿存在跨代,只有包含跨代引用的小块里会被加入GC Roots扫描。
- Partial GC:不完整Java堆垃圾收集
- 新生代收集(Minor GC/Youn GC): 新生代垃圾收集
- 老年代收集(Major GC/Old Gc)
- 混合收集(Mixed GC)
- Full GC
具体收集算法
这是最基础的垃圾收集算法。
首先表基础所有需要回收的对象,标记完成之后统一回收对象。或者相反,统一收回未标记对象。
但是这个缺点有点明显:
a. 效率不稳定,大量的对象需要回收的话,会频繁标记和清除
b. 内存空间碎片话,标记清除之后产生大量不连续内存碎片
Fenichel提出了半区复制
的垃圾收集算法。
将内存按容量分为大小相等的两块,只使用其中一块。若一块用完了,将还存活的对象复制到另外一块,然后将已使用的一次清楚。
如果都是存活的话,会有很大的内存复制开销,但是多数对象是可回收的。只需按顺序移动堆顶指针来分配内存。
目前大多数JVM优先使用此收集算法,IBM研究发现 新生代中98%对象熬不过第一次收集,因此没必要1:1划分新生代内存空间。
因此针对此现象,将新生代分为一块较大的Eden空间与两块较小Survivor空间。
每次分配内存时只使用Eden和其中一块Survivor.
HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1
在老年代,对象存活率高,因此标记复制方法,会进行较多次复制操作。
若不想浪费50%空间,就要有额外空间担保。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
经典垃圾收集器
上图中收集器处于不同分代,若俩之间有连线则可以搭配使用。
Serial收集器是最基础、历史最悠久的收集器。
这是一个单线程收集器,在收集时会暂停其他所有工作线程直到结束。
这其实体验很不好,就好比没过几分钟你电脑就卡死一下然后又正常。
虽然这很不好,并且HotSpot虚拟机开发团队一直在努力构思优秀的垃圾收集器,但是它仍然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。
因为它非常的简单和高效。
其实就是Seriral收集器的多线程版本,但是在收集时仍然要停止所有的用户线程。
在JDK7 之前的遗留系统是首选的新生代收集器。
在JDK5 时发布了CMS 收集器,这是第一款真正意义上并发的垃圾收集器,支持GC线程与用户线程同时工作。但是却无法与 JDK1.4中的Parallel Scavenge收集器配合。因此只能选择ParNew
与 Serial
。
ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果。
一款基于标记-复制支持并行收集的新生代收集器。
PS收集器目标是达到一个可控制吞吐量。
$$吞吐量 = frac{运行用户代码时间}{运行用户代码时间 + 运行垃圾收集时间}$$
而 CMS等收集器是注重尽可能缩短垃圾收集时用户线程停顿时间。
在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现:
与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。
目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。
4. Serial Old收集器
这是Serial的老年代版本,同样也是标记-整理算法。
主要意义也是供客户端模式下的HotSpot虚拟机使用。
在服务端下也可以作为CMS失败后的后备收集器。
CMS收集器一种以获取最短回收停顿时间为目标的收集器。
大量的Java应用集中在互联网网站服务端上,因此希望系统停顿尽量的短。
CMS收集过程有四个步骤:
- 标记 GC Roots 可以直接关联到的对象,速度较快
- 从GC Roots直接关联的对象遍历整个对象图
- 耗时较长无需暂停用户线程
- 为了修正并发标记期间,因为用户线程继续运作的导致的标记改动部分
- 比初始标记时间长,比并发标记时间短
- 清除标记的死亡对象,不需要移动存活的对象
运行示意图:
CMS的缺点:
- 对处理器资源非常敏感,并发阶段虽然不会暂停用户线程,但是会占用部分线程导致应用变慢。
- 默认启动的回收线程数:(处理器核心数量+3)/4
- 当核心数 < 4时影响较大
- 无法处理“浮动垃圾”,标记中用户线程仍然会产生新的垃圾对象, 只能等待下次标记。
- 会产生大量的碎片空间,需要
-XX:+UseCMS-CompactAtFullCollection
来触发Full GC时合并碎片空间。
[!INFO]
垃圾收集器技术发展历史上的里程碑式的成果
JDK7 Update 4 是G1第一个商用版本,到了JDK8 Update 40 G1 提供了并发的类卸载支持。
成为了Oracle官方称为的 全功能垃圾收集器。
HotSpot开发团队对于G1目标是替换CMS.
身为CMS接班人,Developers 希望那个有一款能建立 Pause Prediction Model的收集器。
- 持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标
G1 面向的并不是新生代、老年代或者Java堆。G1 面向堆内任何部分组成回收集(Collection Set)进行回收。
因此它的回收标准不是xx代,而是哪儿的垃圾数量最多。
G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
除此之外,Region中有一个Humongous特殊区域,来存储大对象(大小超过一个Region容量一半的对象)。
Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB。
它的回收过程与CMS大体类似:
分为以下四个步骤:
- 初始标记(暂停用户线程):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
- 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。
Other: 引用
JDK 1.2 之后Java将引用分为了:强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种。
- 强引用: Object obj = new Object()
- 这种就是强引用,只要引用关系还在就不会被回收。
- 软引用:描述一些有用但非必须的对象
- 只有即将发生内存溢出异常时才会列进二次回收。
- 弱引用:描述非必须对象
- 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
- 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。