Java虚拟机快速入门 | JVM引言、JVM内存结构、直接内存

目录

一:JVM引言

1. 什么是 JVM ?

2. 常见的 JVM

3. 学习路线

二:JVM内存结构

1. 程 序 计 数 器(PC Register)

2. 虚 拟 机 栈(JVM Stacks)

3. 本 地 方 法 栈(Native Method Stacks)

4. 堆(Heap)

5. 方 法 区(Method Area)

三:直接内存


tips:首先给大家推荐两款好用的免费软件:动图抓取软件:ScreenToGif和录屏工具:oCam,可用来作为日常的制作动图Gif和录屏,网盘链接:夸克网盘分享

一:JVM引言

1. 什么是 JVM ?

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

好处:

①一次编写,到处运行; 

②自动内存管理,具有垃圾回收功能;

③数组下标越界检查,抛出异常;

④多态,面向对象的基石。

比较:JVM、 JRE、 JDK

从图中我们也可以看出是逐级向上的、包含的关系!

2. 常见的 JVM

最常用的就是:HotSpot,Oracle JDK edition、Eclipse OPenJ9;接下来的讲解都是基于HotSpot!

3. 学习路线

主要分为三大块:类加载器ClassLoaderJVM内存结构执行引擎

学习顺序:先学习JVM内存结构、然后学习GC垃圾回收机制、再学习JavaClass字节码、然后学习类加载器ClassLoader、最后学习执行引擎的其它内容。

二:JVM内存结构

1. 程 序 计 数 器(PC Register)

(1)定义

ProgramCounterRegister 程序计数器 ( 寄 存 器 )

特 点: 是线程私有的、 不会存在内存溢 出!

(2)作用

执行过程:Java源代码---》经过编译生成二进制字节码(一些JVM指令)---》经过解释器---》解释成机器码---》最后交给CPU来执行!

程序计数器的作用:在程序执行的过程中记住下一条JVM指令的执行地址(前面的数字就可以理解为执行地址)。例如:拿到第一条getstatic指令交给解释器、解释器变成机器码、机器码交给CPU;于此同时会把下一条指令(astore_1)的地址(3),放入程序计数器,等到第一条指令执行结束,解释器就会程序计数器中取下一条指令(astore_1)的地址(3),依次重复!

思考:如果没有程序计数器会有什么问题?

就会造成接下来不知道执行哪一条指令!实际上程序计数器在物理上是通过寄存器实现的!

2. 虚 拟 机 栈(JVM Stacks)

栈:是一种数据结构,先进后出或者说后进先出;一个线程一个栈!

(1)定义

Java Virtual Machine Stacks (Java 虚拟机栈)

①每个线程运行时所需要的内存,称为虚拟机栈;

②每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存;

③每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

总结:

---》对应着线程运行所需要的内存空间。

栈帧---》对应着每个方法运行时所需要的内存空间。

我们可以通过下面一段代码理解栈和栈帧,通过Debug模式

问题分析:

(1)垃圾回收是否涉及栈内存?

答:不涉及,栈帧内存每次方法结束后,都会弹出栈,自动释放回收;我们知道垃圾回收机制只能回收堆内存的无用对象。

(2)栈内存分配越大越好吗?

答:不是,栈内存越大,会导致线程数变少,因为物理内存的大小是一定的。例如:一个线程分配1M,物理内存总共500M,理论上只能分配500个线程;若一个线程分配2M,理论上只能分配250个线程!

注:栈内存划分大了,只是能够进行更多次的方法调用,并不会使运行效率提高!

注:在运行时,可以使用 -Xss size来指定分配的栈内存大小;默认情况下:Linux、macOS分配的是1024KB,对于Windows是根据虚拟内存大小进行分配

(3)方法内的局部变量是否线程安全?

例1:分析多个线程调用是否会使变量x的值混乱?

我们知道一个线程一个栈,对于不同的线程进行调用,都会产生新的栈帧,每个线程都有自己私有的变量x。

例2:分析多个线程调用方法,能否保证线程安全?

①m1方法,局部变量,没有逃离方法的作用范围,方法结束变量释放,线程安全的;

②m2方法,方法中的变量(前提是引用数据类型),其它线程可以通过这个方法进行调用,非线程安全的;

③m3方法,把这个局部变量(前提是引用数据类型)通过return返回,其它线程可以接收,然后执行其它操作;

总结:

