JVM那点事儿

JVM那点事儿

Scroll Down

生完孩子逗孩童的时候无聊编译了一下HotSpot,在Windows附带的Linux子系统。正好伴着HotSpot实战这本书来深入理解下JVM层面的一些东西。杂谈一二。

Java那点事儿Java内存模型 俩篇文章中,主要介绍了在Java层面类的加载,内存布局,方法的解析,异常机制,和垃圾回收等。在本篇文章中我将分析在HotSpot中分析类和对象,以及类加载,内存布局,栈和堆以及每一种垃圾回收器和GC日志解读。

类和对象

那么不由得就想HotSpot基于C实现,C是面向对象的,那么JVM中的对象是不是就是一个C对象呢。? 回答你的是没错,当然并不是简单地的在Java语言创建一个对象,在jvm内部就相应的创建一个C对象,首先这种映射关系是基础,之后HotSpot的实际设计呢则是采用了OOP-Klass二分模型来设计这个特定的对象类型。每创建一个Java对象就会在HotSpot内部创建一个OOP对象。,在元数据空间中创建instanceKlass或arrayKlass。
instanceKlass引用了一个java镜像,它是java.lang. class镜像这个类的实例。VM c++通过klassOop访问instanceKlass。

OOP-Klass二分模型
  • OOP

就是普通对象指针,描述对象实例的信息,oop是GC托管指针。所有oopDesc及其子类(除奇葩的markOopDesc外)的实例都由GC所管理。这才是最最重要的,使oop区分与HotSpot所用的其它指针类型的地方。为什么叫做普通对象指针呢,是因为Smalltalk语言与HotSpot VM的渊源。在Smalltalk的对象由GC来管理,而其许多实现里都会用所谓“直接对象”的方式来实现一些简单的值类型对象,例如SmallInteger。所谓“直接对象”(immediate object)就是并不在GC堆上分配对象实例,而是直接将实例内容存在对象指针里的对象。这样的指针也叫做“带标记的指针”(tagged pointer)。于是在这种环境中,每当我们拿到一个对象指针时,都得先问它:你是一个直接对象还是一个真的指针?如果是真的指针,它就是一个“普通”的对象指针了。这样对象指针就有了“普通”与“不普通”之分。

  • Klass

就是JAVA类的C++对等结构,用来描述Java Class,实现JVM层面的Java Class。和Java对象分发(多态)。 ,

OOP呢用来表示对象的实例数据,不持有虚函数, Klass持有VTBL(虚函数列表)当需要执行函数,找OOP对应的Klass就可以拿到要执行的虚函数。OOP对象有很对个如InstanceOop表示一个java类型实例,methodOop表示一个Java方法,constantPoolOop表示常量池等等,这些所有的OOP子类呢有一个共同的父类就是oopDesc

class oopDesc{
	private:
	 volatile markOop _mark;
     union _metadata {
     	wideKlassOop _klass;
       nrrowOop _compressed_klass;
     } _metadata;
}
内存布局

对象头的内存布局关系着对象内存空间的利用率,在内存模型那篇文章中有介绍这个对象头的组成部分,我曾提到过指针压缩。指针压缩涉及到的实际对于元数据类型指针的压缩。-XX:UseCompressedOops -XX:UseCompressedClassPointers, -XX:PrintCompactFieldsSavings(打印字段压实节省的字长),在开启指针压缩的时候元数据类型指针是不一样的,开启时候使用的是narrowOop,关闭的时候使用的是wideKlassOop,在内存布局那篇文章中层提到过HSDB这个SA小工具来查看JVM的内部状态。发现在关闭压缩指针的时候可以检测到klass的地址,开启的时候就无法检测到,后来多方定位,参照R大的理解才明白是SA不支持narrowOop的类型解析导致。

instanceKlass

