JVM自动内存管理(一):Java内存区域与内存溢出异常
文章目录
(一)运行时数据区
-
线程共享: 方法区(JDK8含运行时常量池)、堆内存(JDK8含字符串常量池)
-
线程私有:栈内存(虚拟机栈、本地方法栈)、程序计数器PC
-
直接内存:与NIO有关
程序计数器PC
-
Native方法
定义:一个Native Method就是一个Java调用非Java代码的接口。方法的实现由非Java语言实现,比如C或C++。
通俗地讲:即定义一个类,其中他的部分方法由
native
关键字修饰,这个方法在Java中并不提供实现,而是采用本地库来实现,比如cpp编译的.dll
文件。使用:通过
javah -jni native类名
,可以生成c/cpp的.h
文件,再实现其中定义的c/cpp的方法即可。 -
PC的值:如果线程执行的是Java方法,那么PC的值为虚拟机字节码指令地址;如果执行的是Native方法,PC的值为空(Undefined)
虚拟机栈
- 生命周期:虚拟机栈与线程生命周期相同
- 栈帧:每个方法执行的时候JVM会同步创建一个栈帧(局部变量表、操作数栈、动态连接、方法出口等)
- 局部变量表:编译器可知的基本数据类型、对象引用、returnAddress类型(返回地址的类型)。这个表所需的内存空间在编译期间完成分配(只是确定了局部变量槽的数量,但是每个槽的大小由JVM决定)
- 异常:HotSpot虚拟机不允许栈容量动态扩展,在线程创建过多就会抛出(或者是允许动态扩展栈的虚拟机,扩展的太大)
OutOfMemoryError
异常;HotSpot虚拟机只会在线程请求栈深度大于虚拟机所允许的深度时(递归),抛出StackOverflowError
异常
本地方法栈
- 与虚拟机栈的区别:类似虚拟机栈,但该栈只为本地方法服务。
堆内存
在JDK7之后,字符串常量池从运行时常量池中分离,放到堆中。
String::intern
如果存在堆中的对象,会直接保存对象的引用,而不会重新创建对象,否则在字符串常量池创建对象。
以下代码分析基于JDK7以上
public static void main(String[] args) {
String s = new String("1");//字符串常量池由于class文件的加载,故有"1",堆区创建一个对象为"1",但s指向的是堆区的"1"而非常量池的"1"
s.intern();//尝试将"1"放入堆区中的字符串常量池,但此时已有,故返回字符串常量池中的引用
String s2 = "1";//默认先找字符串常量池有无"1",此时有则直接指向常量池中的"1"
System.out.println(s == s2);//false
String s3 = new String("1") + new String("1");//堆区新增两个"1"对象(共3个不在字符串常量池中的"1"),和一个"11"对象,但字符串常量池 没有 没有 没有 "11"!!!
String s5 = s3.intern();//查找常量池中是否有"11",此时没有,直接保存堆中的"11"的s3引用!!!到字符串常量池;
System.out.println(s3 == s5);//true,s3、s5均指向堆区的"11";
String s4 = "11";//默认先找字符串常量池有无"11",此时常量池直接是堆区的"11"引用,故s4指向的是s3
System.out.println(s3 == s4);//true
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-97jA2APO-1583917881163)(F:\BUPT\javalearning\字符串常量池.jpg)]
图中的双箭头表示引用变量实际指向的内存。
public static void main(String[] args) {
String s = new String("1");//字符串常量池在加载class的时候已经有一个"1",堆创建一个"1",s指向堆中的"1"
String s2 = "1";//s2指向字符串常量池中的"1"
s.intern();//查找字符串常量池中是否有"1",此时有则不创建
System.out.println(s == s2);//false
String s3 = new String("1") + new String("1");//堆中新增两个匿名"1",和一个"11",s3指向堆中的"11"
String s4 = "11";//字符串常量池新增一个"11"由s4指向
s3.intern();//查找字符串常量池中是否有"11",此时有则不创建
System.out.println(s3 == s4);//false
}
综上:
-
过程:加载class文件(含静态常量池);将静态常量池放入方法区中的运行时常量池;读取到某个字面量str,如果字符串常量池中没有对应的引用,则在堆中创建字面量str,字符串常量池保存str的引用,否则不动。
字符串常量池可以认为是通过堆来保存池中的字符串常量,它自身维护一个哈希表(保留引用)
-
能够向字符串常量池添加字符串常量只有两种方式:
- 懒加载字面量:运行时执行到具体的代码处扫描到字面量,才会向字符串常量池中添加对象。
String::intern()
,不再在堆中创建对象,直接保存对应的引用到字符串常量池中。
-
如果字符串常量池内已有,则直接返回引用,否则在堆中new一个,再返回引用。
方法区
-
理解:方法区存放了一些常量、静态变量、类信息等,可以理解成class文件在内存中的存放位置。
-
存储:已被虚拟机加载的**类型信息、常量、静态变量、即时编译器编译后的代码缓存**等数据
-
运行时常量池:
JDK1.7之前:运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
JDK1.7:字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
JDK1.8:hotspot移除了永久代用元空间(Metaspace)取而代之,这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace)
总之,运行时常量区是方法区的一部分,.class
文件中的常量池表存放有编译器生成的各种字面量与符号引用,这部分内容会在类加载后放入方法区运行时的常量池中。常量池无法申请到内存时,会抛出OutOfMemoryError
异常
- 字面量
int i = 1;把整数1赋值给int型变量i,整数1就是Java字面量,
String s = "abc";中的abc也是字面量。
-
符号引用
符号引用以一组符号来描述所引用的目标, 符号可以是任何形式的字面量, 只要使用时能够无歧义的定位到目标即可. 例如, 在Java中, 一个Java类将会编译成一个class文件. 在编译时, Java类并不知道所引用的类的实际地址, 因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类, 在编译时People类并不知道Language类的实际内存地址, 因此只能使用符号org.simple.Language来表示Language类的地址.
-
直接引用
程序运行时可以定位到引用的东西(类, 对象, 变量或者方法等)的地址.
直接内存(DM)
没错,就是DMA去掉Access的那个直接内存,在NIO类中,引入的Channel和Buffer的I/O方式,可以使用Native函数库直接分配JVM堆之外的内存,然后通过存储在Java堆里面的DirectByteBuffer
对象作为这块内存的引用进行操作,使得在Java堆和Native堆之间的数据交互更便捷。
(二)对象生命周期
对象创建
-
过程:
-
预先检查:JVM遇到
new
指令,会去检查常量池中是否有那个类的符号引用,并检查其对应类是否加载(确定对象所需的内存大小)、解析、初始化; -
为对象分配内存:
在堆内存中划分;
如果堆内存不连续(已用和空闲交错),就需要维护一个“空闲列表”,用于找到一个足够大的空间;
堆内存是否连续,取决于垃圾收集器是否能够进行“空间压缩整理”;
内存连续:Serial、ParNew垃圾收集器,指针碰撞算法:假设java堆中内存是绝对规整的,所有用过的内存放一边,未使用过的放一边,中间有一个指针作为临界点,如果新创建了一个对象则是把指针往未分配的内存挪动与对象内存大小相同距离,这个称为指针碰撞。
内存不连续:CMS垃圾收集器,维护一个“空闲列表”。
并发问题:
两种解决方式:
-
分配内存做同步处理:CAS配上失败重试的方式保证更新操作的原子性。
CAS:CAS,compare and swap的缩写,中文翻译成比较并交换。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。
-
每个线程先在Java堆预先分配一小块内存(本地线程分配缓冲TLAB),要分配内存就先在这个缓冲区中分配,用完时分配新的缓冲区需要同步锁定。虚拟机是否使用TLAB,可以通过
-XX:+/-UseTLAB
参数来设定。
-
-
初始化内存空间:全设为0,如果采用TLAB方式,这个工作和TLAB同时进行。
-
设置对象信息:元数据信息(该对象属于哪个类的实例、如何找到类)、哈希码(实际上是调用
Object::hashCode()
才计算),这些信息存储到Object Header
中 -
执行构造函数
-
对象内存布局
HotSpot中,对象在堆中可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐补充(Padding)
-
对象头:
包含两类信息:
-
对象运行时数据,如hashCode等,被称为Mark Word(可以动态变化),下图是其中一种mark word
-
类型指针:对象指向它类型元数据的指针(不一定需要此字段)
-
-
实例数据:
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
-
对齐补充:
这段不必要存在,起占位符的作用,因为内存对象的起始地址必须为XX字节的整数倍,这个用于对其地址。
对象访问定位
JVM会通过虚拟机栈上的本地变量表中的reference数据来查找堆上的对象,有两种主流的对象访问方式:
-
句柄:堆中可能会划分出一块内存作为句柄池(句柄可以是指针也可以不是指针,用于标识对象),reference存储的是对象的句柄地址,句柄包含有对象实例数据、对象类型数据的指针,这种方法的好处是,对象移动时(如GC)仅改变句柄。
-
直接指针:reference存储对象的地址,对象实例数据可能包含有对象类型数据的指针,这种方法好处是访问速度更快。(HotSpot采用此)
(三)对象内存溢出
堆溢出
-
堆溢出测试设置:设置eclipse的debug configuration/run configuration中的Arguments下的VM参数为
-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
其中
-XX:+PrintGC
与-verbose:gc
是一样的,可以认为-verbose:gc
是-XX:+PrintGC
的别名.运行如下代码:
import java.util.ArrayList; import java.util.List; public class HeapOOM { static class OOMObject { } public static void main(String[] args) { List<OOMObject> list = new ArrayList<OOMObject>(); while (true) { list.add(new OOMObject()); } } }
会出现该异常。
其中堆内存的情况为:
Heap PSYoungGen total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000) eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000) from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000) to space 1024K, 46% used [0x00000000fff00000,0x00000000fff75ff0,0x0000000100000000) ParOldGen total 10240K, used 7951K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000) object space 10240K, 77% used [0x00000000fec00000,0x00000000ff3c3c70,0x00000000ff600000) Metaspace used 2597K, capacity 4486K, committed 4864K, reserved 1056768K class space used 285K, capacity 386K, committed 512K, reserved 1048576K
-
JVM堆内存:
- Eden Space(伊甸园)、
- Survivor Space(幸存者区)、
- Old Gen(老年代)。
- eden:Eden 字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。
- survivor:To和From属于Survivor 幸存者区,用于保存在eden space内存区域中经过垃圾回收后没有被回收的对象。这个两个区域的空间大小是一样的。执行垃圾回收的时候Eden区域不能被回收的对象被放入到空的survivor(也就是To Survivor,同时Eden区域的内存会在垃圾回收的过程中全部释放),另一个survivor(即From Survivor)里不能被回收的对象也会被放入这个survivor(即To Survivor),然后To Survivor 和 From Survivor的标记会互换,始终保证一个survivor是空的。
- old:老年代,用于存放新生代中经过多次垃圾回收仍然存活的对象,也有可能是新生代分配不了内存的大对象会直接进入老年代。经过多次垃圾回收都没有被回收的对象,这些对象的年代已经足够old了,就会放入到老年代。当老年代被放满的之后,虚拟机会进行垃圾回收,称之为Major GC。由于Major GC除并发GC外均需对整个堆进行扫描和回收,因此又称为Full GC。
导致
OutOfMemory
异常有两种情况:- 内存泄漏:程序在申请内存后,无法释放已申请的内存空间,需要用工具查看引用链来排查。
- 内存溢出:程序在申请内存时,没有足够的内存空间供其使用,内存中的对象都是存活的,看看是否能够调整JVM堆参数来解决或者检查代码,如果是因为线程过多导致的,可以考虑减少线程数量、更换64位虚拟机、缩小单个线程所占内存这三种方案来解决。
栈溢出
虚拟机栈和本地方法栈
-
hotspot虚拟机不区分这两个栈
-
两种异常:线程请求深度大于虚拟机允许深度
StackOverflowError
和虚拟机允许动态扩展栈内存,但无法申请到足够内存时的OutOfMemoryError
。 -
HotSpot虚拟机产生
OutOfMemoryError
异常:创建线程时无法申请足够的内存导致。
方法区和运行时常量池溢出
- 常量池溢出:JDK7之前可以通过限制永久代来限制方法区的常量池,JDK7之后则不可以。
- 方法区溢出:因为方法区主要存放类型信息,可以通过产生大量的类来使方法区溢出。
本机直接内存(DM)溢出
可以通过-XX:MaxDirectMemorySize
参数来指定,默认值和Java堆最大值(-Xmx
)相同,这种异常往往在Heap Dump文件(堆转储文件)没什么明显的异常,往往是由于使用NIO导致的。