栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

学习笔记【Java 虚拟机快速入门①】内存结构

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

学习笔记【Java 虚拟机快速入门①】内存结构


若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。


总目录
  • 学习笔记【Java 虚拟机快速入门①】内存结构

前言
  • 视频链接:https://www.bilibili.com/video/BV1yE411Z7AP
  • 配套资料:https://pan.baidu.com/s/167mob33EfeD-on6dWrEWGQ( 提取码:bqob)
  • 参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著
  • 参考博客:
    • 看了这篇文章,我搞懂了 StringTable
    • JVM 内存结构 超详细学习笔记(一)

写这篇博客旨在制作笔记,方便个人在线阅览,巩固知识。

博客的内容主要来自视频内容和资料中提供的学习笔记。


引言

JVM 是 Java 程序的运行环境,学习 JVM 方能了解 Java 程序是如何被执行的,为进一步深入底层原理乃至程序性能调优打好基础。


(1)什么是 JVM

定义

  • Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)

好处

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收功能
  • 数组下标越界检查
  • 多态

比较

  • jvm(Java Virtual Machine)、jre(Java Runtime Environment)、jdk(Java Development Kit)


(2)学习 JVM 有什么用
  • 提高面试的竞争力
  • 理解底层的实现原理(有利于长远发展)
  • 中高级程序员的必备技能

(3)常见的 JVM


(一)内存结构
1.整体架构


参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

JAVA 虚拟机 | 运行时数据区域

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


2.程序计数器

Program Counter Register 程序计数器(寄存器)

  • 作用:记住下一条 jvm 指令的执行地址
  • 特点:是线程私有的;不会存在内存溢出

0: getstatic #20 			// PrintStream out = System.out;
3: astore_1					// --
4: aload_1 					// out.println(1);
5: iconst_1 				// --
6: invokevirtual #26 		// --
9: aload_1 					// out.println(2);
10: iconst_2 				// --
11: invokevirtual #26 		// --
14: aload_1 				// out.println(3);
15: iconst_3 				// --
16: invokevirtual #26	 	// --
19: aload_1 				// out.println(4);
20: iconst_4 				// --
21: invokevirtual #26 		// --
24: aload_1 				// out.println(5);
25: iconst_5 				// --
26: invokevirtual #26 		// --
29: return

一段 Java 代码的执行流程:Java 代码 —> 二进制字节码 —> 解释器 —> 机器码 —> cpu(执行具体操作)


参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

Program Counter Register 程序计数器

  • 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
    • 在 JAVA 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,
    • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 由于 JAVA 虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。
    • 在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
    • 因此,为了线程切换后可以恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
    • 各条线程之间计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。
  • 如果线程正在执行的是一个 JAVA 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。
  • 如果正在执行的是本地(Native)方法,这个计数器则应为空(Undefined)。
  • 此内存区域是唯一一个在《JAVA 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域。

3.虚拟机栈

栈:线程运行需要的内存空间。

一个线程需要一个虚拟机栈,多个线程就需要多个虚拟机栈。


每个栈是由什么组成的?

  • 一个栈内是可以看作由多个栈帧组成。

栈帧又是什么?

  • 一个栈帧对应一次方法的调用
  • 线程最终的目的是去执行代码,代码是由一个个的方法组成。
    所以在线程在运行的时候,每个方法运行时所需要的内存我们就称为栈帧。
  • 一个方法包括参数,局部变量,返回地址值,所以栈帧中也会存储这些数据。

3.1.定义

Java 虚拟机栈 (Java Virtual Machine Stacks)

  • 每个线程运行时所需要的总内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
public class Demo1_1 {
    public static void main(String[] args) {
        method1();
    }

    private static void method1() {
        method2(1, 2);
    }

    private static int method2(int a, int b) {
        int c = a + b;
        return c;
    }
}

于主方法中调用 method() 方法处打个断点,进行 debug,观察控制台输出的信息变化。

发现符合栈 后进先出(Last In First Out,LIFO) 的特点。