instanceKlass用来描述类的实例数据的存储信息,如描述对象的继承关系。通过_super字段,_subklass字段,_subklass->next_sibling()可找到下一个子类。Java的类继承层次结构是多叉树——每个Java类有且只有一个父类(根节点的java.lang.Object除外,它没有父类),并且可以有零到任意多个子类。为方便存储这种多叉树,HotSpot将其转换为二叉树来表示,其左子树由_subklass记录,代表继承树上更深一层的类;右子树由_next_sibiling记录,代表继承树上同一层、父类相同的类。多叉树转二叉树: 将多叉树的第一个儿子结点作为二叉树的左结点,将其兄弟结点作为二叉树的右结点。就是用的这个原理。

关于JVM字段分配的策略默认是排列顺序是引用类型在基本数据类型之后,但是由于CompactField字段是默认开启的,所以如归子类中的较小字节占用的字段可能会插入到父类变量的空隙。

类加载

在Java那点事儿那片文章中写字节码是如何加载的。介绍了主要的脉络。在这类我进行JVM内部的加载补充,首先给JVM一个Class。就需要去检验这个文件是不是符合字节码文件的格式。如magic,minor_version,major_version,access_flasg,常量池,接口,字段,方法,属性等等。

类的加载在java层面主要是三大阶段加载阶段,链接阶段(主要干的一件事是解析),初始化阶段。在JVM层面划分更加细腻有下列7种状态

  • unparsable_by_gc

初始状态,未解析

  • allocated

已经分配,但是没链接

  • loaded

已加载 该class已经插入到JVM内部类层次机构中。但是没有链接

  • linked

已经链接但是没初始化

  • begin_initialized

初始化中

  • fully_initialized

初始化完成

  • initialized_error

初始化失败

需要提前提一下几个JVM参数用于我们直观的跟踪类加载过程的

参数含义
-verbose:class跟踪类的加载和卸载
-XX:+TraceClassLoading跟踪类的加载
-XX:+TraceClassUnloading跟踪类的卸载
-XX:+PrintClassHistogram显示类信息柱状图
-XX:+TraceClassResolution跟踪常量池的解析

类加载阶段我在Java那些事儿中写道class文件被加载之后会进入方法区根据类型类和接口则会根据对应的字节流将其加载

类加载流程

可以看到读取*.class的constant_pool创建常量池之后返回可常量池的指针,然后在解析(-XX:+TraceClassResolution)当前类和父类全限定名称,和创建当前类和父类的Klass,然后调用parse_fields(),parse_interfaces()和parse_interfaces()来解析接口,字段(父子都有),方法(父子都有),根据已解析的信息,来计算vtable和itable的大小,然后创建InstanceKlass,然后创建java.lang.Class类然后初始化静态域,然后更新 Perf Data计数器。

类加载这一块代码参见Hotspot源码classFileParser的parseClassFile()函数。

链接阶段

需要提前提一下几个JVM参数用于我们直观的跟踪类链接过程的

参数含义
-XX:+TraceClassinitialization跟踪类的初始化
-XX:+BytecodeVerificationLocal本地字节码验证

在Java那点事儿那片文章中写链接阶段加载的就进入了解析阶段,经过加载时会对静态字段分配内存,构造与该类相关联的方法表,然后在初始化的时候会对其进行初始化,在类进行解析时会把方法区唯一标识的符号引用(类名+方法名+参数类型+返回值类型)解析为实际引用,如果说解析的时候遇到的对应的符号引用所指代得类没有加载会对其进行加载。这段话具体在jvm处是怎么说呢。这就要和前面提到的(-XX:+TraceClassResolution)这个参数,开启之后可以看到解析的过程。这一块代码参见Hotspot源码instanceKlasslink_class_impl()函数。 link_class_impl函数 主要做了验证 准备 解析工作。

链接流程

验证工作主要由verifier模块实现。参见这一块代码参见Hotspot源码Verifier。验证方法的访问控制,参数,静态类型检查,堆栈是否滥用,变量是否初始化,变量是否赋予正确的类型,局部变量,等等

