JVM:内存管理(jvm内存工具)
 南窗  分类:IT技术  人气:105  回帖:0  发布于1年前 收藏

在自动内存管理机制下,不再需要手动回收每个对象,不容易出现内存泄漏和内存溢出问题,但正因为将控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,需要深入了解Java虚拟机的底层原理才能更快的排查问题。

内存管理分为内存动态分配和内存回收,前者需要了解内存分配算法,后者需要了解GC算法。

一、运行时数据区域

方法区和堆随着虚拟机的启动而一直存在,程序计数器和栈随着用户线程的启动和结束而建立和销毁。

运行时数据区

1 程序计数器

程序计数器是一块很小的区域,记录当前线程下一条字节码指令地址,每个线程都有一个独立的计数器。如果执行的是本地方法,则计数器为空。

2 Java虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型,是线程私有,生命周期与线程等同。每个方法执行时,虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、返回地址等信息。

3 本地方法栈

与虚拟机栈作用类似,区别是执行本地方法,而非Java方法,hotspot将本地方法栈和虚拟机栈合二为一,不同虚拟机实现有所不一样。

4 堆

堆是所有线程共享的一块区域,在虚拟机启动时创建,存放几乎所有的对象实例和数组。但随着即时编译,尤其是逃逸分析技术,栈上分配、变量替换优化手段已经可以将部分对象存放在栈上。

从内存分配的角度看,Java堆中也划分出多个线程私有的缓冲区(TLAB),以提升对象分配效率。

5 方法区

方法区存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

6 运行时常量池

常量池表是方法区的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的常量池中。

二、内存分配

1 对象创建

1.1 分配内存

当Java虚拟机遇到一条字节码new指令,首先将去检查这个指令的参数是否能在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有就需要先执行类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存,对象所需内存在类加载完成后便可确定。

内存分配方式

  • 指针碰撞:假设Java堆是绝对规整的,所有使用过的内存在一边,没使用过的内存在另外一边,每次将指针移动一段与对象大小相等的距离。
  • 空闲列表:如果Java堆不是规整的,已被使用的内存和未被使用的内存交错在一起,就需要一个列表来记录哪些内存块可用。

选择哪种分配方式由Java堆是否规整来决定,而Java堆是否规整又由垃圾回收器是否带有空间压缩整理的能力决定。因此,当使用Serial、ParNEW等带压缩整理过程的回收器时,系统采用指针碰撞既高效又简单。而当使用CMS这种基于清除算法的收集器时理论上采用空闲列表来分配。

另外需要考虑分配时的线程安全问题:对象创建在虚拟机中是非常频繁的,为解决线程安全问题有两种可选方案。

  • 对分配空间的动作进行同步,虚拟机采用CAS配上失败重试保证原子更新;
  • 每个线程划分本地缓存,线程优先从本地缓存中分配,不够时分配新的缓存,需要同步。

1.2 对象初始化--虚拟机

虚拟机将分配到的内存空间初始化为零值,并设置对象头,例如这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码、对象GC分代年龄等信息。这些操作后,从虚拟机角度来看,一个新对象已经产生了,但是从Java程序的角度看,对象还未初始化。

1.3 对象初始化--构造函数

经过虚拟机的初始化,一个对象已经创建,但此时对象体是零值,需要调用构造器进行初始化(Class中<init>()方法)按照用户预定的意图进行赋值。

对象初始化源码

2 对象内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:对象头、实例、对齐填充。

对象头部分包括两类信息第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据称为Mark Word。第二类是类型指针。

3 对象访问定位

值传递和引用传递的区别在于引用传递是地址传递。值传递时,Java栈帧的本地变量表中存放的就是变量值。引用传递时,Java栈帧的本地变量表中存放的是Reference,也就是指向Java对象的地址。

目前主流的对象访问方式是直接指针和句柄,Hotspot使用的是句柄。

  • 直接指针:Reference中存放的就是对象地址。
  • 句柄:Reference中存放的是句柄地址。

