📑本篇内容:Java Architecture And Java Virtual Machine (Java 基础架构 与 Java 虚拟机)~

📘 文章专栏:JVM深入挖掘原理调优

📚 更新周期:2022年3月31日 ~ 2022年4月16日

🙊个人简介:一只二本院校在读的大三程序猿,本着注重基础,打卡算法,分享技术作为个人的经验总结性的博文博主,虽然可能有时会犯懒,但是还是会坚持下去的,如果你很喜欢博文的话,建议看下面一行~(疯狂暗示QwQ)

🌇点赞 👍 收藏 ⭐留言 📝 一键三连 关爱程序猿,从你我做起

首先 在这里 先感谢 黑马程序员相关课程——《JVM完整教程,全网超高评价,全程干货不拖沓》
其次 再次感谢 周志明先生的 《深入理解Java虚拟机》
同时 还要感谢 JDK 官方文档《JAVA 虚拟机规范》
希望大家学有所得 学有所获。

🔖 Java Architecture And Java Virtual Machine

📚 引言

📑 什么是 JVM ?

什么是 JVM ?

📖 定义:

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

📖 好处:

  • 一次编写,到处运行的基石。对外提供一致的操作环境
  • 自动内存的管理,提供了垃圾回收的功能。
  • 数组下标越界,越界检查。(如果数组越界可能占据到其他内存的空间这是很危险的)
  • 多态

📖 比较:

  • JVM (Java Virtual Machine):屏蔽 Java 代码与底层操作系统的差异
  • JRE(Java Runtime Environment) : JVM + 基础类库(java.lang.* ...)
  • JDK (Java Development kit): JVM + 基础类库+ 编译工具
  • 开发Java SE程序:JDK + IDE 工具
  • 开发Java EE程序:JDK + 应用服务器 + IDE 工具

📑 学习 JVM 有什么用?

  • 理解底层的实现原理
  • 必备技能 (定位解决一些问题,内存溢出,响应时间缓慢等)

📑 常见的 JVM

在这里插入图片描述

📑 学习路线

在这里插入图片描述

  • 以后创建的类都放在方法区当中,而根据类创建的对象实例都是存放在堆中的,堆中的对象调用方法时又会用到虚拟机栈程序计数器本地方法栈
  • 而方法执行每行代码都是由执行引擎的解释器帮助逐行进行执行的
  • 方法的热点代码,都会由执行引擎的 JIT Compiler 即时编译器对热点代码进行编译,是一种优化。
  • GC模块垃圾回收,会对堆中不在被引用的对象进行垃圾回收。
  • Java 代码不能实现的功能,需要和操作系统的功能打交道就只能通过本地方法接口进行调用。

学习路线

运行时数据区 -----> 执行引擎中的GC垃圾回收 -----> Java的字节码结构 -----> 类加载器系统 -----> 类运行时运行期及时编译器优化等 JIT

📚 JVM内存结构

📑 程序计数器——线程私有

定义

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

下面通过一段代码来理解:

StackStruTest.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/19 15:51
 * @description: StackStruTest
 * @modified By: Alascanfu
 **/
public class StackStruTest {
    public static void main(String[] args) {
        PrintStream out = System.out;
        out.println(1);
        out.println(2);
        out.println(3);
        out.println(4);
        out.println(5);
    }
}

然后我们通过 javap -v [文件名] 来反编译字节码文件(.class)

$ javap -v StackStruTest.class
		# jvm 指令                              # 源代码 
         0: getstatic     #2                  // PrintStream out = System.out;
         3: astore_1
         4: aload_1
         5: iconst_1
         6: invokevirtual #3                  // out.println(1);
         9: aload_1
        10: iconst_2
        11: invokevirtual #3                  // out.println(2);
        14: aload_1
        15: iconst_3
        16: invokevirtual #3                  // out.println(3);
        19: aload_1
        20: iconst_4
        21: invokevirtual #3                  // out.println(4);
        24: aload_1
        25: iconst_5
        26: invokevirtual #3                  // out.println(5);
        29: return

Java代码不能直接进行运行,都是需要编译转换为二进制字节码才能被虚拟机所读取。

这些指令还是会交给CPU来执行,但是并不会直接交付 这些 JVM 指令会通过执行引擎中的解释器把每一条虚拟机指令解释转换为机器语言,然后CPU就会根据机器语言来执行。

  • 程序计数器的作用就是:记住下一条 JVM 指令执行的地址。

在这里插入图片描述

当 当前行指令执行的时候,程序计数器是记录的下一个指令的执行地址,比如此时我们在执行 -3 指令但是此时程序计数器记录的是 -4的地址。

  • 解释器会从程序计数器中读取下一行的命令进行解释成机器码,然后交付给CPU运行处理。

程序计数器的作用与特点

作用:

  • 记住下一条JVM指令的执行地址。

特点:

  • 是线程私有的

如何理解其是线程私有的?