准备阶段呢主要是给类的静态变量分配内存空间和初始化静态变量。

解析阶段将符号引用转换为实际引用,把类,接口,字段,方法转换。把这些原来的常量池索引更新常量池缓存内。字节码重写。为每个方法建立编译器或者解释器的入口。

初始化阶段主要关注Hotspot源码instanceKlass#initialize_impl方法。初始化流程如下:

初始化流程

对象创建

对象创建流程
对象的创建流程如上图,这一块代码参见Hotspot源码bytecodeInterpreter#_new指令

  • 快速分配

实际的代码主要在bytecodeInterpreter#_new中如果开启了TLAB。会优先在本地线程分配缓存中分配空间。如果当前TLAB用完,则会计算最大可以分配的TLAB,(开启PrintTLAB参数可以看到TLAB空间计算日志,成功返回新的空间大小失败返回0),然后扩容之后再分配空间,如果开启了(ZeroTLAB)参数会对实例数据补零填充。不开启则使用分配大小。未开启TLAB直接在Eden区域分配(第一次不加锁分配使用指针碰撞),如果分配失败则原子自旋分配,top_addr返回的是Eden区空闲块的起始地址变量_top的内存地址,end_addr是Eden区空闲块的结束地址变量_end的内存地址。compare_to是Eden区空闲块的起始地址,new_top为使用该块空闲块进行分配后新的空闲块起始地址。这里使用CAS操作进行空闲块的同步操作,即观察_top的预期值,若与compare_to相同,即没有其他线程操作该变量,则将new_top赋给_top真正成为新的空闲块起始地址值。 分配空间之后成功初始化对象头,设置偏向锁,设置对齐填充,将Oop压如当前线程栈顶,更新PC寄存器。

  • 慢速分配

这一块代码参见Hotspot源码interpreterRuntime#_new方法,allocate_instance代码以collectedHeap#obj_allocate为例来看,collectedHeap#obj_allocate的代码直接看common_mem_allocate_noinit方法.如果开启了TLAB。会优先在本地线程分配缓存中分配空间。如果当前TLAB用完,则会计算最大可以分配的TLAB,(开启PrintTLAB参数可以看到TLAB空间计算日志,成功返回新的空间大小失败返回0),然后扩容之后再分配空间,如果开启了(ZeroTLAB)参数会对实例数据补零填充。不开启则只是分配大小。未开启LAB直接在Eden区域分配(第一次不加锁分配),如果分配失败则原子自旋分配,直到成功。 如果一直无法分配成功,则根据gc_overhead_limit_was_exceeded变量的结果打印错误,如果为真,就是OutOfMemory,反之就是,代表超过了gc开销的限制。可以打开(-XX:+HeapDumpOnOutOfMemoryError -XX:OnOutOfMemoryError)查看分配失败日志。分配空间之后成功初始化对象头,设置偏向锁,最后返回interpreterRuntime#_new方法在线程栈设置下对象的引用。

Eden区域分配失败原因

  • 当前年轻代的容量不满足分配的空间大小

  • GC临界区被锁定

  • 堆内存吃紧 --上一次GC的结果已经失败,本次分配很可能再次失败

栈和堆和常量池

存储方法执行中的局部变量,中间值,以及方法返回值,进入方法在栈顶分配数据区域,方法结束清空, 栈的大小可以是固定的也可以是动态控制的,针对固定的大小,如果线程请求分配的栈容量超过设置值,会抛出StackOverflowError如果是动态值,需要设置初始容量和最大容量,如果线程请求分配的栈容量超过设置值,会抛出OutofMemoryError

堆区域用分配内存空间,根据对象的年龄划分为年轻代和年老代也就是Eden区域和Old区域,根据对象的年龄使用不同的垃圾回收策略。

常量池

