前言
如果开发人员不了解虚拟机诸多技术特性的运行原理,就无法写出最适合虚拟机运行和自优化的代码。
代码清单可以从华章图书的网站(http://www.hzbook.com/)上下载。
Java 程序员把控制内存的权力交给了Java虚拟机,所以可以在编码的时候享受自动内存管理的诸多优势,不过也正因为这个原因,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误将会成为一项异常艰难的工作。
推荐书籍
-
《Java虚拟机规范》
-
《Java语言规范》
-
《垃圾回收算法手册:自动内存管理的艺术》
Java 发展史
1996年1月23日,Sun发布JDK 1.0,Java语言首次拥有了商用的正式运行环境,这个JDK中所带的虚拟机就是Classic VM。
2018年3月,Oracle正式宣告Java EE成为历史名词。
在 JDK 9 中引入的 Java 模块化系统(Java Platform ModuleSystem,JPMS)是对Java技术的一次重要升级
互联网之于JavaScript、人工智能之于Python,微服务风潮之于Golang
HotSpot虚拟机中含有两个即时编译器:
-
编译耗时短但输出代码优化程度较低的客户端编译器(简称为C1)
-
编译耗时长但输出代码优化质量也更高的服务端编译器(简称为C2)
Graal编译器:
-
自 JDK 10 起,HotSpot中加入的即时编译器
-
以C2编译器替代者的身份登场
Java 内存区域与内存溢出异常
本地(Native)方法
虚拟机栈:为虚拟机执行Java方法(也就是字节码)服务,
本地方法栈:则是为虚拟机使用到的本地(Native)方法服务
Java堆:
- 被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 此内存区域的唯一目的就是存放对象实例
方法区:与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
JDK 8,完全废弃了永久代的概念,完全使用元空间来代替永久代。
String类的intern()方法
HotSpot虚拟机对象探秘
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:
- 对象头(Header)、
- 实例数据(Instance Data)
- 对齐填充(Padding)
由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。
对象访问方式:
- 由虚拟机实现而定的
- 主流的访问方式主要有使用句柄和直接指针(HotSpot 采用这种方式)两种
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。
内存分配与垃圾收集
在1960年诞生于麻省理工学院的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。
为什么我们要去了解垃圾收集和内存分配?:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。
垃圾收集关注的对象是: java堆和方法区。
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
JVM 中判断对象是否存活的算法:
-
可达性分析(Reachability Analysis)算法:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
-
引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它,计数器值加一;当引用失效时,计数器值减一,当计数器为零时,表示这个对象不再被使用。
对象的自救:
- 对象可以在被GC时自我拯救。
- 只要重新与引用链上的任何一个对象建立关联即可
- 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
经典垃圾收集器
Serial收集器:
-
在JDK1.3.1之前是HotSpot虚拟机新生代收集器的唯一选择
-
是一个单线程工作的收集器
ParNew收集器:实质上是Serial收集器的多线程并行版本
Serial Old:是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
G1:面向全堆的收集器,不再需要其他新生代收集器的配合工作。
CMS(Concurrent Mark Sweep)收集器:
-
一种以获取最短回收停顿时间为目标的收集器。
-
基于标记-清除算法实现
按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间
ZGC收集器:
-
基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术
-
使用标记-整理算法的
-
以低延迟为首要目标
大对象
大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组
大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”
类文件结构
平台无关性:字节码存储格式
语言无关性:虚拟机和字节码存储格式
魔数
每个Class文件的头 4 个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
Class文件的魔数取得很有“浪漫气息”,值为0xCAFEBABE(咖啡宝贝?)。
这个魔数值在Java还被称作“Oak”语言的时候(大约是1991年前后)就已经确定下来了。
类加载机制
虚拟机的类加载机制过程:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历如下七个阶段:
- 加载(Loading)、
- 验证(Verification)、
- 准备(Preparation)、
- 解析(Resolution)、
- 初始化(Initialization)、
- 使用(Using)
- 卸载(Unloading)
其中验证、准备、解析三个部分统称为连接(Linking)。
简写:加载 --> 连接 --> 初始化 --> 使用 --> 卸载
类加载器
类加载器用于实现类的加载动作。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。
每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。
虚拟机字节码执行引擎
执行引擎:
-
是 java 虚拟机核心的组成部分之一。
-
有解释执行和编译执行两种方式。
在实际情况中,即时编译是虚拟机执行代码的主要方式,
所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的
Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。
前端编译与优化
前端编辑:即把 java 文件编译成 class 文件的过程
Javac命令
是字节码生成技术的“老祖宗”
并且Javac也是一个由Java语言写成的程序,它的代码存放在OpenJDK的jdk.compilershareclassescomsun oolsjavac目录中。
Java语法糖
泛型:
-
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,
-
这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
-
Java选择的泛型实现方式叫作“类型擦除式泛型”(TypeErasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)。
在JDK 1.2时,遗留代码规模尚小,Java就引入过新的集合类,并且保留了旧集合类不动。这导致了直到现在标准类库中还有Vector(老)和ArrayList(新)、有Hashtable(老)和HashMap(新)等两套容器代码并存。
Java语言里面被使用最多的 5 种语法糖:
- 泛型
- 自动装箱
- 自动拆箱
- 遍历循环(增强 for 循环)
- 变长参数
后端编译与优化
后端编译:即把 class 文件转换成本地二进制机器码的过程。
HotSpot虚拟机中内置了三个即时编译器,其中有两个编译器存在已久:
-
“客户端编译器”(ClientCompiler),简称为C1编译器
-
“服务端编译器”(Server Compiler),简称C2编译器
-
第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。
解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(MixedMode)
热点代码主要有两类:
- 被多次调用的方法。
- 被多次执行的循环体。
判断某段代码是不是热点代码称为“热点探测”,主要有两种方式:
-
基于采样的热点探测
-
基于计数器的热点探测(HotSpot虚拟机)
在笔者的i7-8750H、32GB内存的笔记本上,编译JDK 11的java.base大约花了三分钟的时间.
编译器优化技术
-
最重要的优化技术之一:方法内联。
-
最前沿的优化技术之一:逃逸分析。
-
语言无关的经典优化技术之一:公共子表达式消除。
-
语言相关的经典优化技术之一:数组边界检查消除。
在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是Java程序员都知道的常识。
Java 内存模型与线程
Java内存模型:
-
规定了所有的变量都存储在主内存(Main Memory)中
-
每条线程还有自己的工作内存(Working Memory
-
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
-
操作简化为read、write、lock和unlock四种
关键字volatile:
-
可以说是Java虚拟机提供的最轻量级的同步机制
-
当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。
-
第二项是禁止指令重排序优化
指令重排序是并发编程中最容易导致开发人员产生疑惑的地方之一
Javap反编译、this引用逃逸
Java 中实现可见性的关键字:
- volatile
- synchronized
- final。
Java语言保证线程之间操作的有序性的关键字:
- volatile
- synchronized
Java与线程
目前线程是Java里面进行处理器资源调度的最基本单位。
主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。
Java与协程
1:1的内核线程模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。
为什么内核线程调度切换起来成本就要更高?
答:内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本。
有栈协程:
- 完整地做调用栈的保护、恢复工作
- 有一种特例实现名为纤程(Fiber)
无栈协程:
- 典型应用,即各种语言中的await、async、yield这类关键字
- 本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限。
纤程:
-
OpenJDK在2018年创建了Loom项目,引入了纤程(Fiber)
-
目的为Java语言引入的、与现在线程模型平行的新并发编程机制
线程安全与锁优化
Amdahl定律代替摩尔定律。
线程安全:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为以下五类:
- 不可变
- 绝对线程安全、
- 相对线程安全、
- 线程兼容
- 线程对立。
不可变(Immutable)的对象一定是线程安全的
Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。
在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。
synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。
可重入代码(Reentrant Code):这种代码又称纯代码(PureCode),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。
锁优化
自旋锁:
-
为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
-
自旋次数的默认值是十次
-
在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的
每天学习一点点,每天进步一点点。