JVM 学习笔记(1):Java内存区域

2022-09-20 11:03:21 浏览数 (1)

1、运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域。--《深入理解Java虚拟机》

Java 内存布局

2、程序计数器

1)定义

程序计数器是一块较小的内存空间,可以看做是当前线程所执行的字节码行号指示器 ,也就是记录下 Java 程序当前指令的地址偏移量,可在线程切换时记录下当前线程执行的位置,给 CPU 提供指令地址,以便下一次切换回来找到继续执行的位置。

2)特点

① 线程私有的:它需要记录当前指令地址偏移量,在线程切换时如果不是每个线程都有一个计数器,将无法区分/辨认不同线程的指令执行地址;

② 随着线程的开始而产生,随着线程的结束而消亡:在线程开始执行到结束的区间,会失去 CPU 的执行权,因此必须在线程开始时就创建该计数器;

③ JVM 规范中唯一一个没有规范内存溢出 (OutOfMemoryError) 的区域:计数器记录的是一个地址量,那么在执行下一指令时改变的只是这个地址量,并不会新分配空间,因此永远不会内存溢出;

④ 当执行 Native 方法时该计数器值为 undefined :本地方法使用 C/C 编写的,由系统调用,不会产生字节码文件,也就无所谓地址偏移量;

3、Java 虚拟机栈

1)定义

①虚拟机栈描述的是 Java 方法执行的线程内存模型,一个线程对应着一个栈;

② 一个栈中有多个栈帧,一个栈帧对应着一个方法调用时所占用的内存,其中存储的是局部变量表、操作数栈、动态连接、方法出口等信息;

③ 每个线程只能有一个活动栈帧,也即是当前正在执行的方法;

2)特点

虚拟机的垃圾回收并不涉及栈空间:每个栈帧在方法调用结束后都会被弹出栈释放掉空间,因此无需使用到垃圾回收;

栈空间可以指定,但不是越大程序越快:物理内存一定的情况下,栈空间设置得越大,线程数量会越少,程序执行效率也会随之有所降低;

方法内的局部变量线程安全问题:每个线程都有自己的栈空间,局部变量存在栈的方法栈帧中,在不同线程中互不影响,但如果该局部变量是引用类型并且当做返回值返回出去【逃离方法作用域】,就需要考虑线程安全问题;

3)栈空间溢出(StackOverflowError):

导致原因:

栈帧过多导致溢出:方法递归调用但没有设置正确的结束条件时会造成死循环调用,直到栈空间耗尽;

单个栈帧过大导致溢出:方法内存在多个变量,导致栈帧分配空间较大,超过栈空间大小,这种情况较少发生;

4、本地方法栈

1)定义

本地方法使用 C/C 编写的(方法前有 native 修饰符),由系统调用,其使用的内存空间称为本地方法栈。

5、堆

1)定义

当我们使用 new 去创建对象时都会在堆空间中分配内存空间,堆空间是线程共享的,也即是不同线程可以共同访问,“几乎”所有的对象实例都在这里分配内存;并且堆中的内存有垃圾回收机制(GC),不再使用的对象所占用的内存空间会被回收掉,因此堆也被称为 GC堆。

2)堆内存分析工具:

① jps :查看当前运行的 Java 进程号-进程名信息;

② jmap :【jmap -heap 进程号】 使用该命令可以查看该进程的堆内存信息;

③ jconsole :图形化的监控程序,可以实时查看内存占用情况、CPU 占用率等情况;

④ jvisualvm :可视化的监控工具,除了可以检测内存占用等信息,还可以把堆信息dump 出来,方便查看占用较大空间的类/对象。

6、方法区

1)定义

方法区中存放的是与类相关的属性字段、类信息、类加载器等以及运行时常量池等信息,规范中它在逻辑上是堆内存的一部分,但是不同的厂商可以有不同实现。该区域内存占用过大也会触发 OOM 错误。

2)实现

在 HotSpot 1.6 中,方法区的实现为永久代,类信息、常量池、StringTable都在其中,所占用内存为虚拟机内存,因此很难直接确定下来其大小,每次Full GC 之后都会改变大小;

在 HotSpot 1.8 中方法区的实现为元空间,该区域空间为本地内存的一部分,不同的是 StringTable 被设置到堆内存中;

方法区位置

7、运行时常量池

1)定义

常量池可以看做是一张表,该部分内容包含在 Class 文件中,用于存放编译期生成的各种字面量与符号引用, 虚拟机指令会根据这张表去找到要执行的类名、方法名、参数类型、字面量等内容,用于后续指令执行。而在 Class 文件被加载时,常量池的内容就会载入运行时常量池,原先的符号地址也会转化为真实的地址。运行时常量池是方法区的一部分。

8、StringTable

1)定义