句柄会多一次访存,但是在垃圾回收时如果移动对象的话,只需要更新句柄中对象指针,相对而言句柄会使堆栈更稳定。直接指针需要更新堆栈中本地变量表,但可以少一次访存。

句柄

三、标记算法

1 三色标记法

目前判断Java对象是否存活会从Java堆栈中的引用收集GC Roots进行遍历,标记存活的对象。

将对象分为白色、灰色、黑色三种状态。

  1. 从Java堆栈收集GC Roots标记为灰色进入灰色队列;
  2. 多线程消费灰色队列,将每个灰色对象直接引用的对象添加到灰色队列,将消费过的灰色对象标记为黑色加入到黑色队列;
  3. 灰色队列消费完后,剩余非黑色对象皆是白色对象,可以被回收。

2 跨代引用

跨代引用相对同代引用仅占极少数,没必要去遍历整个老年代,只需要在新生代建立一个记忆集,将老年代分成若干个小块,标识出老年代的哪一块存在跨代引用。当发生Minor GC时将包含了跨代引用的内存块中的对象加入到GC Roots中进行扫描。这种方法虽然会改变引用关系时维护记录数据的正确性,会增加一些开销,但比起扫描整个老年代还是值得的。

3 漏标/多标问题

收集GC Roots时会暂停用户线程,但并发标记时不会暂停用户线程,此时会产生新的引用关系,多标产生浮动垃圾不致命,但一旦漏标就出现了问题。

case 1:当E被标记为灰色,但此时D断开了对E的引用,此时E、G、F依然会被标记,属于浮动垃圾,本轮GC不会回收这部分对象。

浮动垃圾案例

case 2:当对象D已被标记为灰色,此时产生了D对G的引用,而E却断开了对G的引用,那么G不会被标记,会被回收掉,这属于漏标现象。

漏标案例

三色标记法的理论证明,当且仅当以下两个条件同时满足时会产生漏标问题,即本该是黑色对象被误标为白色对象:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

只要破坏其中一个条件,那么就可以保证不会漏标。

那么如果灰色对象E一开始就不引用白色对象G,后来黑色对象D引用白色对象G,不满足第二个条件,但也会漏标,好像与此并不符合?这是为了防止漏标做的约束,强弱三色不变式:

  • 强三色不变式:黑色对象不能够引用白色对象。
  • 弱三色不变式:如果有灰色对象能够直接或间接引用这个白色对象,那么黑色对象也可以引用该对象。

解决漏标问题通常用的是原始快照(SATB)、增量更新,两者都是基于读写屏障实现。原始快照保留原本的引用关系,会进行重新标记,破坏了第2个条件。增量更新保存了新增的引用关系,可以破坏第1个条件。

4 写屏障+SATB

void oop_field_store(oop* field, oop new_value) {
    pre_write_barrier(field); // 写屏障-写前屏障
    *fieild = new_value // 赋值操作 
    pre_write_barrier(field); // 写屏障-写后屏障
}

当对象E发生新的引用关系时(E.filedG=null),可以基于写屏障记录原本的引用关系,后续重新标记,破坏了灰色对象断开白色对象的引用这个条件。

// void pre_write_barrier(oop* field) {
//     oop old_value = *field; // 获取旧值
//     remark_set.add(old_value); // 记录 原来的引用对象
// }
void pre_write_barrier(oop* field) {
  // 处于GC并发标记阶段 且 该对象没有被标记(访问)过
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) { 
      oop old_value = *field; // 获取旧值
      remark_set.add(old_value); // 记录  原来的引用对象
  }
}

5 写屏障+增量更新

当对象D的成员变量的引用发生变化时(D.filedG=G),可以利用写屏障记录新引用对象,该方法破坏了黑色对象引用白色对象这个条件。

void post_write_barrier(oop* field, oop new_value) {  
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
      remark_set.add(new_value); // 记录新引用的对象
  }
}

CMS采用的是增量更新,G1采用的是原始快照。

四、垃圾回收算法

1 分代收集理论

分代收集建立在:

  • 大多数对象短暂存在,每次可以回收掉大部分;
  • 经过多次回收后还存活的对象,可能会存在更长的时间,可以适当降低回收频率。

