JVM学习笔记

48 阅读21分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

JVM内存结构

Java代码执行流程:

image.png JVM内存结构图:

image.png 程序计数器: cpu在多条线程间来回切换需要知道切换回来后接着哪条指令继续执行,所以需要程序计数器来记录当前指令执行地址。

虚拟机栈: 以栈帧为单位存放局部变量、方法参数等,一个方法对应一个栈帧(静态方法放在哪?)永久代替换为了元空间之后就放在堆中了。

方法区: 存储类信息,比如类的元数据信息(类的名字、类的继承关系、类的字段、类的方法信息(如访问修饰符、返回值类型、方法名))、静态变量、字符串常量池、运行时常量池,不过jdk1.8之后元空间存元数据信息、运行时常量池,静态变量、字符串常量池都移到了堆中。

堆: 存放对象与数组,当new一个对象后,JVM会计算出这个对象占用的空间大小(包括对象的成员变量..),然后放在堆中。

本地方法区/本地方法栈: 一些JVM不能实现的方法会被放在里面,比如s.hashCode(),这种方法需要调用操作系统提供的api,就需要底层C++去实现,不过这只是JVM规范提出的内存结构图,具体实现可以不同,比如在HotSpot中虚拟机栈和本地方法栈被合二为一了。

程序计数器、虚拟机栈、本地方法区是线程私有;堆、方法区线程共享。\color{red}{程序计数器、虚拟机栈、本地方法区是线程私有;堆、方法区线程共享。}

程序计数器不存在OOMGC;虚拟机栈不存在GC\color{red}{程序计数器不存在OOM、GC;虚拟机栈不存在GC。}

  • 堆内存耗尽:对象或者静态变量越来越多 ,但又一直被使用,gc无法回收导致OOM
  • 方法区内存耗尽:加载的类越来越多(很多框架会在运行期间动态产生新的类)导致OOM,jdk1.7之前也可能是类静态变量占的空间过大。
  • 虚拟机栈内存耗尽:调用方法过多,但是上限被设置为了动态调整。
  • 另外虚拟机栈还可能出现StackOverflowError,原因是方法调用次数过多,上限固定。

本地方法接口、本地方法库: 本地方法栈调用的api来自这里。

GC: 自动回收没被引用的对象。

解释器: 字节码cpu并不能识别运行,所以需要解释器将字节码转换成对应操作系统的机器码

JIT即时编译器: 解释器对同一行代码会反复解释,如果一个循环条件执行1千万次,效率就会比较低,JIT就可以把一些热点代码对应的机器码缓存起来,下次读到就直接用对应的机器码,不用去解释了,至于哪些是热点代码其内部会有判断,比如执行多少次就认为是热点代码。

容易混淆的概念:方法区、永久代、元空间

方法区是JVM规范提出的一个理念,具体怎么实现不管,比如Hotspot在1.8之前用永久代实现方法区,1.8及以后用元空间(本地内存)实现方法区。

堆中对象的内存布局

对象头、实例数据、对其填充

image.png

其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息Class Pointer是类型指针指向对象的类元数据,JVM通过该指针确定该对象是哪个类的实例

image.png HotSpot VM的锁实现机制是:

当一个对象已经计算过identity hash code,它就无法进入偏向锁状态;
当一个对象当前正处于偏向锁状态,并且需要计算其identity hash code的话,则它的偏向锁会被撤销,并且锁会膨胀为轻量级锁或者重量锁;
轻量级锁的实现中,会通过线程栈帧的锁记录存储Displaced Mark Word;重量锁的实现中,ObjectMonitor类里有字段可以记录非加锁状态下的mark word,其中可以存储identity hash code的值

JVM常用配置参数:

-Xmx: JVM最大占用内存

-Xms: JVM最小占用内存 这两个值尽量相同,提升效率,否则最小值达到后会有一个扩容过程。

-Xmn: 新生代占用内存数

-XX:SurvivorRatio=8表示伊甸园区与from区大小比例8:1(默认为8)

-XX:NewRatio:控制的是新生代与老年代内存占比

-XX:NewSize: 新生代大小

-XX:MaxNewSize: 表示新生代满了可以扩容,这个参数就是新生代最大扩容到哪

这两个参数相当于一个最小值一个最大值,之前的-Xmn设置的新生代大小是固定的不能扩容(最小最大值都一样)

以上均是关于堆内存参数,接下来是关于元空间参数:

-XX:ReservedCodeCacheSize 之前提到JIT会把热点代码翻译的机器码缓存起来,这个参数就用来决定这个缓存区大小

-Xss 用来控制每条线程占用的内存(也就是虚拟机栈的大小,小了容易栈溢出,linux中默认1m)

下面两个参数是关于与空间大小设置

-XX:CompressedClassSpaceSize 控制存放类信息的空间大小(初始1G,该参数用于控制上限)

至于类方法的字节码、注解等存在元空间中另外的空间

-XX:MaxMetaspaceSize 控制整个元空间大小

image.png 对于JVM内存配置参数: -Xmx10240m -Xms10240m -Xmn5120m -XX:SurvivorRatio=3其最小内存值和Survivor区总大小分别是:10240m ; 2G

解释:Survivor区分为了from和to两个区,SurvivorRatio是指eden和from的大小比例,而from和to的大小相同,所以新生代被分为了5份。其中from和to一共占2份,新生代一共5G,那么Survivor就是2G。

String

字符串常量池

字符串有两种初始化方式:

String s1 = "hello";//字面量
String s2 = new String("hello");

jdk1.8中String部分源码如下:

image.png String被声明为final,不可被继承(jdk对于String的刻画已经非常完备了,不需要我们去扩展),底层用的char型数组存储数据(jdk1.9开始用的byte数组,这对于存汉字来说没影响,但如果是存英文字符会节省一半左右空间,因为char数组一个元素占两个字节刚好适合中文,而英文一个字符占1个字节,基于String类的StringBuilder或StringBuffer等都进行了修改)。

StringTable

StringTable(字符串常量池)是一个固定大小的Hashtable,jdk1.7后默认大小60013,jdk1.8中1009是可设置的最小值,jdk1.6默认大小1009,为什么默认大小增加了?因为调小会导致hash冲突频繁,链表长度过长进而影响String.intern()性能。 -XX:StringTableSize可设置Stringtable长度。

StringTable里面存的是key(字面量“abc”, 即驻留字符串)-value(字符串"abc"实例对象在堆中的引用)键值对。

image.png

image.png 注意出现了字面量也会有对象的创建,毕竟可以直接调用string的方法。

有两种方式往字符串常量池中放入值:

  1. 出现了字面量;
  2. 使用intern() (调用intern一定会保证在字符串常量池中生成该字符串,细节后面重点谈)

注意!new String("ab")不仅在堆创建一个对象,常量池也会指向一个对象,因为出现了字面量。

StringTable的内存分配

1.6及以前分配在永久代;1.7、1.8放在堆中; 

会什么会有调整?因为永久代(permSize)一般比较小,字符串放里面容易导致永久代OOM;即使手动调的比较大,永久代的gc频率低,永久代中用得不多的字符串不能及时回收。

字符串的拼接

常量与常量的拼接结果在常量池,原理是编译期优化(如s1 = "a"+"b"在编译后的class文件中是s1 = "ab")。

但是只要有一个是变量,结果就在堆中,变量拼接原理是StringBuilder。

比如s2 = "a"; s3 = "b"; s1 = s2 + s3,执行细节为:

StringBuiler s = new StringBuilder();//jdk1.5之前是StirngBuffer

s.append("a");

s.append("b");

return s.toString();//toString()类似于new String(),既然是new String就说明是堆中对象了,和字符串常量池中肯定不一样了。

如果拼接结果调用intern(),则主动将常量池中没有的字符串对象放入池中,并返回对象地址(是字符串常量池外的地址还是内的地址?字符串常量池内的地址)

intern()

下面配合几道面试题讲解: 

 new String("11")创建几个对象?2个

两个都在堆中,一个是new出来的,栈中引用指向它,另一个是字符串常量池的value引用指向它(key存字面量)。

new String("a")+new String("b")创建几个对象?6个

对象1:new StringBuilder()

对象2:new String(“a”)

对象3:字符串常量池中value指向的"a"

对象4:new String(“b”)

对象5:字符串常量池中value指向的"b"

对象6:之前的stringbuilder最后会返回一个toString(),这个toStirng内部又new了一个string:"ab",但是这个"ab"是堆空间中的对象,字符串常量池并没有"ab"。

怎么证明?字节码如下图所示

new String("a")+new String("a")创建几个对象?5个

和上面相比少了对象5.

image.png

