JVM入门之内存结构(堆、方法区)
目录
- 1、堆
- 1.1 定义
- 1.2 堆的作用
- 1.3 特点
- 1.4 堆内存溢出
- 1.5 堆内存诊断
- 2、方法区
- 2.1 结构(1.6 对比 1.8)
- 2.2 内存溢出
- 2.3 常量池
- 2.4 运行时常量池
- 2.5 常量池与串池的关系
- 2.6 StringTable的位置
- 2.7 StringTable 垃圾回收
- 2.8 方法区的垃圾回收
- 3、直接内存
- 释放原理
1、堆
1.1 定义
- 是Java内存区域中一块用来存放对象实例的区域【
几乎所有的对象实例都在这里分配内存
】 - 通过
new
关键字创建的对象都会被放在堆内存,jvm 运行时数据区中,占用内存最大的就是堆(Heap)内存!
1.2 堆的作用
- 此内存区域的唯一目的就是存放对象实例
- 方法体中的引用变量和基本类型的变量都在栈上,其他都在堆上
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最大的一块 Java
堆是被所有线程
共享的一块内存区域
1.3 特点
- 所有线程共享,堆内存中的对象都需要考虑线程安全问题
- 有垃圾回收机制,Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”(Garbage)
- Java堆可以分成新生代和老年代 新生代可分为To Space、From Space、Eden
-Xmx -Xms
:JVM初始分配的堆内存由-Xms指定,默认是物理内存的1/64
1.4 堆内存溢出
java.lang.OutofMemoryError :java heap space.
堆内存溢出。
内存溢出案例:
/** * 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space * -Xmx8m 最大堆空间的jvm虚拟机参数,默认是4g */ public class Demo05 { public static void main(String[] args) { int i = 0; try { List<String> list = new ArrayList<>();// new 一个list 存入堆中-------- list的有效范围 --------- String a = "hello"; while (true) {// 不断地向list 中添加 a list.add(a); // hello, hellohello, hellohellohellohello ... a = a + a; // hellohellohellohello i++; }//------------------------------------------------------------------ list的有效范围 --------- } catch (Throwable e) {// list 使用结束,被jc 垃圾回收 e.printStackTrace(); System.out.println(i); } } }
异常输出结果:
// 给list分配堆内存后,while(true)不断向其中添加a 最终堆内存溢出 java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3332) at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124) at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448) at java.lang.StringBuilder.append(StringBuilder.java:136) at com.haust.jvm_study.demo.Demo05.main(Demo05.java:19)
1.5 堆内存诊断
用于堆内存诊断的工具:
- jps
- jmap
- jconsole
- jvirsalvm
2、方法区
方法区概述:
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样都可以是不连续的, 关闭Jvm就会释放这个区域的内存。
- 方法区逻辑上是堆的一个组成部分,但是在不同版本的虚拟机里实现是不一样的,最典型的就是永久代(PermGen space)和元空间(Metaspace)
- (注意:方法区时一种规范,而永久代和元空间是它的一种实现方式)
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:(
java.lang.OutOfMemoryError:PermGen space、java.lang.OutOfMemoryError:Metaspace)。
- 方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
- 类型信息:( 类class、接口interface、枚举enum、注解annotation)JVM必须在方法区中存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java. lang.Object,都没有父类)
- 这个类型的修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
- 域信息(成员变量):
- JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
- 域的相关信息包括:域名称、 域类型、域修饰符(public, private, protected, static, final, volatile, transient的某个子集)。
- 方法信息:JVM必须保存所有方法的以下信息,同域信息一样包括声明顺序
- 方法名称
- 方法的返回类型(或void)
- 方法参数的数量和类型(按顺序)
- 方法的修饰符(public, private, protected, static, final,synchronized, native , abstract的一个子集)
- 方法的字节码(bytecodes)、操作数栈、局部变量表及大小( abstract和native 方法除外)异常表( abstract和native方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
2.1 结构(1.6 对比 1.8)
由上图可以看出,1.6版本方法区是由PermGen永久代实现(使用堆内存的一部分作为方法区),且由JVM 管理,由Class ClassLoader 常量池(包括StringTable) 组成。
1.8 版本后,方法区交给本地内存管理,而脱离了JVM,由元空间实现(元空间不再使用堆的内存,而是使用本地内存,即操作系统的内存),由Class ClassLoader 常量池(StringTable 被移到了Heap 堆中管理) 组成。
- 方法区是什么?
- 是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(比如class文件)、常量、静态变量、即时编译器编译后的代码等数据。
- 什么是类信息:类版本号、方法、接口。
- 方法区作用
- 内存中存放类信息、静态变量、常量等数据,属于线程共享的一块区域。
- Hotspot使用永久代来实现方法区 JRockit、IBM J9VM Java堆一样管理这部分内存。
- 方法区特点
- 并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
- 方法区也会抛出OutofMemoryError,当它无法满足内存分配需求时 。
方法区的演进:
面试常问
- Jdk 1.6 及之前:有永久代(静态变量存放在永久代上)、字符串常量池(1.6在方法区)
- Jdk 1.7 :有永久代,但已经逐步 " 去永久代 ",字符串常量池、静态变量移除,保存在堆中
- dk 1.8 及之后: 无永久代,常量池1.8在元空间。但静态变量、字符串常量池仍在堆中
为什么要用元空间取代永久代?
永久代设置空间大小很难确定:(
①. 永久代参数设置过小,在某些场景下,如果动态加载的类过多,容易产生Perm区的OOM,比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误
②. 永久代参数设置过大,导致空间浪费
③. 默认情况下,元空间的大小受本地内存限制)
永久代进行调优很困难:(方法区的垃圾收集主要回收两部分:常量池中废弃的常量和不再使用的类型,而不再使用的类或类的加载器回收比较复杂,full gc 的时间长)
StringTable为什么要调整?
- jdk7中将StringTable放到了堆空间中。因为永久代的回收效率很低,在full gc的时候才能触发。而full gc是老年代的空间不足、永久代不足才会触发。
- 这就导致StringTable回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足,放到堆里,能及时回收内存。
设置方法区大小
jdk7及以前:
-XX:PermSize=100m
(默认值是20.75M)-XX:MaxPermSize=100m
(32位机器默认是64M,64位机器模式是82M)
jdk1.8及以后:
-XX:MetaspaceSize=100m
(windows下,默认约等于21M)- -
XX:MaxMetaspaceSize=100m
(默认是-1,即没有限制)
2.2 内存溢出
1.8以前会导致永久代
内存溢出
1.8以后会导致元空间
内存溢出
案例
调整虚拟机参数:-XX:MaxMetaspaceSize=8m
/** * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace * -XX:MaxMetaspaceSize=8m */ public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码 public static void main(String[] args) { int j = 0; try { Demo1_8 test = new Demo1_8(); for (int i = 0; i < 10000; i++, j++) { // ClassWriter 作用是生成类的二进制字节码 ClassWriter cw = new ClassWriter(0); // 版本号, public, 类名, 包名, 父类, 接口 cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null); // 返回 byte[] byte[] code = cw.toByteArray(); // 执行了类的加载 test.defineClass("Class" + i, code, 0, code.length); // Class 对象 } } finally { System.out.println(j); } } }
3331 Exception in thread "main" java.lang.OutOfMemoryError: Compressed class space // 元空间内存溢出 at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClass(ClassLoader.java:763) at java.lang.ClassLoader.defineClass(ClassLoader.java:642) at com.haust.jvm_study.metaspace.Demo1_8.main(Demo1_8.java:23)
2.3 常量池
常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型、字面量等信息
类的二进制字节码的组成:类的基本信息、常量池、类的方法定义(包含了虚拟机指令)。
通过反编译来查看类的信息:
- 获得对应类的
.class
文件 - 在JDK对应的bin目录下运行cmd,也可以在IDEA控制台输入
- 输入 javac 对应类的绝对路径
F:\JAVA\JDK8.0\bin>javac F:\Thread_study\src\com\nyima\JVM\day01\Main.javaCopy
输入完成后,对应的目录下就会出现类的.class
文件
在控制台输入javap -v 类的绝对路径
javap -v F:\Thread_study\src\com\nyima\JVM\day01\Main.classCopy
然后能在控制台看到反编译以后类的信息了
- 类的基本信息
- 常量池
- 虚拟机中执行编译的方法(框内的是真正编译执行的内容,#号的内容需要在常量池中查找)
2.4 运行时常量池
- 常量池
- 就是一张表(如上图中的constant pool),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息
- 运行时常量池
- 常量池是*.class文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实内存地址
- 行时常量池( Runtime Constant Pool)是方法区的一部分。
- 常量池表(Constant Pool Table)是Class文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- **(方法区内常量池之中主要存放的两大类常量:字面量和符号引用。**字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
java 8 后,永久代已经被移除,被称为“元数据区”的区域所取代。类的元数据放入native memory, 字符串池和类的静态变量放入java堆中(静态变量之前是放在方法区)。
2.5 常量池与串池的关系
串池StringTable
- 常量池是.class文件,存放堆中数据的引用地址,而不是真实的对象,运行时常量池是jvm运行时将常量池中数据放入池中,此时引用地址真正的指向对象而不是.class文件;Stringtable是哈希表(不能扩容),它也叫做串池,用来存储字符串,这3个不是同一个东西,我们需要进行区分。
- StringTable中存储的并不是String类型的对象,存储的而是指向String对象的索引,真实对象还是存储在堆中
- jdk1.6中,StringTable是放在永久代(方法区)中,jvm进行FullGC才会对常量池进行垃圾回收,影响效率,因此在jdk1.8中将StringTable放在堆中,jvm内存紧张时就会对StringTable进行垃圾回收。
特征
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
注意:无论是串池还是堆里面的字符串,都是对象
串池作用:用来放字符串对象且里面的元素不重复
public class StringTableStudy { public static void main(String[] args) { String a = "a"; String b = "b"; String ab = "ab"; } }
常量池中的信息,都会被加载到运行时常量池中,但这是a b ab 仅是常量池中的符号,还没有成为java字符串
0: ldc #2 // String a 2: astore_1 3: ldc #3 // String b 5: astore_2 6: ldc #4 // String ab 8: astore_3 9: returnCopy
当执行到 ldc #2
时,会把符号 a 变为“a”
字符串对象,并放入串池中(hashtable结构 不可扩容)
当执行到ldc #3
时,会把符号 b 变为“b”
字符串对象,并放入串池中。
当执行到ldc #4
时,会把符号 ab 变为“ab”
字符串对象,并放入串池中。
最终串池中存放:StringTable [“a”, “b”, “ab”
注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2
)时,该字符串才会被创建并放入串池中。
案例1:使用拼接字符串变量对象创建字符串的过程:
public class StringTableStudy { public static void main(String[] args) { String a = "a"; String b = "b"; String ab = "ab"; // 拼接字符串对象来创建新的字符串 String ab2 = a+b; // StringBuilder().append(“a”).append(“b”).toString() } }
反编译后的结果:
Code: stack=2, locals=5, args_size=1 0: ldc #2 // String a 2: astore_1 3: ldc #3 // String b 5: astore_2 6: ldc #4 // String ab 8: astore_3 9: new #5 // class java/lang/StringBuilder 12: dup 13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V 16: aload_1 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String ;)Ljava/lang/StringBuilder; 20: aload_2 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String ;)Ljava/lang/StringBuilder; 24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str ing; 27: astore 4 29: return
通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()
最后的toString()
方法的返回值是一个新的字符串对象,但字符串的值和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存之中
String ab = "ab";// 串池之中 String ab2 = a+b;// 堆内存之中 // 结果为false,因为ab是存在于串池之中,ab2是由StringBuffer的toString方法所返回的一个对象,存在于堆内存之中 System.out.println(ab == ab2);
案例2:使用拼接字符串常量对象的方法创建字符串
public class StringTableStudy { public static void main(String[] args) { String a = "a"; String b = "b"; String ab = "ab"; // 拼接字符串对象来创建新的字符串 String ab2 = a+b;// StringBuilder().append(“a”).append(“b”).toString() // 使用拼接字符串的方法创建字符串 String ab3 = "a" + "b";// String ab (javac 在编译期进行了优化) } }
反编译后的结果:
Code: stack=2, locals=6, args_size=1 0: ldc #2 // String a 2: astore_1 3: ldc #3 // String b 5: astore_2 6: ldc #4 // String ab 8: astore_3 9: new #5 // class java/lang/StringBuilder 12: dup 13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V 16: aload_1 17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String ;)Ljava/lang/StringBuilder; 20: aload_2 21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String ;)Ljava/lang/StringBuilder; 24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/Str ing; 27: astore 4 // ab3初始化时直接从串池中获取字符串 29: ldc #4 // String ab 31: astore 5 33: return
- 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了
“ab”,
所以ab3直接从串池中获取值,所以进行的操作和ab = “ab”
一致。 - 使用
拼接字符串变量
的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuffer来创建
JDK1.8 中的intern方法
调用字符串对象的intern
方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,则放入成功.
- 如果有该字符串对象,则放入失败.
无论放入是否成功,都会返回串池中的字符串对象.
注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象!
例1
public class Main { public static void main(String[] args) { // "a" "b" 被放入串池中,str则存在于堆内存之中 String str = new String("a") + new String("b"); // 调用str的intern方法,这时串池中如果没有"ab",则会将该字符串对象放入到串池中,放入成功~此时堆内存与串池中的"ab"是同一个对象 String st2 = str.intern(); // 给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回 String str3 = "ab"; // 因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true System.out.println(str == st2);// true System.out.println(str == str3);// true } }
例2
public class Main { public static void main(String[] args) { // 此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中 String str3 = "ab"; // "a" "b" 被放入串池中,str则存在于堆内存之中 String str = new String("a") + new String("b"); // 此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回**串池**中的"ab" String str2 = str.intern(); System.out.println(str == str2);// false System.out.println(str == str3);// false System.out.println(str2 == str3);// true } }
JDK1.6 中的intern
方法
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中如果有该字符串对象,则放入失败
无论放入是否成功,都会返回串池中的字符串对象
注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象
2.6 StringTable的位置
如图:
- JDK1.6 时,StringTable是属于常量池的一部分。
- JDK1.8 以后,StringTable是放在堆中的。
2.7 StringTable 垃圾回收
StringTable在内存紧张时,会发生垃圾回收。
2.8 方法区的垃圾回收
(1).有些人认为方法区(如Hotspot,虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java 虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 ZGC 收集器就不支持类卸载)
(2). 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 Hotspot 虚拟机对此区域未完全回收而导致内存泄漏。
- 法区的垃圾收集主要回收两部分内容:
常量池中废奔的常量和不再使用的类型
- 先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。 字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
- HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。回收废弃常量与回收Java堆中的对象非常类似。
- 判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法 - Java虛拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了一Xnoclassgc 参数进行控制,还可以使用一verbose:class以及一XX: +TraceClass一Loading、一XX:+TraceClassUnLoading查看类加载和卸载信息。
- 在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及oSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
3、直接内存
- 属于操作系统,常见于NIO操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理 文件读写流程
使用了DirectBuffer
直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而提高了效率。
释放原理
直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过unsafe.freeMemory来手动释放。
//通过ByteBuffer申请1M的直接内存 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);
申请直接内存,但JVM并不能回收直接内存中的内容,它是如何实现回收的呢?
allocateDirect的实现:
public static ByteBuffer allocateDirect(int capacity) { return new DirectByteBuffer(capacity); }Copy
DirectByteBuffer类:
DirectByteBuffer(int cap) { // package-private super(-1, 0, cap, cap); boolean pa = VM.isDirectMemoryPageAligned(); int ps = Bits.pageSize(); long size = Math.max(1L, (long)cap + (pa ? ps : 0)); Bits.reserveMemory(size, cap); long base = 0; try { base = unsafe.allocateMemory(size); //申请内存 } catch (OutOfMemoryError x) { Bits.unreserveMemory(size, cap); throw x; } unsafe.setMemory(base, size, (byte) 0); if (pa && (base % ps != 0)) { // Round up to page boundary address = base + ps - (base & (ps - 1)); } else { address = base; } cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象 att = null; }
这里调用了一个Cleaner的create方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是DirectByteBuffer)被回收以后,就会调用Cleaner的clean方法,来清除直接内存中占用的内存。
public void clean() { if (remove(this)) { try { this.thunk.run(); //调用run方法 } catch (final Throwable var2) { AccessController.doPrivileged(new PrivilegedAction<Void>() { public Void run() { if (System.err != null) { (new Error("Cleaner terminated abnormally", var2)).printStackTrace(); } System.exit(1); return null; } }); }
对应对象的run方法:
public void run() { if (address == 0) { // Paranoia return; } unsafe.freeMemory(address); //释放直接内存中占用的内存 address = 0; Bits.unreserveMemory(size, capacity); }
直接内存的回收机制总结
- 使用了Unsafe类来完成直接内存的分配回收,回收需要主动调用freeMemory方法。
- ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer。一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存。ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存。
希望大家可以多多关注我们的其他文章!