新生代回收频率最高,老年代其次,整堆收集最少。可能存在老年代对象引用新生代对象的跨代引用问题,对于这种新生代对象需要保存起来。

新生代采用复制算法,有Eden、Survivor1、Survivor2三块区域,每次回收Eden和一块Survivor并将剩余对象放入到另一块Survivor中。因为每次能回收掉90+%的对象,所以三块区域的空间大小比例为8:1:1。为防止出现Survivor区存放不下剩余对象,会由老年代空间进行担保,直接入老年代空间。

2 标记-清除算法

清除算法特点:

  1. 清除对象操作的效率会随着回收对象数量增长而降低;
  2. 不需要移动对象,存在内存碎片化问题,内存不连续。

清除算法不适合可回收对象太多的情况,目前只有CMS回收器在老年代使用,CMS会定期的执行一次内存整理,避免出现无法分配大对象的情况。

3 标记-复制算法

为解决清除算法面对大量可回收对象时执行效率低的问题,诞生了标记复制算法,可以将一块内存上的存活对象直接复制到另外一块内存上。在面向大量回收对象时复制算法效率高,而且不会有碎片,可以通过移动指针的方式按顺序分配,但是可用内存会降低。新生代采用复制算法,根据每次回收90+%对象的情况,将Eden、Survivor1、Survivor2内存块按8:1:1分配,内存利用率达到90%。

4 标记-整理算法

复制算法使用对象存活率较低的情况,但对象存活率较高时会进行大量复制操作,效率会降低,更关键的是会浪费近50%内存,所以老年代不采用该算法。

整理算法一开始不需要清理对象,而是将存活对象往内存一端移动,然后清理边界以外内存。

5 小结

存活率

清除算法和整理算法适合对象存活率较高的情况,一般用于老年代,而复制算法适合存活较低的情况,否则复制的代价太大。

内存利用率

复制算法需要额外的内存来存放存活对象,存活率较低时内存利用率可以提高一些,但需要老年代担保。清除算法容易出现许多碎片,导致大对象无法分配,所以需要定期整理内存。

移动对象

复制算法和整理算法都需要移动对象,移动对象时需要暂停用户线程,更新句柄的对象地址,清除算法不用移动对象,所以清除算法的停顿时间会短一些,更适合追求响应速度的场景。而移动对象内存利用率更高,不移动对象虽然回收效率高,但内存分配频率远高于垃圾收集,这部分耗时增加,总吞吐量还是会降低,所以移动对象能更适合追求吞吐量。

五、垃圾回收器

1 安全点和安全区域

用户线程会在安全点轮询GC状态,决定是否停顿,并非在任意位置都会停顿。轮询操作在代码中频繁出现,这要求它必须高效,Hotspot使用内存保护陷阱方式将轮询操作精简为一条汇编指令。

安全点机制可以保证用户线程尽快进入垃圾回收过程的安全点进行暂停,但如果线程阻塞处于不执行状态时无法进入安全点,此时就必须引入安全区域来解决。

安全区域是能够确保在某一段代码内引用关系不会发生变化,在这个区域内开始垃圾回收是安全的。当线程进入安全区域内时,首先标识自己已经进入安全区域,虚拟机发起垃圾回收时就不必去管这些线程。当线程要离开安全区域时,需要判断此时是否处于垃圾收集需要停顿的阶段(初始标记、重新标记、移动对象等),如果不是,就继续执行,无事发生,否则就一直等待,直至收到信号。

2 常见垃圾回收器

垃圾收集器性能指标:吞吐量、停顿时间、额外内存占用。 高吞吐量可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合后台计算而不需要太多交互的分析任务。 停顿时间越短就越适合与用户交互或者需要保证服务响应质量的程序,良好的响应速度能够提升用户体验。

3 ParNew

ParNew是Serial收集器的多线程并行版本,除了使用多条线程进行垃圾收集之外,其余行为和Serial完全一致。ParNew是目前除了Serial外唯一能够搭配CMS工作的收集器。

