Java-第十四部分-JVM-直接内存、执行引擎和StringTable

324 阅读19分钟

JVM全文

直接内存

  • Direct Memory
  • 元空间使用的本地的直接内存
  1. 直接内存不是java虚拟机的规范中的一部分
  2. 是java堆外的,直接向系统申请的内存空间
  3. 来源NIO,通过存在堆中的DirectByteBuffer操作Native内存
  4. 访问直接内存的速度优于java堆,读写频繁的场合使用直接内存
  5. NIO库允许java程序直接使用直接内存,用于数据缓冲区
  6. -XX:MaxDirectMemorySize设置直接内存大小,默认与堆的最大值参数一致
  • 缺点
  1. 分配回收成本较高
  2. 不受JVM内存回收管理
  • NIO,New IO / Non-Blocking IO,java代码可以直接访问操作系统划出的直接缓存区,直接访问物理磁盘,适合对大文件的读写操作
  1. 通过Buffer传输
  2. Channel来传输 image.png
  • IO,读写文件,需要与磁盘交互,需要由用户态切换到内核态,需要将内容保存两份,通过虚拟机地址再访问内核地址 image.png

异常

  • 导致OOM: Direct buffer memory异常
  • 大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,java堆和直接内存的总和受限于操作系统能给出的最大内存

执行引擎

  • 虚拟机的执行引擎是由软件自行实现的,不受物理条件制约,能够执行不被硬件直接支持的指令集格式
  • jvm主要任务是负责将字节码文件装载进内部,但是字节码文件(实际上是跨平台的通用契约)不能够直接运行在操作系统之上,字节码文件并非等价于本地机器指令,内部包含的仅仅只是一些能够被jvm所识别的字节码指令、符号表,以及其他辅助信息
  • 执行引擎的任务,将字节码指令解释/编译为对应平台上的本地机器指令,充当了高级语言翻译为本地语言的翻译者
  • 工作过程
  1. 执行引擎在执行过程中执行的字节码指令依赖于PC寄存器
  2. 当执行完一项指令操作后,PC寄存器更新下一条需要被执行的指令地址
  3. 执行过程中,有可能通过存储在局部变量表中的对象引用准确定位到存储在java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息
  4. 输入字节码二进制流,输出执行结果
  • 结构 image.png

java的编译和执行

image.png

  1. 橙色部分由前端编译器 javac完成,遍历语法树,形成线性字节码指令流
  2. 绿色部分,解释型语言,逐行翻译
  3. 解释器,当java虚拟机启动时,根据预定义的规范对字节码采用逐行解释的方式执行,翻译成本地机器指令
  4. 蓝色部分,编译型语言
  5. JIT,Just In Time Compiler编译器,虚拟机将源代码直接编译成和本地机器平台相关的机器语言
  • 前端编译过程,将java从一种语言规范转换另一周语言规范的过程 image.png
  • jvm执行引擎执行过程,将字节码转换成对应机器指令,被对应机器识别 image.png
  • Java为半编译半解释型语言,执行引擎中既通过解释器,又通过编译器

机器码、指令和汇编语言

image.png

  • 机器码,二进制编码表示的指令,与CPU紧密相关,CPU直接读取运行,执行速度最快
  • 指令,mov/inc,把机器码中特定的0/1序列简化成对应的指令,不同硬件平台,同一个操作,机器码可能不同,一个对应关系,需要转换成0/1序列,才能被cpu识别
  • 指令集,不同的硬件平台,各自支持的指令是有差别的,x86/ARM指令集
  • 汇编语言,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址,计算机只认识指令码,必须翻译成机器指令码
  • 高级语言,更接近人的语言,执行时,需要把程序解释和翻译成机器指令码
  • 高级语言(C/C++)到机器指令的过程
  1. 编译,读取程序字符流,进行词法和语法分析,将高级语言转换为功能等效的汇编代码
  2. 汇编,将汇编语言翻译成目标机器指令
  • 字节码,是一种中间状态的二进制代码,比机器码更抽象,需要直译器转译后才能成为机器码
  1. 为了实现特定软件运行和软件环境,与硬件无关
  2. 实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台的虚拟机器(执行引擎)将字节码转译成可以直接执行的机器指令
  3. 等同于汇编语言
  • java语言过程 java源代码->字节码->机器指令
  1. jvm直接将字节码转译为机器指令
  2. 汇编不是一个必须的过程,具体看编译器