在这里插入图片描述

  • 比如当我们的线程1此时获得了cpu的处理,运行到了第9行JVM指令时,此时线程1的程序计数器记录的是线程1下一次执行的JVM指令地址,而如果此时线程1的时间片用完又会切换到线程2执行线程2的JVM指令,当线程2的JVM指令执行完或者线程2的时间片又被用完切换回线程1时,此时会继续读取线程1之前程序计数器记录的下一条指令位置继续执行。

综上可以得出程序计数器是线程私有的,每个线程都会具有一个程序计数器,记录当前线程下下一条JVM指令的位置。

  • 同时程序计数器也是唯一一个在JVM规范中不会存在内存溢出的。

📑 虚拟机栈——线程私有

定义

Virtual Stack :线程运行时需要的内存空间,一个栈内它可以看成由多个栈帧组成。

  • 每个线程运行时所需要的内存,称之为虚拟机栈
  • 每个栈由多个栈帧(Frame) 组成对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧对应着当前正在执行的那个方法

栈帧又是什么?

每个方法运行时需要的内存,方法内,参数,局部变量,返回地址。

栈存储过程:当方法运行时该方法栈帧就会入栈,如果该方法中调用了另一个方法,那么此时对应方法的栈帧又会压入栈,此时就会有两个栈帧存于虚拟栈中,其实是可以有多个栈帧存储在虚拟栈中的,当方法执行过后对应的栈帧就会出栈节省空间

进行测试 观察栈帧

对应的程序 VirtualStackDemo.java

VirtualStackDemo.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/22 20:02
 * @description: 演示栈帧
 * @modified By: Alascanfu
 **/
public class VirtualStackDemo {
    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;
    }
}
  • 通过debug方式就可以看到如图所示的相应信息。

在这里插入图片描述

  • 如图可以看到当前的方法二中所占用的变量空间 由 a、b、c 组成。

  • 随着栈帧返回栈帧就会出栈,内存同时也会被释放掉

  • 蓝色指向的位置就是活动栈帧所指的位置。

Java 虚拟机栈的相关面试题

栈问题解析

  • 垃圾回收是否涉及栈内存?
不需要。因为栈内存中无非就是一次次方法调用产生的栈帧时内存,而栈帧会随着每次方法调用完成后栈帧弹出而释放掉对应栈帧的内存,自动被回收,所以无需垃圾回收来管理。
  • 栈内存分配越大越好么?
我们可以通过
-Xss size 来设置其虚拟栈内存
栈内存分配的越大反而会使得线程变少。
比如 及其共有 500M 的内存大小,而我们分配给每个虚拟机栈 2M 那么最多同时只能拥有250个线程同时运行。
而分配的栈内存越大适合多次递归的调用,所以不需要设置太大的栈内存。
  • 方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的。
如果是局部变量引用了对象,并且逃离了方法的作用范围,那么就需要考虑其线程安全问题。

对于局部变量的线程安全问题进阶理解

StackThreadSecurity.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/22 20:47
 * @description: 局部变量的线程安全问题
 * @modified By: Alascanfu
 **/
public class StackThreadSecurity {
    public static void main(String[] args) {
    
    }
    
    public static void m1(){
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        sb.append(4);
        System.out.println(sb.toString());
    }
    
    public static void m2(StringBuilder sb ){
        sb.append(1);
        sb.append(2);
        sb.append(3);
        sb.append(4);
        System.out.println(sb.toString());
    }
    
    public static StringBuilder m3( ){
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        sb.append(4);
        return sb;
    }
}
  • 对于方法m1是不会存在线程安全问题,也是因为此处StringBuilder的对象是在方法内创建的局部变量,其它线程不可能同时访问StringBuilder对象的,所以它一定是线程安全的。
  • 对于方法m2是会出现线程安全问题的,因为StringBuilder的对象是作为参数传入的而多个线程都有可能访问到这个参数对象,对其进行修改。
public class StackThreadSecurity {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append(1);
        sb.append(2);
        sb.append(3);
        new Thread(()->{
            m2(sb);
        }).start();
    }
}

执行结果:

1231234

Process finished with exit code 0

可以得出StringBuilder这个对象参数被修改了的,所以方法m2不是线程安全的。

  • 对于方法m3也是会出现线程安全问题的,因为此时会将局部变量对象作为返回地址返回该对象如果这个返回的对象被其它线程所调用,则说明其对象所对应的数据也会被修改,所以也是非线程安全的

🔖 总结:如果对于方法中首先要看该变量对象是否是不是当前方法中的局部变量的同时,还需要查看该局部变量是否逃出了方法的范围。那么该变量就有可能被其它线程访问到,就不是线程安全的

Java 在开发过程中可能遇到的问题

