关于JVM要掌握的一切知识点

205 阅读15分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

内存划分和溢出

VM内存划分:

JVM把内存划分为以下几个主要的区域:

方法区

所有线程共享的内存区域,用于存储JVM加载的类信息、常量、静态变量、JIT的代码等数据;同时为了和堆区分,方法区也叫非堆(Non-Heap)。

JDK8之前常被称为 永久代,实际上不准确。

运行时常量池

方法区内部还包含一块叫“运行时常量区”,该区域主要存储类的各种字面量和符号引用等数据。

常见的方法有 String类的 intern() 方法。

java堆

所有线程共享的内存区域,一般这是JVM中占用内存最大的区域,几乎所有的对象实例和数组都存储在这个区域(像JIT的代码不在这个区域)。

该区域也是垃圾回收的主要区域,因此也叫GC堆。

java堆可以是固定的,也可以是可扩展的(目前基本都是可扩展的,可通过-Xmx和-Xms设定)。

虚拟机栈

线程私有的,主要存储方法的内存模型,比如方法中的局部变量、操作数、方法出口等,当调用一个方法时,整个过程都对应到栈的IO操作,这个区域也就是平时所说的 栈。

栈内存包括两类异常:

  • 线程请求的栈深度大于虚拟机允许的深度,抛出StackOverflowError
  • 虚拟机容量可以动态扩展,当栈扩展时无法申请足够的内存,抛出OutOfMemory

本地方法栈

线程私有的,和虚拟机栈基本一样,区别就是调用本地方法,虚拟机栈是调用JAVA方法。

抛出异常情况,和虚拟机栈相同。

程序计数器

线程私有的,主要是用来存储当前线程执行指令地址的,这样当多线程来回调度时,知道上次本线程执行到哪个指令。

对象的创建

  1. new后,检查 常量池中是否存在类的符号引用,并检查符号引用的类是否已经加载、解析和初始化(如果没有会先执行类的加1. 载)。
  2. 虚拟机为新生代对象分配内存(有两种分配方式:指针碰撞(BTP)、空闲列表(Free List))
  3. 把分配到的内存空间都初始化为零值
  4. 进行对象必要的设置:对应的类信息、GC分代年龄等
  5. 至此,构造函数完成了,后续再进行对象的初始化

并发申请内存时,可能会冲突,一般采用两个策略保障原子性:

  • 采用CAS失败重试
  • 每个线程的java堆中预先分配一个内存块,即TLAB,线程在自己的TLAB中分配内存非新对象,用完后再启用同步锁去扩充内存

虚拟机是否使用TLAB,通过 -XX: +/-UserTLAB 参数来设定

对象的内存布局

在堆内存中存储布局分三部分:对象头Header、实例数据InstanceData、对齐填充Padding

对象头

包括两类信息:

  1. 存储自身的运行时数据,如锁状态之类
  2. 类型指针,根据指针可以找到类信息(非必须的)

实例数据

对象真正的有效信息,比如字段之类

对齐填充

没特殊意义,非必须的,只是占位符作用。

对象的访问定位

HotSpot使用上图直接指针访问对象的方式

JVM内存溢出

JVM内存溢出常见两种:栈溢出、内存溢出,JVM每个区域(除了PCR之外)都有可能发生OOM异常。

堆溢出

当不断的创建对象,并且对象无法被回收时,容易发生溢出。

// 一个栗子
public class HeapTest {
    public static void main(String[] args) {
        List<OOMTemp> list=new ArrayList<>();
        AtomicInteger count=new AtomicInteger(0);
        Thread.setDefaultUncaughtExceptionHandler((t,e)->{
            System.out.println(count);
            System.out.println(e.getMessage());
        });
        // 810325
        while (true){
            list.add(new OOMTemp());
            count.incrementAndGet();
        }
    }
    static class OOMTemp{    }
}

在执行之前,首先设置堆内存大小:-Xms20m -Xmx20m,其中-xms是堆的最小内存,-xmx是最大内存,都设置为20m

