本文笔记参考创智播客JVM课程 和《深入理解Java虚拟机第三版》
一、JVM 入门介绍
1.1 JVM 定义
Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)。
java程序从源代码到运行一般有下面3步:
JVM 好处:
- 一次编译,到处运行(JVM屏蔽了字节码跟底层操作系统的差异,对外提供一致的运行环境。达到平台无关性)
- 自动内存管理,垃圾回收功能
- 数组下标越界越界检查(C++没有)
- 多态
1.2 比较 JVM JRE JDK
JVM 是运行 Java 字节码的虚拟机。JVM屏蔽了字节码跟底层操作系统的差异,对外提供一致的运行环境。达到平台无关性
JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。
JDK 是 Java Development Kit 缩写,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。
本笔记使用的是HotSpot版本
二、内存结构
整体架构图
学习顺序如下图:由简到难
1、程序计数器(寄存器)
Program Counter Register
1.1 作用与特点
- 作用:是记住下一条jvm指令的执行地址。
- 特点:
是线程私有的 (每条线程都有自己的程序计数器)
不会存在内存溢出 (唯一一个在<<Java虚拟机规范>>中不会存在内存溢出区域)
1.2 JVM指令执行流程:
- 每一条二进制字节码(JVM指令)通过解释器翻译成机器码就可以交给CPU执行了。
- 当一条指令执行完后,解释器会到程序计数器取得下一条jvm指令执行地址。
- 程序计数器在物理层面由寄存器实现
- 因此程序计数器的作用:用于是保存下一条jvm指令的执行地址!
2、Java虚拟机栈
Java Virtual Machine Stack
2.1 定义
- 每个线程运行时所需的总内存,称为虚拟机栈
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2.1.1 演示代码
/**
* 演示栈帧
*/
public class Demo1_1 {
public static void main(String[] args) throws InterruptedException {
method1();
}
private static void method1() {
method2(1, 2);
}
private static int method2(int a, int b) {
int c = a + b;
return c;
}
}
Debug看下执行过程:
此时栈顶部的栈帧为活动栈帧,当方法2执行结束时,它占用的内存随着栈帧出栈被释放掉。
继续执行main方法结束。栈为空。程序结束
2.1.2 面试问题
-
1.垃圾回收是否涉及栈内存?
- 不需要:因为栈内存是一次次方法调用产生的栈帧内存,而栈帧内存在每次方法调用结束后会弹出栈,自动回收内存。垃圾回收只是回收堆内存中的无用对象。
-
2.栈内存分配越大越好吗?
- 不是。因为物理内存的大小是一定的,栈内存越大,可以支持更多的递归调用,反而会让可执行的线程数变少。
- 举例:如果物理内存是500M(假设),如果一个线程所能分配的栈内存为1M的话,那么理论上可以有500个线程。而如果一个线程分配栈内存占2M的话,那么最多只能有250个线程同时执行!
- 所以一般采用系统默认的栈内存大小
-
3.方法内的局部变量是否线程安全?
我们通过一段代码来解释
/**
* 局部变量的线程安全问题
*/
public class Demo1_18 {
// 多个线程同时执行此方法
static void m1() {
int x = 0;
for (int i = 0; i < 5000; i++) {
x++;
}
System.out.println(x);
}
}
假如有2个线程都来调用这个方法,会不会造成x值的混乱呢?
- 不会 因为x变量为方法内的局部变量,一个线程对应一个栈,线程内方法调用都会产生新的栈帧。我们结合一个图来看
如果变量x改为static int x。那么结果就不一样了。
- 总结:**变量如果是共享的就需要考虑线程安全,如果是每个线程私有的就不用考虑线程安全。
接下来看一个案例
package cn.itcast.jvm.t1.stack;
/**
* 局部变量的线程安全问题
*/
public class Demo1_17 {
public static void main(String[] args) {// main 函数主线程
StringBuilder sb = new StringBuilder();
sb.append(4);
sb.append(5);
sb.append(6);
new Thread(()->{// Thread新创建的线程
m2(sb);
}).start();
}
public static void m1() {
// sb 作为方法m1()内部的局部变量,是线程私有的 ---> 线程安全
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}
public static void m2(StringBuilder sb) {
// sb 作为方法m2()外部的传递来的参数,sb 不在方法m2()的作用范围内
// 不是线程私有的 多个线程共享了同一个对象---> 非线程安全
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}
public static StringBuilder m3() {
// sb 作为方法m3()内部的局部变量,是线程私有的
StringBuilder sb = new StringBuilder();// sb 为引用类型的变量
sb.append(1);
sb.append(2);
sb.append(3);
return sb;// 然而方法m3()将sb返回,sb逃离了方法m3()的作用范围,且sb是引用类型的变量
// 其他线程也可以拿到该变量的 ---> 非线程安全
// 如果sb是非引用类型,即基本类型(int/char/float...)变量的话,逃离m3()作用范围后,则不会存在线程安全
}
}
该问题答案:
- 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
- 如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题
2.2 栈内存溢出
什么情况下会导致栈内存溢出?
- 分两种情况:
- 栈帧过多导致栈内存溢出
- 栈帧过大导致栈内存溢出
2.2.1 栈帧过多导致栈内存溢出
抛出java.lang.StackOverflowError栈内存溢出异常
- 栈帧过多导致栈内存溢出情况很多见,列如没有设置好结束条件,无限递归
2.2.2 栈帧过大导致栈内存溢出
- 栈帧所占用内存过大超过了栈内存,这种情况少见。
举2个案例
案例一
方法递归调用导致栈内存溢出
最终结果调用了23624次,即有23624个栈帧产生。
我们通过修改栈内存大小
修改的运行结果为4150次,远小于原来的23624次!!
案例二
/**
* 两个类之间的循环引用问题,导致的栈溢出
*
* 解决方案:打断循环,即在员工emp 中忽略其dept属性,放置递归互相调用
*/
public class Demo04 {
public static void main(String[] args) throws JsonProcessingException {
Dept d = new Dept();
d.setName("Market");
Emp e1 = new Emp();
e1.setName("csp");
e1.setDept(d);
Emp e2 = new Emp();
e2.setName("hzw");
e2.setDept(d);
d.setEmps(Arrays.asList(e1, e2));
// 输出结果:{"name":"Market","emps":[{"name":"csp"},{"name":"hzw"}]}
ObjectMapper mapper = new ObjectMapper();// 要导入jackson包
System.out.println(mapper.writeValueAsString(d));
}
}
/**
* 员工
*/
class Emp {
private String name;
@JsonIgnore// 忽略该属性:为啥呢?我们来分析一下!
/**
* 如果我们不忽略掉员工对象中的部门属性
* System.out.println(mapper.writeValueAsString(d));
* 会出现下面的结果:
* {
* "name":"Market","emps":
* [c
* {"name":"csp",dept:{name:'xxx',emps:'...'}},
* ...
* ]
* }
* 也就是说,输出结果中,部门对象dept的json串中包含员工对象emp,
* 而员工对象emp 中又包含dept,这样互相包含就无线递归下去,json串越来越长...
* 直到栈溢出!
*/
private Dept dept;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Dept getDept() {
return dept;
}
public void setDept(Dept dept) {
this.dept = dept;
}
}
/**
* 部门
*/
class Dept {
private String name;
private List<Emp> emps;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public List<Emp> getEmps() {
return emps;
}
public void setEmps(List<Emp> emps) {
this.emps = emps;
}
}
2.3 线程运行诊断
2.3.1 案例1:CPU占用过多
- Linux环境下运行某些程序时,可能导致CPU的占用过高,这时需要定位占用CPU过高的线程
- top命令可以监测哪个进程对CUP占用过高
- top命令可以监测哪个进程对CUP占用过高
- ps H -eo pid, tid(线程id), %cpu | grep 进程号 刚才通过top查到的进程号 通过ps命令进一步定位是哪个线程引起的cpu占用过高!
- JDK提供的工具jstack查看哪个线程出现问题
- jstack 进程id:可以查看这个进程中所有的线程nid(为16进制),所以tid =32665(tid为线程id)要转换为16进制。其16进制为7f99
- jstack 进程id:可以查看这个进程中所有的线程nid(为16进制),所以tid =32665(tid为线程id)要转换为16进制。其16进制为7f99
2.3.2 案例2:程序运行很长时间没有结果
- 先运行程序
通过jstack 进程id 查看线程问题
通过jstack定位了代码问题出现的代码问题
这样我们通过了jstack排查了一个死锁问题。
3、本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是:
- 虚拟机栈为虚拟机执行java方法(字节码)服务
- 本地方法栈为虚拟机使用到的本地(Native)方法服务
- 他们都是线程私有的
本地方法运行时所使用的内存即为本地方法栈
这样的本地方法有很多比如java.lang.Object其中就有许多native方法,比如clone()
一些带有native 关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法!
所以本地方法栈作用就是为本地方法运行时提供内存空间。
前面讲的程序计数器、虚拟机栈、本地方法栈都是线程私有的。
接下来讲的堆、方法区为线程共享的区域。
4、堆
4.1 定义
Heap 堆
- 通过 new 关键字,创建对象都会使用堆内存特点
- 它是线程共享的,堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制(回收无用对象)
4.2 堆内存溢出
当在使用的对象过多就会导致堆内存溢出现象
4.2.1 案例:演示堆内存溢出
一般报java.lang.OutOfMemoryError异常即为内存溢出
报java.lang.OutOfMemoryError: Java heap space
无限循环执行,导致堆内存溢出。有时我们可以通过修改堆内存大小参数值-Xmx(大小),尽早暴露异常问题。
4.3 堆内存诊断
-
命令行方式
1. jps 工具
查看当前系统中有哪些 java 进程
2. jmap 工具
查看堆内存占用情况 jmap - heap 进程id (只能查询某一时刻) -
图形界面的方式
3. jconsole 工具
图形界面的,多功能的监测工具,可以连续监测
4.3.1 诊断工具使用案例
/**
* 演示堆内存
*/
public class Demo1_4 {
public static void main(String[] args) throws InterruptedException {
System.out.println("1...");
Thread.sleep(30000);//等待30秒 用jps查看java进程 jmap 工具查看堆内存占用情况
byte[] array = new byte[1024 * 1024 * 10]; // 10 MB 我们new 10MB内存对象
System.out.println("2...");
Thread.sleep(20000);//等待20秒 用jmap查看堆内存占用情况
array = null;//将引用对象设为null,不再引用byte数组对象,可以被垃圾回收
System.gc();//进行垃圾回收
System.out.println("3...");//查看此时堆内存的情况
Thread.sleep(1000000L);
}
}
4.3.1.1. jmp工具使用
1.当new byte[]前jps查看进程id,jmap -heap 进程id 查看堆内存占用情况
通过jmap工具我们可以看到一些堆内存的配置信息
3个阶段堆内存占用过程图:
4.3.1.2. 图形界面 jconsole工具使用
打印1时,未创建byte数组前,堆内存占用约20MB
打印2时,byte[]数组已创建,此时堆内存占用约为30MB
打印3时,gc垃圾回收,堆内存降到了10MB以下。
以上就是jconsole工具的使用
4.3.2 案例(垃圾回收后,内存占用仍然很高)
**案例描述:**比如一个程序在多次垃圾回收后,它的内存占用仍然很高!
我先运行一个程序,假如我们并不知道这个程序是如何写的。(模拟真实开发场景)
1.现在我们通过一个诊断工具jps查看程序进程id
2.得到进程id为18732,再通过jmap工具查看该进程堆内存占用情况。
说明总共的内存占用大概为200MB左右
3.现在我们执行一次垃圾回收看看效果
发现内存占用下降了很多,但是仍然有220MB多
,我们再次jmap一下
发现并没有什么很大变化。新生代内存减少了3MB左右。
4.3.2.1 jvisualvm工具
一种可视化展现虚拟机情况的工具。
连接该进程
该工具也具备jconsole功能,检测内存使用情况。
其中重点介绍堆Dump:可以截取当前时刻堆内存占用实际情况的快照。(有哪些类型的对象,每个对象的个数)
其中有个查找功能,可以查找指定前多少个最大内存的对象信息
发现当前堆内存占用最大的对象为ArrayLIst对象。我们点进去查看详情。
发现ArrayList中存了200个Student对象。
而每个Student对象内存了一个byte[]数组,大小约为1MB。
所以得出来是ArrayList对象导致了程序占用高,而且无法回收,现在分析源代码,看看是否是该对象导致的。
/**
* 演示查看对象个数 堆转储 dump
*/
public class Demo1_13 {
public static void main(String[] args) throws InterruptedException {
List<Student> students = new ArrayList<>();
for (int i = 0; i < 200; i++) {//存入了200个Student对象
students.add(new Student());
// Student student = new Student();
}
Thread.sleep(1000000000L);
}
}
class Student {
private byte[] big = new byte[1024*1024];//大小为1MB
}
显然是ArrayList对象导致的问题。
4.3.3 总结:
实际生产环境下,问题排查也是类似的,也是通过堆转储 dump将此刻堆内存占用情况抓取下来,就可以发现究竟是哪些对象占用过大,然后去分析排查源代码问题所在。
5.方法区
5.1 定义
根据jdk1.8中对方法区的定义描述:
- 方法区:和Java堆一样,是线程共享的区域。
- 它存储类的结构相关的信息,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。
- 方法区是在虚拟机启动时创建的。
- 尽管方法区在逻辑上是堆的一部分,但简单的实现可能选择不垃圾收集或压缩它。此《Java虚拟机规范》不强制指定方法区域的位置或用于管理已编译代码的策略。(JDK1.8以前,HotSpot使用永久代来实现方法区,但是其他虚拟机实现不存在“永久代”的概念。直到JDK1.8抛弃了“永久代”的概念,改用在本地内存中实现的元空间(Meta-space)来代替)
- 方法区可以具有固定的大小,或者可以根据计算的需要进行扩展,并且如果不需要更大的方法区域,则可以收缩。方法区不需要连续的内存。
- 如果方法区中的内存无法满足分配请求,Java虚拟机将抛出OutOfMemoryError(内存溢出)。
5.2 组成
HotSpot在1.6和1.8的组成
JDK1.6时HotSpot用永久代实现方法区,此时由JVM管理。到了JDK1.8后,抛弃了永久代,用本地内存实现的**元空间(Meta space)**代替。此时不再由jvm去管理它的内存结构,而是有操作系统管理。
5.3演示方法区内存溢出
5.3.1JDK1.6由于产生了多个类导致元空间内存溢出
5.3.2JDK1.8下由于产生了多个类导致元空间内存溢出
5.3.3 总结
- 1.8以前会导致永久代内存溢出
- 演示永久代内存溢出 java.lang.OutOfMemoryError: PermGen space
- -XX:MaxPermSize=8m
- 1.8之后会导致元空间内存溢出
- 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
- -XX:MaxMetaspaceSize=8m
实际开发中导致内存溢出的场景:
这些框架都用了一些字节码技术
- spring :SpringAop会使用cglib生产被代理对象的子类作为代理
- mybatis:用cglib产生mapper接口的实现类对象
5.4 运行时常量池
二进制字节码(类的基本信息,常量池,类方法定义包括了虚拟机指令)
下面我们通过一段程序了解这些信息。
我们反编译这个程序,查看二进制字节码的详细信息
类的基本信息
常量池
类方法定义
虚拟机指令
5.4.1 总结
- 常量池,就是一张表(Constant Pool Table),虚拟机指令根据这张常量池表找到要执行的类名、方法名、参数类型、字面量
等信息 - 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
5.5 StringTable
运行时常量池中比较重要的组成部分:字符串常量池(StringTable/String Pool)
其结构就是哈希表(hashtable),不能扩容。
String有两种赋值方式
- 第一种是通过“字面量”赋值。
String str = “Hello”;
- 第二种是通过new关键字创建新对象。
String str = new String(“Hello”);
我们来分析一段代码
//StringTable [ "a" , "b" , "ab" ] hashtable 结构 不能扩容
public static void main(String[] args) {
// 常量池中的信息,都会被加载到运行时常量池中, 这时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象 放入StringTable中
// ldc #3 会把 b 符号变为 "b" 字符串对象 放入StringTable中
// ldc #4 会把 ab 符号变为 "ab" 字符串对象 放入StringTable中
String s1 = "a";// 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab
System.out.println(s3 == s4);//false 因为s4实际上是new String("ab")
System.out.println(s3 == s5);//true
}
底层原理图
总结
- 字面量创建字符串会先在字符串池中找,看是否有相等的对象,没有的话就在堆中创建,把地址驻留在字符串池;有的话则直接用池中的引用,避免重复创建对象。
- new关键字创建时,前面的操作和字面量创建一样,只不过最后在运行时会创建一个新对象,变量所引用的都是这个新对象的地址。
5.6 StringTable特性
- 常量池中的字符串仅是符号,第一次用到时才变为对象
- 利用串池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是 StringBuilder (1.8)
- 字符串常量拼接的原理是编译期优化
String x = "ab";
String s = new String("a") + new String("b");
// 在堆中创建 new String("a") new String("b") new String("ab")
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
System.out.println( s2 == x);//true s2返回的是串池中的"ab"
System.out.println( s == x );//fasle s地址为堆中地址
5.6.1 intern()
字符常量池有关的intern()方法。
可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
- 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回。
- 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回,复制的对象与原对象地址不一样。
5.6.2 StringTable面试题
**
* 演示字符串相关面试题
*/
public class Demo1_21 {
public static void main(String[] args) {
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";
x2.intern();//串池已经有“cd”加入失败
System.out.println(x1 == x2);//false
// 问,如果调换了【最后两行代码】的位置呢
//x2.intern();//串池没有"cd"加入x2的对象的引用
//String x1 = "cd";
// System.out.println(x1 == x2);//true
//如果是1.6呢?
//x2.intern();//串池没有"cd"加入x2的对象的拷贝(与x2对象不同)
//String x1 = "cd";
// System.out.println(x1 == x2);//false
}
}
5.7 StringTable位置
字符串常量池的位置跟JDK版本有关。
- jdk1.6 StringTable是常量池的一部分。存储在永久代当中。
- jdk1.7 StringTable转移到了Java堆中
- jdk 1.8 元空间代替了永久代
演示jdk1.6-1.8的StringTable内存溢出
1.jdk1.6环境下运行
将-XX:MaxPermSize=10m,永久代内存设置小一点
/**
* 演示 StringTable 位置
* 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
* 在jdk6下设置 -XX:MaxPermSize=10m
*/
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);
}
}
}
运行结果:java.lang.OutOfMemoryError:PermGen space****永久代内存不足导致内存溢出
2.jdk1.8环境下运行
设置 -Xmx10m -XX:-UseGCOverheadLimit
结果:java.lang.OutOfMemoryError:Java heap space堆空间不足导致内存溢出
结论
从这两个案例也证明了字符串常量池1.8用的是堆空间,1.6用的是永久代。
5.8 StringTable垃圾回收
我们可能认为StringTable存的都是字符串常量,所有不存在垃圾回收。但事实是存在垃圾回收的。
5.8.1 案例
演示 StringTable 垃圾回收
设置参数
-Xmx10m(设置虚拟机堆内存最大值)
-XX:+PrintStringTableStatistics (打印字符串表的统计信息,看到串池中字符串情况)
-XX:+PrintGCDetails -verbose:gc(打印垃圾回收的详细信息)
当try中没有任何处理是。
/**
* 演示 StringTable 垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
*/
public class Demo1_7 {
public static void main(String[] args) throws InterruptedException {
int i = 0;
try {
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}
}
}
运行可以看到
StringTable的底层是哈希表。数组+链表。数组就是桶buckets。可以看出串池中有1741个字符串
当在try catch中加入一下代码,运行对比前后串池中字符串常量的的变化
for (int j = 0; j < 100; j++) { // j=100, j=10000
String.valueOf(j).intern();
i++;
}
此时串池中加了100左右的字符串对象,此时并没有垃圾回收
当加入10000个对象时。
for (int j = 0; j < 10000; j++) { // j=100, j=10000
String.valueOf(j).intern();
i++;
}
结果串池中只有4810个字符串对象。说明GC垃圾回收了。
由于内存分配失败,触发了垃圾回收。
结论:StringTable会发生垃圾回收
5.9 StringTable性能调优
StringTable底层:
哈希表,哈希表的性能跟它的大小有关,如果哈希表的桶的个数比较多,那么元素相对分散,那么哈希碰撞的几率会减少,查找速度会变快。反之则反。
下面通过案例进行StringTable调优,通过调整桶的个数
5.9.1 调优案例
- 1.调整-XX:StringTableSIze=桶个数
我们读取一个文件,该文件中有48万个单词,然后加入串池中。
设置参数StringTableSize=200000
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);
}
}
由于没有设置堆内存大小,所以内存充裕,还是很轻松的存下。
可以看到分配的桶有20万个,平均1个桶放2个字符串对象。速度还是很快的,只花了0.4秒。
那如果不设置StringTableSize大小。默认6万个
运行时间约0.6秒,相对多了一点
那我们把桶大小调成200,StringTableSize=1009(允许的最小值)
这次运行花费了很久,12秒
结论:当你系统里字符串常量多的话,那么把StringTableSize设置的大一点。这样有很好的哈希分布,减少哈希冲突,让StringTable的效率得到明显的提升。
这里我们提出一个问题:为什么我们要用StringTable呢?
- 2.考虑将字符串对象是否入池
这里通过一个案例来看入池与不入池的对比,修改了上一个案例。循环10次读取文件,放入List集合中,防止垃圾回收。
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);
}
System.out.println("cost:" +(System.nanoTime()-start)/1000000);
}
}
System.in.read();
}
我们用VisualVm工具看下堆内存占用实际情况
此时我们开始读取文件
此时VisualVM的情况,char[],string内存增加了很多,占用300MB左右
我们修改一段代码,在存入List集合之前将读取的字符串入池。那么返回的就是串池中的对象加入到List集合中,串池外的就被回收掉了
address.add(line.intern());
此时char[]和String加起来大约40MB,而我们之前没有入池操作的时候是300MB。差距还是很明显的。
结论:如果你的程序中有大量的字符串(存在重复字符串),可以将它入池,来减少对象个数,节约堆内存的占用。
6.直接内存
直接内存是操作系统的内存
直接内存并不属于Java虚拟机的内存管理,而是系统内存
6.1定义
Direct Memory
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
6.2 案例
演示直接内存的使用以及性能上是否比传统IO操作高很多。分别用NIO和IO读写文件,对比效率.
这里简单介绍下IO和NIO操作.
在标准的IO当中,都是基于字节流/字符流进行操作的,而在NIO中则是是基于Channel和Buffer进行操作
Channel用于在字节缓冲区和位于通道另一侧的实体(通常是文件或者套接字)之间以便有效的进行数据传输。借助通道,可以用最小的总开销来访问操作系统本身的I/O服务。
通道必须结合Buffer使用,不能直接向通道中读/写数据,其结构如下图:
6.2.1 简述FileChannel的使用
1.创建FileChannel
InputStream,OutputStream,RandomAccessFile可通过getChannel()方法获取FileChannel实例
2.从FileChannel读取数据
通过调用Channel的read()方法可将Channel中的数据读取到Buffer中
3.FileChannel写入数据
通过Channel的write()方法可想FileChannel写入数据
**
* 演示 ByteBuffer 作用
*/
public class Demo1_9 {
static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
static final String TO = "E:\\a.mp4";
static final int _1Mb = 1024 * 1024;
public static void main(String[] args) {
io(); // io 用时:1535.586957 1766.963399 1359.240226
directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
}
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);
}
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);
}
}
结果对比:IO用了3秒,NIO用了不到1秒。所有说NIO读写效率非常高。
6.3 直接内存读写大文件效率高的原因
过程分析
用户态(java)切换到内核态(System)
Java语言本身并不具备读写能力,需要调用操作系统的函数(本地方法native Method)即用户态(java)切换到内核态(System)。
内核态中首先用CPU函数去读取磁盘文件的内容,会在操作系统内存中划出一个系统缓冲区,系统缓存区java是不能运行的。所有会在Java堆内存中分配java缓冲区byte[]。java从系统缓冲区读取数据到java缓冲区中,然后切换到用户态调用读写操作。最终进行文件的读写。
**问题:**系统内存有缓冲区,java堆也有缓冲区。那么读写必然涉及到数据得存两份。这样就造成了不必要的复制,效率因此低。
使用Direct Memory直接内存后
ByteBuffer.allocateDirect(_1Mb),分配一块直接内存,在操作系统划出一块缓冲区,与上面不同的是,这块缓冲区,java和系统可以直接访问。一块共享的缓冲区。磁盘文件读取到直接内存中,少了java缓冲区的复制操作。所有速度得到了很大提升。
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);//分配一块直接内存
因此:直接内存常见于NIO操作,充当数据缓冲区。读写性能高
6.4 直接内存_内存溢出
直接内存不受JVM内存回收管理,那么会不会出现内存溢出情况呢?
6.4.1 案例:演示直接内存溢出
/**
* 演示直接内存溢出
*/
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 对方法区的实现称为元空间
}
}
运行结果:
java.lang.OutOfMemoryError: Direct buffer memory
因此直接内存不足也会导致的内存溢出
6.5 直接内存_释放原理
我们通过一个案例演示直接内存释放原理
我们分配1GB直接内存
ByteBuffer.allocateDirect(_1Gb);
/**
* 禁用显式回收对直接内存的影响
*/
public class Demo1_26 {
static int _1Gb = 1024 * 1024 * 1024;
/*
* -XX:+DisableExplicitGC 显式的
*/
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();
}
}
我们不能用java的内存监测工具去监测直接内存的情况,我们可以有任务管理器看java进程对内存的占用情况。
运行前,idea程序占用1G左右内存
运行程序,idea程序增加了1G内存
我们回车进行释放
发现idea又变回了原来的1G。直接内存被释放掉了。直接内存不是不归JVM内存管理吗,怎么被垃圾回收了呢?
6.5.1 释放原理
ByteBuffer底层分配和释放内存相关的类型。
类型:Unsafe
/**
* 直接内存分配的底层原理:Unsafe
*/
public class Demo1_27 {
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();
}
//通过反射获取unsafe对象
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);
}
}
}
运行程序。idea程序增加了1G内存
回车释放内存,idea占用明显变小了。
验证了:
- 直接内存的分配和释放是通过Unsafe对象进行管理的,而不是垃圾回收。
- 直接内存的释放与垃圾回收不同,垃圾回收自动释放无用对象,而直接内存需要主动调用unsafe.freeMemory(base),进行内存的释放。
6.5.2 ByteBuffer源码分析
在ByteBuffer的allocateDirect()方法的底层的DirectByteBuffer()构造器中调用了unsafe对象分配空间
直接内存的释放借助了java虚引用的机制
6.5.3 总结
- 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
- ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存