栈内存溢出1

  • 栈帧过多会导致栈内存溢出。(常见的递归方法调用没有合适的递归跳出条件就可能导致栈内存溢出StackOverFlowError
  • 栈帧过大也会导致栈内存溢出。

StackOverFlowTest.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/22 21:49
 * @description: 栈内存溢出测试
 * @modified By: Alascanfu
 **/
public class StackOverFlowTest {
    static int N ;
    public static void main(String[] args) {
        try {
            method();
        }catch (Throwable e){
            e.printStackTrace();
            System.out.println(N);
        }
    }
    static void method(){
        N++;
        method();
    }
}
  • 默认设置时其 N 其输出结果。
java.lang.StackOverflowError
	at com.alascanfu.StackOverFlowTest.method(StackOverFlowTest.java:21)
23264
  • 或者通过-Xss[占用大小]而修改了其运行占用Size

在这里插入图片描述

此时运行结果:

java.lang.StackOverflowError
	at com.alascanfu.StackOverFlowTest.method(StackOverFlowTest.java:21)
3863

栈内存溢出2

  • 有些时候使用第三方的类库时也会导致栈内存溢出。

比如说 JSON 数据转换时导致的

在这里插入图片描述

如果此时我们去运行上述代码JSON进行数据转换的时候就会出现StackOverFlow的问题。

  • 原因是因为当我们进行数据转换的时候 emp 包含了部门数据 而部门又囊括了 员工数据,所以就会一直递归下去。

  • 为了解决上述问题,是可以通过添加注解(@JsonIgnore)忽略一些Json数据转换来解决此问题。

📑 线程运行诊断

案例1:CPU占用过多

  • 通过 top 命令定位哪个进程对CPU占用过高
  • 然后通过指令 ps H -eo pid,tid,%cpu | grep [进程id] 来进一步定位进程中的哪个线程引起的CPU占用过高。
  • 通过 jstack [进程id] :可以根据线程id ,找到有问题的线程,进而定位到问题源代码的行数。
/***
 * @author: Alascanfu
 * @date : Created in 2022/3/22 22:51
 * @description: CPU占用过高侦测手段
 * @modified By: Alascanfu
 **/
public class HighCPUUsageTest {
    public static void main(String[] args) {
        new Thread(null,()->{
            System.out.println("1...");
            while(true){
            
            }
        },"thread1").start();
        
        new Thread(null,()->{
            System.out.println("2...");
            try {
                Thread.sleep(1000000000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"thread2").start();
    
        new Thread(null,()->{
            System.out.println("3...");
            try {
                Thread.sleep(1000000000L);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"thread3").start();
    }
}

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

例如出现死锁问题时就需要使用jstack[线程id]来检查一下。

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/22 22:59
 * @description: 死锁线程排查测试
 * @modified By: Alascanfu
 **/
public class DeadLockJStackTest {
    static A a = new A();
    static B b = new B();
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            synchronized (a){
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (b){
                    System.out.println("我获得了a和b");
                }
            }
        }).start();
        
        Thread.sleep(1000);
        
        new Thread(()->{
            synchronized (b){
                synchronized (a){
                    System.out.println("我获得了a和b");
                }
            }
        }).start();
    }
}
class A{};
class B{};

📑 本地方法栈——线程私有

定义

实际上就是 JVM 需要调用本地方法时提供给这些方法的内存空间。(Native Method) 很多底层的由c/c++实现的代码,完成一些底层的工作,而Java 无法完成这些工作就会去调用这些 native 方法 ,而这些native方法也是需要占用空间的,而本地方法使用的内存就是使用本地方法栈的空间

📑 堆——线程共享

定义

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

堆的特点:

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

堆内存溢出问题

OutOfMemoryHeapSpaceTest.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/24 21:20
 * @description: 测试堆内存溢出
 * @modified By: Alascanfu
 **/
public class OutOfMemoryHeapSpaceTest {
    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "hello";
            while(true){
                list.add(a);
                a = a + a;
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }
    }
}

执行结果

java.lang.OutOfMemoryError: Java heap space
    26
  • 可以通过-Xmx[占用大小]而修改堆内存占用空间,如果需要排查堆内存引起的溢出问题 ,通常都是将其设置较小尽早查出问题所在。
📑堆内存诊断工具使用

jps 工具

  • 用于查看当前系统中有哪些 Java 进程

jhsdb jmap 工具

  • 用于查看堆内存占用情况

jdk 8 之后可以通过 命令 jhsdb jmap --heap --pid [进程id] 来查看当前时刻时的堆内存占用情况~

📚 注意点: 这个只能查询某一时刻的堆内存占用情况。

jconsole 工具

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

通过案例来进行检测

HeapMemoryDemonstrates.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/24 22:27
 * @description: 演示堆内存
 * @modified By: Alascanfu
 **/
public class HeapMemoryDemonstrates {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        Thread.sleep(1000 * 30);
        // 这个byte[] 占用了 10M 的空间
        byte[] array = new byte[1024*1024*10];
        System.out.println("2...");
        Thread.sleep(1000*30);
        array = null;
        System.gc();
        System.out.println("3...");
        Thread.sleep(1000000L);
    }
}
  • 分别在三个时间输出点执行 jhsdb jmap --heap --pid [进程id] 即可查看不同时刻时,堆内存的使用情况。
  • 或者在程序启动之后,在控制台输入 jconsole 利用图形化界面监测该进程的堆内存使用情况。

在这里插入图片描述

实战案例:垃圾回收时,内存占用依旧很高

如何利用部分工具进行检查

  • 通过 Jconsole 中内存栏中 执行GC进行垃圾回收,进行查看
  • 然后通过jhsdb jmap --heap --pid [进程号] 查看老年代与新生代清除过后的内存占用率。
  • jvisualvm 来 堆转储 dump 即快照来检测哪些对应的对象导致的内存居高不下, 来修改源代码。

在这里插入图片描述

📑 方法区——线程共享

定义

方法区是所有JVM虚拟机线程所共享的一个区域。和堆一样都是线程共享的。

方法区不同版本结构理解:

JDK 1.6

在这里插入图片描述

在1.6中是如何实现的呢?

  • Method Area 只是一个概念上的东西,规范中定义的,在1.6中通过PermGen永久代来作为方法区实现的。

PermGen 永久代实现了什么?

  • 可以存储类原信息,构造器,类加载器等,当然还存在一个运行时常量池。而在运行时常量池中有一个很重要的东西,是字符串表String Table

JDK 1.8

在这里插入图片描述

在1.8中是如何实现的呢?

  • MethodArea也是一个通过Metaspace概念上的实现,元空间存储的包括类,类加载器,常量池的信息而此时已经无需交由给JVM来管理从而占用堆空间了
  • 而此时Metaspace 占用的是操作系统的内存空间。
  • String Table不再放在方法区中的实现反而是放在了堆中。
方法区的内存溢出问题
  • JDK1.8之前方法区的内存溢出(即PermGen永久代内存溢出)
  • JDK1.8之后方法区的内存溢出(即元空间metaspace内存溢出)

这里演示的是由于类加载的过多,可能引起的元空间内存溢出

可以通过 -XX:MaxMetaspaceSize=8m 来进行设置可以占用的元空间内存大小来进行测试。

MetaspaceOutOfMemory.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/27 16:09
 * @description: 演示元空间内存溢出
 * @modified By: Alascanfu
 **/
public class MetaspaceOutOfMemory extends ClassLoader{ // ClassLoader可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0 ;
    
        try {
            MetaspaceOutOfMemory test = new MetaspaceOutOfMemory();
            for (int i = 0 ; i < 10000;i++){
                // 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);
            }
        } finally {
            System.out.println(j);
        }
    }
}