3.2.问题辨析
  1. 垃圾回收是否涉及栈内存?
    • 不会。
    • 栈内存是一次次的方法调用所产生的一个个栈帧内存。方法调用结束后,栈帧内存会被弹出栈,即被自动回收掉。
  2. 栈内存分配越大越好吗?
    • 不是。
    • 物理内存一定时,栈内存越大,虽然可以进行更多次的方法递归调用,但是可执行的线程数目会变少。
  3. 方法内的局部变量是否线程安全?
    • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

3.3.栈内存溢出
  • 栈帧过多导致栈内存溢出
  • 栈帧过大导致栈内存溢出

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

在 《JAVA 虚拟机规范》中,对这个内存区域规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflow
  • 如果 JAVA 虚拟机栈容量可以多态扩展,当栈扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常

3.4.线程运行诊断
3.4.1.案例1:CPU 占用过多

定位

  • 用 top 定位哪个进程对 cpu 的占用过高
  • ps H -eo pid,tid,%cpu | grep 进程id(用 ps 命令进一步定位是哪个线程引起的 cpu 占用过高)
  • jstack 进程id(其输出的 线程编号 为 16 进制)
    • 可以根据 线程 id 找到有问题的线程,进一步定位到问题代码的源码行号

3.4.2.案例2:程序运行很长时间没有结果

推测:可能是线程此时发生了死锁

使用 jstack 进程id 观察其最后的输出(详细地分析了代码异常)

之后再根据其输出的信息进一步定位,从而找到问题代码的源码行号


4.本地方法栈

本地方法栈(Native Method Stacks)

JAVA 有时候没法直接和操作系统底层交互,需要用到用 C 或者 C++ 语言编写的本地方法。

本地方法运行时使用的内存即为本地方法栈。

一些带有 native 关键字的方法就是用 用 C 或者 C++ 语言来编写的,Java 代码则是通过本地方法接口来间接调用。

诸如:object 中的 clone 方法,wait 方法,synchonized 关键字;


参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

本地方法栈与虚拟机栈所发挥的作用是非常相似的。

  • 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务
  • 本地方法栈则为虚拟机栈使用到本地(Native)方法服务。

