文章目录
1 虚拟机内部构造
1.1 何为虚拟机
JVM 是可运行 Java 代码的假想计算机 ,包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收,堆 和 一个存储方法域。
JVM 是运行在操作系统之上的,它与硬件没有直接的交互。
我的理解是,虚拟机 就是一个进程。
Java 为什么能够跨平台?
每一种平台的解释器是不同的,但是实现的虚拟机是相同的。
当一个程序开始运行,此时虚拟机就开始实例化了,多个程序启动就会存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例消亡。多个虚拟机实例之间数据不能共享。
1.2 虚拟机中的线程
这里所说的线程指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。
Hotspot JVM 中的 Java 线程与原生操作系统线程有直接的映射关系。当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程 。
Java 线程结束,原生线程随之被回收。操作系统负责调度所有线程,并把它们分配到任何可用的 CPU 上。当原生线程初始化完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。
我的理解是:虚拟机的线程与操作系统线程一一映射对应,同生同灭。
1.3 虚拟机中的内存区域
- 1.程序计数器( 线程私有 )
一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。
正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果是 Native 方法,则为空。
(也可以理解为一个指针,指向方法区中的方法字节码,即指向将要执行的指令代码)
此区域是虚拟机中唯一一个没有规定任何 OutOfMemoryError 情况的区域。
- 2.虚拟机栈( 线程私有 )
是描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)。
作用:用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁(无论正常还是异常结束)
- 3.本地方法区\本地方法栈( 线程私有 )
本地方法区和 Java Stack 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务, 如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用, 那么该栈将会是一个C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。
Native 方法:
- 4.堆(Heap-线程共享)-运行时数据区
是被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最主要的内存区域。
由于现代 VM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和 To Survivor 区) 和 老年代。
- 5.方法区/永久代(线程共享)
即Permanent Generation, 用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
(永久代的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般很小。)
运行时常量池: 即 Runtime Constant Pool,是方法区的一部分。
Class 文件中除了有类的版本、字段、方法、接口等描述等信息外, 还有一项信息是常量池(Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。
举个例:
public void test(){
DmDealer a = new DmDealer();
int b = 1;
}
public class DmDealer{
XXX
}
a 是 DmDealer 对象的引用所以放在栈中, DmDealer 是自定义对象所以放在堆中.
b 是基础数据类型所以在栈中.
2 垃圾回收(GC)
2.1 如何确定垃圾
2.1.1 引用计数法
概念:每个对象有一个引用计数器,当对象被引用一次则计数器加1,当对象引用失效一次则计数器减1,对于计数器为0的对象意味着是垃圾对象,可以被GC回收。
缺陷分析:比如说
GcObject obj1 = new GcObject(); //Step 1
GcObject obj2 = new GcObject(); //Step 2
obj1.instance = obj2; //Step 3
obj2.instance = obj1; //Step 4
obj1 = null; //Step 5
obj2 = null; //Step 6
此时 执行到Step 4,GcObject实例1和实例2的引用计数都等于2。
但是当 [obj1 = null && obj2 = null] 执行之后:
很明显,此时obj1 obj2已经与GcObject无关了,但是因为它们的引用计数器不为0(此时为1),所以这两个实例所占的内存将得不到释放,这便产生了内存泄露。
2.1.2 可达性分析
从GC Roots作为起点开始搜索,那么整个连通图中的对象便都是活对象,对于GC Roots无法到达的对象便成了垃圾回收的对象,随时可被GC回收。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则面临回收。
该算法的核心算法是从GC Roots对象作为起始点,可达对象便是存活对象,而不可达对象则是需要回收的垃圾内存。
可作为GC Roots的对象:
- 虚拟机栈的栈帧的局部变量表所引用的对象;
- 本地方法栈的JNI所引用的对象;
- 方法区的静态变量和常量所引用的对象;
再回到上图,虽然堆中的两个实例相互引用,但并没有任何一个GC Roots与之相连,这便是GC Roots不可达的对象,这就是GC需要回收的垃圾对象。
采用引用计数算法的系统只需在每个实例对象创建之初,通过计数器来记录所有的引用次数即可。而可达性算法,则需要再次GC时,遍历整个GC根节点来判断是否回收。
2.2 四种垃圾回收算法
2.2.1 标记清除算法(Mark-Sweep)
最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。如下图:
从图中我们就可以发现,该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
2.2.2 复制算法(copying)
按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉,如下图:
这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
2.2.3 标记整理算法(Mark-Compact)
标记阶段和 Mark-Sweep 算法相同,但标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。如下图:
2.2.4 分区收集算法(目前用的不多)
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。
2.3 分代收集算法
其实我个人觉得它不能称之为算法,更合理一些应该是方式。
分代收集法是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将堆内存划分为不同的区域, 根据不同区域选择不同的算法。
两个区域:新生代、老年代。
2.3.1 新生代
新生代占据堆的 1/3 空间,用来存放新生的对象。一般采取复制( Copying )算法。由于频繁创建对象,所以每次垃圾收集都会发现大批对象已死, 只有少量存活,因此选用复制算法, 只需要付出少量存活对象的复制成本就可以完成收集。 即 要复制的操作比较少。
在新生代区域发生的垃圾回收称为 ‘MinorGC’。
新生代分为: Eden 区、ServivorFrom、ServivorTo 三个区。
Eden 区:
Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。
ServivorFrom:
上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
ServivorTo:
保留了一次 MinorGC 过程中的幸存者。
新生代GC过程:
首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果ServicorTo 不够位置了或有对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄+1;
接着,清空 Eden 和 ServicorFrom 中的对象;
最后,把ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom区。
2.3.2 老年代
一般占据堆内存的2/3,主要存放应用程序中生命周期长的内存对象。因为对象存活率高、没有额外空间对它进行分配担保, 所以就必须采用’标记 - 整理’算法来进行回收, 不必进行内存复制, 且直接腾出空闲内存。
在老年代区域发生的垃圾回收称为 ‘MajorGC’。因为老年代的对象比较稳定,所以 MajorGC 不会频繁执行。
MajorGC过程:
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。
MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。
当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
2.3.2 永久代
指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
在 Java8 中,永久代已经被移除,被一个称为’元数据区’(元空间)的区域所取代。
元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存 。
因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。
方法区是一个抽象的概念。而永久代/元空间是方法区的具体实现,是实实在在存在的
2.4 垃圾回收总结
1. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老年代。
2. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后,Eden Space 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。
3. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老年代。
4. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。
5. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。默认情况下年龄到达 15 的对象会被移到老年代中。
6. 处于方法区的永久代(Permanet Generation),它用来存储 class 类、常量、静态变量、方法描述等。对永生代的回收主要包括废弃常量和无用的类。
0 . 写这篇博文目的是自己有时候想回顾虚拟机知识时总是要到处找资料,太累了不如自己花时间写一篇算了。在[JVM如何确定垃圾]小节中我有借鉴知乎上@Gityuan大神的文章,听君一席话,胜读十年书。
如果博文里有错误的地方麻烦朋友们指正,让我也多学习学习。。