这样会输出(第二行开始显示创建了dump文件,是为了分析为什么溢出,在执行前加上opts:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:/):

-XX: +HeapDumpOnOutOf-MemoryError 会让虚拟机在出现内存溢出异常时Dump当前内存堆转储快照以备事后分析

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid4612.hprof ...
Heap dump file created [42076663 bytes in 0.208 secs]
1215487
Java heap space
Process finished with exit code 1

通过dump文件可以看出来是OOMTemp实力问题:

工具:下载eclipse memory analyzer,打开dump文件即可

栈溢出

HotSpot虚拟机不区分 虚拟机栈和本地方法栈,所以 -Xoss参数(设置本地方法栈大小)无效,栈只能使用-Xss设置

因HotSpot不支持栈动态扩展,所以除非在创建线程时,因申请不到足够的内存而出现OOM,否则在线程运行时,只会因栈容量不足而导致SOF。

在单线程环境下,不太容易模拟OOM,但是SOF还是可以的,直接不停的递归调用一个方法,方法会不断的执行压栈操作,即会发生StackOverflowError。

在执行之前,还是首先配置内存大小:-Xss1m,发生SOF的原因就是栈被打满,请求地址已经超出栈的最大地址。

如果想模拟OOM,可以采用多线程方式,并且每个线程的栈内存越大,越容易发生OOM。

每个进程的内存是有限的,比如32位window最大2GBit,那么 剩余内存 = 2GBit - 堆内存(xmx) - 方法区内存(maxpermsize),栈内存只能在剩余内存中分割,所以每个线程中栈内存越大,线程多了就会OOM。

方法区和运行时常量池溢出

JDK7去掉永久代,在JDK8中使用元空间来替代永久代。

  • 在JDK6或之前版本中,常量池可以通过 -XX:PermSize和-XX:MaxPermSize限制永久代大小
  • JDK7之后可使用-XX:MaxMeta-spaceSize参数设置元空间大小

JDK8 之后,永久代退出,元空间上场。字符串常量池从永久代迁移到了堆中。

HotSpot依然提供了配置:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认-1,即受限于本地内存
  • -XX:MetaspaceSize:元空间初始大小,单位字节。达到该值便会触发GC

常用优化参数及说明

1、-Xmx512m :设置Java虚拟机的堆的最大可用内存大小,单位:兆(m),整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m。堆的不同分布情况,对系统会产生一定的影响。尽可能将对象预留在新生代,减少老年代GC的次数(通常老年回收起来比较慢)。实际工作中,通常将堆的初始值和最大值设置相等,这样可以减少程序运行时进行的垃圾回收次数和空间扩展,从而提高程序性能。

2、-Xms512m :设置Java虚拟机的堆的初始值内存大小,单位:兆(m),此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。

3、-Xmn170m :设置年轻代内存大小,单位:兆(m),此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。一般在增大年轻代内存后,也会将会减小年老代大小。

4、-Xss128k :设置每个线程的栈大小。JDK5.0以后每个线程栈大小为1M,以前每个线程栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

5、 -XX:NewRatio=4 :设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5 。

6、-XX:SurvivorRatio=4 :设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6。

7、-XX:MaxPermSize=16m :设置持久代大小为16m,上面也说了,持久代一般固定的内存大小为64m。

8、-XX:MaxTenuringThreshold=0:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概论。

垃圾回收

在上节介绍过,其中堆中存储几乎所有的对象实例,如上图(这里没有PermGen,因为JDK8以后已经取消了永久代)。

【简单过程】对象是有生命周期的,new一个对象时,会被分配到eden区域,当eden满了以后触发一次Minor GC,仍生存的对象被复制到from区域,当再次GC时,对象从from被复制到to,这个时候from和to的角色互换了,以后再GC,对象会又被辅助到from,来来回回,每次对象的代龄都+1,当累加到一定值(默认18)后,对象还存活的话,被复制到old。