《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构没有任何强制规定。

  • 因此具体的虚拟机可以根据需要自由实现它。
  • 甚至有的 JAVA 虚拟机(譬如 HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常。


5.堆
5.1.定义

堆(Heap)

  • 通过 new 关键字,创建对象都会使用堆内存

特点

  • 它是线程 共享 的,堆中对象都需要考虑线程安全的问题
  • 有垃圾回收机制

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。

Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。

此内存区域的唯一目的就是存放对象实例,Java 世界里 “几乎” 所有的对象实例都在这里分配内存。

由于即时编译技术的进步,说 Java 对象实例都分配在堆上也渐渐变得不是那么绝对了。

Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作 “GC 堆”(Garbage Collected Heap)。

根据《Java虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中。

但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。

但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。


5.2.堆内存溢出


控制台输出报错信息

java.lang.OutOfMemoryError: Java heap space
... ...

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

JAVA 堆既可以被实现成固定大小的,也可以是可拓展的。

不过当前主流的 JAVA 虚拟机都是按照可扩展来实现的(通过参数 -Xmx 和 Xms 设定)。

如果在 JAVA 堆中没有内存完成实例分配,并且堆也无法再扩展时,JAVA 虚拟机将会抛出 OutOfMemoryError 异常。


5.3.堆内存诊断

jps 工具

  • 查看当前系统中有哪些 java 进程

jmap 工具

  • 查看堆内存占用情况:jmap -heap 进程id

jconsole 工具

  • 图形界面的,多功能的监测工具,可以连续监测

5.4.案例:垃圾回收后内存占用依然高

使用 jvisualvm 工具里面的 堆 Dump 功能

堆 Dump 功能界面有 检查 功能,抓取堆的当前快照,从而进一步定位分析

在其中可以仔细查看实例的情况,可以设置按内存大小次序排序实例的数目

找到占用最大内存的实例,并进一步结合源码定位分析


6.方法区
6.1.定义

JVM 规范-方法区定义


参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。

  • 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
  • 虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分。
  • 但是它却有一个别名叫作 “非堆”(Non-Heap),目的是与 Java 堆区分开来。

《Java虚拟机规范》对方法区的约束是非常宽松的。

  • 除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。
  • 相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久” 存在了。
  • 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意。
    • 尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。
  • 以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
  • 根据《Java 虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。

6.2.永久代

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

说到方法区,不得不提一下 “永久代” 这个概念。


  • 尤其是在 JDK 8 以前,许多 Java 程序员都习惯在 HotSpot 虚拟机上开发、部署程序。
  • 很多人都更愿意把方法区称呼为 “永久代”(Permanent Generation),或将两者混为一谈。
  • 本质上这两者并不是等价的。
    • 仅仅是因为当时的 HotSpot 虚拟机设计团队选择把收集器的分代设计扩展至方法区。
      • 或者说使用永久代来实现方法区而已。
    • 这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存。
      • 省去了专门为方法区编写内存管理代码的工作。

  • 但是对于其他虚拟机实现,譬如 BEA JRockit、IBM J9 等来说,是不存在永久代的概念的。

  • 原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。
  • 但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意。
    • 这种设计导致了 Java 应用更容易遇到内存溢出的问题
      • 永久代有 -XX:MaxPermSize 的上限,即使不设置也有默认大小。
      • 而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,就不会出问题。
        • 例如 32 位系统中的 4GB 限制
    • 而且有极少数方法会因永久代的原因而导致不同虚拟机下有不同的表现。
      • 例如 String::intern()

  • 当 Oracle 收购 BEA 获得了 JRockit 的所有权后。
  • 准备把 JRockit 中的优秀功能移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。
    • 譬如 Java Mission Control 管理工具
  • 考虑到 HotSpot 未来的发展
  • 在 JDK 6 的时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了。
  • 到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出。
  • 而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替。
    • 把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。


6.3.方法区内存溢出

jdk 1.8 以前会导致永久代内存溢出

  • 永久代内存溢出:java.lang.OutOfMemoryError: PermGen space
  • -XX:MaxPermSize=8m

jdk 1.8 之后会导致元空间内存溢出

  • 元空间内存溢出:java.lang.OutOfMemoryError: Metaspace
  • -XX:MaxMetaspaceSize=8m

6.3.1.案例演示
import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

public class Demo extends ClassLoader {// ClassLoader可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_11 test = new Demo1_11();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                
                // 参数:版本号, 类的权限修饰符, 类名, 包名, 父类, 接口
                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);
        }
    }
}

输出(jdk 1.6 版本)

cMethodAreaTests/permGenSpace/Demo1_12.java

19315
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClassCond(ClassLoader.java:631)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:615)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:465)
	at cMethodAreaTests.permGenSpace.Demo1_12.main(Demo1_12.java from InputFileObject:24)

Process finished with exit code 1

输出(jdk 1.8 版本)

cMethodAreaTests/metaSpace/Demo1_11.java

5411
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.ClassLoader.defineClass1(Native Method)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:642)
	at cMethodAreaTests.metaSpace.Demo1_11.main(Demo1_11.java:31)

Process finished with exit code 1

6.3.2.场景

举例 1:spring

  • 使用 cglib 生成一些代理类,这些代理类是 Spring 中 AOP 的核心

举例 2:mybatis

  • 使用 cglib 去产生 Mapper 接口的实现类

补充介绍:cglib(Code Generation Library)

  • cglib 是一个强大的高性能的代码生成包。
  • cglib 包的底层是通过使用一个小而快的字节码处理框架 ASM,来转换字节码并生成新的类。

若调用次数十分频繁就有可能导致永久代方法区内存溢出


6.4.运行时常量池
6.4.1.了解字节码文件

*.Class 文件的内容中

  • 除了有类的版本、字段、方法、接口等描述信息外
  • 还有一项信息是 常量池表(Constant Pool Table)
    • 用于存放编译期生成的各种字面量与符号引用
    • 这部分内容将在类加载后存放到方法区的运行时常量池中

Java 虚拟机对于 Class 文件每一部分(自然也包括常量池)的格式都有严格规定

  • 如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行

创建一个文件:HelloWorld.java

// 二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

在该 HelloWorld.java 文件所在目录下,打开终端执行下面的命令。

  1. 编译文件
javac HelloWorld.java

使用上面的代码编译文件的话,控制台的内容输出缺少 LocalVariableTable