解释器

  • 解释型语言,边解释边执行
  1. Java、C#、PHP、JavaScript、VBScript、Perl、Python、Ruby、MATLAB
  2. 每执行一次都要翻译一次。因此效率比较低。在运行程序的时候才翻译,专门有一个解释器去进行翻译,每个语句都是执行的时候才翻译。效率比较,依赖解释器,跨平台性好
  • 将字节码文件中的内容翻译为对应平台的本地机器指令执行
  • 当一条字节码指令被解释执行完成后,再根据PC寄存器记录的下一条需要被执行的字节码指令执行解释操作
  • 字节码解释器,执行时,通过纯软件代码模拟字节码执行,效率低下
  • 模板解释器,每一条字节码和一个模板函数相关联,模板函数直接产生这条字节码执行时的机器码
  • jvm中,解释器主要由Interpreter模块和Code模块组成
  1. Interpreter,实现解释器核心功能
  2. Code,管理hotspot vm在运行时生成的本地机器指令

JIT编译器

  • 编译型语言,先编译成机器码,再执行
  1. C/C++、Pascal/Object Pascal(Delphi)、Golang
  2. 典型的就是它们可以编译后生成.exe文件,之后无需再次编译,直接运行.exe文件即可
  • 避免函数被解释执行,将整个函数体编译成机器码,每次函数执行,只执行编译后的机器码即可
  • 将代码翻译成机器指令,并进行缓存
  • java解释器和编译器并存的架构
  1. 程序启动后,编译器可以马上发挥作用,立即执行,响应速度快;编译器需要编译成本地机器指令,需要一定的执行时间,但编译完成后执行效率高
  2. java虚拟机启动后,解释器首先发挥作用,不必等待JIT编译器全部编译后再执行,省去不必要的编译时间,随着程序运行,JIT编译器发挥作用,根据热点探测功能,将有价值的字节码编译成本地机器指令
  3. JRockit vm不包含解释器,全部依靠即时编译器编译后执行,针对于服务端应用,启动时间并非是关注重点
  4. 在编译器进行激进优化不成立的时候,将解释器作为逃生门/后备方案
  • 前端编译器,将.java文件转换为字节码/.class文件
  • 后端编译器,将字节码/.class文件转换为机器码,JIT编译器,hotspot vm的C1/C2编译器
  • 静态编译器,AOT,Ahead Of Time Compiler,直接把.java文件编译成本地机器代码的过程,GCJ/Excelsior JET
  • 热点代码及探测,决定是否需要启动JIT编译器将字节码直接编译成对应平台的本地机器指令
  1. 根据代码被调用执行的频率,进行深度优化,将其直接编译为对应平台的本地机器指令
  2. 热点代码,一次被多次调用的方法,或是一个方法体内部循环次数较多的循环体;这种编译方式发生在方法的执行过程中,称为栈上替换/OSR On Stack Replacement
  3. 热点探测功能,决定被调用多少次或者循环多少次才能达到这个标准,hotspot采用的是基于计数器的热点探测
  4. 基于计数的热点探测,为每一个方法建立两个不同类型的计数器,分别为方法调用计数器,统计方法被调用的次数;回边计数器,统计循环体执行的循环次数;两个计数值之和是否超过阈值
  • 方法调用计数器
  1. 默认阈值Client模式1500次,Server模式下10000次,超过阈值,出发JIT编译;
  2. -XX:CompileThreshold设置阈值
  3. 达到阈值后,缓存热点代码
  4. 热点衰减(Counter Decay),如果不做任何设置,方法调用计数器统计的是一个相对的执行频率,一段时间之内方法被调用的次数;当超过一定的时间限度,调用次数仍然不足触发JIT编译,那这个方法的调用计数器就会减少一半,这段时间被称为半衰周期 Counter Half Life Time;进行热度衰减的动作实在虚拟机进行垃圾收集时顺便进行的
  5. -XX:-UseCounterDecay关闭热度衰减,时间足够长,绝大部份方法都会被编译成本地代码
  6. -XX:CounterHalfLifeTime,设置半衰周期时间,单位秒 image.png
  • 回边计数器,统计循环体执行的次数,当字节码中遇到控制流向后跳转的指令被称为回边 Back Edge image.png