public class TestIntern{
    public static void main(String[] args){
        String s = new String("1");
        s.intern();
        String s2 = "1";
        System.out.println(s == s2);//

        String s3 = new String("1") + new String("1");
        s3.intern();
        String s4 = "11";
        System.out.println(s3 == s4);//
    }
}

jdk1.6结果是false、false;jdk1.7及之后是false、true 

这个jdk1.6中的答案很好理解

先说第一个false:

s存的是堆中地址,s2存的是常量池的地址,虽然有s.intern(),但是并不是s = s.intern(),所以这里intern没用。

再来说第2个false:

大概思路和第一个false差不多,区别在于s3.intern()会在常量池中创建"11",但也是因为没有用s3接收返回值,所以为false。

jdk1.7中第一个false和jdk1.6一样,主要讲第二个为什么是true:

区别在于intern底层处理方式不同了,1.6中如果常量池没有该字符串,就直接创建一个该字符串对象,这个对象和堆中对象不同(虽然值相同),但是1.7调用intern如果发现常量池中没有该字符串,就创建一个value用来存放堆中该字符串的地址,相当于常量池中放了个地址指向堆中的对象,而栈中的地址又指向常量池的地址,所以自始至终都只有一个对象在堆中。

再来一道巩固一下: 

String s = new String("1") + new String("2");

String s2 = s.intern();

System.out.println(s2 == "12");
System.out.println(s == "12");

jdk1.6:true、false;jdk1.7:true、true

JVM垃圾回收算法

这里的标记是指对不能被回收的对象做标记,将来gc时没有被标记的就会被清除 

  1. 标记清除

从GC Root开始沿着引用关系找,找到的就会被标记,GC Root是指一定不能被垃圾回收的,比如一个方法中有一个局部变量指向了一个对象,那么这个对象就可以作为GC Root(因为局部变量后面可能还会被用到,如果执行了GC,使用的时候就是null了),还有一些静态变量引用的对象也可以,因为只有类卸载静态变量才会消失。

缺点:可能会造成内存碎片问题,因为清除掉的对象在内存中不连续,每一段空闲的内存可能仍然很小,当我们要给数组分配连续内存空间时空间可能还是不够,目前没有JVM用这种算法了。

  1. 标记整理

在标记清除的基础上多加了一步整理,就是把存活对象朝一端靠拢,这样就没有内存碎片,不过效率低了些。(老年代用的多)

  1. 标记复制

把内存分为两个区,一个from,一个to,标记阶段和前面一样,只是不用清除,而是把存活对象由from复制到to,时间快,不会有碎片,但是内存占用太多。(新生代垃圾回收用了这个算法,因为新生代一般存活对象少,复制不会花费太大的代价,而老年代因为存活对象多,所以不适用这种算法)。

GC和分代回收算法

 GC目的在于找到无用对象并自动释放内存并尽可能减少内存碎片。

GC要点:

  1. 使用可达性分析算法、三色标记法标记存活对象,回收未标记对象。

  2. GC具体实现称为垃圾回收器。

  3. GC大都采用了分代回收思想,因为有些对象朝生夕死,有些对象存活时间很长,因此可分为新生代和老年代,不同区域应用不同回收策略。

  4. 根据GC的规模可分为minor GC、Full GC、Mixed GC(G1独有)

分代回收:

  1. 伊甸园区eden,最初对象都被分配到这里,和幸存者区合称为新生代

  2. 幸存者区survivor,当伊甸园区空间不足,gc后存活的对象会来这里。其分为from区和to区,采用标记复制算法。

  3. 老年代old,当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区空间不足或大对象有可能提前晋升)

Minor GC发生在新生代的垃圾回收,暂停时间短

Full GC发生在新生代+老年代完整垃圾回收,暂停时间长,应尽量避免。 

Mixed GC新生代+部分老年代垃圾回收,G1回收器独有。

注意如果一次minorGC后to区装不下了会把所有新生代对象移到老年代,然后新对象加入伊甸园。(分配担保机制)

这里是老年代进行担保,就是说minor GC前会去看看老年代剩余空间是否大于新生代所有对象大小之和,如果大于那么这次minorGC是安全的(to区装不下就直接移到老年代),如果小于并且虚拟机参数HandlePromotionFailure为true就去看是否大于历次晋升对象的平均大小,大于就尝试Minor GC,如果小于或者参数为false,就进行一次Full GC

三色标记与并发漏标问题

用三种颜色记录对象状态

黑色:已标记

灰色:标记中

白色:还未标记 