所以还是老老实实用 idea 等 IDE 工具来运行代码吧

  1. 显示 HelloWorld.class 字节码文件的详细信息
javap -v HelloWorld.class

在控制台输出信息中可以找到 Constant Pool,其中有字节码的具体信息


6.4.2.定义
  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
  • 运行时常量池(Runtime Constant Pool) 是方法区的一部分。
    • 常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求。

  • 不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。
  • 不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性

  • Java 语言并不要求常量一定只有编译期才能产生
    • 也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池
    • 运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制

  • 当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

6.5.StringTable

StringTable(字符串常量池),又称 String Pool:用于存放字符串常量


6.5.1.问题

代码 1(问和答都在下方代码块中)

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";// "ab"

String s4 = s1 + s2;// new String("ab")

String s5 = "ab";

String s6 = s4.intern();

System.out.println(s3 == s4);// 答:flase
System.out.println(s3 == s5);// 答:true
System.out.println(s3 == s6);// 答:true

代码 2(问和答都在下方代码块中)

预设:环境可以是 java 1.8 ,也可以是 java 1.6

String x2 = new String("c") + new String("d");// new String("cd")

String x1 = "cd";// "cd"
x2.intern();

System.out.println(x1 == x2);// 答:false

问:如果调换上方代码中最后两行的位置呢(环境是 java 1.8 )?具体情况如下

String x2 = new String("c") + new String("d");// new String("cd")

x2.intern();
String x1 = "cd";


System.out.println(x1 == x2);// 答:true

问:如果调换上方代码中最后两行的位置呢(环境是 java 1.6 )?具体情况如下

String x2 = new String("c") + new String("d");// new String("cd")

x2.intern();// "cd" 副本
String x1 = "cd";


System.out.println(x1 == x2);// 答:false

6.5.2.常量池与串池的关系

在类加载后,常量池的内容会存放到方法去的运行时常量池中,此时 a、b、ab 都是常量池中的符号,还没有变为 java 字符串对象。

执行到引用它们的代码,即具体的某一行(如 String s2 = "b")时,会把常量池中的符号变为 java 字符串对象

从字节码角度来看的话,就是当指令执行到 ldc #2 这条指令的时候才会把 a 符号变为 “a” 字符串对象


在 String 对象创建完成后(即程序运行到具体的某一行时),虚拟机会开启一个空的空间 StringTable

  • 它的底层数据结构是 HashTable
  • 每个元素都是 key-value 结构
  • 采用了 数组+单向链表 的实现方式
  • 里面存储的是字符串对象的地址引用值,该引用是指向堆中对应的对象

在 String 对象创建完成后(即程序运行到具体的某一行时),会去 StringTable 中查找相应的对象。

  • 如果没有一个值相同的 key-value,就会把新创建的 String 对象放入 StringTable 中,再把地址给新建的变量
  • 如果有一个值相同的 key-value,就直接从 StringTable 中取出来用,即直接返回对应地址给新建的变量

6.5.3.StringTable 字符串拼接

常量拼接

String s1 = "a";
String s2 = "b";
String ss = "ab";

String s3 = "a" + "b";

此处是 javac 在编译期间的优化,因为都是 “a”、“b” 常量,其结果已经在编译期确定为 “ab”

此时 s3 就已经在 StringTable 中了。


变量拼接

String s1 = "a";
String s2 = "b";

String s4 = s1 + s2;

此处可以理解为 new StringBuilder().append("a").append("b").toString()

其中 StringBuilder 类中的 toString() 方法如下

@Override
public String toString() {
    // Create a copy, don't share the array
    return new String(value, 0, count);
}

所以我们可以认为这里的字符串变量的拼接,就是新建了一个值为 “ab” 的 String 对象。

基本可以认为堆内存就是用来存放对象实例的。


s3 是常量拼接出的字符串,在编译期即确定并存储在 StringTable 中(其实 StringTable 也是在堆中的)

而 s4 是在堆中新创建的一个字符串对象(是俩变量动态拼接出来的字符串),是在堆内存中的

所以说 s3 和 s4 俩地址是不同的

String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";

String s4 = s1 + s2;
String s5 = "ab";
System.out.println(System.identityHashCode(s1));//1836019240