设置程序执行方式

  • 控制台
  1. java -Xint -version 完全采用解释器模式
  2. java -Xcomp -version 完全采用即时编译器模式,如果编译出现问题,解释器介入
  3. java -Xmixed -version 混合模式
  • run->edit configurations,添加参数-Xint/-Xcomp/-Xmixed
  • Clint Complier C1
  1. -client,指定java虚拟机运行在client模式下,使用c1编译器
  2. 会对字节码进行简单和可靠的优化,耗时短,达到更快的编译速度
  3. 优化策略,方法内联、去虚拟化、冗余消除
  4. 方法内联,将引用的函数代码编译到引用点处,减少栈帧的生成,减少参数传递和跳转
  5. 去虚拟化,对唯一的实现类进行内联
  6. 冗余消除,运行期间把一些不会执行的代码折叠
  • Server Complier C2,64位只有server,不能设置client,C++编写
  1. -server,指定java虚拟机运行在server模式下,使用c2编译器
  2. 进行较长的优化,以及激进优化,优化后的代码执行效率更高
  3. 主要是在全局层面,逃逸分析是优化的基础
  4. 标亮替换,用标量值代替聚合对象的属性值
  5. 栈上分配,对于未逃逸的对象分配对象在栈上
  6. 同步消除,如果一个对象被发现只能从一个线程被访问到,清除同步操作
  • 逃逸分析
  1. 发生在JIT即时编译期间
  2. 确定某个指针可以存储的所有地方,以及确定能否保证指针的生命周期只在当前进程或在其它线程中
  3. 对象象被赋值给堆中对象的字段和类的静态变量,放入堆中,无法追踪;对象被传进了不确定的代码中去运行。满足其中一个,则为逃逸成功
  • 分层编译
  1. 程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,进行简单优化
  2. 可以加上性能监控,C2编译根据性能监控信息进行激进优化
  3. C1/C2协同执行编译任务
  4. C2编译器启动时长比C1编译器慢,系统稳定执行后,C2编译器执行速度远快于C1编译器

其他

  • 激活Graal编译器,目标代替C2,与C1/C2并列,-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
  • AOT编译器,Ahead Of Time Compiler,静态提前编译器,
  1. 引用jaotc工具,将.class转换为.so,借助Graal编译器,转换为机器码,并存放至生成的动态共享库之中
  2. JIT是在程序运行过程中,将字节码转换为机器码,并进行缓存
  3. AOT,在程序运行之前,将字节码转换为机器码
  4. 不必等待即时编译器的预热
  5. 但是必须为每个不同硬件,不同操作系统生成对应的发行包;降低了java链接过程的动态性,加载的代码在前端编译期就必须全部已知

面试

  • 我们写的Java代码到底是如何运行起来的?

Java 程序通过 javac 编译成 .class 文件,然后虚拟机将其加载到元数据区,执行引擎将会通过混合模式执行这些字节码。执行时,会翻译成操作系统相关的函数。

StringTable

  • String:字符串 ""
  1. String s1 = "test"; //字面量定义方式,存储在字符量常量池,不允许存储相同的字符串
  2. String s2 = new String("test");
  • final修饰,不能被继承
public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    ....
}
  • 底层
  1. 1.8及前 char[] 两个字节
  2. 1.8后 byte[]
  3. 大部分String包含的都是拉丁文,拉丁文只用一个字节就可以存储,因此改为用byte数组,并有属性coder记录字符集编码LATIN1/UTF16;针对UTF-16两个字节的字符集,底层有COMPACT_STRINGS,默认为true 启动压缩,压缩失败就用StringUTF16.toBytes处理
  4. 数组+链表/红黑树
  • 字符串常量池不会存储相同内容的字符串
  1. 字符串常量池底层是一个固定大小的Hashtable(存放字符串哈希值)
  2. jdk6中 默认长度1009,如果放进的string非常多,就会造成Hash冲突
  3. jdk7中 默认长度60013;jdk8后,1009是可设置的最小值 1009 ~ 2305843009213693951
  4. -XX:StringTableSize,设置
  • 不可变的字符序列,不可变性
  1. 对字符串重新赋值,需要重写指定内存区域赋值,不能使用原来的value进行赋值
  2. 对现有字符串进行连接操作,重新指定内存区域赋值,生成新的字符串
  3. 调用replace()修改指定字符或字符串,生成新的字符串
  • 相同的字符串字面量,包含相同的Unicode字符序列(包含同一份码点序列的常量),并且必须指向同一个String类实例
  • 案例
