Hytale之我见 专注hytale服务器技术

java内存模型

2019-10-29
hytaleme

本文研究了java8的内存模型,包括参数配置,GC与优化等

JVM内存模型

根据java8的JVM规范,JVM内存分为:

  1. VM Stacks(虚拟机栈): 每个线程都有一个私有的栈
    • 栈帧: 每个方法都有个栈帧,里面存放了局部变量表,操作数栈,方法出口等信息
  2. Native Method Stack(本地方法栈): 与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法栈执行native方法
  3. PC Register(PC寄存器/程序计数器): 每个线程都有一个PC寄存器,指示当前线程执行到哪里了

    如果当前线程执行的是java方法,则寄存器中保存当前指令地址; 如果当前线程执行的是native方法,则寄存器中保存的值为空.

  4. Heap(堆): 所有线程共享,所有对象和数组都在堆上分配,堆可以通过GC进行回收.
  5. Method Area(方法区/非堆): 所有线程共享,主要存储类的信息,常量池,变量池,静态变量,方法代码等.

Heap(堆)内存池模型为:

  • Young Generation(新生代/年轻代)
    • Eden Space(伊甸园区): 新对象会放到这个区,GC后未回收的进入Survivor Space(幸存者区)
    • Survivor Space(幸存者区)
      • From Survivor
      • To Survivor: 大小与From Survivor相同
  • Old Generation(老生代/老年代)

PermGen(永久代)与Metaspace(元空间)

PermGen(永久代)是HotSpot对JVM规范中的Method Area(方法区)的具体实现.

对于动态生成类的情况比较容易出现永久代的溢出,如jsp页面比较多时.

java8中移除了PermGen(永久代),使用Metaspace(元空间)代替. 最大区别在于: 元空间使用本地内存,而不在虚拟机中. 默认情况下,Metaspace(元空间)的上限没有限制,只受制于本地内存大小.

使用Metaspace(元空间)的优点:

  1. 字符串存在永久代中,容易出现性能问题和内存溢出.
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出.
  3. 永久代会为GC带来不必要的复杂度,并且回收效率偏低.

参数配置

以下只介绍一些常用的参数,更详细可以参考Java HotSpot VM Options

堆参数

  • -Xms/-Xmx: 初始堆大小(默认为物理内存的1/64)/最大堆大小(默认为物理内存的1/4)
  • -Xss: 每个线程堆栈大小.jdk5以后线程堆栈大小为1M,如果栈不是很深,一般128k够用了,大的应用建议256k(这个选项对性能影响比较大,需要严格测试)
  • -XX:MinHeapFreeRatio/-XX:MaxHeapFreeRatio: 堆空间的最小空闲比例(默认40),空闲的小于这个值就会进行扩展/最大空闲比例(默认70),空闲的超过这个值时就释放掉部分

    在xmx与xms大小相同时,此配置将无效果.

  • -XX:SurvivorRatio: 伊甸园与(一个)幸存者大小的比值(而幸存者有两个: From与To,并且永远一样大)

    比如5,表示伊甸园与From幸存者比例为5:1,与To幸存者比例也是5:1,即伊甸园占总的比例为5/(5+1+1)=5/7

    如果比例太小,即伊甸园太小,那么新生代GC次数将增加;如果比例太大,那么大部分幸存对象会过早转移到老生代.

  • -XX:PretenureSizeThreshold: 大对象直接进入老生代的阀值,单位Byte

年龄

  • -XX:TargetSurvivorRatio: 幸存者区目标使用率,默认50(大致表示达到50%使用率时对象会向老生代压缩)
  • -XX:MaxTenuringThreshold: 晋升年龄阀值,默认15(即对象在15次Minor GC后会进入老生代)

新生代/老生代大小

关于新生代/老生代大小设置,按优先级顺序(从高到低)有以下几种参数:

  1. -XX:NewSize初始新生代大小/-XX:MaxNewSize最大新生代大小
  2. -Xmn: 新生代大小(相当于初始值与最大值相同)
  3. -XX:NewRatio: 老生代与新生代的比例(如4表示老生代:新生代=4:1=4,即新生代占整个堆的1/5)