执行结果

  • JDK 1.6
持久代溢出java.lang.OutOfMemoryError: PermGen space
-XX:MaxPermSize=8m
  • JDK1.8
元空间溢出java.lang.OutOfMemoryError: Metaspace
-XX:MaxMetaspaceSize=8m

注意:如果本机安装了jdk11的话光设置-XX:MaxMetaspaceSize=8m 是无法显示出元空间溢出的,取而代之的是一个叫做指针压缩类空间的异常

java.lang.OutOfMemoryError: Compressed class space

解决方案:

  • 一般很少会遇到这个异常.如果启用了UseCompressedClassesPointers的话(打开UseCompressedOops的话之后,会默认启用),那么原生内存上会有两个独立的区域用来存储类和它们的元数据。
  • 启用UseCompressedClassesPointers之后,64位的类指针会使用32位的值来表示,压缩的类指针会存储在压缩类空间(compressed class space)中。默认情况下,压缩类空间的大小是1GB并且可以通过CompressedClassSpaceSize进行配置。

在这里我们只需要关闭使用UseCompressedOops即可

-XX:MaxMetaspaceSize=12m -XX:-UseCompressedOops

方法区内存溢出场景
  • JDK1.8之前 Spring 、Mybatis等一系列框架用到的字节码技术,例如CGLIB动态代理时,在运行期间动态生成类的字节码可能会导致方法区内存溢出
运行时常量池

先来看下什么是常量池

常量池就是一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等信息。

可以通过javap -v [xxx.class]进行反编译获取得到class文件的信息。

运行时常量池定义

常量池是 * .class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。