这里有一个区别,如果是大对象,直接进入old,不执行来来回回的过程。

那么如何判定这个对象是不是还活着呢?java采用的是可达算法(除此之外还有计数算法),如果该对象有到达GC Roots的引用路径,就认为是活的,可作为GC Roots的对象包括:

  1. 栈中引用的对象;
  2. 方法区类静态属性引用的对象;
  3. 方法区常量引用的对象;

引用路径根据强弱又分为四种(算上 JNI Weak Reference 就是五种):强引用(Strong Ref)>软引用(Soft Ref)>弱引用(Weak Ref)>虚引用(Phantom Ref)

  1. 强引用:比较常见,如 Student stu=new Student();这个类就是强引用;
  2. 软引用:一般在内存不够时,会被回收,这种引用可有可无,使用SoftReference实现;并不是每次GC都被回收,只有在内存不足时,才根据LRU策略回收。
  3. 弱引用:也是可有可无的引用,一旦遇到GC就被回收,使用WeakReference实现。
  4. 虚引用:最弱的引用,也是形同虚设的引用,唯一的目的是对象被GC时接收系统通知,使用PhantomReference实现。

当GC时,如果判断该对象和GC Roots没有可达路径,也不是立即被回收,过程如下:

首先判断是否可达,如果不可达,第一次标记该对象,标记后会判断该对象要不要执行一次finalize()方法;然后把对象放入F-Queue队列中,等待再次标记;GC对F-Queue中的对象再做一次标记,这个时候如果发现某对象又可达了,则被移出F-Queue队列,不再回收,否则被最终回收掉。

所以如果一个对象不想被回收,在GC的第二次标记时,还是有机会再抢救一次,比如把自己赋给某个类的变量等等,这也是为什么有时候看到一个对象的finalize()方法被执行了,但是没有回收的原因。另外说到finalize()方法,因为对象的该方法只会被执行一次,所以只有在第一次被GC时可以在这个方法内抢救自己一次,以后再被GC时该方法不会再执行,也就没办法抢救自己了,只能任命了。

垃圾回收算法

标记算法:

  1. 标记-清楚算法(Mark-Sweep):首先标记需要被回收的对象,然后再进一步清除。这种算法会产生很多内存碎片,因为被清除的对象内存地址并不是连贯的,当再有一个对象生成时,可能找不到能放下自己的内存空间,不得不再执行一次GC,效率不是很高。
  2. 复制算法(Copying):把内存分为两块A和B,当A中内存用完之后,把仍然存活的对象复制到B中,然后清空整个A。这种方式虽然高效,但是可用内存只剩下一半。
  3. 标记-整理算法(Mark-Compact):如果新生代对象存活率比较高,采用复制算法效率会变低,所以采用了标记-整理算法,和标记-清理算法第一步一样,但第二步不清理,而是把存活的对象向一端移动,这样剩下的就是可用内存空间。
  4. 分代收集算法(Generational Collection):根据对象存活周期的不同,把内存分为新生代、老年代,这样针对不同的对象采用不同的回收算法。

现在商业JVM都采用复制算法来处理新生代,但不是按照 1:1 来划分内存,研究表明,其中98%的对象是朝生夕死,所以新生代Eden和Survivor空间的比例是8:1,这样只会减少10%的可用内存。如果新生代存活率>98%,那就需要老年代old来承担了,因为survivor内存会不够用。针对老年代一般采用“标记-清理”或“标记-整理”算法来回收。

GC Roots枚举过程:

在前面介绍过,GC之前需要标记哪些对象还活着,就需要从GC Roots开始,判断哪些对象可达,这里介绍整个过程。

  1. 首先要明确一点,CPU指令执行速度很快,所以内存中的地址一直在变化,在这种情况下判断对象是否可达毫无意义,所以,必须让所有线程暂停,然后再去判断这一时刻对象是否可达,这就是所谓的Stop The World事件,也是为什么GC时会卡顿的原因。
  2. 程序中的引用关系千千万万,JVM不可能每次GC前都把所有的引用判断一次。首先JVM中有一个OopMap数据结构用来记录对象中的引用信息,JVM直接扫面OopMap就可以;然后只会判断哪些对象是被静态变量、常量或上下文中引用;