System.out.println(System.identityHashCode(s2));//325040804

System.out.println(System.identityHashCode(s3));//1173230247

System.out.println(System.identityHashCode(s4));//856419764

System.out.println(System.identityHashCode(s5));//1173230247
System.out.println(s3 == s4);//flase
System.out.println(s3 == s5);//true

此外,s3 和 s5 的情况还说明了:如果 StringTable 中已经有了相同的对象,其就会指向同一个对象而不是指向新的对象。


此处再贴上一个示例代码的截图


6.5.4.StringTable 字符串延迟加载

所谓延迟加载,即程序运行到具体的某一行的时候,才会去加载。


此处可以借助 idea 工具演示一下字符串延迟加载的现象

相关的 count

System.out.println();//2393

System.out.println("1");//2394
System.out.println("2");//2395
System.out.println("3");//2396

System.out.println("1");//2397
System.out.println("2");//2397

6.5.5.intern 方法

Java 语言并不要求常量一定只有编译期才能产生

  • 也就是说,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池
  • 运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。
  • java 1.6 和 java 1.8 中 intern() 中的实现方式是不一样的(因为两者的 JVM 不同)。

intern() 方法的作用(Java 1.8)

  • 尝试将一个字符串放入 StringTable 中
    • 若不存在就放入 StringTable 并返回 StringTable 中的地址
    • 若存在,就直接返回 StringTable 中的地址。
String s1 = new String("a") + new String("b");
String s2 = s1.intern();

System.out.println(s1 == s2);//true

System.out.println(System.identityHashCode(s1));//1836019240
System.out.println(System.identityHashCode(s2));//1836019240
String x = "ab";
String s1 = new String("a") + new String("b");
String s2 = s1.intern();

System.out.println(s1 == x);//false
System.out.println(s2 == x);//true

System.out.println(System.identityHashCode(x));//1836019240
System.out.println(System.identityHashCode(s1));//325040804
System.out.println(System.identityHashCode(s2));//1836019240

intern() 方法的作用(Java 1.6)

  • 尝试将一个字符串放入 StringTable 中
    • 若不存在,就会把对象复制一份放入 StringTable ,并返回 StringTable 中的副本地址
      • 需要注意的是:副本地址与原来的对象的地址是不同的
    • 若存在,就直接返回 StringTable 中的地址。
String s1 = new String("a") + new String("b");
String s2 = s1.intern();

System.out.println(s1 == s2);//false

System.out.println(System.identityHashCode(s1));//1290300832
System.out.println(System.identityHashCode(s2));//796216018
String x = "ab";
String s1 = new String("a") + new String("b");
String s2 = s1.intern();

System.out.println(s1 == x);//false
System.out.println(s2 == x);//true

System.out.println(System.identityHashCode(x));//1290300832
System.out.println(System.identityHashCode(s1));//796216018
System.out.println(System.identityHashCode(s2));//1290300832

6.5.6.StringTable 特性总结
  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder(java 1.8)
  • 字符串常量拼接的原理是编译期优化

  • 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池。
    • java 1.8 将这个字符串对象尝试放入串池。
      • 如果有则并不会放入串池;如果没有则放入串池。
      • 无论有无,都会把串池中的对象的地址返回
    • java 1.6 将这个字符串对象尝试放入串池。
      • 如果有则并不会放入;如果没有会把此对象复制一份(此时会获得新的地址),并放入串池。
      • 无论有无,会把串池中的对象的地址返回

6.5.7.StringTable 位置

jdk1.6 的 StringTable 位置是在永久代中,jdk 1.8 的 StringTable 位置是在堆中。

之前在 6.2.永久代 中讲述了 JVM 内存结构变化的原因,此处只贴图,不赘述。


可以使用下面的代码来检验上图的情况

import java.util.ArrayList;
import java.util.List;


public class Demo1_18 {