二进制字节码(类基本信息,常量池,类方法的定义,包含虚拟机指令

  • 类的基本信息

在这里插入图片描述

  • 常量池

在这里插入图片描述

  • 方法理解

在这里插入图片描述

📑 StringTable 面试题
📑 StringTable 特性
  • 常量池中的字符串仅是符号第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量的拼接原理编译期优化
  • 可以使用 intern方法 ,主动将串池中还没有的字符串对象放入串池
    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池会把串池中的对象返回
    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入如果没有则复制一份放入串池会把串池中的对象返回
/***
 * @author: Alascanfu
 * @date : Created in 2022/3/27 18:09
 * @description: StringTable的常见面试题
 * @modified By: Alascanfu
 **/
public class StringTableInterview {
    public static void main(String[] args) {
        String s1 = "a";
        String s2 = "b";
        String s3 = "a" + "b";
        String s4 = s1 + s2;
        String s5 = "ab";
        String s6 = s4.intern();
    
        System.out.println("s3 == s4 ? " + (s3 == s4) );
        System.out.println("s3 == s5 ? " + (s3 == s5) );
        System.out.println("s3 == s6 ? " + (s3 == s6) );
        
        String x2 = new String("c") + new String("d");
        //x2.intern();
        String x1 = "cd";
        x2.intern();
        
        System.out.println(x1 == x2);
    }
}

上述类型题,我们可以通过字节码与常量池的角度来进行分析

  • 首先通过 javap -v [StringTableInterview.class]来进行反编译
  • 在对应的方法栈帧中 存储着 LocalVariableTable 这一个当前栈帧局部变量表

在这里插入图片描述

  • 从图中我们可以从上述的代码行中找到对应变量的起始位置(Start),以及变量所占的槽位编号(slot),名称(Name)等信息。
📑 常量池与串池关系

常量池与串池关系:

  • 常量池中的信息 , 都会被加载到运行时常量池中,这时 a b ab都是常量池中的符号还没有变为 java 字符串对象
  • ldc #2把 a 符号 变为“a” 字符串对象 , 在StringTable[ ]中去寻找,如果没有就会将其加入其中
  • StringTable[] hashtable 结构,不能扩容
  • 值得注意的是:每个字符串对象并非事先放在串池当中,而是在执行用到它的这行代码,才开始创建字符串对象,懒惰行为。
📑字符串变量拼接

字符串变量拼接一

String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
  • 上述代码通过 javap -v StringTableInterview.class 进行反编译 查看字节码
		 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/String;
        27: astore        4
  • 第9行(new): 创建 #5的实例 : java/lang/StringBuilder
  • 第12行(dup):复制上一步创建对象引用压入栈
  • 第13行(invokespecial):调用 StringBuilder 的特殊方法 init 方法(构造方法)
  • 第16行(aload_1):加载变量1压入栈
  • 第17行(invokevirtual):调用 #7的 StringBuilder实例 的 append 方法将上一步加载的变量1作为参数传入 append 方法
  • 第20行(aload_2):加载变量2压入栈
  • 第21行(invokevirtual):调用 #7 的 StringBuilder实例 的 append 方法将上一步加载的变量2作为参数传入 append 方法
  • 第24行(invokevirtual):调用 StringBuilder 的toString方法
  • 第27行(astore):将上一步返回的引用赋值给变量4 即 s4

📚 小结:上述的 String s4 = s1 + s2 实际相当于执行了下述代码。

new StringBuilder().append("a").append("b").toString()

StringBuilder 中 toString() 源码:

public final class StringBuilder
    extends AbstractStringBuilder
    implements java.io.Serializable, CharSequence
{ 
	@Override
    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }
}
  • 实际上这里就是新创建了一个字符串 new String("ab")

我们再来看到上面的代码来理解一道题

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

System.out.println(s3 == s4); // false
  • 上述为什么是false呢?

知识点一:StringTable ["a" ,"b" ,"ab"] 会随着jvm指令 执行到对应的行,从常量池中读取对应的符号,变为对应符号的字符串对象到 StringTable中,存入之前先会判断当前字符串对象是否在StringTable中已经存在,如果没有则存入,反之不存入

知识点二:s3ab已经存在于串池当中的一个字符串对象,而 s4 引用的是一个新的字符串对象,虽然二者 值相同,但是 s3 在串池 当中,而 new 出来的s4 是在堆中创建的位置不一样所以 返回false

字符串变量拼接二:

String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s5 = "a" + "b";
System.out.println(s3 == s5); // true
  • 有了字符串变量拼接一的基础,这道题就显得格外简单了。

知识点:还未执行到 s3这行 时,只是此时常量池中的符号还未加入到运行时常量池当中,所以此时字符串ab对象也没有加载到串池当中。 当执行到了这行时,首先常量池中对应的符号会 通过 ldc 将其变为字符串对象,然后拿着这个对象去串池中找,是否存在一致的字符串对象,如果不存在则放入,反之不放入,因为此时串池中没有这个字符串对象,所以此时 ab 就被放入到了串池当中,随后执行到 s5 这行时,会直接从串池中读取 (javac 编译期 的优化)。两个常量的拼接肯定是定死的了,编译时就确定结果为 ab 了,所以常量无需StringBuilder 进行拼接了,这也就是为什么结果为true。

字符串变量拼接三:

String s = new String("a") + new String("b");// new StringBuilder().append("a").append("b").toString();
// 堆中new String("a") new String("b")  new String("ab") 动态拼接的字符串并不会直接放入串池,而是呆在堆中的,但是可以通过 intern()方法将字符串对象尝试放入串池中,如果有则并不会放入,反之会放入,并且把串池中的对象返回。
String s2 = s.intern();
System.out.println(s == "ab");// true
--------------------分界线--------------------
String s2 = "ab";
String s3 = s.intern();
System.out.println(s == "ab");// false
System.out.println(s3 == "ab");// true
  • 上述这条指令在加载的时候 字符串常量"a" 和字符串常量"b"从常量池中加载到串池当中而new 出来的字符串对象是存储在堆中的哪怕值相等但是他们的地址并不相等
  • **分界线之前:**当我们的字符串 s这个存储在堆中的字符串对象尝试将其放入串池当中,如果不存在则会放入,反之不放入,放入成功返回的是一个串池中的对象进行返回。
  • **分界线之后:**因为我们当运行到s2这一行时,常量池中的字符 ab会通过ldc变为ab 字符串对象加入到串池当中而运行到 s3 这一行时,s这个存放在堆中的字符串对象会尝试将其放入到串池当中,而不幸的是此时串池当中 已经有了 ab这个字符串对象,所以无法放入,所以此时的s 还是存储在堆中的字符串对象故这里是false。而此时s.intern()无论如何都是返回的是串池中的字符串对象所以此时s3 == "ab" 是 true。
📑 字符串延迟加载

TestString.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/3/29 21:03
 * @description: 演示字符串字面量也是【延迟】成为对象的
 * @modified By: Alascanfu
 **/
public class TestString {
    public static void main(String[] args) {
        int x = args.length;
        System.out.println();
        System.out.println("1"); // 断点观察 Memory 字符串个数2055
        System.out.println("2"); // 断点观察 Memory 字符串个数2056
        System.out.println("3"); // ...
        System.out.println("4");
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");
        System.out.println("0");
        
        System.out.println("1");// 断点观察 字符串个数 2065
        System.out.println("2"); // 断点观察 字符串个数 2065
        System.out.println("3"); // 断点观察 字符串个数 2065
        System.out.println("4"); // ...
        System.out.println("5");
        System.out.println("6");
        System.out.println("7");
        System.out.println("8");
        System.out.println("9");
        System.out.println("0");
    }
}
📑 StringTable 位置

在这里插入图片描述

在这里插入图片描述

StringTable 的位置针对于不同的 JDK 版本 不同

  • JDK 1.6 中 StringTable 是存在于 永久代的常量池当中。而JDK 1.8 中 StringTable单独拉出来到堆当中了,主要是因为在永久代中,触发垃圾回收的效率会很低

验证当前 JDK 下 StringTable 存在的位置

WhereIsStringTable.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/4 19:40
 * @description: 测试StringTable 所在的位置
 * @modified By: Alascanfu
 **/
public class WhereIsStringTable {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        int i = 0;
    
        try {
            for (int j = 0 ;i <= 260000;i++){
                list.add(String.valueOf(j).intern());
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }
    
    }
}

VM Options 中进行设置:

  • 1.6 中我们认为 StringTable 是存在于 PermGen 永久代当中的,所以我们可以设置一下 永久代的空间大小-XX:MaxPermSize=10M 来进行测试,此时输出结果如下。
java.lang.outOfMemoryError: PermGen space
259066

所以验证了我们的猜想,在 1.6 中StringTable 是存在于 PermGen 永久代当中的。

  • 1.8 中我们认为StringTable 中存在于堆中,所以我们同样进行对VM options 设置进行测试-Xmx8m 即可,运行案例,查看运行结果。
java.lang.OutOfMemoryError: Java heap space
    106710

正如我们猜想那样,在 JDK1.8 中 StringTable 是存在于 堆中的。

  • 如果在上述操作中出现了 GC overhead limit exceededOutOfMemoryError 时,我们需要通过设置 -XX:-UseGCOverheadLimit 来完成我们的实验,为什么会出现上述错误呢?

当我们的程序在花费 98 % 的时间在垃圾回收上,但是仅有 2%的堆空间被回收。那就证明 JVM 无药可救的地步了。此时就不会再进行垃圾回收了。所以当我们关闭这个开关就可以正常演示我们之前的堆空间溢出问题了。

📑 StringTable 垃圾回收

为了演示一下StringTable 的垃圾回收 我们需要了解一些 VM Options 参数:

-Xmx10m 设置其堆空间大小为10m
-XX:+PrintStringTableStatistics 开启StringTable的统计显示
-XX:+PrintGCDetails -verbose:gc 打开垃圾回收的详细信息 只要进行了垃圾回收都会显示

-Xmx8m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

StringTableGarbageCollection.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/4 20:27
 * @description: 演示一下StringTable的垃圾回收机制
 *  -Xmx10m 设置其堆空间大小为10m
 *  -XX:+PrintStringTableStatistics 开启StringTable的统计显示
 *  -XX:+PrintGCDetails -verbose:gc 打开垃圾回收的详细信息 只要进行了垃圾回收都会显示
 * @modified By: Alascanfu
 **/
public class StringTableGarbageCollection {
    public static void main(String[] args) {
        int i = 0;
        try{
        
        }catch (Throwable e){
            e.printStackTrace();
        }finally {
            System.out.println(i);
        }
    }
}

运行结果

当我们的堆空间不够时系统会检测并且进行一次垃圾回收,下述就是垃圾回收的信息。

