JVM第一卷
- 引言
-
- 定义:
- 学习路线
- 内存结构
-
- 1. 程序计数器
-
- 定义
- 作用
- 2.虚拟机栈
-
- 定义
- 问题辨析
- 栈内存溢出
- 线程运行诊断
-
- 案例1: cpu 占用过多
- 案例2:程序运行很长时间没有结果,死锁现象
- 3.本地方法栈
- 4.堆
-
- 定义
- 堆内存溢出
- 堆内存诊断
- 5. 方法区
-
- 定义
- 组成
- 方法区内存溢出
- 常量池
- 运行时常量池
- StringTable
-
- 常量池和字符串池的关系
- 字符串变量拼接
- 编译期优化---常量拼接
- 字符串延迟加载验证
- StringTable特性
- StringTable 位置
- StringTable 垃圾回收
- StringTable 性能调优
- 6. 直接内存
-
- 定义
- 直接内存溢出
- 直接内存释放原理
- 分配和回收原理
引言
- 什么是 JVM ?
- 学习路线
定义:
Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
好处:
- 一次编写,到处运行
- 自动内存管理,垃圾回收功能
- 数组下标越界检查
- 多态
比较:
jvm jre jdk
学习路线
内存结构
- 程序计数器
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
1. 程序计数器
定义
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虚拟机的概念模型里,
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
,它是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。
大白话: 程序计数器负责记录当前执行到哪一行字节码指令了;
字节码解释器就是从程序计数器拿到当前执行的字节码指令进行解释,然后翻译为机器码交给cpu执行
并且同时程序计数器会获取下一条字节码指令的行号
2.虚拟机栈
定义
Java Virtual Machine Stacks (Java 虚拟机栈)
- 每个线程运行时所需要的内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
代码演示
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
mehtod1();
}
public static void mehtod1()
{
int a=1,b=2;
method2(a,b);
}
private static int method2(int a, int b) {
int c=a+b;
return c;
}
}
问题辨析
- 垃圾回收是否涉及栈内存?
栈内存就是一次次的方法调用产生的栈帧内存,而栈帧内存在每一次方法调用后,都会被弹出栈,即自动被回收掉,不需要垃圾回收来管理栈内存
- 栈内存分配越大越好吗?
栈内存可以在运行时,用过一个
虚拟机参数-Xss来指定大小
栈内存越大,线程数越少;如果内存有500m,我们设置每个线程的栈内存为2m,那么只能同时最多运行250个线程;
如果设置为1m,那么可以同时最多运行500个线程;
由此可知,栈内存设置的越大,反而会影响运行的效率;
栈内存越大,只能够提高方法更多次的递归调用
- 方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用访问,它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
局部变量只要没有逃离方法的作用范围,便是线程安全的:
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
for(int i=0;i<3;i++)
{
new Thread(()->{
mehtod1();
}).start();
}
}
public static void mehtod1()
{
int i=0;
for (int j=0;j<10;j++)
{
i++;
}
log.debug("i= {}",i);
}
}
输出
[Thread-0] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
[Thread-1] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
[Thread-2] [DEBUG] [2022年01月09日 10时47分08秒639毫秒] 消息:i= 10
栈内存溢出
- 栈帧过多导致栈内存溢出 -----无限递归
- 栈帧过大导致栈内存溢出
线程运行诊断
案例1: cpu 占用过多
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main {
public static void main(String[] args)
{
new Thread(()->{
while(true){}
},"大忽悠线程1号").start();
}
}
定位
- 用top定位哪个进程对cpu的占用过高
top命令提供了实时的对系统处理器的状态监视.它将显示系统中CPU最“敏感”的任务列表.该命令可以按CPU使用.内存使用和执行时间对任务进行排序
- ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)
ps 为我们提供了进程的一次性的查看,它所提供的查看结果并不动态连续的;如果想对进程时间监控,应该用 top 工具
H是列出当前进程下所有线程信息,-eo是选择自己感兴趣的属性进行展示
- jstack 进程id
可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号
案例2:程序运行很长时间没有结果,死锁现象
/**
* @author 大忽悠
* @create 2022/1/8 15:20
*/
@Slf4j
public class Main
{
private static Object a=new Object();
private static Object b=new Object();
public static void main(String[] args)
{
new Thread(()->{
synchronized (a)
{
try {
Thread.sleep(Long.parseLong("1000"));
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b)
{
System.out.println("鸡汤来喽12...");
}
}
},"大忽悠线程1号").start();
new Thread(()->{
synchronized (b)
{
try {
Thread.sleep(Long.parseLong("1000"));
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a)
{
System.out.println("鸡汤来喽2...");
}
}
},"大忽悠线程2号").start();
}
}
3.本地方法栈
本地方法就是java调用非java代码的接口,并不是所有的 JVM都支持本地方法, 因为 Java虚拟机规范上, 并没有明确要求本地方法的使用语言和具体实现方法. Hotspot VM是本地方法栈和虚拟机栈合二为一的虚拟机
- 本地方法栈是管理本地方法运行的, 本地方法是通过 C语言实现的, 在 Execution Engine执行时加载本地方法库(Native Method Library)
- 与虚拟机栈相同: 没有 GC, 不同线程间是隔离的(线程私有的)
- 当一个线程调用本地方法时,他就进入了一个全新的不再受虚拟机限制的世界,他和JVM有同样的权限。
- 本地方法可以访问JVM运行时数据区,可以直接使用寄存器,可以直接从堆中分配任意数量的内存。
java中本地方法的体现: native标识
- 标识符native可以与所有其它的java标识符连用,但是abstract除外。这是合理的,因为native暗示这些方法是有实现体的,只不过这些实现体是非java的,但是abstract却显然的指明这些方法无实现体。native与其它java标识符连用时,其意义同非Native Method并无差别,比如native static表明这个方法可以在不产生类的实例时直接调用,这非常方便,比如当你想用一个native method去调用一个C的类库时。上面的第三个方法用到了native synchronized,JVM在进入这个方法的实现体之前会执行同步锁机制(就像java的多线程。)
- 一个native method方法可以返回任何java类型,包括非基本类型,而且同样可以进行异常控制这些方法的实现体可以制一个异常并且将其抛出,这一点与java的方法非常相似。当一个native method接收到一些非基本类型时如Object或一个整型数组时,这个方法可以访问这非些基本型的内部,但是这将使这个native方法依赖于你所访问的java类的实现。有一点要牢牢记住:我们可以在一个native method的本地实现中访问所有的java特性,但是这要依赖于你所访问的java特性的实现,而且这样做远远不如在java语言中使用那些特性方便和容易。
- 如果一个含有本地方法的类被继承,子类会继承这个本地方法并且可以用java语言重写这个方法,同样的如果一个本地方法被fianl标识,它被继承后不能被重写。
为什么要使用Native Method
- 与java环境外交互:
有时java应用需要与java外面的环境交互。这是本地方法存在的主要原因,你可以想想java需要与一些底层系统如操作系统或某些硬件交换信息时的情况。本地方法正是这样一种交流机制:它为我们提供了一个非常简洁的接口,而且我们无需去了解java应用之外的繁琐的细节。
- 与操作系统交互:
JVM支持着java语言本身和运行时库,它是java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。然而不管怎 样,它毕竟不是一个完整的系统,它经常依赖于一些底层(underneath在下面的)系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们得以用java实现了jre的与底层系统的交互,甚至JVM的一些部分就是用C写的,还有,如果我们要使用一些java语言本身没有提供封装的操作系统的特性时,我们也需要使用本地方法。
- JVM怎样使Native Method跑起来:
我们知道,当一个类第一次被使用到时,这个类的字节码会被加载到内存,并且只会回载一次。在这个被加载的字节码的入口维持着一个该类所有方法描述符的list,这些方法描述符包含这样一些信息:方法代码存于何处,它有哪些参数,方法的描述符(public之类)等等。
如果一个方法描述符内有native,这个描述符块将有一个指向该方法的实现的指针。这些实现在一些DLL文件内,但是它们会被操作系统加载到java程序的地址空间。当一个带有本地方法的类被加载时,其相关的DLL并未被加载,因此指向方法实现的指针并不会被设置。当本地方法被调用之前,这些DLL才会被加载,这是通过调用java.system.loadLibrary()实现的
4.堆
定义
Heap 堆
- 通过 new 关键字,创建对象都会使用堆内存
特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
堆内存溢出
对象可以当做垃圾被回收的条件是没有人在使用该对象,如果我不断的产生对象,并且这些对象有人在使用他们,这些对象就会占用堆内存而不释放
堆内存溢出演示:
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);
}
注意:Stirng对象是不可变对象,因此每次string操作后得到的是一个新的对象
堆内存诊断
- jps 工具
查看当前系统中有哪些 java 进程
- jmap 工具
查看堆内存占用情况 jmap - heap 进程id;
只能查询某一时刻堆内存占用情况,不能对堆内存做连续监测
Windows 操作系统系统 IDEA 中 jmap 命令 Error
使用演示:
/**
*
* 展示堆内存
*/
public class Main
{
public static void main(String[] args) throws InterruptedException {
System.out.println("1.....");
Thread.sleep(30*1000);
//10 MB
byte[] array=new byte[1024*1024*10];
System.out.println("2....");
Thread.sleep(30*1000);
//让堆内存可以被垃圾回收
array=null;
//手动调用,进行垃圾回收
System.gc();
System.out.println("3....");
Thread.sleep(1000000L);
}
}
- jconsole 工具
图形界面的,多功能的监测工具,可以连续监测
- jvirsualvm–JVM可视化工具
垃圾回收后,内存占用仍然很高。
执行GC之后,堆内存只释放了30M左右。
输入jvirsualvm打开JVM可视化工具,然后复制当前堆内存的快照信息,进行分析排查
这样对堆快照的分析,就可以看出问题所在
jdk自带监控程序jvisualvm的使用
jdk工具之JvisualVM、JvisualVM之一–(visualVM介绍及性能分析示例)
5. 方法区
定义
官网定义
下面是Method Area的中文翻译:
Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区,或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化和接口初始化中使用的特殊方法
(第 2.9 节)。
方法区是在虚拟机启动时创建的
。尽管方法区在逻辑上是堆的一部分
,但简单的实现可能会选择不进行垃圾收集或压缩它。本规范不要求方法区域的位置
或用于管理已编译代码的策略。方法区域可以是固定大小,也可以根据计算需要扩大,如果不需要更大的方法区域,可以缩小。方法区的内存不需要是连续的。
Java 虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,以及在方法区域大小可变的情况下,对最大和最小方法区域大小的控制。
以下异常情况与方法区相关:
如果方法区域中的内存无法满足分配请求,Java 虚拟机将抛出 OutOfMemoryError。
组成
方法区是一个概念,不同的jvm对其的实现不同
Oracle JDK 8之前方法区的实现与JDK8之后
方法区内存溢出
演示:
1.8 之后会导致元空间内存溢出
元空间默认没有内存上限设置,因此最好手动设置上限,才能观察到内存溢出的情况 -XX: MaxMetaspaceSize=8m
/**
* 演示元空间内存溢出
* -XX: MaxMetaspaceSize=8m
*/
public class Main extends ClassLoader//可以用来加载类的二进制字节码
{
public static void main(String[] args){
int j=0;
try{
Main main=new Main();
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[] code = cw.toByteArray();
//执行类的加载
main.defineClass("Class"+i,code,0,code.length);
}
}finally {
System.out.println(j);
}
}
}
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m
1.8 以前会导致永久代内存溢出
* 演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
* -XX:MaxPermSize=8m
常量池
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
实例演示
//下面的程序想要运行,首先要编译成二进制字节码
//二进制字节码包含以下几个部分:
//类的基本信息,常量池,类方法定义包含了虚拟机指令
public class Main
{
public static void main(String[] args){
System.out.println("Hello World");
}
}
javap -v 编译后的字节码文件: 该指令可以反编译二进制字节码文件,输出二进制字节码包含的信息
类方法定义:
{
public com.dhy.Main(); //默认提供的构造方法
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/dhy/Main;
public static void main(java.lang.String[]); //main方法
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code: //下面就是虚拟机指令
stack=2, locals=1, args_size=1
//获取一个静态变量,去常量池中寻找#2对应的值
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
//加载常量池中的值,去常量池找#3对应的值
3: ldc #3 // String Hello World
//执行一次虚方法调用,同理
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "Main.java"
上面的类方法定义部分与还没讲的常量池部分有什么联系呢?
常量池:
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
//#2是成员变量 具体在#21和#22
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello World
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/dhy/Main
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/dhy/Main;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 Main.java
#20 = NameAndType #7:#8 // "<init>":()V
//#21是类,具体类对应#28
#21 = Class #28 // java/lang/System
//#22表示名字和类型, 具体对应#29 后面:#30是类型
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello World
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/dhy/Main
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
运行时常量池
运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址(#2等)变为真实地址
StringTable
首先分析一下下面程序的字节码文件:
public class Main
{
public static void main(String[] args){
String s1="a";
String s2="b";
String s3="ab";
}
}
常量池和字符串池的关系
常量池和字符串池的关系
- 常量池中的信息,在程序运行时都会被加载到运行时常量池中,这时a b ab 都是常量池中的符合,还没有变为java字符串对象
- 执行完ldc命令时,会将新生成的字符串对象a放入String Table[]字符串常量池
如果字符串常量池中已经存在了符合a对应的字符串对象,就直接引用字符串常量池中的,否则会新创建一个值为a的字符串对象在堆上,然后将该对象的引用放入字符串常量池中
字符串变量拼接
public class Main
{
public static void main(String[] args){
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
}
}
这次我们重点看一下第四行代码对应的字节码
StringBuilder的tostring方法源码:
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
可以看出是新创建了一个String对象
问:s3和s4是否是同一个对象
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
System.out.println(s3==s4);
输出
false
原因:
- s3是放在字符串常量池中String对象
- 而S4是放在堆内存中的String对象,因此两者不一样,只是值相同
编译期优化—常量拼接
String s1="a";
String s2="b";
String s3="ab";
String s4=s1+s2;
String s5="a"+"b";
System.out.println(s3==s5);
请判断输出结果
输出
true
原因
- 先来分析字节码
- 本质是javac在编译期间的优化,结果在编译期间就已经确定了
常量做拼接,结果是确定的,因此javac会对其做优化
变量做拼接,运行的时候引用的值可能被修改,结果是不确定的,因此会在运行期间通过stringbuilder动态做拼接
字符串延迟加载验证
测试可以得出下面的数据:
System.out.println("a"); //字符串个数为2373
System.out.println("b");//字符串个数为2374
System.out.println("a");//字符串个数为2374
System.out.println("b");//字符串个数为2374
System.out.println("x");//字符串个数为2375
- 字符串对象只有在使用到的时候,才会进行创建
- 如果常量池中已经存在了,就不会进行创建
StringTable特性
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (jdk 1.8)
- 字符串常量拼接的原理是编译期优化
- 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- jdk 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串 池中的对象返回
解释一下jdk8版本的intern方法: 如果字符串对象的引用在字符串常量池中存在,则再次放入的时候,发现存在相同的字符串对象,不会进入放入操作,而是返回此时字符串常量池中对象的引用
- jdk 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回
String s1 = "a"; //字符串常量池一开始没有放入字符串常常量池
String s2 = "b"; //同上
String s3 = "a" + "b"; //编译器优化后,同上
String s4 = s1 + s2; //new StringBuilder.append("a").append("b").toString()
String s5 = "ab"; //字符串常常量池中有,拿字符串常常量池中的引用
String s6 = s4.intern(); //尝试将s4在堆上的ab放入字符串常常量池中,但是此时字符串常常量池中存在,直接返回字符串常常量池中的对象引用(jdk 1.8)
// 问
System.out.println(s3 == s4);//false,一个在串池中,一个在堆上
System.out.println(s3 == s5); //true都是串池中的引用
System.out.println(s3 == s6);//true,s6最终得到的是串池的引用,s3是串池引用
//下面首先将使用到的常量c和d放入串池
//但是new String("c")是在堆上的,new String("d")也在堆上
//这里x2是StringBuilder创建出来的
String x2 = new String("c") + new String("d");
String x1 = "cd";//"cd"入池,返回池中引用
System.out.println(x1 == x2);//false
x2.intern();//尝试放入常量池,池已有
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);//false,因为池中早一步有了
StringTable 位置
jdk 1.6是存放在永久代中的,jdk1.8是存放在堆中的,原因如下:
- 永久代垃圾回收效率低,需要等到父gc时,而父gc需要等待老年代空间不足才会触发
如何验证?
- jdk 1.6如果字符串常量池占用内存过多,触发的应该是永久代内存溢出异常
- jdk 1.8如果字符串常量池占用内存过多,触发的应该是堆内存溢出的异常
StringTable 垃圾回收
- 程序在运行时,类名方法名也是以字符串常量的形式存储在字符串常量池中的
我们现在了堆内存最大值为10m,现在我们向字符串常量池中再放入10000个字符串常量,此时堆内存不足,会触发垃圾回收
/**
* <p>
* 演示StringTable垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
* 设置堆内存最大值 打印字符串表的统计信息 打印垃圾回收的详细信息
* </p>
*/
public class Main
{
public static void main(String[] args)
{
for(int i=0;i<10000;i++)
{
String.valueOf(i).intern();
}
}
}
注意:垃圾回收只有在内存紧张的时候才会触发
StringTable 性能调优
StringTable底层是HashTable的实现方式,是桶加链表的实现方式,即桶的个数越多,产生hash冲突的概率越小,如果桶的个数越小,不仅hash冲突次数增加,每一次放入链表的查询次数也会耗费很多时间
- 调整 -XX:StringTableSize=桶个数
- 考虑将字符串对象是否入池
思考:如果同时存在一百万个字符串对象,但是其中大部分字符串对象的值都是重复的,如果将这一百万个字符串对象保存在对象,大约需窈几个G的内存,如果将字符串对象都入池,可以起到去重的效果,这样一来,发现占用内存减少到了几百兆
6. 直接内存
定义
Direct Memory
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
java并不具备磁盘读写的能力,如果要进行磁盘的读写操作,需要调用操作系统的方法,也就是我们说的本地方法,此时cpu运行状态会从用户态切换到内核态
切换到内核态后,体现在内存这边,操作系统会将数据从磁盘文件读入系统缓冲区中,系统缓冲区java的代码不能控制,因此需要在java堆内存中分配一块java的缓冲区,也就是我们使用nio时,使用的ByteBuffer,然后再将数据从系统缓冲区读入到java缓冲区,然后java就可以操作ByteBuffer里面的数据
上面的操作中数据复制造成了不必要的浪费,即从系统缓冲区读取数据到java缓冲区的过程
直接内存就是在操作系统这边划分出一块内存,这块内存java代码可以直接访问,即操作系统和java代码可以共享这块内存
这样数据直接读取到直接内存,java也可以直接操作直接内存,这样避免了数据的复制
直接内存溢出
直接内存不受垃圾回收管理
/**
* <p>
* 演示直接内存溢出
* </p>
*/
public class Main
{
static int _100MB=1024*1024*10;
public static void main(String[] args)
{
List<ByteBuffer> list=new LinkedList<>();
int i=0;
try{
while(true)
{
System.out.println(i++);
list.add(ByteBuffer.allocateDirect(_100MB));
}
}finally {
System.out.println(i);
}
}
}
直接内存释放原理
直接内存的释放底层是通过Unsafe对象控制的
下面分析ByteBuffer分配的直接内存,底层是怎么和Unsafe对象联系起来的
直接内存的释放涉及到了垃圾回收过程中的虚引用机制
Cleaner是虚引用类型,特点是当他所关联的对象被垃圾回收时,
Cleaner会触发虚引用的clean方法,
DirectByteBuffer是java对象,会被gc管理
这里会开辟一个线程来处理,这里clean()方法是通过一个refereneceHandler线程来检测这些虚引用对象,当虚引用对象关联的实际对象被回收后,会调用虚引用对象的clean方法,然后调用任务对象的run方法
分配和回收原理
- 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
- ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer对象被垃圾回收,那么就会由 ReferenceHandler 线程(守护线程)通过 Cleaner 的 clean 方法调用freeMemory 来释放直接内存
之所以不推荐直接显示的进行垃圾回收操作,是因为显示垃圾回收时Full GC,会同时触发新生代和老年代的回收,比较费资源