目录
一: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. 学习路线
主要分为三大块:类加载器ClassLoader、JVM内存结构、执行引擎。
学习顺序:先学习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 来释放直接内存
文章来源:https://www.toymoban.com/news/detail-451808.html
-XX:+DisableExplicitGC:禁用显式回收对直接内存的影响,就是让System.gc()无效,但是此时就会影响直接内存的释放,我们就可以使用unsafe对象手动释放直接内存!文章来源地址https://www.toymoban.com/news/detail-451808.html
到了这里,关于Java虚拟机快速入门 | JVM引言、JVM内存结构、直接内存的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!