1. JVM内存
- 照例絮絮叨叨一下吧,虽然已经是一个工作狗了,其实自己对JVM的内存并不了解
- 只是知道一些很凌乱的知识点:
- 堆存放对象和数组,栈存放基本数据类型和对象的引用
- 如果程序如果不能在递归中触底反弹,会出现StackOverflowError,就是所谓的栈溢出
- 如果存在内存泄漏,积少成多使得堆内存不足,会出现OutOfMemoryError,就是所谓的OOM
- 当维护的集群出现OOM异常时,自己拿到dump文件都不知道如何分析
- 甚至,一度因为不懂GC而排斥去学习、解决与GC有关的问题
1.1 JDK1.6的JVM内存布局
- JVM运行起来后,会从本机内存中申请一部内存,用于运行Java程序
- JVM管理的内存可以划分为5大运行时数据区域:堆、方法区、虚拟机栈、本地方法栈、程序计数器
- 除了JVM内存,JVM还会与直接内存打交道
- 综合起来,JDK 1.6的JVM内存布局如下:
1.2 程序计数器
- 程序计数器,又被翻译为PC寄存器,与计算机系统中PC寄存器有着相同的作用
- 如果当前线程正在执行的是Java方法,则程序计数器记录的是虚拟机字节码指令的地址;如果当前线程正在执行的是native方法,则程序计数器的值为空(Undefined)
程序计数器为线程私有
- 字节码解释器工作时,通过改变程序计数器的值来选取下一条需要执行的字节码指令,从而实现分支、循环、跳转、线程恢复等基础功能
- Java多线程依靠CPU时间片实现,线程之间存在上下文切换
- 为了线程获取CPU时间片后能恢复到正确的执行位置,每个线程都应该有自己的独立的程序计数器
程序计数器,是JVM个运行时数据区域中,唯一一个不会发生 OutOfMemoryError 情况的区域
1.3 Java虚拟机栈
- Java虚拟机栈(简写为VM栈),同程序计数器一样也是线程私有,其生命周期与线程相同
VM栈为执行Java方法服务
- Java方法在执行时,都会在VM栈创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息
- Java方法从开始执行到执行结束,对应着栈帧在VM栈中入栈和出栈
局部变量表
- 最受程序员关注的是堆内存与栈内存,这两个内存区域与对象内存分配的关系最为密切
- 所谓的栈内存,其实就是VM栈,更确切地说是VM栈中的局部变量表
- 局部变量表中存放了编译期可知的基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)
- 其中,对象引用为reference类型,可能为指向对象内存地址、对象的句柄或其他与对象相关的位置
- 局部变量表所需的空间在编译时确定,执行Java方法时,栈帧中的局部变量表大小固定且不变的
- 其中,long和double占据两个局部变量空间(slot),其余数据类型占据一个slot
VM栈的两种异常情况
- 如果线程请求的栈深度超过虚拟机所允许的栈深度,将会抛出 StackOverflowError 异常
- 所谓的栈深度,其实就是栈内存,这与当前线程方法调用的深度息息相关
- 也就是说,线程请求的栈内存超过最大栈内存时,会栈溢出
- 栈溢出,可能是栈内存太小,导致方法调用无法过深;也可能是已用的栈内存太大,导致无法增加方法的调用深度
- 如果大量创建线程,新建线程时无法申请到足够的内存,将会抛出 OutOfMemoryError 异常
VM栈的配置参数:
- 可以通过
-Xss
或-XX:ThreadStackSize
设置VM栈的大小,参数详情可以查看官方文档// 将栈大小设置为1MB -Xss1m -XX:ThreadStackSize=1m
1.4 本地方法栈
- 本地方法栈为native方法服务
- JVM规范并未规定本地方法栈中方法语言、使用方式与数据结构,不同JVM中本地方法栈的实现有所差异
- HotSpot 虚拟机中,VM栈与本地方法栈合二为一
- 同VM栈一样,本地方法栈也会出现StackOverflowError 和 OutOfMemoryError
1.5 Java堆
Java堆的特点
-
Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建
-
大多数Java应用程序,Java堆是JVM中最大的一块内存
-
几乎所有的对象实例和数组都在堆上分配内存,因此,Java堆是垃圾回收主要区域,又称GC堆
JVM规范的原文:对象实例和数组都在堆上分配内存
-
但是由于JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得对象实例可能不在堆上分配内存
Java堆的内存划分
- Java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可
- 从内存回收的角度看:
- 收集器基本都采用分代收集算法,Java堆可以划分为新生代、老年代
- 新生代又被划分为Eden、From Survivor(简称S0)和To Survivor(简称S1)三大块
- 从内存分配的角度看
- 线程共享的Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),其存储的内容仍然是对象实例和数组
- TLAB的划分是为了更好地回收内存、更快地分配内存
Java堆的异常
- 当Java堆无法继续扩展且无足够内存去完成实例分配时,将会抛出OutOfMemoryError 异常,
Java堆的配置参数
-
通过
-Xms
设置Java堆的初始值,与-XX:InitalHeapSize
作用相同,其值必须为 MB的整数倍且大于1 MB -
通过
-Xmx
设置Java堆的最大值,与-XX:MaxHeapSize
作用相同,其值必须为MB的整数倍且大于2 MB建议将堆内存的初始值和最大值设置为相同值,避免每次垃圾回收完成后JVM重新分配内存
-
通过
-Xmn
同时设置新生代的初始值和最大值,初始值和最大值相等。若想分开设置,使用-XX:NewSize
设置新生代初始值, 使用-XX:MaxNewSize
设置新生代的最大值 -
通过
-XX:SurvivorRatio
设置Eden和Survivor的大小比例,默认值为8,即 Eden:S0:S1 = 8:1:1老年代空间大小 = 堆内存大小 - 新生代空间大小,因此只需设置新生代空间大小即可影响老年代空间大小
-
JVM参数的配置示例:
-Xms2048k -Xmx80m -Xmn48m -XX:SurvivorRatio=4
1.6 方法区
1.6.1 JDK 1.6中的方法区
- 方法区与Java堆一样,属于线程共享的内存区域,用于存储已被JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码(codeCache)
方法区与永久代的关系
- 在HotSpot虚拟机中,为了能将GC分代收集扩展到方法去,使用永久代(Permanent Generation,简称PermGen)实现方法区
- 其他JVM,如BEA JRockit、IBM J9等并没有永久代的概念
- 永久代实现方法的方式在JDK 1.8中被移除,JDK 1.8中使用元空间(Metaspace)替代永久代
- 总的来说,方法区是JVM的一种规范,而永久代是方法区的一种实现
永久代的垃圾回收
- 方法区的内存空间可以在物理上不连续,可以设置初始值和最大值(或者说支持固定大小、可扩展),甚至可以不进行垃圾回收
- 基于永久代的方法区实现,可以通过
-XX:PermSize
设置初始值,通过-XX:MaxPermSize
设置最大值。 - 永久代的不足:
- 永久代存在最大值限制,因此更容易因为内存空间不足而触发OutOfMemoryError,具体错误信息为 OutOfMemoryError: PermGen space
- 永久代内存回收的主要是针对常量池的回收和对类的卸载,但是这个区域的内存回收效果不是特别理想,容易因为回收不完全而出现内存泄漏,甚至进一步地导致内存溢出
1.6.2 JDK 1.7的方法区实现
- 针对通过实现方法区存在问题,Oracle从JDK 1.7开始逐步移除永久代
- JDK 1.7方法区实现变化:
- 字符串字面量(interned strings),也就是将字符串常量池转移到了Java堆
- 静态变量(class statics)转移到了Java堆
- 符号引用(Symbols)转移到了 Native堆
- JDK 1.7仍然保留了永久代的实现,只是将部分数据进行了转移到了Java堆或Native堆
1.6.3 JDK 1.8的方法区实现
- JDK 1.8在JDK 1.7的基础上,使用元空间替代了永久代
- 基于元空间的方法区实现,可以通过
-XX:MetaspaceSize
设置初始值,通过-XX:MaxMetaspaceSiz
设置最大值 - 从JDK 1.6到JDK 1.8,整个方法区的变化如下:
1.6.4 通过代码体会方法区实现的变化
-
通过学习我们知道,JDK 1.6及以前,字符串常量池位于方法区中;JDK 1.7开始,字符串常量池位于Java堆中
-
执行如下代码,JDK 1.6和JDK 1.7中都会触发OutOfMemoryError,但是具体的错误信息将会有所不同
import org.apache.commons.lang.RandomStringUtils; import java.util.ArrayList; import java.util.List; import java.util.Locale; public class GcTest { private final static int MB = 1024 * 1024; private final static int LEN = 12; public static void main(String[] args) { List<String> list = new ArrayList<>(); String base = RandomStringUtils.random(1024, true, true).toUpperCase(Locale.ROOT); // 不停向字符串常量池中添加字符串字,直到内存溢出 while (true) { String str = base + base; // 字符串长度不断增加 base = str; // 将字符串添加到list中,避免因为gc使得字符串常量池中的字符串被回收,从而无法触发内存溢出 // 同时,将字符串添加到字符串常量池 list.add(str.intern()); } } }
-
在 src/main/java 目录下执行如下命令,运行Java程序(关于无法加载主类的问题,可以参考之前的博客:OOM JVM参数配置 —— dump文件的产生以及执行shell脚本)
java -XX:PermSize=8m -XX:MaxPermSize=8m -Xms16m -Xmx16m internet.gc.GcTest
-
JDK 1.6中,因为字符串常量池存储了大量无法被垃圾回收的字符串,导致永久代内存溢出
-
JDK 1.7中,因为字符串常量池中存储了大量被垃圾回收的字符串,导致Java堆内存溢出
-
在JDK 1.8中,若使用同样的Java命令执行代码,首先会提示关于永久代的配置不起作用,其次;同JDK 1.7一样,将出现Java堆内存溢出的情况
-
将方法区的内存大小配置改为元空间的配置参数
-XX:MetaspaceSize=8m -XX:MaxMetaspaceSize=8m
,将提示8m的参数配置太小
-
查看官方文档对参数的说明:-XX:MetaspaceSize的默认值取决于不同的平台 😂
1.6.5 运行时常量池
- 上面的代码示例中,将新拼接出来的字符串通过
str.intern()
被放入字符串常量池中 - 其实,还可以认为字符串被放到了方法区的运行时常量池(Runtime constant Pool)中
运行时常量池 VS Class文件常量池
- Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一个常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用
- 类加载后,Class文件常量池中的内容将被加载到运行时常量池中
- 运行时常量池具备动态特性:除了编译期生成的常量,还允许运行期间将新的常量放入常量池
- String类的native方法 intern() 就具备这样的作用,可以将新的字符串字面加入到常量池中
- String类的native方法 intern() 就具备这样的作用,可以将新的字符串字面加入到常量池中
关于OutOfMemoryError
- 《深入理解Java虚拟机(第2版)》一书中,这样描述运行时常量池的OutOfMemoryError
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常
- 个人理解,这句话在JDK 1.6的方法区实现中并没有任何问题
- 因为,JDK1.6中方法区基于永久代实现的,此时字符串常量池和静态变量都还没有被移动到堆中
- 但在JDK 1.7及以后,字符串常量池被移动到了堆中,运行时常量池是否整体移动尚待确认
- 如果运行时常量池被移动到了堆中,那它的的OutOfMemoryError其实受限于Java堆内存的大小
1.7 直接内存
1.7.1 直接内存概述
- JDK 1.4新加入的NIO(New Input/Output) 类,NIO采用一种基于通道(Channel)与缓冲区(Buffer)的I/O方式
- NIO类可以使用native函数库直接分配堆外内存(即直接内存),然后通过Java堆中的DirectByteBuffer 对象作为这块内存的引用,从而操作这块内存
- 直接内存避免了在Java堆和Native堆之间来回拷贝数据,在一些场景中可以显著提高性能
- 同事的说法:避免I/O读写时数据需要在系统内存、用户内存之间重复存储和两次拷贝
直接内存的OOM
- 直接内存不属于JVM运行时数据区域,也不是JVM规范定义的内存区域
- 直接内存虽然不受Java堆的限制,但是会受到本机总内存的影响以及处理器寻址空间的限定
- 如果只关注Java堆的设置,而忽略直接内存的大小设置,很容易使得Java程序各内存区域总和超过物理内存限制,从而在动态扩展时容易出现OutOfMemoryError
直接内存配置参数
- 为了避免NIO通信时申请过多的直接内存,可以通过
-XX:MaxDirectMemorySize
限制直接内存的上限 - 设置了直接内存的上限后,如果申请的直接内存超过上限,将触发OutOfMemoryError
1.7.2 直接内存的两种分配方法
方法一:通过ByteBuffer 类的allocateDirect() 方法,分配直接内存
-
该方法将返回一个DirectByteBuffer对象,通过DirectByteBuffer对象可以操作直接内存
public static void main(String[] args) { ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024); System.out.println(byteBuffer.toString() ); }
-
从执行结果和源代码都可以看出,ByteBuffer 类的allocateDirect() 方法返回的实际是一个DirectByteBuffer对象
public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); }
-
注意:
- DirectByteBuffer的构造函数中,实际调用Unsafe类的allocateMemory() 方法分配直接内存
- DirectByteBuffer类,为包访问权限,通过ByteBuffer类的allocateDirect()获取的DirectByteBuffer对象,并不能赋值给DirectByteBuffer引用
- DirectByteBuffer是间接继承ByteBuffer的
方法二:通过Unsafe类的native方法allocateMemory() ,分配直接内存
-
Unsafe类是一个单例类,其全局访问点限制只有当类加载器为SystemDomainLoader(引导类加载器)时,才会返回唯一实例theUnsafe
-
theUnsafe这个唯一实例的初始化,是放到静态语句块中的,也就是说Unsafe类为饿汉模式的单例
public static Unsafe getUnsafe() { Class var0 = Reflection.getCallerClass(); if (!VM.isSystemDomainLoader(var0.getClassLoader())) { throw new SecurityException("Unsafe"); } else { return theUnsafe; } }
-
在JDK源码外,通过 getUnsafe() 方法获取Unsafe,将会抛出SecurityException
public static void main(String[] args) { Unsafe unsafe = Unsafe.getUnsafe(); unsafe.allocateMemory(1024 * 1024); }
-
因此,只能通过反射破坏Unsafe类的单例模式,然后调用allocateMemory() 方法分配直接内存
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException { Class unsafeClass = Unsafe.class; Field field = unsafeClass.getDeclaredField("theUnsafe"); // 允许访问 field.setAccessible(true); // 获取theUnsafe对象,然后分配直接内存 Unsafe unsafe = (Unsafe) field.get(null); unsafe.allocateMemory(1024 * 1024); }
2. 实战:代码触发栈溢出或内存溢出
2.1 栈溢出
-
在Hot Spot虚拟中,没有区分VM栈和本地方法栈,参数
-Xss
可以设置栈空间大小 -
如果线程请求的栈深度大于栈所能允许的最大深度,将抛出StackOverflowError
-
栈溢出一般都是由于方法调用层次太深导致
- 每次方法调用都会向VM栈中push一个栈帧,如果方法调用的层次太深,线程私有的、空间有限的VM栈将会内存不足,从而JVM将抛出StackOverflowError
- 方法调用层次较深,一般又是因为不正常的递归导致(不存在触底反弹)
-
其实,StackOverflowError就是当前线程的栈空间不足
-
下面的代码中,将递归调用func() 方法却未从调用中返回(不正常的递归),最终栈空间将不足
public class GcTest { int stackDepth = 0; public void func() { stackDepth++; func(); } public static void main(String[] args) { GcTest test = new GcTest(); new Thread(() -> { try { test.func(); } catch (StackOverflowError error) { System.out.println("线程" + Thread.currentThread().getName() + "栈溢出,方法调用深度为: " + test.stackDepth); } }, "thread-1").start(); } }
-
在IDE的运行配置中,通过
-Xss8M
设置栈空间为8M,在进行476705次递归调用时栈空间不足
2.2 栈内存溢出
-
而栈内存溢出,一般都是因为新建线程时,系统没有足够的内存为线程分配栈,从而触发JVM的OutOfMemoryError异常
-
系统内存除去Java堆、方法区占用的内存(程序计数器占用的内存可以忽略不接),剩下的就由VM栈和本地方法栈瓜分
-
如果创建的线程过多,系统内存将被耗尽,JVM将抛出OutOfMemoryError
-
下面的代码,将不停创建新的线程,从而需要不停地为新的线程分配栈内存
public static void main(String[] args) { while (true) { new Thread(() -> { System.out.println("新建线程" + Thread.currentThread().getName()); // 执行循环,让线程一直占用一定大小的栈空间 while (true) { } }).start(); } }
-
不知什么原因,笔者将线程的VM栈都设置为256MB,堆内存初始和最大值都设置为6GB了,仍然创建了很多线程且并未触发OutOfMemoryError 😂
-
盗信息错误信息一波:栈内存溢出,其实是系统无法新建线程
Caused by: java.lang.OutOfMemoryError: unable to create new native thread at java.lang.Thread.start0(Native Method) at java.lang.Thread.start(Thread.java:597) at java.util.Timer.<init>(Timer.java:154)
2.3 堆内存溢出
-
堆内存溢出,其实就非常简单了:创建的Java对象实例或数组过多,都将导致堆内存溢出
-
JDK 1.7开始,字符串常量池中字符串过多,也将导致堆内存溢出
-
下面的代码将不停地创建OOMObject对象,最终导致Java堆内存溢出
static class OOMObject { private final int[] array = new int[1024]; } public static void main(String[] args) { List<OOMObject> list = new ArrayList<>(); while (true) { list.add(new OOMObject()); System.out.println("成功创建对象OOMObject-" + list.size()); } }
-
当创建近3万个OOMObject对象后,JVM抛出Java堆内存溢出的错误信息
-
参考链接:由多线程内存溢出产生的实战分析
2.4 方法区内存溢出
-
因为本人JDK版本的问题,方法区的内存溢出实质是元空间的内存溢出
-
通过CGLib动态代理机制,动态生成的Class被加载进元空间,将导致元空间内存溢出
static class OOMObject { private final int[] array = new int[1024]; } public static void main(String[] args) { long count = 0; while (true){ Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setUseCache(false); enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(o, objects)); enhancer.create(); System.out.println("动态创建Class - " + (++count)); } }
-
将元空间大小设置为24MB,
-XX:MetaspaceSize=24m -XX:MaxMetaspaceSize=24m
-
运行程序,当创建大约2300个动态类后,将导致元空间内存溢出
2.5 直接内存溢出
-
通过
-XX:MaxDirectMemorySize
设置了直接内存的上限,如果Java程序分配过多的直接内存,JVM将抛出OutOfMemoryErrorpublic static void main(String[] args) { List<ByteBuffer> list = new ArrayList<>(); long count = 0; while (true){ // 不停分配直接内存 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024); list.add(byteBuffer); // 打印分配情况 System.out.println("申请1MB的直接内存,其序号为:" + (++count)); } }
-
设置-XX:MaxDirectMemorySize=8m,运行程序
-
当申请第9块1MB的直接内存时,超过直接内存的上限,JVM抛出OutOfMemoryError
-
注意: 《深入理解Java虚拟机(第二版)》中有这样一段话,是否正确待验证
DirectByteBuffer分配内存也会抛出OOM异常,但是它抛出异常是并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配
3. 总结
内容总结
- 以JDK 1.6的JVM(Hot Spot)的内存设计为基础:
- 五大内存区域,线程共享的:方法区和Java堆,线程私有的:程序计数器、VM栈、本地方法栈
- 每个内存区域存储的数据、参数配置、可能发生的异常(内存溢出或栈溢出)等
- 重点:Java堆基于分代收集算法的划分以及TLAB、方法区从JDK 1.6到JDK 1.8的演变(永久代到元空间、某些数据的迁移)
- 除此之外,堆外内存(又叫直接内存)的作用、申请直接内存的两种方法
- 编写Java代码,配置JVM参数,模拟JVM的内存异常
JVM内存的相关参考链接
- 原书第一章的总结:【搞定Jvm面试】 Java 内存区域揭秘附常见面试题解析
- JVM的系统性总结:Java8内存模型—永久代(PermGen)和元空间(Metaspace)
- JDK中方法区的演变和验证:JVM之 方法区、永久代(PermGen space)、元空间(Metaspace)三者的区别
- 优秀的图例:8张图 带你理解Java内存区域
- 后续深入学习:JVM(hotspot)为什么使用元空间替换了永久代
- 后续验证:你知道 JVM 的方法区是干什么用的吗?