JVM 相关知识梳理(二) ----运行时数据区、Java内存模型、GC
运行时数据区
这个名词听起来陌生也不陌生…但是它的定义到底是什么呢?
The Java Virtual Machine defines various run-time data areas that are used during execution of a program. Some of these data areas are created on Java Virtual Machine start-up and are destroyed only when the Java Virtual Machine exits. Other data areas are per thread. Per-thread data areas are created when a thread is created and destroyed when the thread exits.
大意是:
Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。
其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会销毁。
其他数据区域是每个线程,每个线程的数据区域在线程创建时创建,在线程退出时销毁。
运行时数据区-区域划分 :
1)方法区:
- 方法区是各个线程共享的的内存区域。在虚拟机启动时候创建。
The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is created on virtual machine start-up.
- 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来。
Although the method area is logically part of the heap,…
- 用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.
- 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
If memory in the method area cannot be made available to satisfy an allocation request, the Java Virtual Machine throws an OutOfMemoryError.
在上一篇文章的类加载篇幅,我们提到了Classs文件除了有类的类版本、字段、方法、接口等描述信息(也含有常量池信息(javaP查看)),用于存放编译期产生的各种字面量和符号引用。
呢么这部分内容将在类加载后保存到方法区的运行时常量池。
2)堆:
The Java Virtual Machine has a heap that is shared among all Java Virtual Machine threads. The heap is the run-time data area from which memory for all class instances and arrays is allocated.
The heap is created on virtual machine start-up.
- Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。
- Java对象实例以及数组都在堆上分配。
在上一篇文章中,介绍了对象的创建流程,就是这里的对象创建。产生了对象,便可以作为对方法区访问的入口。
但是,随着JIT编译器的发展与逃逸分析技术逐渐成熟,在栈上分配内存、标量替换优化,所有的对象分配在堆上也不绝对。
堆内存区域在细分可以分为 Eden
、From Survivor
、To Survivor
,以及线程私有的分配缓冲区(TLAB
)
3)虚拟机栈:
- 虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。
Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread.
- 每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。
A Java Virtual Machine stack stores frames (§2.6).
- 调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。
A new frame is created each time a method is invoked. A frame is destroyed when its method invocation completes.
每个方法在执行的同时都会创建一个栈帧,用于存储局部表量表
、动态链接
、方法出口
等;
每一个方法从调用到执行完成的过程,对应着一个栈帧在虚拟机栈中从入到出的过程。
栈帧中的分配布局:
局部表量表:
- 方法中定义的局部变量以及方法的参数存放在这张表中。
- 局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。
操作数栈:
以压栈和出栈的方式存储操作数的。
动态链接:
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
方法返回地址:
当一个方法开始执行后,只有两种方式可以退出。 一种是遇到了方法返回的字节码指令; 一种是遇见异常,并且这个异常没有在方法体内得到处理。
4)程序计数器:
我们都知道一个JVM进程中有多个线程在执行,而线程中的内容是否能够拥有执行权,是根据CPU调度来的。
假如线程A正在执行到某个地方,突然失去了CPU的执行权,切换到线程B了,然后当线程A再获得CPU执行权的时候,怎么能继续执行呢?这就是需要在线程中维护一个变量,记录线程执行到的位置。
如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是Native方法,则这个计数器为空;
5)本地方法栈:
如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。
那如果在Java方法执行的时候调用native的方法呢?
以上5个部分按照线程独享/共享分类 :
以上5个结构按照GC分类:
JVM内存模型
运行时数据区跟JVM内存模型两个概念对于初次接触,是很容易产生懵逼感的一对概念。我们暂时先理解为 JVM运行时数据区是一种规范,而JVM内存模型是对该规范的实现。 在该实现中,我们关注堆与方法区(非堆),其余几块区域我们暂且按住不表。
堆区 与 非堆 在jmm概念中, 分配大致如下:
对以上区域划分,我们结合GC相关的概念,以FAQ的形式进行讨论,来阐明它这几个区域都是什么意思,以及为什么这么分(你需要知道GC的大概流程)。
case 1
: GC 名词解释
- Minor GC:新生代
- Major GC:老年代
- Full GC:新生代+老年代
case 2
: 为什么需要Survivor区(S0、S1)? 只有Eden不行吗?
如果没有Survivor区,Eden区每一次的minor GC 后,仍旧存活的对象只能投递到老年代里面,这样老年代的空间会很快被打满,进而触发Major GC(因为Major GC 一般伴随着minor GC ,也可以看做触发了Full GC)。
老年代的内存空间是远大于新生代的,触发一次FULL GC 消耗的时间会很长 ,并且FULL GC 的情景下,根据垃圾回收器的不同,会生产STW,暂停业务线程。
诚然,我之前觉得这种STW中断业务线程是差强人意可以接受的,但随着业务量增加,生产触发了一次3S的STW ,百笔交接直接被网关熔至超时,这个月的成功率又达不到相关指标了。。。
所以Survivor区存在的意义,可以理解为一个中间区域。尽可能在这里对对象进行GC,提供老年代入场门槛,最终规避大的FULL GC。
case 3
: 为什么需要两个Survivor区?
这里个人理解是 跟GC算法中的复制算法初衷相似,主要是避免空间碎片化。
eden满后了 -> minor GC -> s0 。下一次s0 与 s1 进行对调。
case 4
: 新生代中Eden:S1:S2为什么是8:1:1 ?
新生代中的可用内存:复制算法用来担保的内存为9:1。
可用内存中 Eden:S1 = 8:1
即新生代中 Eden:S1:S2 = 8:1:1
现代的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象大概98%是“朝生夕死”的。
case 5
: 堆内存中都是线程共享的区域吗?
JVM默认为每个线程在Eden上开辟一个buffer区域,用来加速对象的分配,称之为TLAB,全称:Thread Local Allocation Buffer。
对象优先会在TLAB上分配,但是TLAB空间通常会比较小,如果对象比较大,那么还是在共享区域分配。
以上篇幅尝试解释了jmm实现的思路,呢么管对象创建也得管’‘埋’’ , GC 相关内容与行为又是怎么样的呢?
GC
先问是不是在,在问为什么。
GC 这里的知识也同样适合这句话,是不是垃圾?如果是垃圾如何回收?
1)如何判定相关对象可被GC?
-
引用计数器法
:对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。 但是 A->B 、B->A 这样的指向关系难道不算垃圾? 其实是算的,他们理应称之为 ‘‘一对垃圾’’ 。 - 因为有上述问题,现在的垃圾判定往往都是基于
可达性分析
。
可达性分析
:通过GC ROOT 对象,开始向下寻找,看某个对象是否可达。
而可以做GC ROOT的对象有 类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等。
2)我该什么时候进行GC ?
GC是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。
但下述操作有可能会触发GC:
- 当Eden区或者S区不够用了
- 老年代空间不够用了
- 方法区空间不够用了
- System.gc()
当然,我们可以手动进行垃圾回收,比如调用System.gc()方法通知JVM进行一次垃圾回收,但是具体什么时刻运行也无法控制。也就是说System.gc()只是通知要回收,什么时候回收由JVM决定。
但是不建议手动调用该方法,因为GC消耗的资源比较大。如果某个场景担心GC识别,你可以尝试将一些不需要的变量手动置null,helper gc 。
3)我该怎么进行GC ?
第一种 :
从堆中所有的对象进行一次扫描,一个个问对象还活着吗?谁该被回收了,打上一个TAG。 这个行为我们称之为 标记
。
接下来我们对刚打上tag的对象,进行回收,做相应空间的释放。这个行为我们称之为清除
产生出来Plan A : 标记-清除
标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
- 标记和清除两个过程都比较耗时,效率不高
- 会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
第二种 :
我们直接把内存分为两组相等的区域,每次只用一组。当一组空间告罄时,我们将存活的对象复制到另外一块区域,随后将使用的内存空间整体清理掉。
产生出来Plan B : 标记-复制
缺点:
- 空间利用度低。
- 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
- 如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都有100%存活的极端情况(==》老年代无法使用这种方式)。
第三种 :
在第一种标记-清除
中,如果后续步骤不直接对可回收的对象进行清理,而是让所有存活的对象都移向另一端,随后直接清理掉边界以外的内存。
呢么这种做法 我们可以称之为 标记-整理
;
第四种 :
Young区:对象在被分配之后,可能生命周期比较短。
Old区:Old区对象存活时间比较长,复制来复制去没必要。
新生代、老年代 各自特点是不一致的,如果可以有针对的选择垃圾回收算法,呢么应该是一种不错的选择。
对于新生代的"朝生夕死“ ,标记-复制
或许是一个不错的选择。
对于老年代的GC成功率,标记-整理
或许更为稳妥。
呢么Plan C ,就是 分代收集
了。
4)垃圾回收器又都有什么呢 ?
//todo