根据JVM规范,在创建类或者接口的时候,会在该类的.class文件中建立静态常量池constant_pool,然后再该.class文件被解析和链接之后,JVM根据constant_pool创建运行时常量池,

垃圾回收器
  • 分代的垃圾回收器

Serial 年轻代 串行回收

PS 年轻代 并行回收

ParNew 年轻代 配合CMS的并行回收

SerialOld 老年代单线程垃圾回收器,CMS降级之后的兜底策略

ParallelOld 老年代多线程线程垃圾回收器

ConcurrentMarkSweep 老年代 并发的, 垃圾回收和应用程序同时运行,降低STW的时间(200ms)

  • 不分代的垃圾回收器

G1(10ms)

ZGC (1ms) PK C++

Shenandoah

Eplison


ConcurrentMarkSweep

CMS的问题

  1. Memory Fragmentation

    -XX:+UseCMSCompactAtFullCollection默认true
    -XX:CMSFullGCsBeforeCompaction 默认为0 指的是经过多少次FGC才进行压缩

  2. Floating Garbage

    Concurrent Mode Failure
    产生:if the concurrent collector is unable to finish reclaiming the unreachable objects before the tenured generation fills up, or if an allocation cannot be satisfiedwith the available free space blocks in the tenured generation, then theapplication is paused and the collection is completed with all the applicationthreads stopped

    解决方案:降低触发CMS的阈值

    PromotionFailed

    解决方案类似,保持老年代有足够的空间

    –XX:CMSInitiatingOccupancyFraction 92% 可以降低这个值,让CMS保持老年代足够的空间

常见垃圾回收器组合
  • -XX:+UseSerialGC = Serial New (DefNew) + Serial Old
  • -XX:+UseParNewGC = ParNew + SerialOld
  • -XX:+UseConc(urrent)MarkSweepGC = ParNew + CMS + Serial Old
  • -XX:+UseParallelGC = Parallel Scavenge + Parallel Old
  • -XX:+UseParallelOldGC = Parallel Scavenge + Parallel Old
  • -XX:+UseG1GC = G1
    1.8默认的垃圾回收:PS + ParallelOld
分代模型
  1. 部分垃圾回收器使用的模型
  2. 新生代 + 老年代 + 永久代(1.7)/ 元数据区(1.8) Metaspace
    1. 永久代 元数据 - Class
    2. 永久代必须指定大小限制 ,元数据可以设置,也可以不设置,无上限(受限于物理内存)
    3. 字符串常量 1.7 - 永久代,1.8 - 堆
    4. MethodArea逻辑概念 - 永久代、元数据
  3. 新生代 = Eden + 2个suvivor区
    1. YGC回收之后,大多数的对象会被回收,活着的进入s0
    2. 再次YGC,活着的对象eden + s0 -> s1
    3. 再次YGC,eden + s1 -> s0
    4. 年龄足够 -> 老年代 (15 CMS 6)
    5. s区装不下 -> 老年代
  4. 老年代
    1. 顽固分子
    2. 老年代满了FGC Full GC
  5. GC 调优
    1. 尽量减少FGC
    2. 不要过度调优
调优关键点
  1. 吞吐量:用户代码时间 /(用户代码执行时间 + 垃圾回收时间)
  2. 响应时间:STW越短,响应时间越好

所谓调优,首先确定,追求啥?吞吐量优先,还是响应时间优先?还是在满足一定的响应时间的情况下,要求达到多大的吞吐量...

问题:

科学计算,吞吐量。数据挖掘,thrput。吞吐量优先的一般:(PS + PO)

响应时间:网站 GUI API (1.8 G1)

什么是调优?
  1. 根据需求进行JVM规划和预调优
  2. 优化运行JVM运行环境(慢,卡顿)
  3. 解决JVM运行过程中出现的各种问题(OOM)