String str = new String("hello");
//string不可变的特性,仅仅是让传入的str指向了字符串常量池的"ok",而并没有修改成员变量str的指向;传入的是成员变量str的地址
public void change(String str) {
    str = "ok";

}
public static void main(String[] args) {
    StrngTest st = new StrngTest();
    st.change(st.str);
    System.out.println(st.str); // 输出hello
}

内存分配

  • jdk6及以前,字符串常量池在永久代中
  • jdk7及以后,在堆中
  1. 永久代默认比较小
  2. 永久代垃圾回收频率低

字符串拼接

  • 常量与常量的拼接结果放在常量池中,编译器优化
//字节码
0 ldc #2 <abc>
2 astore_1
3 ldc #2 <abc>
5 astore_2
//源码
String s1 = "a" + "b" + "c"; //在字节码中等同于"abc"
String s2 = "abc"; //指向常量池中的"abc"
System.out.println(s1 == s2); //true
  • 只要其中有一个是变量,结果就在堆中(非常量池的部分),拼接原理是StringBuilder,相当于在堆空间中new String(),具体字符串内容为拼接结果
  • 如果拼接结果调用inter(),如果常量池中还没有这个字符,就主动将这个字符串对象放入池中,加载一份,并返回这个字符串在常量池中的地址
String s1 = "hello";
String s2 = "world";

String s3 = "helloworld";
String s4 = "hello" + "world";
String s5 = s1 + "world";
String s6 = s1 + s2;

System.out.println(s3 == s4); //true
System.out.println(s3 == s5); //false 非常量池的堆中
System.out.println(s3 == s6); //flase
System.out.println(s5 == s6); //flase 另一个空间

String s7 = s5.intern(); //指向常量池中这个字符串
System.out.println(s3 == s7); //true
  • 拼接原理,5.0之后StringBuilder(线程不安全,效率高);5.0之前StringBuffer(线程安全)
//源码
String s1 = "a";
String s2 = "b";
String s3 = "ab";
//对应 9~27 通过StringBuilder进行字符串拼接
String s4 = s1 + s2;

//字节码
9 new #8 <java/lang/StringBuilder>
12 dup
13 invokespecial #9 <java/lang/StringBuilder.<init> : ()V>
16 aload_1
17 invokevirtual #10 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
20 aload_2
21 invokevirtual #10 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
24 invokevirtual #11 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
27 astore 4

//字节码过程
StringBuilder s = new StringBuilder();
s.append("a");
s.append("b");
//类似于 new String("ab")
s4 = s.toString();
  • final 修饰,被编译器认为是一个常量,已经是确定值了;
  1. 针对final修饰类、方法、基本数据类型、引用数据类型的地方,能使用就使用;
  2. finanl在编译的时候就会分配,准备阶段会显示初始化;
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4);

intern

  • 如果字符串常量池中没有该字符串,则在常量池中生成,返回刚字符串在字符串常量池中的地址
  • 确保字符串在字符串常量池中只有一份拷贝
  • 保证s指向的是字符串常量池中的数据
  1. String s = "hello"; //字面量
  2. String s = .....intern(); //让前面的字符串对象都调用intern()返回常量池中的地址
  • new String("ab"); 常量池中有"ab"
String s1 = new String("ab");
//一个对象是new关键字创建的Sttring对象;一个对象时常量池中"ab"对象
0 new #2 <java/lang/String>
3 dup
4 ldc #3 <ab>
6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
9 astore_1
  • new String("a") + new String("b") 常量池中只有"a"和"b"
String s2 = new String("a") + new String("b");