①如果方法内局部变量没有逃离方法的作用范围,它是线程安全的;

②如果是局部变量是一个引用类型(基本数据类型肯定还是线程安全的),并逃离方法的作用范围,需要考虑线程安全;

(2)栈内存溢出

情况一:栈帧过多导致栈内存溢出

栈的大小是固定的,假如我们不断的调用,使得栈帧不断的压栈,最终就会导致栈内存溢出;例如:递归调用,没有结束条件,最终抛出StackOverflowError异常!

情况二:栈帧过大导致栈内存溢出

栈帧过大,一下子就超过了栈的大小;很少见!

(3)线程运行诊断

案例1: cpu 占用过多(有可能是死循环)

定位 :在Linux环境下,运行Java代码,nohub java 类 &

nohub:意思是不挂断 。使用Xshell 等Linux 客户端工具,远程执行 Linux 脚本时,有时候会由于网络问题,导致客户端失去连接,终端断开,脚本运行一半就意外结束了。这种时候,就可以用nohup 指令来运行指令,即使客户端与服务端断开,服务端的脚本仍可继续运行。

&:表示在后台进行运行。

先使用top命令可以定位哪个进程对cpu的占用过高

ps H -eo pid,tid,%cpu | grep 进程id用ps命令进一步定位是哪个线程引起的cpu占用过高)

H:显示树状结构,表示进程间的相互关系

-eo:规定输出那些感兴趣的内容,例如:进程id(pid)、线程id(tid)、Cpu的占用情况(%cpu)

|:代表管道符,经常与grep筛选命令一起使用

jstack 进程id:可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号!

注意:上面显示的32665线程号是十进制,而jstack显示的十六进制对应的就是7F99

案例2:程序运行很长时间没有结果 (有可能是发生了线程的死锁)

先执行Java程序,nohub java 类 &,就会显示进程id

 jstack 进程id:此时我们无法获知线程id,看末尾执行结果的提示

何时发生线程死锁?

对于一个类,含有a、b属性,对于t1线程先锁a,在锁b;对于t2线程先锁b,在锁a;这种情况程序就会僵持在哪里,也没有抛出异常,这种情况下的排查错误是非常难的!

3. 本 地 方 法 栈(Native Method Stacks)

定义:JVM调用本地方法时,需要给这些本地方法提供的内存空间!

解释本地方法(Native Method):指那些不是由Java代码编写的方法,例如:利用C、C++编写的本地方法来与操作系统打交道,Java代码可以通过这些本地方法来调用到这些底层的功能;只写本地方法使用的内存就是本地方法栈!

例如:Object类的克隆方法

4. 堆(Heap)

前面学习的栈Stack是线程私有的、堆Heap是线程共享的!

堆(Heap):通过 new 关键字,创建对象都会使用堆内存

特点:

①它是线程共享的,堆中对象都需要考虑线程安全的问题 ;

②有垃圾回收机制;

(1)堆 内 存 溢 出

首先先看下面这段代码:

首先创建一个ArrayList集合,写一个死循环,字符串不断进行拼接,然后放到List集合当中!

public class Demo1_5 {

    public static void main(String[] args) {
        int i = 0;
        try {
            List<String> list = new ArrayList<>();
            String a = "hello";
            while (true) {
                list.add(a); // hello, hellohello, hellohellohellohello ...
                a = a + a;  // hellohellohellohello
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
            System.out.println(i);
        }
    }
}

执行结果:内存溢出,抛出OutOfMemoryError异常

可以使用 -Xmx size来指定分配的堆空间大小

(2)堆 内 存 诊 断

①jps工具:查看当前系统中有哪些java进程

②jmap工具:查看堆内存占用情况,查看的是某一个时刻; jmap -heap 进程id

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

案例1:

首先先创建一个byte数组,会在堆内存中开辟10M的空间;然后把数组的引用arr置为null,开启垃圾回收机制进行回收;中间的sleep睡眠是为了方便执行指令进行监控。

public class Demo1_4 {

    public static void main(String[] args) throws InterruptedException {
        System.out.println("1...");
        Thread.sleep(30000);
        byte[] array = new byte[1024 * 1024 * 10]; // 10 Mb
        System.out.println("2...");
        Thread.sleep(20000);
        array = null;
        System.gc();
        System.out.println("3...");
        Thread.sleep(1000000L);
    }
}

使用IDEA运行此程序,打开自带的dos窗口,输入命令

①先输入jps命令,查看有哪些Java进程

②使用jmap进行检测

第一步:在控制台打印输出1,也就是未创建10M内存空间时,使用jmap -heap 18756进行检测

第二步:在控制台上打印出2,使用jmap -heap 18756进行检测(此时创建10M的空间)

第三步: 在控制台上打印出3,使用jmap -heap 18756再次进行检测(此时引用置为null),并启用了垃圾回收机制进行回收