ParNew收集器在单核处理器的环境中绝对不会有比Serial更好的效果,存在线程交互的开销。

4 Parallel Scavenge

它基于标记-复制算法实现的新生代收集器(高吞吐量优先收集器),是能够并行收集的多线程收集器,目标是达到一个可控制的吞吐量。它可以通过控制最大垃圾收集停顿时间和设置吞吐量大小来精确控制吞吐量,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价的。另外,它可以开启自适应调节策略,虚拟机根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

5 CMS

5.1 CMS基本原理

CMS是基于标记清除算法实现的多线程、老年代回收器,目标是获取最短停顿时间,目前大部分Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端,直接面向用户,这类应用通常较为关注服务的响应速度。

整个过程分为四个步骤:

  1. 初始标记:需要STW,标记一下GC Roots直接关联的对象,停顿时间短;
  2. 并发标记: 从GC Roots直接关联的对象开始遍历整个对象图,耗时长但不需要停顿,用户线程和回收线程并发运行;
  3. 重新标记:修正并发标记期间因用户程序继续运行而导致的引用更新,停顿时间比初始标记长一些,但也比并发标记阶段短许多;
  4. 并发清除:清除掉标记阶段认为死亡的对象,由于不需要移动存活对象,这个阶段也是可以与用户线程并发的。

5.2 CMS特点

CMS优点是并发收集、低停顿,但缺点也很明显:

  1. 对处理器资源敏感:虽然不会停顿用户线程,但是会占用一部分线程而导致应用程序变慢;
  2. 无法处理浮动垃圾:可能出现“Concurrent Mode Failure”失败进而导致另一次完全STW的Full GC的产生。并发清理阶段用户线程依然在运行,伴随着新的垃圾对象,但由于在标记阶段后,无法在本次回收处理掉。另外,在垃圾回收期间用户线程还会继续运行,因此不能等到老年代被完全被填满后再收集,必须预留一部分空间供并发收集时程序运行使用。
  3. 内存碎片:标记清除算法会有许多碎片,导致大对象无法分配,不得不提前触发Full GC。因此提供UseCMS-CompactAtFullCollection参数用于在CMS收集器进行Full GC时开启内存碎片的整理过程,整理过程需要移动对象,无法并发,停顿时间变长。因此又提供了CMSFullGCsBeforeCompaction,要求CMS收集器执行过若干次不整理碎片的Full GC后,下一次进入Full GC前会进行碎片整理。这两个参数在Java 9后被废弃。

CMS无法搭配Parallel Scavenge的原因是:

  • CMS面向低延迟,Parallel Scavenge面向高吞吐量;
  • Parallel Scavenge没有使用分代框架,而是另外实现。

CMS只能搭配ParNew、Serial收集器,CMS+ParNew是Java 8及之前官方推荐的组合,从Java 9开始官方推荐的是面向全堆的G1收集器。

5.3 存在的问题

promotion failed发生在Minor GC过程中,老年代内存碎片太多,没有足够的连续内存担保新生代对象。然后触发老年代垃圾回收,此时用户线程无法创建新对象,STW降级为Serial Old,进而产生concurrent mode failure。

concurrent mode failure是CMS特有的问题,老年代正在垃圾回收,此时老年代内存放不下从年轻代晋升的对象或者直接在老年代分配的大对象,就会抛出这个问题。造成的后果是用户线程停顿时间增加,CMS的优势(最短停顿时间)荡然无存。

5.4 解决方式

  1. CMS触发的太晚,CMS会有浮动垃圾,可以较早进行垃圾回收,-XX:CMSInitiatingOccupancyFraction=N,将N调小一些。
  2. 内存碎片太多,以下参数是指经过多少次FullGC后进行一次内存整理,设置一个合适的值。
-XX:+UseCMSCompactAtFullCollection (空间碎片整理)
-XX:CMSFullGCsBeforeCompaction=n
  1. 垃圾产生的太快: i. Eden、Survivor过小,导致对象频繁进入老年代; ii. 存在大对象; iii. 晋升阈值太低。

讨论这个帖子(0)垃圾回帖将一律封号处理……