1.JVM内存模型
JVM内存模型根据jdk版本不同,有部分变化,主要是jdk1.8之后,方法区移至直接内存中的元空间处。对比图如下所示:
由上图可以看出来,版本之间的变化主要是共享线程区中的 方法区 的位置,jdk8之后转移到直接内存,而不是原先的共享线程区中。
线程私有的 虚拟机栈、本地方法栈、程序计数器;线程共有的 堆、方法区、直接内存(非运行时数据区)。
1.1 虚拟机栈
虚拟机栈是线程私有的。虚拟机栈跟线程的生命周期相同,它描述的是java方法执行的内存模型,每次java方法调用的数据,都是通过栈传递的。
java内存可以粗糙的分为 堆内存(heap)和 栈内存(stack) ,其中栈内存就是指的虚拟机栈,或者说是虚拟机栈中局部变量表中的部分。实际上,虚拟机栈就是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。
局部变量表主要存放的是编译期间可知的各种数据类型(八大基本数据类型)、对象引用(Reference类型,不同于对象,可能是指向对象地址的指针或者与此对象位置相关的信息)
虚拟机栈可能抛出两种错误:StackOverflowError 、OutOfMemoryError。
java中方法的调用实际上就是虚拟机栈出栈的操作,每一次方法调用,都有对应的栈弹出,根据每个栈帧中的 局部变量表、操作数栈等信息,执行方法。
1.2 本地方法栈
本地方法栈的工作原理跟虚拟机栈并无区别,唯一的区别就是本地方法栈面向的不是.class字节码,而是Native修饰的本地方法。
本地方法的执行过程,也是本地方法栈中栈帧的出栈过程。
同虚拟机栈一样,本地方法栈也是会抛出 StackOverflowError 、OutOfMemoryError 两种异常。
1.3 程序计数器
程序计数器是一块较小的内存空间,可看作是当前线程所执行字节码的行号指示器。字节码解释器根据这个计数器来获取当前线程需要执行的下一条指令,分支、循环、跳转、异常、线程恢复等功能都需要依赖程序计数器来完成。
此外,在线程争夺CPU时间片的时候,需要线程切换,这时候,就需要这个计数器来帮助线程恢复到正确执行的位置,每一条线程有自己的程序计数器,所以才能够保证当前程序能够正确恢复到上次执行的步骤。
注意:程序计数器是唯一一个不会出现OOM错误的内存区域,它的生命周期伴随线程的创建而创建,随程序的消亡而消亡。
1.4 堆
堆是java内存管理中最大的一块内存,也是所有线程共享的一块内存,在虚拟机启动时创建。堆中主要存放的是对象实例以及数组。几乎 所有的对象实力和数组都在这一块内存中分配。
随着编译技术的发展与逃逸分析技术的进步,栈上分配、标量优化等技术使得并不是所有的对象实例都是在堆中分配的。从1.7开始已经默认开启了逃逸分析,如果方法中的对象引用没有被返回或者未被外面使用(未逃逸出去),那么对象可以直接在栈中分配。
堆也是GC垃圾回收的主要区域。垃圾回收现在主要采取的是分代垃圾回收算法。为了方便垃圾回收,java堆还进行了细分,分成:新生代、和老年代;再细致还分成Eden、from survivor、to survivor空间等,如下图:
jdk8之前:
jdk8及之后:
大部分情况下,对象都会在Eden区分配,再一次新生代垃圾回收之后,存活下来的对象进入 survivor区域,并且年龄增加;当年龄增加到一定岁数时(默认15岁),就会进入到 老生代。对象进入老生代的年龄阈值,可以根据JVM参数
-XX:MaxTenuringThreshold
来设置。
1.5 方法区
方法区和堆一样,是多线程共享的内存区域。用来存放已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。
方法区也被称为永久代。但是两者之间还是有区别的:方法区是JVM虚拟机规范中的定义,而永久代是这一规范的一种实现。 也就是说,只有HopSpot虚拟机中才会有永久代这个个概念。
可以通过一些参数来调整方法区内存大小:
jdk1.8之前:设置永久代大小
--XX: PermSize=N
--XX: MaxPermSize=N
jdk1.8 :设置元空间大小
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
之所以把永久代去掉,换成元空间,原因是元空间在直接内存中,受本机可用内存控制,虽然元空间仍然有几率会出现溢出,但是几率很小。元空间溢出时,会报错:OutOfMemoryError:MetaSpace
。
1.5.1 运行时常量池
运行时常量池是方法区的一部分,存放的主要是字面量和引用。
String str = "abc";
Integer i = 2;
像这样的都是存放在常量池中;
String str1 = new String("abc"); //存放在堆中,创建了两个字符串对象,还有一个在常量池
1.6 直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分频繁使用,也有可能抛出 OOM错误出现。
2.几种内存溢出异常
2.1Java堆溢出
public class HeapOOM {
static class OOMObject { }
/**
* VM args: -Xms1024k 最小堆空间
* -Xmx1024k 最大堆空间 最小和最大堆空间设置相同,则表示不需要自动扩展
* -XX: +HeapDumpOnOutOfMemoryError 发生OOM错误时导出当前内存快照便于分析
* -XX: +PrintGCDetails 打印GC详情
* @param args
*/
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
///out
......省略GCDetail信息
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at com.lavendor.learn.java.basic.jvm.HeapOOM.main(HeapOOM.java:24)
注意: -Xms 是表示设置堆空间最小容量 -Xmx设置堆空间最大容量 这两个值设置一样,则表示堆空间不会自动扩展
2.2虚拟机栈和本地方法栈溢出
public class JavaVMStackSOF {
private int stackLength = 1;
//不断循坏入栈
public void stackLeak() {
stackLength++;
stackLeak();
}
//使用多线程来使虚拟机栈抛出OOM
private void dontStop() {
while (true) {
}
}
public void stackLeakByThread() {
while (true) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
dontStop();
}
});
thread.start();
}
}
/**
* VM args: -XX:+PrintGCDetails
*
* @param args
*/
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
//VM args: -Xss128k 设置栈空间容量,抛出栈溢出异常
oom.stackLeak(); // StackOverflowError
//VM args: -Xss128m 这个时候可以设置得稍微大些,让线程的虚拟机栈更大,从而不需要多少线程就能够把内 // 存撑满
//oom.stackLeakByThread(); //OutOfMemoryError
} catch (Throwable tx) {
System.out.println("stack length: " + oom.stackLength);
throw tx;
}
}
}
注意: -Xss 设置栈大小
单线程情况下无论是栈帧太大还是虚拟机栈容量太小,都只是抛出
StackOverflowError
,不是OutOfMemoryError
在多线程情况下,会抛出
OutOfMemoryError
,但是看起来并不像是虚拟机栈满而导致,更像是多个线程在分配虚拟机栈空间时互相争夺资源,导致内存空间不足,从而抛出OOM异常。从这个角度来说,这时候把栈空间设置很大,更加容易出现OOM异常。在Windows环境下不要轻易尝试多线程抛出OOM异常,因为Windows环境Java的多线程是映射到操作系统的,可能会导致系统死机
2.3方法区和运行时常量溢出
在JDK1.7
及以上版本中,逐渐会去掉“永久代”这个概念,方法区和运行时常量都会放置到堆上,-XX:PermSize
和 -XX:MaxPermSize
参数也不再支持。
这个模块溢出跟 Java堆溢出相似,通过控制堆大小可以看到OOM溢出。
public class MethodAreaOOM {
/** 使用cglib动态代理来无限生成类,去填满方法区
* cglib生成的类不太容易被GC回收
* VM args: -Xms10m -Xmx10m
* @param args
*/
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return method.invoke(o,objects);
}
});
enhancer.create();
}
}
static class OOMObject{}
}
2.4本地直接内存溢出
public class DirectMemoryOOM {
public static final int SINGLE_MB = 1024 * 1024;
/**
* VM args:-XX:MaxDirectMemorySize=10m
* @param args
*/
public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true){
unsafe.allocateMemory(SINGLE_MB);
}
}
}
-XX: MaxDirectMemorySize指定直接内存最大容量,不指定,则默认跟堆最大容量(-Xmx)相同
由直接内存导致的OOM,一个很明显的特征是Heap Dump文件不会有明显的异常,如果发现导出的文件很小,而且程序中直接或者间接的使用了NIO,那么可以考虑直接内存这方面的问题。
3.Java垃圾回收
程序计数器、虚拟机栈、本地方法栈这些都是线程私有的,随线程而生,消亡而灭。栈中的栈帧随着方法的进入和退出有条不紊的执行出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来之后就会确定好的,因此,这几个地方的内存是确定的,不需要过多的考虑内存回收的问题。
Java堆和方法区则不同,他们是线程共享的,程序在运行中需要创建多少对象,分配多少内存,这些都是动态的,所以这部分需要来及回收,GC关注的也是这部分内存,后面讨论的回收也只是指这部分内存。
3.1 判断对象可回收
1.引用计数算法
大体思路: 给对象一个引用计数器,有引用时+1, 无引用时-1,当引用计数器为0,则表示可以回收。
弊端: 当对象之前互相引用,并且已无意义时,不能回收。
目前虚拟机 并不是 采用这种算法。
2.可达性分析算法
大体思路: 通过一系列称为“GC Root”的对象作为起点,从起点开始向下搜索,搜索通过的路径称为引用链(reference chain),当一个对象从GC Root开始没有引用链连接,即不可达时(如下图 object5 object6 object7三个对象),他们将会被认为是可以回收的。
这是目前虚拟机中,判断对象是否可以被回收的主流实现方法。
有以下几种对象可以作为GC Root:
- 虚拟机栈中(栈帧中的本地变量表)引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈(native方法)中引用的对象
3.finalize()方法拯救快要被GC回收的对象
/**
* @author yanghao
* @date 2021-08-10
* @description
* 这个例子演示了两点:
* 1.对象可以在被GC时自我拯救
* 2.这种自救机会只有一次,因为一个对象的finalize()方法系统最多只会调用一次
*/
public class FinalizeEscape {
public static FinalizeEscape SAVE_HOOK = null;
public void isAlive(){
System.out.println("yes, I'm still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed");
//把当前对象指给再关联起来,拯救自己一次
SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscape();
//对象第一次拯救自己
//SAVE_HOOK = null;
System.gc();
//finalize()方法优先级很低,等待一段时间
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am dead :(");
}
//下面这段代码与上面完全相同,但是却失败了
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else {
System.out.println("no, i am dead :(");
}
}
}
3.2 垃圾回收算法及几种实现
1.算法
目前主流的垃圾回收算法是 分代回收算法 ,所以我们堆空间分成新生代、老年代是有必要,这可以使得垃圾回收的时候根据不同年代特点选择合适的垃圾回收算法。
- MinorGC 发生在新生代的垃圾回收动作,MinorGC非常频繁,执行速度也非常快。
- MajorGC/FullGc 发生在老年代的垃圾回收动作,MajorGC的出现有可能会伴随着至少一次MinorGC,MajorGC执行速度会比MinorGC慢10倍以上。
1)分代收集算法 根据对象存活周期,分成不同的分区,java中常见就是分成新生代、老年代,然后根据不同年龄代的特点分别回收。
对象分代策略:
- 对象优先在新生代分配
- 大对象直接分配到老年代
- 长期存活的对象直接分配到老年代
2)标记-清除算法 分成两部分:标记 和 清除 。首先标记出所有不需要回收的对象,标记完成后,把没有标记的对象全部清除掉。这个算法会有效率问题;以及空间不连续,内存碎片化的问题。
3)标记-整理算法 跟 标记-清除算法 类似,区别就是把存活对象,全部移动到一端,然后再把标记的全部清除掉,这样可以使得清理对象后,内存连成一块,不至于太碎片化。
4)复制算法 把内存分成大小相同的两块,每次使用一块。当这一块内存使用完后,就把还存活的对象复制到另一块去,然后把这块内存全部清除掉。这样效率增加了,但是太耗费内存。
当前的主流虚拟机中,分区成Eden、From Survivor、To Survivor等空间分区,都是采用的此方法:在Eden、From Survivor区中新的对象如果还活着,就会复制到 To Survivor,然后对Eden、Survivor区进行垃圾回收
2.实现
-
Serial GC收集器 Serial Young GC + Serial Old GC (实际上是Full GC)
是最基本,历史最久的收集器,是单线程的收集器。垃圾回收的线程是单线程,并且在回收垃圾时,需要其余所有的用户线程暂停--Stop the world,回收线程完成之后再恢复用户线程。
-
ParNew GC收集器
ParNew就是Serial GC的多线程版本,把垃圾回收的单线程改成多线程,其余不变。这是真正意义上的第一款并发(concurrent)收集器
并发和并行:
并发(Concurrent):指用户线程与垃圾回收线程同时执行
并行(Parallel):值多条垃圾回收线程并行工作,但是用户线程等待状态
-
Parallel GC收集器 Parallel Young GC + 非并行的PS MarkSweepGC/并行的Parallel Old GC(这两个实际上也是全局范围内的Full GC) (JDK1.8默认GC)
并行收集器组合 Parallel Scavenge + Parallel Old 年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。
-
CMS收集器 ParNew(Young) GC + CMS(Old)GC + Full GC for CMS算法
-
G1 GC收集器 Young GC + mixed GC(新生代GC ,再加上部分老生代) + G1 GC for CMS算法
参考 https://www.zhihu.com/question/41922036
3.3 垃圾回收器参数总结
1.GC日志理解
在jvm中添加参数 :-XX:+PrintGCDetials
即可打印出GC日志,内容和含义如下:
[GC (Allocation Failure) [PSYoungGen: 3931K->824K(9216K)] 8035K->8000K(19456K), 0.0023478 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 824K->0K(9216K)] [ParOldGen: 7176K->7888K(10240K)] 8000K->7888K(19456K), [Metaspace: 3471K->3471K(1056768K)], 0.0065113 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (Allocation Failure) [PSYoungGen: 6617K->859K(9216K)] 6617K->4963K(19456K), 0.0033870 secs]
|----------A------------||-----B----| |--------C--------| |--------D---------| |-------------
[Times: user=0.00 sys=0.00, real=0.00 secs]
-----------E-----------------------------|
段A:发生GC的停顿类型,出现Full GC表示发生了Stop-The-World
段B:发生GC的内存区域。此处是使用 Parallel Scavenge 回收器,所以新生区为:PSYoungGen
段C:GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)
段D:GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)
段E:此次GC所消耗时间
2.GC参数
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在client模式下的默认值,设置此参数后,使用 Serial+Serial Old的收集器组合回收 |
UseParNewGC | 设置此参数后,使用 ParNew+Serial Old的收集器组合回收 |
UseConMarkSweepGC | 设置此参数后,使用ParNew+CMS+Serial Old收集器组合回收,Serial Old将作为CMS回收失败Concurrent Mode Failure的后备收集器回收 |
UseParallelGC | 虚拟机运行在Server模式下的默认值,设置此参数后,使用 Parallel Scavenge+Serial Old(PS MarkSweep)组合进行内存回收 |
UseParallelOldGC | 设置此参数后,使用 Parallel Scavenge+Parallel Old组合进行内存回收 |
SurvivorRatio | 新生代Eden区:Survivor区,默认是8,即表示Eden:Survivor=8:1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,大于这个参数的对象,将直接放在老年代 |
MaxTenuringThreshold | 晋升到老年代的对象的年龄。每个对象在坚持过一次Minor GC之后,年龄+1,当对象的年龄超过这个参数时,放入老年代 |
UseAdaptiveSizePolicy | 动态调整Java堆中各个区域的大小及进入老年代的年龄 |
ParallelGCThreads | 设置并行GC线程数,默认4个。视操作系统而定 |
GCTimeRatio | GC时间占总时间比值,默认99,即允许1%GC时间。仅在使用 Parallel Scavenge收集器时有效 |
MaxGCPauseMillis | 设置GC最大停顿时间,仅在使用 Parallel Scavenge收集器时有效 |
4.类加载器
4.1类加载过程
java中类加载过程大致分为 加载---->连接---->初始化 ,其中连接过程由分为 验证---->准备---->解析 。
类的加载都是由加载器来完成的,加载就是指把.class字节码文件加载到JVM当中去执行。
4.2 类加载器
我们常见的有三种类加载器,除了BootstrapClassLoader 另外两种加载器都是继承自java.lang.ClassLoader
:
-
AppClassLoader 应用加载器,负责加载当前应用classpath下面的jar和类,包括我们自己正在开发的类。
-
ExtClassLoader 是 AppClassLoader 的父加载器,主要负责加载
%JRE_HOME/lib/ext%
目录下的jar包和类,或者被java.ext.dirs
系统变量指定的目录下得jar包。 -
BootstrapClassLoader 是终极的加载器,由java底层实现,是 ExtClassLoader 的父加载器,此加载器没有父加载器。此加载器主要负责加载
%JAVA_HOME/lib%
下的jar包和类,或者加载-Xbootclasspath
变量指定的路径下的jar包和类。
4.3双亲委派模式加载
每一个类都会有对应的加载器加载,java默认使用的类加载模式是双亲委派模式 。即在类加载的时候,会首先判断类是否被加载,如果被加载则直接返回被加载的类,如果没有被加载,就开始尝试加载。加载的时候会首先 委派 父类加载器加载,因此所有的类加载实际上都会传送到 BootstrapClassLoader 加载器中,如果父类不能加载,则由自己加载。如果父类加载器返回null,则会启动 BootstrapClassLoader来作为父类加载。
双亲委派模式加载的好处:
保证了java稳定运行,可以避免类的重复加载,保护了java内部核心API不被篡改。如果不使用这种模式,那用户也能够被允许编写
java.lang.Class
这样的类,就会跟java自带的类冲突,造成混乱。