调优规划
  • 调优,从业务场景开始,没有业务场景的调优都是耍流氓
  • 无监控(压力测试,能看到结果),不调优
  • 步骤:
    1. 熟悉业务场景(没有最好的垃圾回收器,只有最合适的垃圾回收器)
      1. 响应时间、停顿时间 [CMS G1 ZGC] (需要给用户作响应)
      2. 吞吐量 = 用户时间 /( 用户时间 + GC时间) [PS]
    2. 选择回收器组合
    3. 计算内存需求(
    4. 选定CPU(越高越好)
    5. 设定年代大小、升级年龄
    6. 设定日志参数
      1. -Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCCause
      2. 或者每天产生一个日志文件
    7. 观察日志情况
日志和dump
GC日志

ParNew+CMS模式日志分析

java -Xms20M -Xmx20M -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC com.moodfly.top.TestEngine

[GC (Allocation Failure) [ParNew: 6144K->640K(6144K), 0.0265885 secs] 6585K->2770K(19840K), 0.0268035 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]

ParNew:年轻代收集器 6144->640:收集前后的对比 (6144):整个年轻代容量 6585 -> 2770:整个堆的情况(19840):整个堆大小

[CMS: promo attempt is safe: available("13696K") >= av_promo("3108K"),""max_promo("5696K")] 
初始标记 只扫描root的应用。低stw时间
[GC (CMS Initial Mark) [1 CMS-initial-mark: 8511K(13696K)] 9866K(19840K), 0.0040321 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[CMS-concurrent-mark-start]
并行标记
[CMS-concurrent-mark: 0.018/0.018 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] 
并行预清理
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
	//标记Card为Dirty,也称为Card Marking
最终标记
[GC (CMS Final Remark) [YG occupancy: 1597 K (6144 K)][Rescan (parallel) , 0.0008396 secs][weak refs processing, 0.0000138 secs][class unloading, 0.0005404 secs][scrub symbol table, 0.0006169 secs][scrub string table, 0.0004903 secs][1 CMS-remark: 8511K(13696K)] 10108K(19840K), 0.0039567 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
	//STW阶段,YG occupancy:年轻代占用及容量
	//[Rescan (parallel):STW下的存活对象标记
	//weak refs processing: 弱引用处理
	//class unloading: 卸载用不到的class
	//scrub symbol(string) table: 
		//cleaning up symbol and string tables which hold class-level metadata and 
		//internalized string respectively
	//CMS-remark: 8511K(13696K): 阶段过后的老年代占用及容量
	//10108K(19840K): 阶段过后的堆占用及容量
并发清理
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.005/0.005 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
重置标记
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

关于分配失败发生老年代GC的情况(ParNew+CMS)模式下的源码追踪。

ParNew垃圾收集代码参见parNewGeneration.cpp#918.阅读源码看到collection_attempt_is_safe方法这里的注释提示判断新生代gc之后能付晋升到老年代,追踪之后定位改方法在父类defNewGeneration中定义.发现实际上使用的是CMS的promotion_attempt_is_safe方法计算的最大可能晋升的对象大小跟CMS剩余空间。

bool ConcurrentMarkSweepGeneration::promotion_attempt_is_safe(size_t max_promotion_in_bytes) const {
  size_t available = max_available();
  size_t av_promo  = (size_t)gc_stats()->avg_promoted()->padded_average();
  bool   res = (available >= av_promo) || (available >= max_promotion_in_bytes);
  if (Verbose && PrintGCDetails) {
    gclog_or_tty->print_cr(
      "CMS: promo attempt is%s safe: available("SIZE_FORMAT") %s av_promo("SIZE_FORMAT"),"
      "max_promo("SIZE_FORMAT")",
      res? "":" not", available, res? ">=":"<",
      av_promo, max_promotion_in_bytes);
  }
  return res;
} 

比较的规则在于以前晋升的对象平均值、当前Eden已使用的大小(也就是最大可能晋升的对象大小)跟当前CMS的最大可用空间大小相比较。只要CMS的剩余空间比前两者的任意一者大,CMS就认为晋升还是安全的。
之后是开启GC打印之后输出CMS: promo attempt (is||is not)safe;如果说每次晋升到老年代的平均大小大于年老代的剩余空间大小”如果外加“当前minor GC时ParNew里已使用空间同样大于CMS年老代的剩余空间大小”,那么ParNew的minor GC就会在通知CMS说“我不干了”之后直接跳过自己,交给CMS,然后再回到父类中执行GenCollectedHeap::do_collection()的循环里。发现ParNew不干了之后会让CMS年老代收集。然后CMS会根据UseCMSCompactAtFullCollection、CMSFullGCsBeforeCompaction和当前收集状态去决定是降级成为单线程Serial Old GC执行回收,还是 CMS的GC 主要关系在于UseCMSCompactAtFullCollectionCMSFullGCsBeforeCompaction参数,这个参数决定了是去选择单线程整个堆进行GC还是单纯的去执行下老年代的(串行的)mark sweep.

部分代码

 *should_compact =
    UseCMSCompactAtFullCollection &&
    ((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) ||
     GCCause::is_user_requested_gc(gch->gc_cause()) ||
     gch->incremental_collection_will_fail(true /* consult_young */));
     
(在设置了UseCMSCompactAtFullCollection标志,并且FGC数量已经超过了cmsfullgcsbefore设置的阈值,或者增量收集失败的情况下,判断是需要压缩,需要压缩执行的是整个堆的GC stw超级长时间,反之则进行下老年代的标记清除回收)。
 if (should_compact) {
    // If the collection is being acquired from the background
    // collector, there may be references on the discovered
    // references lists that have NULL referents (being those
    // that were concurrently cleared by a mutator) or
    // that are no longer active (having been enqueued concurrently
    // by the mutator).
    // Scrub the list of those references because Mark-Sweep-Compact
    // code assumes referents are not NULL and that all discovered
    // Reference objects are active.
    ref_processor()->clean_up_discovered_references();

    if (first_state > Idling) {
      save_heap_summary();
    }

    do_compaction_work(clear_all_soft_refs);

    // Has the GC time limit been exceeded?
    DefNewGeneration* young_gen = _young_gen->as_DefNewGeneration();
    size_t max_eden_size = young_gen->max_capacity() -
                           young_gen->to()->capacity() -
                           young_gen->from()->capacity();
    GenCollectedHeap* gch = GenCollectedHeap::heap();
    GCCause::Cause gc_cause = gch->gc_cause();
    size_policy()->check_gc_overhead_limit(_young_gen->used(),
                                           young_gen->eden()->used(),
                                           _cmsGen->max_capacity(),
                                           max_eden_size,
                                           full,
                                           gc_cause,
                                           gch->collector_policy());
  } else {
    do_mark_sweep_work(clear_all_soft_refs, first_state,
      should_start_over);
  }

不贴代码了 文章太长了,这里提供下链接吧concurrentMarkSweepGeneration#do_compaction_work这里做执行单线程Serial Old GC回收。这里提供下链接吧concurrentMarkSweepGeneration#do_mark_sweep_work这里做执行老年代的(串行的)mark sweep回收。

Crash dump
Thread dump
Arthas

upload successful

命令含义
jvm打印jvm详情
thread查看指定线程堆栈  
thread批量查看CPU消耗topN的线程堆栈(长时间高CPU可能有死循环);
thread(长时间BLOCKED可能死锁) 查看指定状态的线程及状态分组统计
jad 反编译指定文件,可以指定到具体方法
trace调用链路追踪, 性能分析,未排除trace本身的性能消耗
watch打印方法出入参
stack 方法调用链

官方文档也比较全,而且中文友好。强烈推介学习。此外还有Btrace可以学习用来跟踪运行是数据。

小资料

Btrace教程

sourcegraph,一个非常好用的GitHub源码跟踪的插件