    public static void main(String[] args) throws InterruptedException {
        List list = new ArrayList();
        int i = 0;
        try {
            for (int j = 0; j < 2600000; j++) {
                list.add(String.valueOf(j).intern());
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}

java 1.6

java.lang.OutOfMemoryError: PermGen space

java 1.8

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

6.5.8.StringTable 垃圾回收

StringTable 在内存紧张时,会发生垃圾回收

public class Demo1_19 {
    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        try {
            for (int j = 0; j < 100000; j++) { // j=100, j=10000
                String.valueOf(j).intern();
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }

    }
}

在输出的内容中,控制台最上层的几行(诸如下方代码块所示)就是垃圾回收的信息 (随便看看就行)

[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->692K(9728K), 0.0009676 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2740K->772K(9728K), 0.0008896 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2552K->488K(2560K)] 2820K->788K(9728K), 0.0008643 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

在输出的内容中,需要关注的是 StringTable statistics 这段信息 (随便看看就行)

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :     25686 =    616464 bytes, avg  24.000
Number of literals      :     25686 =   1497648 bytes, avg  58.306
Total footprint         :           =   2594216 bytes

6.5.9.StringTable 性能调优

StringTable 底层是一个 HashTable,是 key-value 结构。


参考博客:看了这篇文章,我搞懂了 StringTable

在介绍性能调优之前先阐述下 StringTable 的底层实现

当存入一个元素的时候,就会将其 key 通过 hash 函数计算得出数组的下标并存放在对应的位置。

哈希冲突

  • 比如现在有一个 key-value,这个 key 通过 hash 函数计算结果为 2,那么就把 value 存放在数组下标为 2 的位置。
  • 但是如果现在又有一个 key 通过 hash 函数计算出了相同的结果,比如也是 2,但 2 的位置已经有值了。
  • 这种现象就叫做哈希冲突

关于哈希冲突的解决办法之一是 链表法

链表法 就是将下标一样的元素通过链表的形式串起来

如果数组容量很小但是元素很多,那么发生哈希冲突的概率就会提高。

众所周知,链表的效率远没有数组那么高,哈希冲突过多会影响性能。

为了减少哈希冲突的概率,我们可以适当的增加数组的大小。

数组的每一格在 StringTable 中叫做 bucket,我们可以增加 bucket 的数量来提高性能。


① 可以适当增加 HashTable 中桶的个数,来减少字符串放入串池所需要的时间

-XX:StringTableSize=桶个数

但是需要注意的是 bucket 的最小个数为 1009,小于这个数就会报错

Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
StringTable size of 100 is invalid; must be between 1009 and 2305843009213693951

代码

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;


public class Demo1_20 {

    public static void main(String[] args) throws IOException {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("D:\IdeaProjects\JVMStudies\chapter01\src\main\resources\linux.words"), "utf-8"))) {
            String line = null;
            long start = System.nanoTime();
            while (true) {
                line = reader.readLine();
                if (line == null) {
                    break;
                }
                line.intern();
            }
            System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
        }
    }
}

输出结果(部分)

StringTable statistics:
Number of buckets       :      1009 =      8072 bytes, avg   8.000
Number of entries       :    482776 =  11586624 bytes, avg  24.000
Number of literals      :    482776 =  29826168 bytes, avg  61.781
Total footprint         :           =  41420864 bytes

② 此外,也可以考虑将字符串对象入池来减少数据对内存的大量占用,之前的 intern 方法就可以减少重复入池。


7.直接内存
7.1.定义

Direct Memory 不属于 JVM 管理内存,而是属于操作系统的内存。

  • 常见于 NIO 操作是,用于数据缓存区
  • 分配回收成本较高,但读写性能更加强
  • 不受 JVM 内存回收管理

参考书籍:《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明 著

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。

在 JDK 1.4 中新加入了 NIO(New Input / Output)类

  • 引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式
  • 它可以使用 Native 函数库直接分配堆外内存
  • 然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。
  • 这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制。

但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器寻址空间的限制。

一般服务器管理员配置虚拟机参数时,会根据实际内存去设置 -Xmx 等参数信息。

但他们经常忽略掉直接内存。

使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。


7.2.文件读写流程

需要通过到操作系统中的系统内存来读取到磁盘文件,Java 代码是不能直接访问到磁盘文件的。

从下图可以看出来一个问题,系统内存中有缓存区,JAVA 堆内存中也有缓存区,有两份缓存区。

这就造成了一个不必要的数据的复制,这样的话效率会降低。