此值对系统性能影响较大,官方推荐新生代为堆大小的3/8(即老生代占5/8)

元空间

(一般不需要设置)

  • -XX:MetaspaceSize: 初始空间
  • -XX:MaxMetaspaceSize: 最大空间
  • -XX:MinMetaspaceFreeRatio: GC后,最小的空闲空间百分比,如40,如果空闲空间小于这个值,就会增加内存(值设太小可能会影响后面类加载效率,设太大会浪费内存)
  • -XX:MaxMetaspaceFreeRatio: GC后,最大的空间空间百分比,如70,如果空闲空间大于这个值,就会释放部分

GC

  • -XX:+PrintGC/-XX:+PrintGCDetails: 输出GC日志/详细日志
  • -XX:+PrintGCTimeStamps: 输出GC的时间戳(单位秒,从JVM启动开始计算)
  • -XX:+PrintGCDateStamps: 输出GC的时间点(日期的格式)
  • -XX:+PrintHeapAtGC: 在GC前后打印堆信息
  • -Xloggc:<文件名>: GC日志文件输出路径

GC收集器

  • -XX:+UseSerialGC: 使用Serial(新生代) + SerialOld(老生代)
  • -XX:+UseParNewGC: 使用ParallelNew(新生代) + SerialOld(老生代)
  • -XX:+UseParallelGC: 使用ParallelScavenge(新生代) + SerialOld(老生代)

    • -XX:+UseParallelOldGC: 如果这个参数一起开启,则会使用ParallelScavenge(新生代) + ParallelOld(老生代),这样老生代效率也更好
    • -XX:+UseAdaptiveSizePolicy: 开启自适应调节策略,这个参数打开后,新生代大小,伊甸园与幸存者区比例,晋升年龄阀值等就不需要手动指定了(默认开启)
  • XX:+UseConcMarkSweepGC: 使用ParallelNew(新生代) + CMS(老生代) + SerialOld(老生代备用,CMS失败时启用)

而在生产环境上,一般使用的是以下几种组合:

  • -XX:+UseSerialGC
  • -XX:+UseParallelGC -XX:+UseParallelOldGC
  • -XX:+UseParNewGC -XX:+UseConcMarkSweepGC
  • -XX:+UseG1GC

GC

GC即Garbage Collection,垃圾回收的意思

GC类型

  • Minor GC/Young GC/小型GC: 只对Young Generation(新生代)进行GC

    Eden Space内未回收的对象与另一个Survivor内的对象都放入空的那个Survivor里,保持始终有一个Survivor为空.

    此GC实际上忽略了老生代,从老生代指向新生代的引用都被认为是GC Root.

  • Major GC/Full GC/大型GC: 会对整个堆进行GC(主要对老生代进行GC,也可能会对新生代以及永久代/元空间进行GC)

GC算法