//对象1 - StringBuilder
10 new #5 <java/lang/StringBuilder>
13 dup
14 invokespecial #6 <java/lang/StringBuilder.<init> : ()V>
//对象2 - String
17 new #2 <java/lang/String>
20 dup
//对象3 - "a"
21 ldc #7 <a>
23 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
26 invokevirtual #8 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
//对象4 - String
29 new #2 <java/lang/String>
32 dup
//对象5 - "b"
33 ldc #9 <b>
35 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V>
38 invokevirtual #8 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
//对象6 - 在返回时调用了toString中,也new String对象
41 invokevirtual #10 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
44 astore_2
  • StringBuilder的toString() 创建了一个对象,在字符串常量池中不存在ab
0 new #41 <java/lang/String>
3 dup
4 aload_0
5 getfield #42 <java/lang/StringBuilder.value : [C>
8 iconst_0
9 aload_0
10 getfield #43 <java/lang/StringBuilder.count : I>
13 invokespecial #44 <java/lang/String.<init> : ([CII)V>
16 areturn
  • intern的改版
  1. jdk6中,在永久代的字符串常量池中,当没有这个字符串,将这个字符串对象复制一份放入串池,有了新的地址;s.intern()去常量池中查找"11",发现没有该常量,则在常量池中开辟空间存储"11",返回常量池中的值,s1指向堆空间地址,所以二者不相等。
  2. jdk7/8中,字符串常量池在堆中,为了节省空间,当没有这个字符串,将对象的引用地址复制一份在池中;当使用了s3.intern();,此时在字符串常量池中保存的是"11"对象在字符串常量池外的堆区地址,常量池中并没有真正创建
  3. 对于jdk7/8,如果字符串常量池没有11这个字符串,先有类似String s3 = new String("1") + new String("1"); 形成的11String对象,那么当调用s3.intern(),就会将常量池中的11直接指向对象11
//返回堆空间的对象
String s =new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2); //jdk6/7/8 false
//执行完后,字符串常量池中不存在"11"
String s3 = new String("1") + new String("1"); 
s3.intern();
String s4 = "11"; //指向上一行代码在字符串常量池生成的"11"
System.out.println(s3 == s4); //jdk6 false jdk7/8 true

image.png

  • 对于程序中大量存在的字符串,尤其是存在很多重复的字符串,使用intern()可以节省内存空间
  1. intern的优化,当重复生成字符串对象时,可以让字符串的引用执行字符串常量池中的对象
  2. 原先生成的对象随着执行时间,会被垃圾回收器回收
int[] ints = {1,2,3,4,5,6,7,8,9,10};
for (int i = 0; i < Integer.MAX_VALUE; i++) {
    String str = new String(String.valueOf(ints[i % ints.length])).intern();
}

垃圾回收

  • -XX:+PrintStringTableStatistics 打印字符串常量池统计信息
StringTable statistics:
//哈希表的个数
Number of buckets       :     60013 =    480104 bytes, avg   8.000
//条目的个数
Number of entries       :     58357 =   1400568 bytes, avg  24.000
//字面量的个数
Number of literals      :     58357 =   3277192 bytes, avg  56.158
Total footprint         :           =   5157864 bytes
Average bucket size     :     0.972
Variance of bucket size :     0.769
Std. dev. of bucket size:     0.877
Maximum bucket size     :         5
  • G1的String去重操作,
  1. 去重操作针对非字符串常量池中堆空间的相同字符的String对象
  2. 堆存活数据集合里面的String对象占了25%,重复的String对象有13.5%,平均长度是45
  3. 垃圾回收器工作的时候,会访问堆上存活的对象,对每一个访问的对象都会检查是否是候选要去重的String对象
  4. 如果是,则把这个对象的一个引用插到队列中等待处理,去重的线程在后台运行,处理这个队列,意味着从队列删除这个元素,然后去重的它引用的String对象
  5. 使用一个hashtable记录所有被String对象使用的不重复的char数组,去重的时候,查这个hashtable,如果存在一模一样的char数组,String对象会被调整引用的那个数组,释放对原来数组的引用,最后被垃圾回收器收集;如果不存在,char数组就会被插入到hashtable
  • -XX:+UseStringDeduplication开启String去重,默认不开启
  • -XX:+PrintStringDeduplicationStatistics 打印详细的去重统计信息,jdk16报错
  • -XX:StringDeduplicationAgeThreshold=15达到这个年龄的String对象会被认为是去重对象