早期gc的线程和用户的业务线程是分开的,gc线程运行时用户线程会被停止 (即"stop the world"),现在逐渐开始支持并发了,但是并发就会带来问题,用户线程会给gc线程带来干扰,比如gc在标记过程中用户线程那边改了对象之间的引用关系,这样就会对标记结果产生影响,即"漏标"问题。

问题描述

对象A引用了对象B,对象B引用了对象C,现在gc执行了一半,A被标记为黑色,B被标记为灰色,此时用户线程断开了B和C的引用关系,但是加上了A和C的引用关系(比如让A对象的成员变量引用C对象),由于标记操作已经执行到了B,B和C又没有关系,所以C不会被标记为黑色,一会就会被清除,但A又引用了C,所以就出现了“漏标”。

解决方法:

  1. 增量更新

只要有赋值操作发生,那个被赋值的对象就会被记录,等并发标记做完了,一定会有Stop the world,然后重新由这些新的记录再去标记一次。

  1. 原始快照

记录标记过程中新增对象和被删除引用关系的对象,同样等并发标记做完了,进入STW,然后重新标记一次。重新标记阶段就把记录对象再过一次,以防遗漏。

垃圾回收器

Parallel GC(新生代有一个,老年代有一个)

  1. eden内存不足发生Minor GC,标记复制 STW

  2. old 内存不足发生Full GC,标记整理 STW(时间更长)

  3. 比较注重吞吐量(因为STW时是用的多个线程并行执行gc)

补充一点,因为新生代对象存活率低,那么复制就不会复制太多的对象。但是,如果对象存活的时间很长,存活率很高,每次清理都只有少部分对象死亡(这正是老年代的特点),那么,这种算法消耗的时间会大大减少。

ConcurrentMarkSweep GC(CMS)

  1. old并发标记,重新标记时会STW,并发清除

  2. Failback Full GC(清除速度小于对象产生速度则并发失败触发Full GC,算是保底策略)

  3. 注重响应时间

由于用的标记清除,会有内存碎片,所以这个gc回收器很少用了。

G1 GC(jdk1.9开始的默认垃圾回收器)

 1. 响应时间与吞吐量兼顾

  1. 整个堆内存划分为多个区域,每个区域都可充当eden,survivor,old,humongous,最后这种

专门用来存大对象,因为G1采用标记复制算法,大对象复制成本太高,因此要单独处理,采用了连续空间存大对象。 

  1. 新生代回收:eden内存不足,标记复制STW

  2. 并发标记:old并发标记(原始快照),重新标记时需要STW

  3. 混合收集:并发标记完成,开始混合收集,参与复制的eden、survivor、old,其中old会根据预设的暂停时间目标,选择部分回收价值高(存活对象少的回收价值就高)的取余,复制时STW

当老年代区域大小达到总大小45%就触发mixed gc 

  1. Failback Full GC

ZGC

是jdk11引入的,用到了着色指针和读屏障技术

ZGC和G1一样分区,但是每个区大小可变,具体来说会分为三种页面:

小页面:2M一个,对象小于256k

中页面:32M一个,对象在256k到4M

大页面:大于32M,对象大于4M

用了ZGC我们在调参时会非常方便

着色指针

传统垃圾收集器是是用的对象头的markword里面的4bit用于记录gc状态,这样会影响效率(需要对每个对象的markword都设置) 那么怎么给指针带颜色呢?首先这个ZGC只支持64位系统,不然没有足够长的bit用于着色

待更...

项目中可能出现内存溢出的场景及解决方案

  1. 误用线程池导致的内存溢出

如果我们使用Executors的newFixedThreadPool创建线程池,那么该线程池的工作队列是无界的,如果工作线程卡住了,之后新的任务过来就全部塞到工作队列中,直到队列塞满报错OOM,所以尽量不要用Executors创建队列,而是使用new ThreadPoolExecutor自定义线程池的参数。(我记得好像阿里巴巴开发手册中也说过不用Executors,应该就是这个原因)

  1. 查询数据量太大导致内存溢出

比如调用findAll()方法查商品信息,商品一般会有desc之类字符串非常多的属性,findAll返回的对象是带了所有属性的,如果一次直接查询100000个商品,那么就有可能内存溢出(更不要说并发场景多用户同时操作了),所以最好不要用findAll,并且最好加limit限制返回的数据数目。

类加载过程、双亲委派 

类加载过程分为加载、链接、初始化

