Java 虚拟机—内存模型
一、JVM 内存模型
本节来分析 Java 对象如何进行分配
和回收
。
JVM 运行时数据区
主要由线程私有区域
和线程共享
区域组成。
- 线程私有区域:
- 虚拟机栈
- 本地方法栈
- 程序计数器
2.线程共享区域:
- 堆
- 方法区
下面绘制一个草图来描述 JVM 运行数据区的组成:
JVM 运行数据区1.1、线程私有区域
线程私有区域组成为:
- 程序计数器
- 虚拟机栈
- 本地方法栈
1.1.1、程序计数器
什么是程序计数器呢?
因为 Java 本身就是一个多线程的,每一个线程都有一个程序计数器, CPU 在切换线程时,会使用程序计数器记录下当前线程正在执行的字节码指令的地址(行号),这样线程再次回来工作时,就知道执行到哪个位置了。
为了更加深入的理解程序计数器,下面来看这样一段代码:
demo通过 javap -c -l MMDemo.class 得到对应字节码:
程序计数器这个 Code 对应的这些数就是程序计数器了。
1.1.2、虚拟机栈
虚拟栈属于线程私有部分,在线程内部中一般会调用很多方法,而每一个方法使用一个栈帧
来描述。
下面用一个草图来描述一下栈帧
与虚拟机栈
的关系:
虚拟机栈是由多个栈帧组成,每调用一个方法就相当于有一个栈帧入栈到虚拟机栈中。
栈帧1.1.3、栈帧的组成
在前面描述过,在线程中,一个方法被调用就会一个栈帧
被压入虚拟机栈
中。栈帧就是用来描述这个方法,一个栈帧是由局部变量表
,操作数栈
,返回值地址
,动态链接
组成。
下面还是回到上面示例,结合草图,看它们之间的关系:
虚拟机栈局部变量表:
方法内部声明的变量存放表
32位地址,寻址空间为 4G 。如果需要存放64位的数据,需要使用高位和地位表示。
局部变量表下面是 pay() 生成的局部变量表:
- this 表示当前对象
- i
- obj
操作数栈:
对局部变量表中的变量进行出栈入栈的操作。
返回值地址:
一个方法被执行之后,有一个返回值,返回给对应的调用处。
动态链接:
主要对应的多态,只有代码执行时才知道具体的实现类是那个对象。
1.1.4、StackOverflowError
这个异常想必很多人都遇过,字面意思就是栈溢出。我们通过上面的分析我们知道,虚拟机栈如果不断出现栈帧入栈,当虚拟机栈空间达到上限,那么就会出现 StackOverflowError
。
下面来模拟这个错误的产生:
public class StackOverflowError {
private static int count = 0;
public static void main(String[] args) {
try {
recursion();
} catch (Throwable e) {
System.out.println("deep of calling = " + count);
e.printStackTrace();
}
}
public static void recursion() {
count++;
recursion();
}
}
StackOverflowError
如果是死循环出现这样的错误StackOverflowError,那么通过 -Xss
参数的设置也是没有用。
当然,如果是因为虚拟机栈空间比较少到导致频繁出现这个错误,那么是可以合理的调节这个参数的。
例如设置参数:-Xss164K
1.1.5、本地方法栈
虚拟机栈对应的方法是 Java 方法,而本地方法栈对应的是 native 方法。其他方面应该和虚拟机栈差不多。
1.2、线程共享区域
1.2.1、方法区
方法区所存放的数据为类信息
,常量
,静态变量
,即时编译后的代码
。
在 JDK1.8 以下的 JVM 中,方法区就是对应的永久代
,而 JDK1.8 之后方法区就变成了元空间(MetaSpace)
1.2.2、堆空间
在堆空间中主要存放的是通过 new 创建出来的对象或者数组。
1.3、JVM 内存模型-堆
在 JVM 内存模型中,将线程共享部分分为了堆空间和方法区,下面主要来看堆空间
是如何进一步划分的。
在下面这张草图中,JVM 堆空间按照分代思想
划分为新生代
和老年代
两部分,它们两者空间分别为堆空间的1/3和2/3。
对于新生代这个区域又进一步进行划分为 Eden区
, from区
,to区
这三个区域。
- 对象或者数组的创建优先在 Eden 区分配内存空间。
- 如果新创建的大对象在新生代放不下,那么会直接移入老年代空间。
- 在每一次进行 Minor GC 之后,Eden 区的垃圾对象就会被回收,并且存活的对象会进入 from区 或者 to区。在每次 Minor GC 之后存活的对象的年龄会累加,当多次 GC 之后,对象的年龄达到 15 ,那么将进入老年代。
- 如果 Eden 区存活的对象太大,放不进 from 或者 to 中,那么将进入老年代。
注意:在新生代中发生的 GC 为 Minor GC 而老年代发生的 GC 为 Full GC, Full GC 比 Minor GC 的效率要低。
1.4、垃圾回收算法
GC 是如何判断一个对象是否需要被回收呢?
在 JVM 中主要有两种判断方法:
- 对象引用记数法
当一个对象被一个变量引用时,那么引用计数累加。如果一个对象没有被其他变量引用,那么就是需要被 GC 回收的。但是这里有一个弊端,那就是对象间相互引用导致无法被回收的问题。
- 可达性分析算法
从一个 GCRoot 开始遍历,当一个对象到 GCRoot 没有直达路径时,就标记为不可达,是需要被 GC 回收的。
那什么对象可以作为 GCRoot 呢?
- 局部变量表所引用的对象
- 静态变量/常量引用的对象
- 本地方法引用的对象
JVM垃圾收集算法
- 标记清除法
标记清除算法分为两个阶段:
标记阶段负责将将可回收的对象进行标记出来,清除阶段负责将这些标记出来的对象进行回收。从下面的图可以看出,这种算法会造成内存碎片。
标记清除算法- 复制算法:
复制算法它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。
- 标记整理法:
标记整理算法复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
- 分代收集算法:
根据分代思想对堆区划分位新生代和老年代,针对这个两个区只用不同的回收算法。
新生代:使用复制算法
老年代:使用标记清理算法或者标记整理算法
总结
...
记录于2019年3月27日