以下所有算法在执行时都会进行STW(stop the world,停止整个程序)

  • 标记-清除: 标记存活对象,将没有标记的对象清除.

    缺点是效率低空闲内存不连续

  • 标记-整理: 标记存活对象,然后移动存活对象(按内存地址排序)

    缺点是效率不高(效率比复制算法低)

  • 复制: 将内存分为两个区间,任意时间点都有一个活动区间,一个空闲区间.GC时,将活动区间内存活对象复制到空闲区间(按内存地址排序),然后更新对象内存地址引用,这就完成了活动区间与空闲区间的转换.

    缺点是浪费一半内存,如果对象存活率很高,那么复制时间将很长.

  • 分代搜集: 相当于以上几种算法的结合.此算法假定大部分都是短期对象,只有少数是长期对象,现实中大部分java程序都符合,如下图:

    因此,针对不同生命周期的对象采用不同的GC策略,这即是分代收集算法的由来.

    • 短期对象: 朝生夕死,生命周期很短的对象(如局部变量,循环内的变量等).放在新生代中,采用复制算法,这里假定大部分对象都是短期对象,因此GC时复制的对象数量很少,速度很快.

      但是,复制算法会有50%的内存浪费,考虑到这些对象存活率低的特点,可以使用两块10%的内存作为活动与空闲空间,剩下的80%给新建对象分配内存用. 这样在GC时,将80%与活动的10%中存活的对象放入10%的空间中,再将这90%释放掉,这样子更加优化.

    • 长期对象: 生命周期较长的对象(如缓存对象,数据库连接池对象,单例对象等).放在老生代中,采用标记-整理算法,相对而言算最合适的算法了,但效率仍然不高.
    • 永久对象: 指相对而言接近永久的对象(如类信息).对于这些对象的存放大致经历了以下几个阶段

      1. 很早以前,这些对象直接放在老生代中
      2. 后来,考虑到这些对象几乎不会销毁,因此放在独立的PermGen(永久代)
      3. java8开始,使用Metaspace(元空间)代替永久代

PS: 通过遍历GC Roots来判断对象是否可达,其中GC Roots包括局部变量,活动线程,静态域,JNI引用

GC收集器

收集器即GC算法的具体实现.

  • JVM运行在Client模式时,默认使用Serial(新生代) + SerialOld(老生代)
  • JVM运行在Server模式时,默认使用ParallelScavenge(新生代) + SerialOld(老生代)

新生代收集器

  • Serial收集器: 单线程收集器,收集时,暂停其它工作线程.

    CPU利用率最高,停顿时间较长.适合单CPU环境与小型应用.

  • ParallelNew收集器: Serial收集器的多线程版本,多线程扫描并压缩堆.

    停顿时间短,回收效率高,吞吐量大.适合重交互式的服务器.

  • ParallelScavenge收集器: 目标是达到可控制的吞吐量,使用多线程.

    此GC目标是吞吐量大,但停顿时间可能比较长.适合后台应用.

老生代收集器

  • SerialOld收集器: Serial收集器的老生代版本
  • ParallelOld收集器: ParallelScavenge收集器的老生代版本
  • CMS收集器: Concurrent Mark Sweep(并发的标记-清除), 采用标记-清除算法,使用多线程扫描堆.

    响应时间优先,停顿时间短.适合重交互式的服务器.

新生/老生代收集器

  • G1收集器: 在G1中,堆被划分为许多连续的区域(region).

    支持很大的堆,吞吐量大,主线程暂停时,使用并行收集;主线程运行时,使用并发收集.

    可配置在N毫秒内最多只占用M毫秒的时间进行垃圾回收.

    可以使用参数–XX:+UseG1GC指定

GC日志

要打印每次GC的日志,可以设置以下启动参数:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps

打印出的GC日志如下:

2018-03-17T10:16:29.976+0800: 5.508: [GC (Allocation Failure) [PSYoungGen: 384000K->49872K(448000K)] 384000K->49880K(1472000K), 0.1276945 secs] [Times: user=0.07 sys=0.07, real=0.13 secs]

日志的含义如下:

  1. 2018-03-17T10:16:29.976+0800: GC开始时间
  2. 5.508: GC开始的时间戳,相对于JVM启动时间,单位秒
  3. GC: 区分是Minor GC(显示为GC)还是Full GC(显示为Full GC)
  4. Allocation Failure: 垃圾回收原因,这里指新生代已经没有合适的内存区域用来分配需要的数据
  5. PSYoungGen: 使用的垃圾收集器的名字
  6. 384000K->49872K: 新生代在收集前与收集后的内存
  7. (448000K): 新生代总大小
  8. 384000K->49880K: 整个堆在收集前与收集后的内存
  9. (1472000K): 整个堆总大小
  10. 0.1276945 secs: GC持续时间(程序暂停的时间),单位秒
  11. [Times: user=0.07 sys=0.07, real=0.13 secs]: GC持续时间详情

    1. user: 垃圾收集线程消耗的总CPU时间
    2. sys: 操作系统调用以及等待系统事件的总时间
    3. real: GC持续时间(有的收集器使用多线程,因此不一定与user+sys相等)

