一. HotSpot虚拟机对象探秘
1. 对象的创建
1. 创建对象的方式
- new: 最常见的方式
- 变形1: Xxx的静态方法
- 变形2: XxxBuilder/XxxFactory的静态方法
- Class的newInstance(): 反射的方式, 只能调用空参的构造器, 权限必须是public
- Constructor的newInstance(Xxx): 反射的方式, 可以调用空参, 带参的构造器, 权限没有要求
- 使用clone(): 不调用任何构造器, 当前类需要实现Cloneable接口, 实现clone()
- 使用反序列化: 从文件中, 从网络中获取一个对象的二进制流
- 第三方Objenesis
2. 创建对象的步骤
1. 加载元信息
判断对象对应的类是否被加载, 链接, 初始化
- 虚拟机遇到一条new指令, 首先去检查这个指令的参数是否在Metaspace的常量池中定位到一个类的符号引用. 然后检查这个符号引用代表的类是否已经被加载, 解析和初始化(即判断元信息是否存在)
- 如果已经存在, 则直接使用. 如果没有, 那么在双亲委派模式下, 使用当前类加载器以ClassLoader + 包名 + 类名为key进行查找对应的class文件
- 如果没有找到文件, 则抛出ClassNotFoundException异常; 如果找到了, 则进行类加载, 并生成对应的Class类对象.
2. 为对象分配内存
首先计算对象占用空间大小, 接着在堆中划分一块内存给新对象. 如果实例成员变量是引用变量, 仅分配引用变量空间即可, 即4个字节大小
- 假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)
- 如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)
- 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
- 当使用Serial、ParNew等带压缩 整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存
3. 处理并发安全问题
- 采用CAS失败重试, 区域加锁保证更新的原子性
- 每个线程预先分配一块TLAB, 通过
-XX:+/-UseTLAB
参数来设定
4. 属性的默认初始化(零值初始化)
所有属性设置默认值, 保证对象实例字段在不赋值时可以直接使用
5. 设置对象的对象头
将对象的所属类(即类的元数据信息), 对象的HashCode和对象的GC信息, 锁信息等数据存储在对象的对象头中, 这个过程的具体设置方式取决于JVM实现.
6. 执行init方法进行初始化
在java程序的视角来看, 初始化才正式开始. 初始化成员变量, 执行实例化代码块, 调用类的构造方法, 并把对类对象的首地址赋值给引用变量.
因此一般来说(又字节码中是否跟随又invokespecial指令所决定), new指令之后会接着执行方法, 把对象按照程序员的意愿进行初始化, 这样一个正真可用的对象才算完全被创建出来
2. 对象的内存布局
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。
1. 对象头(Header)
HotSpot虚拟机对象的对象头部分包括两类信息。
-
运行时元数据(Mark Word).
哈希值, GC分代年龄, 锁状态标志, 线程持有的锁, 偏向线程ID, 偏向时间戳
-
类型指针
对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。
如果是数组, 还需要记录数组的长度
2. 实例数据(Instance Data)
是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
规则:
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在之类之前
- 如果CompactFields参数为true(默认为true), 子类的窄变量可能插入到父类的空隙
3. 对齐填充(Padding)
这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作 用。
4. 图示
-
代码
public class Customer{ int id = 1001; String name; Account acct; { name = "匿名客户"; } public Customer(){ acct = new Account(); } } class Account{ } public class CustomerTest { public static void main(String[] args) { Customer cust = new Customer(); } }
-
图
3. 对象的访问定位
-
主流的访问方式主要有使用句柄和直接指针两种
-
如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息.
-
如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关 信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销.
-
使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
-
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访 问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本
-
HotSpot主要使用第二种方式进行对象访问.
二. 执行引擎
1. 执行引擎概述
1. 说明
- 执行引擎是java虚拟机就和兴的组成成分之一
- 虚拟机的执行引擎是由软件自行实现的, 因此可以不受物理条件制约地定制指令集与执行引擎的结构体系, 能够执行那些不被硬件直接支持的指令集格式.
- java的主要任务是负责装载字节码到其内部, 但字节码并不能够直接运行在操作系统之上, 因为字节码指令并非等价于本地机器指令, 它内部包含的仅仅只是一些能够被JVM所识别的字节码指令, 符号表, 以及其他辅助信息.
- 如果想要让一个java程序运行起来, 执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以. 简单来说, JVM中的执行引擎充当了将高级语言翻译为机器语言的译者.
2. 执行引擎的工作过程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
- 每当执行完一项指令操作后, PC寄存器就会更新下一条需要被执行的指令地址.
- 在执行的过程中, 执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在java堆区中的对象实例信息, 以及通过对象头重点元数据指针定位到目标对象的类型信息.
2. java代码编译和执行过程
1. 执行过程
-
大部分的程序代码转换成虚拟机的目标代码或虚拟机能执行的指令集之前, 都需要经过一下各个步骤
-
java代码编译是由java源码编译器来完成, 流程图如下所示:
-
java字节码的执行是由jvm执行引擎来完成, 流程图如下所示:
2. 什么是解释器, 什么是JIT编译器
-
解释器(Interpreter)
当java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方法执行, 将每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行.
-
JIT(Just In Time Compiler)编译器:
就是虚拟机将源代码直接编译成和本地机器平台习惯的机器语言
3. 为什么说java是半编译半解释型语言?
-
jdk1.0时代, 将java语言定义为"解释执行"还比较准确.
-
再后来, java也发展出可以直接生成本地代码的编译器
-
现在jvm在执行java代码的时候, 通常都会将解释执行与编译执行二者结合起来进行.
3. 机器码, 指令, 汇编语言
1. 机器码
- 各种用二进制码方式i表示的指令, 叫做机器指令码. 这就是机器语言.
- 机器语言虽然能够被计算机理解和接收, 但和人类的语言差别太大, 不易被人们理解和记忆, 并且用它变成个容易出差错.
- 用它编写的程序一经输入计算机, CPU直接读取运行, 因此和其他语言遍的程序相比, 执行速度更快.
- 机器指令与CPU紧密相关, 所以不同种类的CPU所对应的机器指令也就不同.
2. 指令
- 由于机器码是由0和1组成的二进制序列, 可读性实在太差, 于是人们发明了指令
- 指令就是把机器码中特定的0和1序列, 简化成对应的指令, 可读性稍好
- 由于不同的硬件平台, 执行同一个操作, 对应的机器码可能不同, 所以不同的硬件平台的统一指令, 对应的机器码也可能不同
3. 指令集
-
不同的硬件平台, 各自支持的指令, 是有差别的. 因此每个平台所支持的指令, 称之为对应平台的指令集
-
如常见的
x86指令集, 对应的是x86架构的平台
ARM指令集, 对应的是ARM架构的平台
4. 高级语言
- 为了使计算机用户编程序更容易, 后来就出现了各种高级计算机语言. 高级语言比计算机, 汇编语言更接近人的语言
- 当计算机执行高级语言编写程序时, 任然需要把程序解释和编译成机器的指令码. 完成这个过程的程序就叫做解释程序或编译程序.
5. 字节码
-
字节码是一种中间状态(中间码)的二进制代码(文件), 它比机器码更抽象, 需要直译器转译后才能成为机器码
-
字节码主要为了实现特定软件运行和软件环境, 与硬件环境无关
-
字节码的实现方式是通过编译器和虚拟机器. 编译器将源码编译成字节码, 特定平台上的虚拟机器将字节码转译为可以直接执行的指令.
6. C, C++源程序执行过程
编译过程又可以分为两个阶段: 编译和汇编.
-
编译过程
是读取源程序(字节流), 对之进行词法和语法的分析, 将高级语言指令转换成功能等效的会汇编语言了
-
汇编过程
实际上就是把汇编语言代码翻译成目标机器指令的过程
4. 解释器
1. 说明
jvm设计者的初衷仅仅只是单纯地为了满足java程序实现跨平台特性, 因此避免采用静态编译的方式直接生成本地机器指令, 而是诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法.
2. 工作机制(工作任务)
- 解释器正真一样上所承担的角色就是一个运行时"翻译者", 将字节码文件中的内容"翻译"为对应平台的本地机器指令执行.
- 当一条字节码指令被解释执行完成后, 接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作.
3. 分类
在java的发展历史里, 一共有两套解释执行器, 即古老的字节码解释器 和 现在普遍使用的模板解释器
- 字节码解释器在执行时通过纯软件代码模拟字节码的执行, 效率非常低下.
- 模板编译器将每一条字节码和一个模板函数高度关联, 模板函数中直接产生这条字节码执行时的机器码,1 从而很大程度上提高了解释器的性能
- 在Hotspot VM中, 解释器主要有Interpreter模块和Code模块构成
- Interpreter模块: 实现了解释器的核心功能
- Code模块: 用于管理Hotspot VM在运行时生成的本地机器指令
5. JIT编译器
1.java代码的执行分类
- 将源代码编译成字节码文件, 然后再运行时通过解释器将字节码文件转为机器码执行
- 编译执行(直接编译成机器码). 现代虚拟机为了提高执行效率, 会使用即使编译技术(JIT, Just In Time)将方法编译成机器码后再执行.
- HotSpot VM采用解释器与即时编译器并存的架构. 再Java虚拟机运行时, 解释器和即时编译器能够相互写作, 各自取长补短, 尽力区选择最适合的方式来权衡编译本地代码的时间和直接解释执行代码的时间.
2. 问题
既然Hotspot VM中已经内置了JIT编译器了, 那么为什么还需要再使用解释器来"拖累"程序的执行效率?
- 当程序启动后, 解释器可以马上发挥作用, 省去编译的时间, 立即执行. 编译器要想发挥作用, 把代码编译成本地代码, 需要一定的执行时间, 但编译为本地代码后, 执行效率高
- 当java虚拟机启动时, 解释器可以首先发挥作用, 而不必等待即时编译器全部编译完成之后再执行, 这样可以省去许多不必要的编译时间. 随着时间的推移, 编译器发挥作用, 把越来越多的代码编译成本地代码, 获得更高的执行效率.
3. JIT编译器
- java语言的"编译期" 其实是一段"不确定"的操作对象, 因为他可能是找一个前端编译器. 把
.java
文件转变成.class
文件 - 也可能是指虚拟机的后端运行时白脸一齐(JIT编译, Just In Time Compiler
- 还可能是指使用静态前提前编译器(AOT编译器, Ahead Of Time Compiler)直接把
.java
文件编译成本地机器码的过程
4. 如何选择?
5. 热点代码及探测方式
6. 方法调用计数器
7. 回边计数器
它的作用是统计一个方法体中循环体代码执行的次数, 在字节码中遇到控制流向后跳转的指令称为"回边"
三. String
1. String的基本特性
1. 说明
-
String: 字符串, 使用一对""引起来表示
-
String声明为final的, 不可被继承
-
String实现了Serializable接口: 表示字符串支持序列化的.
-
String实现了Comparable接口: 表示字符串可以比较大小
-
String在jdk8及以前内部定义了final char[] value用于存储字符串数据. jdk9时改为byte[]
-
通过桌面字面量的方式(区别于new)各一个字符串赋值, 此时的字符串值申明在字符串常量池中.
-
字符串常量池中不会存储相同内容的字符串
2. String存储结构变更
String再也不用char[] 来存储, 改为byte[]加上编码标记, 节约了一些空间
-
jdk8及之前
public final clzhiyeass String implements java.io.Serializable, Comparable<String>, CharSequence { /** The value is used for character storage. */ private final char value[]; }
-
jdk9及以后
public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[]; }
3. String的不可变性
-
当字符串重新赋值时, 需要重写指定内存区域赋值, 不能使用原有的value进行赋值
@Test public void test1() { String s1 = "abc";//字面量定义的方式,"abc"存储在字符串常量池中 String s2 = "abc"; s1 = "hello"; System.out.println(s1 == s2); //判断地址:true --> false System.out.println(s1); // hello System.out.println(s2); //abc }
-
当对现有的字符串进行连接操作时, 也需要重新指内存区域赋值, 不能使用原有的value进行赋值
@Test public void test2() { String s1 = "abc"; String s2 = "abc"; s2 += "def"; System.out.println(s2);//abcdef System.out.println(s1);//abc }
-
当调用String的replace()方法修改指定字符或字符串是.
@Test public void test3() { String s1 = "abc"; String s2 = s1.replace('a', 'm'); System.out.println(s1);//abc System.out.println(s2);//mbc }
2. String的内存分配
1. 说明
-
在java语言中有8种基本数据类型和一种比骄傲特殊的类型String. 这些类型为了使它们在运行过程种速度更快, 更节省内存, 都提供了一种常量池的概念.
-
常量池就类似于一个java系统级别提供的缓存. 8种基本数据类型的常量池都是系统协调的, String类型的常量池比较特殊. 它的主要使用方法有两种.
-
直接使用双引号声明出来的String对象会直接存储在常量池种
比如:
String name = "Jiang锋时刻"
-
可以使用String提供的intern()方法
-
-
java 6及以前, 字符串常量池存放在永久代.
-
java 7中字符串常量池的位置调整到java堆内
- 所有的字符串都保存在堆(Heap)中, 和其他普通对象一样, 这样可以让你在进行调优应用时仅需要调整堆的大小既可以了
- 字符串常量池概念原本使用得比较多, 但是这个改动使得我们有足够的理由让我们重新考虑在java 7 中使用
String.intern()
-
java 8 将永久代改为了元空间, 但是字符串常量依然在堆内
3. String拼接操作
1. 说明
-
常量与常量的拼接结果在常量池, 原理是编译期优化
@Test public void test1(){ String s1 = "a" + "b" + "c";//编译期优化:等同于"abc" String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2 /* * 最终.java编译成.class,再执行.class * String s1 = "abc"; * String s2 = "abc" */ System.out.println(s1 == s2); //true System.out.println(s1.equals(s2)); //true }
-
常量池中不会存在相同内容的常量
-
只要其中一个是变量, 结果就在堆中非常量池的区域. 变量的拼接原理是StringBuilder
@Test public void test3(){ String s1 = "a"; String s2 = "b"; String s3 = "ab"; /* 如下的s1 + s2 的执行细节:(变量s是我临时定义的) ① StringBuilder s = new StringBuilder(); ② s.append("a") ③ s.append("b") ④ s.toString() --> 约等于 new String("ab") 补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer(StringBuilder是在5.0时引入的) */ String s4 = s1 + s2;// System.out.println(s3 == s4);//false }
-
如果拼接的结果调用intern()方法, 这主动将常量池中还没有的字符串对象放入常量池, 并返回此对象的地址.
@Test public void test3(){ String s1 = "javaEE"; String s2 = "hadoop"; String s3 = "javaEEhadoop"; String s4 = s1 + s2; //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(), // 具体的内容为拼接的结果:javaEEhadoop System.out.println(s3 == s4);//false // intern():判断字符串常量池中是否存在javaEEhadoop值, // 如果存在,则返回常量池中javaEEhadoop的地址; // 如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。 String s5 = s4.intern(); System.out.println(s3 == s5);//true }
2. 拼接效率比较
通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
-
StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
-
使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。
-
改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
4. intern()的使用
1. 说明
-
如果不是用双引号声明的String对象, 可以使用String提供的intern方法: intern方法会从字符串常量池中查询当前字符串是否存在, 若不存在就会将字符串放入常量池中.
如:
String name = new String("Jiang锋时刻").intern()
-
也就是说, 如果任意字符串上调用String.intern方法, 那么其返回结果所指向的那个类实例, 必须和直接以常量形式出现的字符串实例完全形同.
因此:
("a" + "b" + "c").intern() == "abc"
-
通俗点讲, Interned String就是确保字符串在内存里只有一份拷贝, 这样可以节约内存空间, 加快字符串操作任务的执行速度. 注意: 这个值会被存放在字符串常量池
2. 面试题
-
题目
public static void main(String[] args) { String s = new String("1"); s.intern(); String s2 = "1"; System.out.println(s == s2); String s3 = new String("1") + new String("1"); s3.intern(); String s4 = "11"; System.out.println(s3 == s4); }
-
要回答上面这个面试题, 先要理解下面这两个问题
(1) new String(“ab”)会创建几个对象
2个.
- 对象1: new关键字在堆空间创建的; new String(“ab”)
- 对象2: 字符串常量池中的对象, “ab”
(2) new String(“a”) + new String(“b”)会创建几个对象
- 对象1: new StringBuilder()
- 对象2: new String(“a”)
- 对象3: 常量池中的"a"
- 对象4: new String(“b”)
- 对象5: 常量池中的"b"
深度剖析: StringBuilder的toString()
强调: toString()的调用, 并不会在字符串常量池中生成"ab"
- 对象6: new String(“ab”)
-
分析
public static void main(String[] args) { String s = new String("1"); s.intern();//调用此方法之前,字符串常量池中已经存在了"1" String s2 = "1"; // s中的数据在堆中, s2的数据在常量池中, 地址肯定不一样 System.out.println(s == s2);//jdk6:false jdk7/8:false String s3 = new String("1") + new String("1"); //s3变量记录的地址为:new String("11" //执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!! s3.intern(); // 在字符串常量池中生成"11"。如何理解: String s4 = "11"; // s4变量记录的地址:使用的是上一行代码代码执行时,在常量池中生成的"11"的地址 // jdk6:创建了一个新的对象"11",也就有新的地址。 // jdk7:此时常量中并没有创建"11",而是创建一个指向堆空间中new String("11")的地址 System.out.println(s3 == s4);//jdk6:false jdk7/8:true }
-
拓展1
public static void main(String[] args) { String s3 = new String("1") + new String("1");//new String("11") //执行完上一行代码以后,字符串常量池中,是否存在"11"呢?答案:不存在!! String s4 = "11";//在字符串常量池中生成对象"11" String s5 = s3.intern(); System.out.println(s3 == s4);//false System.out.println(s5 == s4);//true }
-
拓展2
public static void main(String[] args) { String s = new String("a") + new String("b");//new String("ab") //在上一行代码执行完以后,字符串常量池中并没有"ab" String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab" //jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回 System.out.println(s2 == "ab");//jdk6:true jdk8:true System.out.println(s == "ab");//jdk6:false jdk8:true }
-
拓展3
public static void main(String[] args) { String x = "ab"; String s = new String("a") + new String("b");//new String("ab") //在上一行代码执行完以后,字符串常量池中并没有"ab" String s2 = s.intern();//jdk6中:在串池中创建一个字符串"ab" //jdk8中:串池中没有创建字符串"ab",而是创建一个引用,指向new String("ab"),将此引用返回 System.out.println(s2 == "ab");//jdk6:true jdk8:true System.out.println(s == "ab");//jdk6:false jdk8:true }
3. intern执行效率
对于程序中大量存在存在的字符串,尤其其中存在很多重复字符串时,使用intern()可以节省内存空间。
4. 总结
JDK1.6中, 将这个字符串对象尝试放入串池
- 如果串池中有, 则并不会放入. 返回已有的串池中的对象的地址
- 如果没有, 会把此对象赋值一份, 放入串池, 并返回串池中的对象地址
JDK1.7起, 将者字符串对象尝试放入串池
- 如果唇齿中有, 者并不hi放入. 返回已有的串池中的对象的地址
- 如果没有, 这回把对象的引用地址复制一份, 放入串池, 并返回串池中的引用地址
5. G1的String垃圾去重
- 当垃圾收集器工作的时候, 会访问堆上存活的对象. 每一个访问的对象都会检查是否是候选的要去重的String对象
- 如果是, 把这个对象的一个引用插入到队列中等待后续的处理. 一个去重的线程在后台运行, 处理这个对象. 处理队列的一个元素意味着从队列删除这个元素, 然后尝试去重它引用的String对象.
- 使用一个hashtable来记录所有的被String对象使用的不重复的char数组. 当去重的时候, 会检查这个hashtable, 来看堆上是否已经存在一个一模一样的char数组
- 如果存在, String对象会被调整引用那个数组, 释放对原来的数组的引用, 最终会被垃圾回收器回收掉
- 如果查找失败, char数组会被插入到hashtable, 这样以后的时候就可以共享这个数组了.