加载

 1. 将类的字节码载入方法区,并创建类.class对象(对象在堆中)

  1. 如果父类没有加载,先加载父类

  2. 加载是惰性的(用到才加载)

链接

  1. 验证 - 验证类是否符合Class规范,合法性、安全性检查

  2. 准备 - 为static变量分配空间,设置默认值(如果有赋值操作这里并不会执行,但是如果加了final修饰那么这里就会执行赋值)

  3. 解析 - 将常量池的符号引用解析为直接引用 

初始化

  1. 执行静态代码块与非final静态变量的赋值 

非final静态变量、或者用了final但是引用类型的变量、静态代码块这些会被合并在cinit方法中,只有初始化才去执行。

  1. 初始化是惰性的,真正用到才会去执行

image.png

c和m是Student类的静态final修饰的变量,在执行Student.c或Student.m时并不会涉及Student类的加载,而是编译器将c、m复制一份到了TestFinal类中,字节码中Student.c是直接写死,Student.m是放在了ldc中。

双亲委派

就是指优先委派上级类加载器进行加载,如果上级类加载器

  1. 能找到这个类,由上级加载,加载后的类对下级加载器可见

  2. 找不到这个类,则下级类加载器才有资格执行加载 

双亲委派的目的:

  1. 让上级类加载器加载的类对下级共享,反之则不行

  2. 让类的加载有优先次序,保证核心类优先加载

双亲委派好处:

安全

比如我们自定义的Integer类由于双亲委派机制就不会被加载,因为找到根类加载器的时候,他发现自己的rt.jar包中有java.lang.Integer这个类,就直接加载了,自定义的Integer就不会加载。

这样就可以避免有人自定义一个有破坏功能的java.lang.Integer被加载。

能不能自己写个类叫java.lang.System?

不能,java开头的包名运行会报安全异常,比如我们使用java.abc:

image.png  jdk1.9之后甚至连编译都过不了,因为每个java开头的包会和系统模块进行绑定,比如java.util、java.lang... 自己写的如java.lang这种已存在的包,就会因为java.lang已存在于某个模块中而导致编译不通过。

双亲委派怎么实现的?

1、先检查类是否已经被加载过

2、若没有加载则调用父加载器的loadClass()方法进行加载

3、若父加载器为空则默认使用启动类加载器作为父加载器

4、如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载

对象引用类型

强引用

普通的赋值建立的就是强引用(基本数据类型或引用类型都是) 

被强引用的对象不会被垃圾回收 

软引用

SoftReference a = new SoftReference(new A());

 可以看出a并不是和A直接建立关系,而是在中间加了一道软引用对象

第一次gc时,a所指对象不会被回收,如果内存仍不足,再次回收才会释放对象(相当于2条命),软引用对象并不会被释放,软引用自身需要配合引用队列来释放,典型例子是反射数据,我们通过类对象获取的成员变量,方法信息就是软引用的。

内存够不会清理,不够就优先清理,缓存可以用软引用。

弱引用

WeakReference a = new WeakReference(new A());

只要发生gc,就会回收该对象,自身同样要配合引用队列释放,典型例子是ThreadLocalMap中的Entry。这个Entry虽然能用弱引用释放key的空间,但value空间由于是强引用并不会被释放,这样有可能导致内存泄漏,我们可以配合引用队列使用,这样当key被回收,Entry就会被加入引用队列,我们去队列取,如果取出来了Entry对象,就说明这个Entry的value是需要释放的,我们就去遍历Entry所在map,把里面对应Entry置位null就可以断开和value的强引用。

虚引用

PhantomReference a = new PhantomReference(new A());

必须配合引用队列一起使用,当虚引用引用对象被回收,会将虚引用对象入队,由Reference Handler线程释放其关联的外部资源,典型例子是Cleaner释放DirectByteBuffer占用的直接内存。

 引用队列可用来释放a、b对象所占用的外部资源,因为gc只是回收jvm内部的内存空间,物理内存也可能会被占用,引用队列的对象会由Reference Handler线程处理,一个个去释放这些对象占用的外部资源。

DirectBuffer指向的内存被称为直接内存,不归gc管不会被gc,就可以用虚引用指向它,他会被放到队列中被特殊处理(在这里面被回收)。

TLAB

多个线程创建对象如果都看上了内存中的同一块区域,必然会出现锁竞争,所以为了提升效率避免锁竞争,采用线程本地分配缓冲区策略,就是每个线程在内存中会有自己专属的一块区域,优先把对象放入这块区域。(一个小优化)