OopMap中并不是记录程序中所有的引用关系(对象偏移量上的类型信息),否则需要暂定的地方就太多了,每次GC会卡到爆,而是只会记录安全点(SafePoint)和安全区(SafeRegion)中的指令:

安全点:执行时间比较久的指令,如方法调用、循环、异常跳转等,这些位置的指令才算安全点。那么GC时如何保证所有线程正好在安全点暂定呢?两种算法:抢先式中断和主动式中断:

· 抢先式:基本不用,简单介绍一下。GC发生时中断所有线程,然后判断哪些线程没有在安全点,就恢复该线程,让其执行到安全点位置;

· 主动式:GC时设置一个中断标记,所有线程在执行到安全点时,都会判断该标记,如果发现中断标记=真,当前线程中断;

安全区: 如果GC时线程正好处于中断状态(如sleep),没办法判断中断标记,这个时候用到安全区(对安全点的扩展)。如果一段代码中引用关系不会发生变化,这个区域就是安全区(记不记得中断的原因就是为了防止引用关系变化?)。

如果当前线程正在执行安全区的指令,这个时候不用判断GC的中断标记,但当要退出安全区时,就必须要判断一次当前是否正在GC扫描,如果是就必须等待可以离开的信号,否则正常执行就可以。

说面说了这么多,都是为了GC标记对象是否存活的过程,标记时必须防止引用关系变化,所以才有了中断,然后衍生出 安全点 和 安全区 概念,举个例子:

当你在打扫卫生时,最希望就是大家不要再乱扔垃圾了,如果这个时候有人不停的扔垃圾,你将永远打扫不完;同时,如果有人不停在你的纸篓里面拣文件,说:”这个还有用“,这种情况下,卫生完全无法打扫。

JStat性能检测

常用指令

  • -Xms:堆最小内存
  • -Xmx:堆最大内存
  • -Xss:栈最大内存
  • -XX:+DeapDumpOnOutOfMemoryError内存溢出时记录内存堆快照
  • -XX:PermSize和-XX:MaxPermSize方法区大小
  • -XX:MaxDirectMemorySize本机直接内存
  • -XX:NewRatio=4 新生代和老年代的比例1:4
  • -XX:SurvivorRatio=8 eden和suivivor的比例1:1:8
  • jps -l 查看机器上jvm列表
  • jinfo -flags pid 查看jvm配置
  • jstat pid 查看状态

使用jdk自带工具监测性能

# 2s 获取一次,总共获取3次
jstat -gc pid 2s 3
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
87040.0 87040.0  0.0    0.0   524800.0 372183.2 1398272.0   16584.1   21248.0 20183.8 2816.0 2523.3      1    0.033   1      0.045    0.078
87040.0 87040.0  0.0    0.0   524800.0 372183.2 1398272.0   16584.1   21248.0 20183.8 2816.0 2523.3      1    0.033   1      0.045    0.078
87040.0 87040.0  0.0    0.0   524800.0 372183.2 1398272.0   16584.1   21248.0 20183.8 2816.0 2523.3      1    0.033   1      0.045    0.078
  • SOC 第一个幸存区分配容量
  • SIC第二个幸存区容量
  • SOU已使用
  • SIU第二个已使用
  • EC Eden区分配容量
  • EU已使用
  • OC老年代已分配
  • OU已使用
  • MC元数据区已分配
  • MU已使用
  • YGC年轻代垃圾回收次数
  • YGCT年轻代垃圾回收时间
  • FGC年老代垃圾回收次数
  • FGCT年老代垃圾回收时间
  • GCT总垃圾回收时间