在HotSpot 1.6中,StringTable 位于永久代中,而 HotSpot 1.8 中,StringTable 位于堆空间中。当区域中对象数量过多并且内存容量紧张时,会触发垃圾回收机制。

2)特点

① 字符串池中的字符串仅仅是符号,在第一次引用时才会变成对象;

② 利用串池可以避免字符串对象被重复创建;

③ 在字符串常量与字符串常量的拼接中,底层会进行编译期优化;

④ 只要字符串拼接中设计到字符串变量,则底层使用的是 StringBuilder 进行【 JDK 1.8】;

3) 测试

① Test 1:

代码语言:javascript复制
String s1 = "a";
String s2 = "b";
String s3 = "ab";
// s4涉及到字符串变量拼接 底层使用StringBuilder拼接
// 完成后使用toString方法重新生成一个新对象
String s4 = s1   s2;
System.out.println(s4==s3); // false
// s6 同理 只要涉及变量拼接都会生成新对象
String s6 = "a"   s2;
System.out.println(s6==s3); // false
// 这里涉及的是两个常量字符串的拼接 
// 因此编译器会优化成 s5 = "ab" 可见与s3引用的是同个对象
String s5 = "a"   "b";
System.out.println(s5==s3); // true

② Test 2:

代码语言:javascript复制
// 该语句执行完毕之后堆中有三个对象 new String("a")对象、new String("b")对象以及拼接出来的new String("ab")对象
String ab = new String("a")   new String("b");

// 此时 StringTable中的对象为 [“a”,“b”]

// intern方法可以将变量ab对应的字符串对象存入StringTable中作为池的成员【入池】
// 入池成功则返回该对象,如果池中有“ab” 将加入失败,返回的是原来的池中的“ab”对象
String intern1 = ab.intern(); 

// 此时 StringTable中的对象为 [“a”,“b”,“ab”]

System.out.println(intern1 == "ab"); // true
System.out.println(ab == "ab"); // true

// 此时再新建一个字符串对象
String newStr = new String("ab");
// 此时入池会失败 那么返回的是原来池中的“ab”对象
String intern2 = newStr.intern();
System.out.println(intern2 == "ab"); // true
// 此时intern2接收到的是池中的对象 而newStr明显是一个新的对象
System.out.println(newStr == intern2); // false
System.out.println(intern2 == ab); //true
4)调优

① -XX:StringTableSize=大小 :该值代表 StringTable 的 buckets 数值,假设现在需要存入100w个字符串,buckets 的值为 2000 ,那么在桶内的链表长度就可能比较长,每次存入字符串进行判断时耗费的时间会比较多,自然效率就变低了。如果需要存入大量字符串的话,可以考虑适当增大该值;

9、直接内存

1)定义

直接内存不是虚拟机运行时数据区的一部分,也不是 JVM规范中定义的内存区域,而是在系统内存中,该区域内存可供 Java 程序和系统共同操作。

2)特点

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作。--《深入理解Java虚拟机》

常用于 NIO 操作时的数据缓冲区:正常 IO 读取文件时,磁盘文件需要先读入系统缓冲区,而 Java 程序只能读取 JVM 中的缓冲区内容,因此如果 Java 程序需要读取磁盘文件内容时需要先把系统缓冲区内容复制到 JVM 缓冲区,再进行读取。而 NIO 操作的缓冲区为 ByteBuffer ,这块区域分配的是直接内存,系统磁盘文件可以直接写入其中,Java 程序也可以直接从这块区域中读取,减少了一次中间的复制操作;

正常IO读取

分配直接内存

分配和回收的成本较高,但效率也高:直接内存分配大小不受 JVM 堆大小限制,但会受到主机内存的限制。直接内存使用 Unsafe 类的 native 方法 allocateMemory 进行分配,相比于申请 JVM 中的内存空间,这种方式成本会较高;

不受 JVM 的内存回收管理:当我们使用 System.gc() 主动回收掉 ByteBuffer 时,会发现直接内存也被回收掉了,但这实际上不是 Java虚拟机操作的,而是 DirectByteBuffer 中在分配完内存之后会创建一个 Cleaner 虚引用对象,当检测到 ByteBuffer 对象被回收掉时会执行 Deallocator 的 run 方法,执行直接内存的回收;

添加参数 -XX: DisableExplicitGC 可以关闭 System.gc() 触发的垃圾回收,但同时直接内存也不会触发内存释放,因此如果开启此功能后对于直接内存的垃圾回收可以手动调用上面示例的方法进行。否则只能等老年代满了后 Full GC,然后“顺便”帮它清理掉内存的废弃对象。

参考资料:

[1] 《深入理解Java虚拟机(第三版)》——周志明: https://book.douban.com/subject/34907497/ [2] 黑马 JVM: https://www.bilibili.com/video/BV1yE411Z7AP [3] 深入理解Java内存模型(一)——基础-InfoQ: https://www.infoq.cn/article/java-memory-model-1

0 人点赞