 ③使用jconsole进行检测(图形化界面进行显示)

步骤:直接输入jconsole--->显示图形化界面,找到要检测的类---》选择不安全连接;就会动态显示每一个时刻的检测效果!

案例2:调用垃圾回收后,内存占用仍然很高

这里先把这段代码给出来,假如我们不知道代码的具体实现,怎么去一步排查

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

public class ClazzMapperTest {

    public static void main(String[] args) throws InterruptedException {
        List<Student> students = new ArrayList<>();
        for (int i = 0; i < 200; i++) {
            students.add(new Student());
        }
        Thread.sleep(1000000000L);
    }
}
class Student {
    private byte[] big = new byte[1024*1024];
}

第一步:使用jps查看进程的id

第二步:使用jmap -head 进程id查看内存使用情况;分为两部分:

Eden区: 

Old Generation区: 

第三步:使用jconsole工具,执行垃圾回收机制GC;发现相对于最初的状态确实回收了一部分内存,但是还有200多M没有被回收!

第四步:实际上200多M,Eden区确实被回收了不少,但是Old Generation区却没有被回收;使用更加好用的检测工具jvisualvm(JDK9以后就没有了,需要下载插件)进行检测

①找到堆 Dump表示抓取当前堆的快照

② 查找前20个占用堆内存最大的对象

③可以找到占用堆内存最大的对象是一个ArrayList对象

④点击去ArrayList,查看它的属性都是Student对象 ;总共有244个项,其中Student项有200个,其它的都是Object对象(已经被释放掉了);一个Student对象占用1M左右,200个就是占用200多兆,这样就能排查出来。

⑤再结合源码分析,在主方法main执行结束之前(调用了sleep方法睡眠),ArrrayList集合中存储了大量的Student对象,无法释放;最终使得垃圾回收后,内存占用仍然很高!

5. 方 法 区(Method Area)

(1)定义

(1)方法区域类似于用于传统语言的编译代码的存储区域,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。方法区域是在虚拟机启动时创建的方法区域在逻辑上是堆的一部分、方法区域中的内存无法满足分配请求,Java虚拟机将抛出OutOfMemoryError。

(2)特点:

①方法区是线程共享的,如果多个线程用到同一个类的时候,若这个类还未被加载,此时只能有一个线程去加载类,其他线程需要等待;

②方法区的大小可以是非固定的,jvm可以根据应用需要动态调整,jvm也支持用户和程序指定方法区的初始大小;

③方法区有垃圾回收机制,一些类不再被使用则变为垃圾,需要进行垃圾清理。

(2)组成

JVM1.6版本内存结构:

使用一个PermGen永久代作为方法区的实现,这个永久代包括以下信息:Class类的信息、ClassLoader类加载器信息、StringTable(字符串表)运行时常量池

JVM1.8版本内存结构:

使用一个Metaspace元空间作为方法区的实现,存储以下信息:Class类的信息、ClassLoader类加载器信息、常量池(和上面不同的地方);已经不占用堆内存了,换句话说不是由JVM来管理它的内存结构了;移到本地内存(操作系统内存)

 (3)方法区内存溢出

①JDK1.8以前会导致永久代内存溢出

我们没有设置内存的上限,它会把10000个类全都加载到内存当中,可以使用参数进行设置,指定源空间内存的大小:-XX:MaxPermSize=8m

package cn.itcast.jvm.t1.metaspace;

import jdk.internal.org.objectweb.asm.ClassWriter;
import jdk.internal.org.objectweb.asm.Opcodes;

// 把下面10000个类加载到内存当中
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 < 20000; 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);
        }
    }
}

加上-XX:MaxPermSize=8m 只循环了19314次,就抛出了永久代溢出异常

②JDK1.8之后会导致元空间内存溢出

相同的代码,使用参数进行设置,指定源空间内存的大小:-XX:MaxMetaspaceSize=8m

加上-XX:MaxMetaspaceSize=8m 只循环了5411次,就抛出了元空间内存溢出

(4)运行时常量池