在 JDK 1.4 中新加入了 NIO 类

  • 引入了一种基于 Channel 与 Buffer 的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存。
  • 然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。
  • 这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制。

简而言之,就是直接内存是操作系统和 Java 代码都可以访问的一块区域

无需将代码从系统内存复制到 Java 堆内存,效率也因此得到了提高。


7.3.分配和回收原理
  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner(虚引用) 来监测 ByteBuffer 对象
    • 一旦 ByteBuffer 对象被垃圾回收
    • 那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存

参考博客:JVM 内存结构 超详细学习笔记(一)

直接内存的释放不是通过 JVM 的回收来管理的,而是通过 Unsafe 类来管理的

通过 Unsafe 中的 freemMemory() 方法进行释放的

public native void freeMemory(long var1);

了解 Unsafe 类的使用(下面的代码块是视频里的一段演示代码)

import sun.misc.Unsafe;

import java.io.IOException;
import java.lang.reflect.Field;


public class Demo1_26 {
    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        Unsafe unsafe = getUnsafe();

        // 分配内存
        
        long base = unsafe.allocateMemory(_1Gb);
        unsafe.setMemory(base, _1Gb, (byte) 0);
        
        System.in.read();

        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }

    public static Unsafe getUnsafe() {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

接下来再看一下 allocateDirect() 方法具体是怎么和 Unsafe 类产生联系的

public abstract class ByteBuffer extends Buffer implements Comparable

public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

再来查看 DirectBuffer 类

class DirectByteBuffer

// Primary constructor
//
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));
    
    
    att = null;

}

在上面的代码块里,有一个很特殊的类库,虚引用类型的 Cleaner 类

其特点是如果这个虚引用对象所关联的对象被回收后,那么 Cleaner 就会触发 Cleaner> 的 clean 方法

其中 Cleaner.create(this, new Deallocator(base, size, cap) 中的 this 指的是 ByteBuffer(DirectBuffer 类嘛,自己调用自己)

ByteBuffer 对象是 Java 对象,可以被 JVM 垃圾回收。

当 ByterBuffer 对象被回收后,会触发 Cleaner 中的 clean() 方法

并且执行 clean 方法的线程并不是主线程,而是有一个专门的线程在后台监测这个虚引用对象

Cleaner extends PhantomReference

public static Cleaner create(Object var0, Runnable var1) {
    return var1 == null ? null : add(new Cleaner(var0, var1));
}

Cleaner extends PhantomReference

public void clean() {
    if (remove(this)) {
        try {
        
        	
        	//这个 thunk 在之前定义过:private final Runnable thunk;
            this.thunk.run();
            
            
        } catch (final Throwable var2) {
            AccessController.doPrivileged(new PrivilegedAction() {
                public Void run() {
                    if (System.err != null) {
                        (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                    }

                    System.exit(1);
                    return null;
                }
            });
        }

    }
}

ByteBuffer 被 JVM 回收后,触发了 this.thunk.run(); 这行代码

此时再回到 DirectBuffer 类中

DirectBuffer 类中的 run 方法调用了 freeMemory() 方法

class DirectByteBuffer

public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

于是乎分配内存讲了,内存回收也讲了,溜了溜了。


public class Demo1_25 {
    static int _1Gb = 1024 * 1024 * 1024;

    
    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null;
        System.gc(); // 显式的垃圾回收,Full GC
        System.in.read();
    }
}

JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。

System.gc(),是会显示触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存

其不但会回收新生代还会回收老年代,是一种比较吃性能的操作,会降低程序执行的效率。

此外,System.gc() 调用附带一个免责申明,无法保证对垃圾收集器的调用。

使用参数:-XX:+DisableExplicitGC 可以禁用显示的垃圾回收。

但是这个参数可能会影响到直接内存的回收。

没有 System.gc() 来释放直接内存,那么只有等到真正的垃圾回收(空间是真的不足的时候)是才会一起释放直接内存

这样造成的后果就是直接内存的使用过大。

当然也有方法可以避免,即手动内存释放,本质上可以通过 unsafe 对象调用 freeMemory 的方式释放直接内存。


转载请注明:文章转载自 www.mshxw.com
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号