[GC (Allocation Failure) [PSYoungGen: 1536K->504K(2048K)] 1536K->659K(7680K), 0.0197106 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
0

📖 Heap的占用情况

Heap
 PSYoungGen      total 2048K, used 789K [0x00000000ffd80000, 0x0000000100000000, 0x0000000100000000)
  eden space 1536K, 18% used [0x00000000ffd80000,0x00000000ffdc7748,0x00000000fff00000)
  from space 512K, 98% used [0x00000000fff00000,0x00000000fff7e030,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 5632K, used 155K [0x00000000ff800000, 0x00000000ffd80000, 0x00000000ffd80000)
  object space 5632K, 2% used [0x00000000ff800000,0x00000000ff826e70,0x00000000ffd80000)
 Metaspace       used 3259K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 359K, capacity 388K, committed 512K, reserved 1048576K

📖 SymbolTable 的统计信息

SymbolTable statistics:
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13472 =    323328 bytes, avg  24.000
Number of literals      :     13472 =    575264 bytes, avg  42.701
Total footprint         :           =   1058680 bytes
Average bucket size     :     0.673
Variance of bucket size :     0.676
Std. dev. of bucket size:     0.822
Maximum bucket size     :         6

📖StringTable 的统计信息

StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      1758 =     42192 bytes, avg  24.000
Number of literals      :      1758 =    157656 bytes, avg  89.679
Total footprint         :           =    679952 bytes
Average bucket size     :     0.029
Variance of bucket size :     0.029
Std. dev. of bucket size:     0.172
Maximum bucket size     :         3
📑StringTable 性能调优

📖 性能优化 1 -XX:StringTableSize=20000

**调优之前我们需要了解一下针对StringTable的 VM Options调优参数 : **

  • -XX:StringTableSize=20000 设置StringTable的每个桶bucket的大小,也就是说很多个数据被平均分配到这些桶当中,进行回收时只是清理当前桶的数据,如果bucket设置过小清理次数增加时间增加

linux.words 文件下载地址:

链接:https://pan.baidu.com/s/1TD1RmKmG1DrYUJExvZQouA?pwd=HHXF
提取码:HHXF
–来自百度网盘超级会员V4的分享

StringTableGCGarbageCollectionPerformanceTuning.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/4 21:15
 * @description: StringTable 性能调优 1
 * -XX:StringTableSize=20000 设置StringTable的每个桶bucket的大小
 * 也就是说很多个数据被平均分配到这些桶当中,进行回收时只是清理当前桶的数据,
 * 如果bucket设置过小清理次数增加时间增加。
 * -XX:PrintStringTableStatistics 显示信息
 * @modified By: Alascanfu
 **/
public class StringTableGCGarbageCollectionPerformanceTuning {
    public static void main(String[] args) throws IOException {
        try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
            String line = null;
            long start = System.currentTimeMillis();
            while (true) {
                line = br.readLine();
                if (line == null) break;
            }
            long end = System.currentTimeMillis();
            System.out.println((double) (end - start) / 1000 + "s 完成读取字典文件...");
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }
}

📖 性能优化 2 考虑将字符串对象是否入池

如果有大量的相同字符串对象 对字符串对象入池与不入池对内存空间的占用影响

即是否在程序中存在入池动作 , 字符串对象调用intern()方法使得该字符串入池,如果池中有该字符串对象就不会再次读入到堆内存大幅降低了堆内存的占用空间

📑 直接内存——占用的系统内存

定义

  • 常见于NIO操作时,用于数据缓冲区

  • 分配回收成本较高,但读写性能高

  • 不受 JVM 内存回收管理

📑 演示使用直接内存与非直接内存进行IO传输
/***
 * @author: Alascanfu
 * @date : Created in 2022/4/6 21:22
 * @description: 演示 如何使用 ByteBuffer
 * @modified By: Alascanfu
 **/
public class HowToUseByteBuffer {
    static final String FROM = "R:\\JDKApi.CHM";
    static final String TO = "H:\\jdk_api.CHM";
    static final int _1Mb = 1024 * 1024;
    
    public static void main(String[] args) {
        io();
        directBuffer();
    }
    
    static void directBuffer() {
        long start = System.currentTimeMillis();
        try (FileChannel from = new FileInputStream(FROM).getChannel();
             FileChannel to = new FileOutputStream(TO).getChannel();
        ) {
            ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
            while (true) {
                int len = from.read(bb);
                if (len == -1) break;
                bb.flip();
                to.write(bb);
                bb.clear();
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println((double)(System.currentTimeMillis() - start) / 1000 + " s");
        }
    }
    
    static void io() {
        long start = System.currentTimeMillis();
        try (FileInputStream from = new FileInputStream(FROM);
             FileOutputStream to = new FileOutputStream(TO);
        ) {
            byte[] buf = new byte[_1Mb];
            while (true){
                int len = from.read(buf);
                if (len == -1)break;
                to.write(buf,0,len);
            }
        } catch (Throwable e) {
            e.printStackTrace();
        }finally {
            System.out.println((double)(System.currentTimeMillis() - start) / 1000 + " s");
        }
    }
}

执行结果

0.09 s
0.036 s

当缓冲区大小设置一定时,采用直接内存进行io操作速度大概是传统io速度的二~三倍

理解为什么会导致上述的速度差异

在这里插入图片描述

  • Java 本身是不具有读写磁盘文件的能力的,需要调用磁盘读写时,必须调用操作系统的函数,调用Native方法。此时操作系统CPU的状态会由用户态切换到内核态。

  • 通过从磁盘中读取文件,读入到系统内存中的系统缓存区。Java的代码是无法运行系统的缓存区,所以 Java 会在堆中 新建一个Java 缓冲区 byte[] ,随后会将系统缓冲区的数据读取到Java 的缓冲区 byte[] 当中。

  • 重复上述操作进行读写操作。

在这里插入图片描述

  • 当我们使用了 ByteBuffer.allocateDirect 方法时,会分配一块直接内存。它相当于在系统内存划分了一块内存缓冲区这段区域与之前不一样的地方在于这块区域 Java 代码也可以访问,说白了就是二者共享的缓冲区域。

总结:我们很容易发现上述的操作需要多进行一次从系统缓存区读入到Java缓冲区的操作,这样一来,时间就会增加,而采用了 Direct Memory 则会节省此次操作,这也就是为什么时间会大幅提高的原因。

📑 直接内存溢出

DirectMemoryOutOfMemory.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/6 23:07
 * @description: 直接内存 内存溢出问题
 * @modified By: Alascanfu
 **/
public class DirectMemoryOutOfMemory {
    static final int _100Mb = 1024*1024*100;
    
    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try{
            while(true){
                ByteBuffer bb = ByteBuffer.allocateDirect(_100Mb);
                list.add(bb);
                i++;
            }
        }catch (Throwable e){
            e.printStackTrace();
            System.out.println(i);
        }
    }
}

直接内存溢出结果

java.lang.OutOfMemoryError: Direct buffer memory
	36
📑 直接内存 —— 释放原理

DirectMemoryReleasePrinciple.java

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/6 23:23
 * @description: 演示直接内存释放原理
 * @modified By: Alascanfu
 **/
public class DirectMemoryReleasePrinciple {
    static final int _1Gb = 1024*1024*1024;
    public static void main(String[] args) throws IOException {
        System.out.println("开始分配内存...");
        ByteBuffer bb = ByteBuffer.allocateDirect(_1Gb);
        System.in.read();
        System.out.println("开始直接内存释放...");
        bb = null;
        System.gc();
        System.in.read();
    }
}

📖 释放原理:

/***
 * @author: Alascanfu
 * @date : Created in 2022/4/6 23:37
 * @description: 了解直接内存释放原理
 * @modified By: Alascanfu
 **/
public class DirectMemoryReleasePrincipleUnsafe {
    static final int _1Gb = 1024*1024*1024;
    public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
        
        Unsafe unsafe = getUnsafe();
        System.out.println("开始分配直接内存...");
        long assignAddress = unsafe.allocateMemory(_1Gb);
        unsafe.setMemory(assignAddress,_1Gb,(byte)0);
        System.in.read();
        System.out.println("正在释放直接内存空间...");
        unsafe.freeMemory(assignAddress);
        System.in.read();
    }
    
    public static Unsafe getUnsafe() throws NoSuchFieldException, IllegalAccessException {
        try {
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("Error get Unsafe Class");
        }
    }
}
  • 直接内存的释放原理

源码分析

ByteBuffer.allocateDirect(int capacity)

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

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;
    }
  • 从上述 我们很容易的就看到了 base 这个 通过allocateMemory(size) 方法获取分配大小为size的内存地址位置

  • 然后拿着这个 base地址通过调用 unsafe.setMemory()来分配直接内存空间

  • cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 会主动调用 Deallocator 中的 run()方法,Cleaner 是一个 虚引用对象来监测ByteBuffer对象,如果 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 这个守护线程通过 Cleanerclean 方法调用 freeMemory 来释放直接内存

-XX:+DisableExplicitGC 显示的垃圾回收

通常 我们在 程序当中 ,采用显示垃圾回收

System.gc(); 
  • 显式的垃圾回收 Full GC 是一种比较影响性能的垃圾回收,既要回收新生代,也要回收老年代。所以造成的回收时间会比较长。
  • 而我们在进行 JVM 调优时,通常都会给虚拟机加上 -XX:+DisableExplicitGC 参数 、禁用显式的垃圾回收。
  • 但是需要注意,当我们禁用了 显式 的垃圾回收时,我们使用的直接内存在上述案例当中并没有被回收。直接内存此时只有等待JVM真正的垃圾回收才能释放没有被用到的直接内存空间。
  • 如果要解决上述出现的问题,就需要通过自行获取得到unsafe对象,然后调用 unsafe.freeMemory() 来释放直接内存空间。
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