①先理解常量池

对于二进制字节码包括类基本信息,常量池,类方法定义,包含了虚拟机指令;先看以下代码,编译生成HelloWorld.class文件,使用:javap -v HelloWorld.class进行反编译

package cn.itcast.jvm.t5;
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

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

②运行时常量池

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

(5)StringTable

StringTable特性:

①常量池中的字符串仅是符号,第一次用到时才变为对象

② 利用串池的机制,来避免重复创建字符串对象

③ 字符串变量拼接的原理是 StringBuilder (1.8);

④ 字符串常量拼接的原理是编译期优化 ;

⑤可以使用 intern方法主动将串池中还没有的字符串对象放入串池

对于1.8 :将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有则放入串池, 会把串池中的对象返回

对于1.6: 将这个字符串对象尝试放入串池,如果有则并不会放入;如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回。

验证上面的特性:

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

进行反编译:

#2就对应着String a,#3就对应着String b,#4就对应着String ab

 astore_1就把加载好的字符串对象存入1号局部变量s1,其它依次类推

常量池是存在字节码文件.class里,当运行的时候会放到运行时常量池当中;但是加载到运行时常量池当中时,还没有成为java字符串对象,直到具体执行到引用它的那一行代码;例如:执行到String s1 = "a",会把ldc #2 会把a符号变为“a”字符串对象;此时还会准备一块空间StringTable,把“a”字符串对象放进去(如果里面没有的话),这实际上是一个延迟加载(懒惰的)行为;如果串池中有的话,就会直接使用,总而言之,只会存在一份!

所以:会把s1、s2、s3引用指向的“a”、“b”、“ab”放到字符串常量池StringTable当中,StringTable [ "a", "b" ,"ab" ] 底层是hashtable结构,不能扩容。

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

s4=s1+s2,变量拼接,s4引用首先会会创建一个StringBuilder对象,然后调用append方法,把“a”和“b”拼接进去,然后调用toString方法;我们通过查看StringBuilder的toString方法底层原码发现是创建一个新的字符串对象:new String("ab")。

String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
// 问
System.out.println(s3 == s4);

s3 == s4的结果?

s3对应的"ab"是在字符串常量池中的对象,但s4是一个新创建的字符串对象,虽然值是相同的,但是s3是在串池当中的,s4是先创建出来的,== 对比的就是地址,肯定是不一样的,结果是false。

String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // 变量拼接
String s5 = "a" + "b"; // 常量拼接
// 问
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true

s5 = "a" + "b" 直接找的就是已经拼接好的“ab”对象;这是javac在编译器的优化,在编译期间我们就能确定肯定就是“ab”对象,此时常量池中已经存在这个对象,所以 s3 == s5的结果就是true。

注:s4=s1+s2是在运行期间才能确定,去动态拼接!

JDK1.8:会将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回!

String s = new String("a") + new String("b");
// 使用JDK1.8,会将这个字符串对象尝试放入串池,
// 如果有则不会放入,如果没有则放入串池,并把串池中的对象返回
String s2 = s.intern();
System.out.println(s2 == "ab"); // true
System.out.println(s == "ab"); // true

s = new String("a") + new String("b"); 首先会把“a”和“b”放到常量池当中,但是s="ab"不会放进去,因为是变量拼接,会创建一个字符串对象,是存放在堆当中,要想把"ab"放到常量池当中,可以调用intern方法,这样就可以把"ab"放入字符串常量池当中。此时就把s的对象放入常量池当中,并且s2是串池中对象的返回值;所以两个都为true。

String s = new String("a") + new String("b");

String x = "ab";

String s2 = s.intern();
System.out.println(s2 == x); // true
System.out.println(s == x); // false

此时String x = "ab',会把“ab”放到串池当中;此时串池当中已经存在“ab”对象,此时s.intern(),就不会把“ab”对象放入串池当中;而s2 = s.intern返回的一定是一个串池当中的对象;所以此时s2 == x是true,s == x是false。

JDK1.6:将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回!

String s = new String("a") + new String("b");
// 使用JDK1.6,会将这个字符串对象尝试放入串池,
// 如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
String s2 = s.intern();
System.out.println(s2 == "ab"); // true
System.out.println(s == "ab"); // false

使用JDK1.6最主要的区别就是s.intern,此时是拷贝一份放入串池当中,而不是把s本身的对象放入串池,s还是堆中的对象,此时s == "ab"就是false。