优化

工具

  • jmx: 以下几个工具都可以连接到本地jvm或使用jmx连接到远程服务器,并且有客户端界面
    • Java Mission Control(Java控制中心): jdk的bin目录下的jmc
    • JVisualVM: jdk的bin目录下的jvisualvm,可以安装各种插件
    • JConsole: jdk的bin目录下的jconsole
  • jstat: 没有客户端界面,因此可以直接在服务器上使用(也可以使用rmi连接远程服务器)
  • GC日志: 查看上文介绍,会打印出每次程序的GC日志,适合在服务器上使用.
  • GCViewer: GCViewer是开源的日志分析工具,简单的说就是GC日志的图形化展示工具.

    可以导入GC日志,会分析显示图表并显示出一系列的数据与指标.

    重要的是它涵盖了大部分的java虚拟机与收集器(否则你只能去读冗长复杂的GC日志,而且不同收集器打印出的GC日志格式也不完全一致).

  • Profilers(分析器/抽样器): 简单说就是分析对象的大小定位.能确定哪种类型对象最占用内存,以及定位到在哪里创建的对象,以及哪些线程创建了最多的对象.
    • hprof: 添加启动参数-agentlib:hprof=heap=sites,在程序退出时,会打印出dump到java.hprof.txt文件内
    • JVisualVM
    • AProf: 它被设计为可以在生产环境使用.

      与其它分析器相比,它的优点是占用资源少,同时可以得到精确的统计结果 (而其它分析器可能占用资源多,并且为了减少资源占用,会采用取样的方式,导致统计结果并不精确)

性能指标

  • Throughput(吞吐量): 运行用户代码时间/(运行用户代码时间+垃圾收集时间)
  • Allocation rate(分配速率): 单位可以用MB/s,分配速率过高会影响程序性能,造成巨大GC开销.

    分配速率可以从GC日志中得到,值=(下一次GC前新生代用量-上一次GC后新生代用量)/时间

  • Promotion rate(提升速率): 单位可以用MB/s,提升速度过高会造成老生代快速填满,增加Major GC的次数.

    提升速率可以从GC日志中得到,值=(新生代减少的量-整个堆内存减少的量)/时间.(在Minor GC时) (不能根据Major GC的日志计算,因为Major GC时会清理掉老生代中的部分对象)

    过早提升: 对象存活时间还不够长就被提升到了老生代,会影响老生代的GC效率. 表现为短时间内频繁Major GC,并且每次Major GC后老生代使用率都很低,提升速率接近分配速率.

建议

稳定的堆与动荡的堆

一般来说,稳定的堆对GC比较有利.

将xmx与xms设置为相同的值可以获取稳定的堆.

稳定堆的好处是可以减少GC次数,坏处是增加了每次GC的时间

对象年龄

每个堆中的对象都有年龄,每次GC后年龄加1,当年龄达到阀值,就移入老生代.

这个年龄阀值可以用-XX:MaxTenuringThreshold来设置,但并不意味着必须要达到这个年龄才会进入老生代(实际晋升年龄是综合-XX:TargetSurvivorRatio动态计算的)

让大对象进入老生代

大对象出现在新生代容易扰乱新生代的GC,并破坏新生代原有的对象结构. 因为在尝试分配大对象时,很可能导致空间不足,因此JVM会尝试将大量的年轻小对象移入老生代,这对GC不利.

可以通过设置-XX:PretenureSizeThreshold来让大对象直接进入老生代

避免短期的大对象

这个违反了分代算法依据的原则,应该尽量避免.

假设短期大对象放在新生代,那么复制算法将花费大量时间. 假设短期大对象放在老生代,那么大量产生时会造成频繁的Full GC.


上一篇 Tomcat研究

下一篇 forge搭建教程

目录