经典面试题:

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); // false
System.out.println(s3 == s5); // true
System.out.println(s3 == s6); // true
String x2 = new String("c") + new String("d"); // new String("cd")
String x1 = "cd"; // "cd"
x2.intern();
System.out.println(x1 == x2); // false

// 问,如果调换了x1,x2的位置呢?如果是jdk1.6呢?
String x2 = new String("c") + new String("d"); // new String("cd")
x2.intern(); //先把“ab”入常量池
String x1 = "cd"; 
System.out.println(x1 == x2); 
// 此时对于JDK1.8-true,对于JDK1.6-false

(6)StringTable的位置

对于JDK1.6

对于JDK1.8

 那么能通过代码直观上体现出StringTable的位置吗?

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

public class Demo1_6 {

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

对于JDK1.6把永久代的内存设置小一点:-XX:MaxPermSize=10m

对于JDK1.8把堆的内存设置小一点:-Xmx10m,此时并没有提示堆内存不足错误;下面的提示表示使用98%的精力去回收,但是值回收了2%,就会报这个错误提示。

此时需要在加上一个参数,关闭这个提示 -Xmx10m -XX:-UseGCOverheadLimit

(7)StringTable的垃圾回收机制

StringTable也是受到垃圾回收机制的管理的,当内存空间不足时,StringTable中那些还没有被引用的字符串常量就会被垃圾回收器回收!

设置参数:-Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc

-Xmx10m:设置虚拟机堆内存的最大值;

-XX:+PrintStringTableStatistics:打印字符串表的统计信息;

-XX:+PrintGCDetails -verbose:gc:打印垃圾回收的一些信息(若发生了垃圾回收的话);

package cn.itcast.jvm.t1.stringtable;

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

public class Demo1_7 {
    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);
        }

    }
}

①先不添加循环代码,此时查看StringTable的存储情况

②若循环100次,此时还没有超过堆内存的大小,不会触发垃圾回收机制

③若循环10000次,此时已经超过堆内存的大小,会触发垃圾回收机制进行回收

 启动了垃圾回收机制的打印信息:

(8)StringTable性能调优

方法1:调整 -XX:StringTableSize = 桶个数

StringTable的底层是一个哈希表(数组+链表),哈希表的性能是和它的大小密切相关的:如果哈希表桶的个数比较多,元素就会比较分散,哈希碰撞的几率就会减少,查找的速率也会变快。如果桶的个数较少,哈希碰撞的几率就会增高,导致链表比较长,查找的速度就会受到影响!

package cn.itcast.jvm.t1.stringtable;

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

/**
 * 演示串池大小对性能的影响
 * -Xms500m -Xmx500m -XX:+PrintStringTableStatistics -XX:StringTableSize=1009
 */
public class Demo1_24 {

    public static void main(String[] args) throws IOException {
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("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); // 毫秒
        }


    }
}

所谓的调优,最主要的就是调整桶的个数 -XX:StringTableSize = 桶 数,不设置虚拟机的内存的最大值,对于四万多个数据可以轻松入池!

-XX:StringTableSize = 200000,把桶的个数调整为200000

②不加-XX:StringTableSize这个参数,使用的默认桶大小60013

结论:桶的个数越小,耗费的时间最多;并且最小的桶个数是1009!

方法二:考虑将字符串对象是否入池

假设现在有大量的字符串对象被创建,例如:linux.words文件中有4.8万个串,循坏10次,对比入池与不如池的内存使用情况。

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;

public class Demo1_25 {

    public static void main(String[] args) throws IOException {

        List<String> address = new ArrayList<>();
        System.in.read();
        for (int i = 0; i < 10; i++) {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream("linux.words"), "utf-8"))) {
                String line = null;
                long start = System.nanoTime();
                while (true) {
                    line = reader.readLine();
                    if(line == null) {
                        break;
                    }
                    // 不入池
                    address.add(line);
                    // 入池
                    address.add(line.intern());
                }
                System.out.println("cost:" +(System.nanoTime()-start)/1000000);
            }
        }
        System.in.read();


    }
}

①address.add(line)不入池,此时相当于有48万个数据被添加到List集合当中。

使用jvisualvm工具,选择抽样器:

对内存的占用进行图形化的展示:

读取之前,此时字符串占用的内存大概是1M左右:

 读取之后,此时字符串占用的内存大概是110M左右:

②address.add(line.intern())入池,入池以后,后面循环9次的数据都是重复的,都是直接使用只有第一次入池的数据即可,此时读取之后,String+创建的char数组也才40M不到,大大节省了堆内存空间!

三:直接内存

(1)定义

直接内存不属于Java虚拟机里面的内存,是操作系统的内存!

直接内存:DirectMemory

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

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

③不受JVM内存回收管理;

 案例:使用传统的IO流和直接内存进行比较

package cn.itcast.jvm.t1.direct;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * 演示 ByteBuffer 作用
 */
public class Demo1_9 {
    static final String FROM = "E:\\编程资料\\text.txt";
    static final String TO = "E:\\a.txt";
    static final int _1Mb = 1024 * 1024;

    public static void main(String[] args) {
        io(); // io 用时:3秒左右
        directBuffer(); // directBuffer 用时:1秒左右
    }
    // 使用直接内存的方式
    private static void directBuffer() {
        long start = System.nanoTime();
        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 (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
    }
    // 传统的IO流
    private static void io() {
        long start = System.nanoTime();
        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 (IOException e) {
            e.printStackTrace();
        }
        long end = System.nanoTime();
        System.out.println("io 用时:" + (end - start) / 1000_000.0);
    }
}

我们会发现,使用直接内存ByteBuffer比传统的IO流拷贝文件(特别是大文件)的速度明显快很多,就从文件的读写过程进行分析!

对于传统的IO流:

Java本身并不具备读写磁盘的能力,必须调用操作系统提供的函数;就是从Java的方法调用到本地的方法;此时CPU会从用户态切换到内核态;此时就可以读取磁盘文件的内容,此时会在操作系统中划出来一层缓冲区(系统缓冲区),磁盘的内容就会先读取到这个系统缓冲区(分次读取,并且Java的代码是不能读取系统缓冲区的内容的),此时Java也会在堆内存中分配一块Java的缓冲区;Java要想读取到数据,必须先从系统缓存数据读入到Java缓冲区;两块缓冲区,相当于读取的时候必须存两份(造成不必要的数据复制),效率较低!

对于直接内存:

当ByteBuffer调用allocateDirect方法时,会在操作系统间划出一块缓冲区(direct memory),这块区域Java代码是可以直接访问的;这块内存无论是操作系统还是Java代码都是可以直接访问的,共享的一块内存区域;只有一次缓冲区的读入,效率较高!

不受JVM内存回收管理,所以直接内存也会导致内存溢出,例:

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;


// 演示直接内存溢出
public class Demo1_10 {
    static int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);
        }
        // 方法区是jvm规范, jdk6 中对方法区的实现称为永久代
        //                  jdk8 对方法区的实现称为元空间
    }
}

直接内存使用操作不当,也会导致内存溢出:

(2)分配和回收原理

例:

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

    public static void main(String[] args) throws IOException {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb); // 分配1G的空间
        System.out.println("分配完毕...");
        System.in.read();
        System.out.println("开始释放...");
        byteBuffer = null; // 空引用
        System.gc(); // 启动垃圾回收
        System.in.read();
    }
}

查看任务管理器,分配1G:

查看任务管理器,置为null,启动垃圾回收机制,发现竟然被回收了!前面不是说直接内存不受JVM内存回收管理吗?为什么垃圾回收之后,直接内存就被回收释放了?

这就需要解释一下直接内存的释放原理:首先通过某种方式获取unsafe对象,通过unsafe对象就可以完成直接内存的分配和回收!

注:对于直接内存的监控,就不能使用IDEA中的那些监控工具,需要看任务管理器中的进程

分配内存:

long base = unsafe.allocateMemory(_1Gb);
unsafe.setMemory(base, _1Gb, (byte) 0);

释放内存:

unsafe.freeMemory(base);

ByteBuffer.allocateDirect方法底层是使用了ByteBuffer的实现类DirectByteBuffer

DirectByteBuffer的构造器中就调用了unsafe的allocateMemory方法对直接内存进行分配

DirectByteBuffer的构造器中内部还使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程通过Cleaner的clean方法去执行任务对象Deallocator,任务对象在调用unsafe对象的freeMemory 来释放直接内存

-XX:+DisableExplicitGC:禁用显式回收对直接内存的影响,就是让System.gc()无效,但是此时就会影响直接内存的释放,我们就可以使用unsafe对象手动释放直接内存!

猜你喜欢

转载自blog.csdn.net/m0_61933976/article/details/129228283