深入理解Java虚拟机

简介

Java技术体系主要是由:Java虚拟机,Java类库,Java编程语言以及第三方Java框架组成

因为Java的一个重要优点:Java虚拟机在各个平台上建立了统一的运行平台,使得普通的开发人员只需要了解Java常用类库,基本语法,第三方框架即可完成大部分日常工作

如果开发人员不了解虚拟机的技术特征的运行原理,就无法写出最适合虚拟机运行和自优化的代码

由于OracleJDK在OpenJDK中占据绝对优势,所以本书大多以HotSpot为例子进行讲解

由于版面原因,本书中的许多示例代码都没有遵循最优的程序编写风格,如使用的流没有关闭 流、直接使用System.out输出日志等,请读者在阅读时注意这一点。

2.网站资源 ·高级语言虚拟机圈子:http://hllvm.group.iteye.com/。 里面有一些关于虚拟机的讨论,并不只限于Java虚拟机,包括了所有针对高级语言虚拟机(High- Level Language Virtual Machine)的讨论,不过该网站针对Java虚拟机的讨论还是绝对的主流。圈主 RednaxelaFX(莫枢)的博客(http://rednaxelafx.iteye.com/)是另外一个非常有价值的虚拟机及编译原 理等资料的分享园地。 ·HotSpot Internals:https://wiki.openjdk.java.net/display/HotSpot/Main。 这是一个关于OpenJDK的Wiki网站,许多文章都由JDK的开发团队编写,更新很慢,但是有很大 的参考价值。 ·The HotSpot Group:http://openjdk.java.net/groups/hotspot/。 HotSpot组群,里面有关于虚拟机开发、编译器、垃圾收集和运行时四个邮件组,包含了关于 HotSpot虚拟机最新的讨论。

第一部分、走进Java

第一章、走进Java

由于Java的热点代码检测和运行时编译及优化,使得Java程序能随着运行时间的增长而获取更高的性能

Java技术体系:

从广义上讲,Kotlin、Clojure、JRuby、Groovy等运行于Java虚拟机上的编程语言及其相关的程序 都属于Java技术体系中的一员。

从传统意义上讲:Java程序设计语言,Java虚拟机实现,Class文件格式,Java类库API,第三方Java类库

在不引起歧义的地方都能 使用JDK(Java Development Kit)来代替整个Java技术体系,JDK是支持Java程序开发的最小环境,包括:Java语言,Java虚拟机,Java类库

可以把Java类库中的子集------Java SE API 和Java虚拟机两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境

如果按照重点业务来划分的话:

  • Java Card:支持Java小程序(applets)运行在小内存设备(智能卡)上

  • Java ME:支持Java程序运行在移动终端上的平台

  • Java

    SE:支持面向桌面级应用(如Windows的应用程序)的Java平台,在JDK6之前被称为J2SE

  • Java EE:支持使用多层架构的企业应用的Java平台,并在Java

    SE的基础上做出了拓展(Javax.*包,Java

    SE是Java.*包,由于历史原因,一部分javax进入到了Java SE

    API中),在JDK6之前被称作J2SE,在JDK10被Oracle放弃,捐赠给了Eclipse基金会,改名Jakarta

    EE

Java历史(书籍p25):

1995年5月23日,Oak语言改名为Java,并且在SunWorld大会上正式发布Java 1.0版本。Java语言第 一次提出了"Write Once,Run Anywhere"的口号。

1996年1月23日,JDK 1.0发布,Java语言有了第一个正式版本的运行环境。JDK 1.0提供了一个纯 解释执行的Java虚拟机实现(Sun Classic VM)。JDK 1.0版本的代表技术包括:Java虚拟机、Applet、 AWT等。

Sun公司在JDK7研发期间股票大跌,无力推动JDK7的研发,JDK7中就包含lambda表达式等特性,最后Sun公司被Oracle公司收购,并推迟了技术

JDK 8提供了那些曾在JDK 7中规划过,但最终未能在 JDK 7中完成的功能,主要包括: ·JEP 126:对Lambda表达式的支持,这让Java语言拥有了流畅的函数式表达能力。 ·JEP 104:内置Nashorn JavaScript引擎的支持。 ·JEP 150:新的时间、日期API。 ·JEP 122:彻底移除HotSpot的永久代。

从此以后,每六个JDK大版本中才会被划出一个长期 支持(Long Term Support,LTS)版,只有LTS版的JDK能够获得为期三年的支持和更新,普通版的 JDK就只有短短六个月的生命周期。JDK 8和JDK 11会是LTS版,再下一个就到2021年发布的JDK 17了。

Oracle收购Sun是Java发展历史上一道明显的分界线。在Sun掌舵的前十几年里,Java获得巨大成 功,同时也渐渐显露出来语言演进的缓慢与社区决策的老朽;而在Oracle主导Java后,引起竞争的同时 也带来新的活力,Java发展的速度要显著高于Sun时代。Java的未来是继续向前、再攀高峰,还是由盛 转衰、锋芒挫缩,你我拭目以待。 Java面临的危机挑战前所未有的艰巨,属于Java的未来也从未如此充满想象与可能。

Java虚拟机家族:

虚拟机始祖:Sun Classic/Exact VM

JDK1.0中自带的虚拟机,唯一的功能就是以纯解释器的方式来运行Java代码,如果需要及时编译的功能,就必须外挂编译器,但编译器会完全接管虚拟机的执行系统,解释器将毫无用武之地。正因为编译器与解释器不能共存,使用编译器就不得不对每一个方法每一行代码都进行编译,效率很低,解释器就更不用说了。

在JDK1.2时候,sun公司提供了另一种虚拟机来改善Classic VM的低效率问题------Exact VM,允许编译器与解释器共存,还支持热点嗅探等操作。虽然性能较Classic VM优化了很多,但立马就被外部引进的HotSpot VM淘汰(并不是技术上的原因,而是公司的内部争吵决策),反而是Classic VM,在JDK1.2之前是唯一的VM,在JDK1.2,他是默认的虚拟机选择,在JDK1.3时,HotSpot成为默认选择,Classic VM成为备选,直到JDK1.4才被淘汰。

武林盟主:HotSpot虚拟机

sun公司在1997年收购了Longview Technologies公司,从而获得了 HotSpot虚拟机,HotSpot不是用的Java语言开发

在Oracle公司收购Sun后,选择将BEA的JRockit的优秀点融入到HotSpot中并在JDK8中发布,移除到了永久代,加入了Java Mission Control 监控工具等等

小家碧玉:Mobile/Embedded VM

面向移动端和嵌入式的虚拟机产品

目前位置比较尴尬,在移动端Android和IOS二分天下

而在嵌入式设备上,Java ME Embeddad VM面对自家的Java SE Embeddad VM的竞争,人们更愿意选用 SE的产品,Oracle基本砍掉了这一块,归入了Java SE Embeddad VM中,Java SE Embeddad VM 本质上还是HotSpot,只是为嵌入式设计进行了定制,并减少了内存消耗和资源占用

反倒是很早就应该被淘汰的更低端的Java ME VM活的更好,在国外的老人机和经济欠发达的功能手机还在广泛使用

天下第二:BEA JRockit/IBM J9 VM

曾经的三足鼎立关系,JRockit和J9都曾宣称自己是世界上最快的虚拟机,总体上三者虚拟机之间的性能是交替上升的

对未来的一种期盼:

无语言倾向的Graal VM,在后面介绍到

及时编译器

HotSpot里存在两个及时编译器用于编译热点代码,分别是:

  • 编译时间短但输出代码优化程度较低的客户端编译器(简称C1)

  • 编译时间长但输出代码优化程度较高的服务器编译器(简称C2)

自JDK10起,加入了一种新的即时编译器Graal(到如今仍是非常年幼,没有见过足够的实战)企图在将来取代C2。C2是Cliff Click博士在博士期间的C++作品,但睡着C2的发展就连Cliff Click本人都不愿意维护。而Graal比C2晚问世20多年,并且使用了一种名为"Sea-of-Nodes"的高级中间表示,可以很轻易的借鉴C2的优势,具有更高的可扩展性,在测试中的数据逐渐赶上C2,在有些方面还超越了。

Graal可以作为18年Oracle提出的Graal VM的基础

Graal VM被官方称为"Universal VM"和"Polyglot VM",这是一个在HotSpot虚拟机基础上增强而成 的跨语言全栈虚拟机,可以作为"任何语言"的运行平台使用,这里"任何语言"包括了Java、Scala、 Groovy、Kotlin等基于Java虚拟机之上的语言,还包括了C、C++、Rust等基于LLVM的语言,同时支 持其他像JavaScript、Ruby、Python和R语言等。Graal VM可以无额外开销地混合使用这些编程语言, 支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。

向native迈进:

由于Java的启动时间相对较长,需要预热才能达到最高性能等特点,使得对几年在从大型单体应用架构向小型微服务应用架构发展的技术潮流不太适应

已经陆续推出了跨进程的、可以面向用户程序的类型信息共享(Application Class Data Sharing,AppCDS,允许把加载解析后的类型信息缓存起来,从而提升下次启动速度,原本CDS只支 持Java标准库,在JDK 10时的AppCDS开始支持用户的程序代码)

但提出的一个彻底的解决方案(目前正在实行):提前编译

直接给JVM运行已经编译好的字节码,使得不用去在线编辑,避免了Java第一次运行事假慢的问题。

但坏处也非常明显:1.有悖于Java"一次编写,到处运行"的承诺,开始以来操作系统。2,显著降低Java的动态性,必须要求信息在静态编译的时候就是已知的,而不是运行时候才确定。

直到Substrate VM出现,才算是满足了人们心中对Java提前编译的全部期待。但相应地,原理上也决定了Substrate VM必须要求目标程序是 完全封闭的,即不能动态加载其他编译器不可知的代码和类库。

Substrate VM具有轻量级的特性,在运行时候不会占用过多的内存,并且速度客观

语言语法持续增强:

但一门语言的功能、语法又是影响语言生产力和效率的重要因素,很多语言特性和语法糖不论有没有,程序也照样能写,但即使只是可有可无的语法糖,也是直接影响语言使用者的幸福感程度的关键指标。

改进期盼值关注度高的项目:

  • Loom:Java的线程都是直接调度本地方法,间接依赖于操作系统,对线程的操作太过重量级(早期Java也提供了一套轻量级的线程操作)Loom项目就准备提供一套与目前Thread类API非常接近的Fiber实现。

  • Valhalla:提供值类型和基本类型的泛型支持,并提供明确的不可变类型和非引用类型的声明。不可变类型在并发编程中非常重要,Java只能通过将类中的全部字段声明为final来保证

  • Panama:目的是消弭Java虚拟机与本地代码之间的界线,Panama项目的目

    标就是提供更好的方式让Java代码与本地代码进行调用和传输数据。

自己编译JDK

以OpenJDK为例子,在linux环境下编译

第二部分、自动内存管理

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

第二章、Java内存区域与内存溢出异常

C/C++ 程序员在内存管理领域拥有最高的权限,但也需要担负着每一个对象生命从开始到终结的维护者

Java程序员将内存管理权利交给了Java虚拟机,但一旦出现内存泄漏和洗出方面的问题,一旦不了解虚拟机就很难修复

根据《Java虚拟机规范》,Java虚拟机所管理的内存将会被划分成为以下几块:

组件的介绍:

  • 程序计数器

较小的内存空间,可以被看成当前线程所执行的字节码的行号指示器,是用来选取下一条需要执行的字节码指令。

是程序控制流的指示器,分支,循环,跳转,异常处理,线程恢复等等都依赖于这个计数器

每个线程都会拥有一个程序计数器便于获取CPU时间片的时候恢复到正确的位置,并且计数器之间互不影响,独立存储。这类内存区域被称为"线程私有"的内存

如果执行的是Java方法,则存储的是下一条指令的地址,如果执行的是本地方法,则该计数器为null。是唯一不会出现OutOfMemoryError的地方

  • 虚拟机栈

描述的是Java方法执行的线程内存模型,每个方法对应着一个栈帧,方法的调用到结束就对应着栈帧从虚拟机栈中入栈到出栈的全过程,栈帧中存放有:局部变量表(就是方法体之内定义的变量),操作数栈,动态链接,方法出口等等

线程私有的,生命周期与线程相同

不能笼统的将Java内存划分成为堆内存和栈内存,这种划分方式来源于C/C++,无法适用于Java。但这也间接说明了程序员们最关注的对象分配关系最密切的区域:堆和栈,栈这里通常指的是虚拟机栈,或者更多情况下只是用来指虚拟机栈中的局部变量表部分

局部变量表存放:基本数据类型,对象引用(reference类型,不同于对象本身,可能是指向对象起始地址的引用指针等)和returnAddress类型(指向字节码的地址)

局部变量表的存储空间由局部变量槽来表示,long和double(64位)会占用两个局部变量槽,其余的数据只占用一个。可以这样理解:一个局部变量槽可以存储32位的数据,且只能单独存放一个数值(有问题,后面有解释)。局部变量表的分配在编译期间就可以确定的。

局部变量表的大小指的是:

​ 局部变量槽的数目,而不是局部变量槽的大小,他的大小由虚拟机自行决定的事情。

会抛出两种异常:

  1. StackOverflowError异常:请求的栈深度大于虚拟机所允许的深度,就会抛出该异常

  2. OutOfMemoryError异常:如果虚拟机能动态扩展,在需要扩展的时候却无法申请到足够的内存就会抛出该异常

  3. 本地方法栈

与虚拟机栈发挥相同的功效,只不过虚拟机栈服务于Java方法(分配内存,存储局部变量等等),而本地方法栈服务于本地方法

《Java虚拟机规范》对本地方法栈的使用方式和数据结构没有明确规定,有些虚拟机(例如HotSpot)因为他和虚拟机栈的功能尤其类似,直接合并到了虚拟机栈中去了

和虚拟机栈一样,也会抛出OutOfMemoryError和StackOverflowError异常

  • Java堆

Java堆是虚拟机管理的内存中最大的一块,存在的唯一目的就是:存放对象实例。几乎所有的对象实例(包括数组)都在这里分配内存。

Java堆是垃圾回收器管理的内存区域,有时也被称为"GC堆"

分类:

1.从回收内存的角度看

对Java堆的一点补充:

从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现"新生代""老年代""永久代""Eden空间""From Survivor空间""To Survivor空间"等名词,这些概念在本书后续章节中还会反登场亮相,在这里笔者想先说明的是这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。不少资料上经常写着类似于"Java虚拟机的堆内存分为新生代、老年代、永久代、Eden、Survivor......"这样的内容。在十年之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于"经典分代"[3]来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。

摘自:《深入理解Java虚拟机(第三版)》

2.从内存分配的角度看:

会为每个线程划分其私有的分配缓冲区,以提升对象的分配效率。

==无论从什么角度,都不会改变堆的共性,存储的都是对象实例,将Java堆划分只是为了更好的进行垃圾回收,或者更快的分配内存==

Java堆可以固定大小,也可以动态分配(指定`-Xmx 和 -Xms),如果内存满了,就会抛出OutofMemoryError异常

  • 方法区

用于存放虚拟机已加载的类型信息,常量,静态变量,即时编译器编译出的代码缓存等等

方法区,永久代,元空间的关系:

永久代和元空间只是方法区的两种不同的实现方式

尤其在JDK8之前,HotSpot没有实现方法区,而是使用对堆划分一块名为永久代的内存在在逻辑层面上实现方法区的概念,这样就可以直接使用堆的内存回收机制来管理这一块内存,而永久代有内存上限的限制,会出现很多BUG,而BEA的JRockit和IBM的J9则没有永久代这一概念,当Sun收购BEA时候,想整合HotSpot和JRockit,就因为此出现诸多困难,因此选择逐步使用元空间来替代永久代

JDK7成功将永久代的字符串常量池,静态变量等移除

在JDK8时候废除永久代,采用元空间,并具有自己的垃圾回收算法

如果无法满足内存分配,就会抛出OutOfMemoryError异常

  • 运行时常量池

运行时常量池从属于方法区。方法区对类加载文件(Class文件)会进行内存分配,Class文件包含类的版本,字段,方法,接口等描述,还有常量池表(里面存储的是各种字面量和符号引用),常量池表在类加载后存放到方法区的运行时常量池中

上面只是类的静态编译的处理方式,运行时常量池也支持对动态内存分配来支持动态编译所带来的常量,这种特性被利用的多的地方就是String类的intern()方法

//描述:调用intern方法时,如果池中包含一个equals返回true的对象,那么就直接返回这个池中对象,否则就将当前字符串加入池中,并返回引用
public native String intern();

因为运行时常量池从属于方法区,和方法区一样也可能抛出OutOfMemoryError异常

  • 直接内存

不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》的一部分,因为频繁的被使用,并可能==导致==OutOfMemoryError异常

Java1.4新加入了NIO(New Input/Output)类,可以使得Native函数直接分配堆外内存,并将这块地址的引用返回给DirectByteBuffer对象,显著提高性能,避免Java堆和Native堆来回复制数据

堆外内存没有限制大小,但是随着堆外内存的增大,其他的内存区域动态扩展时候很有可能会抛出OutOfMemoryError异常(可以通过给堆外内存设置-Xmx等参数来解决问题)

上面介绍完了内存区域,下面深入探讨一下HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

对象的创建:

创建对象(例外:复制和反序列化)通常仅仅只需要一个new关键字,但在JVM层面,对象(不包括数组和Class对象)的创建详细详细:

当JVM遇到new指令

1.会先到常量池中检查能否定位到这条指令的参数,并且检查这个符号引用所代表的类是否被加载,解析和初始化过,如果没有,则进行类加载过程

2.为新生对象分配内存,内存大小在类加载过程中便已经确定。分配内存的任务又由Java堆内存是否工整决定的。如果是工整的,就会存在一个指针作为分界点的显示器,隔绝被使用的线程和空闲的线程,此时的分配仅仅只需要挪动指针向空闲内存方向即可分配空间,这种分配方式被称为"指针碰撞"。而如果是不工整的,虚拟机就会维护一个列表,上面记录着空闲内存和被占用内存,并从空闲内存中找到一块足够大的空间交给对象实例,这种分配方式被称为"空闲列表"。影响Java堆是否工整的因素是垃圾回收器,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

需要考虑的问题:并发

可能分配内存后,指针还没来得及挪动就又开始了第二次分配内存

可选方案:

  1. 内存分配的动作进行同步------实际上Java虚拟机就是采用CAS来保证内存分配操作的原子性

  2. 在Java堆上为每个线程都分配一份私有的本地线程分配缓冲(Thread Local

    Allocation

    Buffer,TLAB),直接在自己的本地缓冲区分配,只有当本地缓冲区满了之后,在扩展本地缓冲区的时候进行同步即可,虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

3.将分配到的内存空间(不包括对象头)都初始化为零,如果是TLAB分配的话就直接在分配本地线程区的时候顺便进行,这样能使得对象的实例字段在不赋初值的情况下能直接使用,对应的就是各自的零值(基本数据类型)或者NULL(引用)

4.对对象进行必要的设置,并将信息存储在对象头中,例如:类的元数据信息,对象的哈希码(实际上会延后到调用hashcode方法的时候再计算),对象的GC分代年龄信息等等

到此为止就Java虚拟机方面对象已经分配完成了,但是就程序员来说还没有,所有的字段还是零值

5.调用<Init>{=html}()方法,Java编译器在new关键字之后会紧接着调用new指令和init指令用于初始化,整个初始化工作完成

对象的内存布局:

在HotSpot,对象内存布局分配三部分:对象头(Header),实例数据(Instance Data)和对齐填充(Padding)

  • 对象头

对象头包含两类信息,一类用于存储对象自身的运行时数据,被称为"Mark Word",主要有哈希码(应该是懒加载的),GC分代信息,锁标记状态,线程持有的锁等等只启用一个标记状态记录下来,分配的比特位为虚拟机的比特位,空间极小,所以实现了高效的利用(几个比特位共同管理几种状态),状态码部分信息如下:

对象头的另一部分数据是类型指针,指向该对象的元数据类型(但也不是必须存在的,即Java虚拟机获取对象类型不一定要通过对象的元数据信息),此外,如果是一个数组,还需要存储记录数组长度的数据,这样Java虚拟机才能在类加载的时候确定该对象(把数组看成一个对象)需要分配多大的内存空间。

  • 实例数据

对象真正的有效信息,存储我们定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段,HotSpot的默认存储顺序为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),且同一层级的变量父类定义的会在子类定义之前,且相同长度的字段会被分配到一起存放(减少内存开销)。

  • 对其填充

不是必须的,但由于HotSpot要求对象的起始地址是8字节的整数,所以对象的大小都必须是8字节的整数倍,不满8字节则用此来补充

对象的访问定位:

Java程序是操作栈上的reference数据来操作堆上的具体对象,《Java虚拟机规范》只是指定了该reference存储的是对象的引用,具体怎么访问没有说明,由虚拟机自己实现,主流访问方式有:

  • 句柄访问

Java堆划分出一片内存作为句柄池,reference存储的就是句柄地址,在通过句柄值来找到对象的信息,示例图如下:

对象中的静态全局变量存储在方法区中,非静态全局变量存储在堆区,要同时读取两部分内容

  • 直接指针访问

reference直接存储的是对象堆内存的地址,会少读取一次内存

都需要读取对象头的元数据类型来获取静态全局变量

各有优劣:

句柄存储能保存reference存储的是稳定的句柄地址,如果对象内存修改了,只需要改变句柄地址即可

直接指针能保证高速的访问速度(对HotSpot中主要是使用第二种方式,第一种也很常见,打开任务管理器即可看到句柄数量)

实战:OutOfMemoryError异常

1.模拟堆溢出:

/**
 * @author Lixiang(LuckyCurve)
 * @date 2020/4/22 20:48
 * @Desc 模拟OutOfMemoryError异常
 */
public class Test {
    static class OOMObject {
    }

    public static void main(String[] args) {
        ArrayList<OOMObject> list = new ArrayList<>();

        for (; ; ) {
            list.add(new OOMObject());
        }
    }
}

虚拟机参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError

通过将最大内存和最小内存置为一样保证其无法扩展,通过-XX:+HeapDumpOnOutOfMemoryError保存内存堆转换快照以便后面分析

输出:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid12888.hprof ...
Heap dump file created [28167387 bytes in 0.079 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)
    at java.util.ArrayList.grow(ArrayList.java:265)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
    at java.util.ArrayList.add(ArrayList.java:462)
    at cn.luckycurve.vmdemo.Test.main(Test.java:18)

前三行输出为指定记录堆内存快照独有的

使用测试工具Jprofiler来进行分析:

指定运行环境,选择最右边的按钮

主要将其设置为CPU recording和Save and immediately open a snapshot

启动CPU记录和并保存一个快照,保证一开始的数据就有效,以及当虚拟机被迫中断之后数据不会丢失

3.如果是一个立马关闭的程序,或许当CPU记录还没启动起来JVM已经停止了,例如上面那个分配了20M内存的程序

在第二章图里面很容易看出是哪个对象造成了内存泄漏

中间只存在0.01s,持续时间非常的短(一般不会出现这种情况,因为虚拟机有足够大的内存)

2.模拟栈异常:

HotSpot不会单独区分虚拟机栈和本地方法栈,所以虽然-Xoss参数存在,但是设置本地方法栈大小不会有任何效果,直接使用-Xss来设定栈容量大小即可

《Java虚拟机规范》中提出栈可能出现两种异常:

  • StackOverflowError:请求的栈深度大于栈的最大深度

  • OutOfMemoryError:支持动态扩展的虚拟机如果请求内存扩展无效时抛出(很遗憾HotSpot不支持动态扩展,理论上是很难出现这个异常,也有可能出现:当线程申请分配栈内存的时候无法获取足够内存,也会抛出这个异常)

栈中存放的是栈帧,也叫方法帧,用于记录运行时候的方法信息,最简单的模拟栈溢出的方法就是:方法去调用自己,类似于一个递归函数但是没有递归出口,栈帧会一直记录外层方法,直到栈帧大到虚拟机栈装不下了,就报错,例子如下:

/**
 * @author Lixiang(LuckyCurve)
 * @date 2020/4/23 11:08
 * @Desc 模拟栈异常
 */
public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }


    public static void main(String[] args) {
        JavaVMStackSOF javaVMStackSOF = new JavaVMStackSOF();
        try {
            javaVMStackSOF.stackLeak();
        } catch (Throwable e) {
            System.out.println(javaVMStackSOF.stackLength);
            throw e;
        }
    }
}

虚拟机参数:-Xss128k,指定栈大小,快速模拟出溢出效果,并输出方法调用的次数

还有一种方法和上面例子达到的效果是一样的:

​ 不控制栈的大小,保证每个方法的方法帧足够大(创建100个局部变量,使得方法帧足够大),目的是一样的,但第一种方式优雅些

模拟HotSpot的OutOfMemoryError异常(通过一直创建线程,并保证每个线程的栈内存大小足够大(通过-Xss参数指定分配的栈大小),就很容易出现了)

/**
 * @author Lixiang(LuckyCurve)
 * @date 2020/4/23 11:40
 * @Desc 模拟HotSpot抛出OOM异常(很难见到的,不支持动态内存分配)
 */
public class JavaVMStackOOM {
    public static void main(String[] args) {
        for (; ; ) {
            new Thread(() -> {
                //    保证线程一直执行执行,占用物理内存
                for (; ; ) {

                }
            }).start();
        }
    }
}

虚拟机参数:-Xss2m

:warning::在Windows上由于Java的线程会直接映射到操作系统的内核线程上,操作系统会有很大的压力,很可能造成假死,运行上述语句有很高风险(亲测风险很高)

通常出现StackOverflowError会有明确的信息可供你分析,容易定位到问题信息。

但如果是因为过多的线程导致的OutOfMemoryError,很有可能是线程脱离了管理,GC无法回收(类似于野指针的形式),也有可能只是单纯的线程太多,此时则需要来减少堆内存大小和栈容量(默认情况下大多数达到1000~2000是没有问题的)来换取更多的线程了。

3.方法区与运行时常量池溢出

先来测试运行时常量池的溢出(使用String.intern方法来不断添加对象到常量池里面去)。

在JDK6及以前,运行时常量池在永久代,使用-XX:PermSize-XX:MaxPermSize限制永久代的大小,即可达到内存溢出目的。

在JDK7及以后,开始使用元空间取代了永久代,此时使用-XX:MaxPermSize限制永久代还是在JDK8以后使用-XX:MaxMeta-spaceSize限制元空间都无济于事,因为运行时常量池移动到了堆区,使用-Xmx来指定堆区的大小

/**
 * @author Lixiang(LuckyCurve)
 * @date 2020/4/23 14:28
 * @Desc 运行时常量池的OOM异常
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //防止发生垃圾回收
        HashSet<String> set = new HashSet<>();
        int i = 0;
        for (; ; ) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

虚拟机参数:-Xmx6m

对String的intern方法的测试:

public static void main(String[] args) {
        String a = new StringBuilder("计算机").append("科学").toString();
        System.out.println(a.intern() == a);

        String a2 = new StringBuilder("计算机").append("科学").toString();
        System.out.println(a2.intern() == a2);
    }

输出结果:true false(Java8环境下)

方法区的OOM测试:

方法区存放的是:类名,访问修饰符,常量池(不是运行时常量池),字段描述,方法描述等,产生OOM的基本思路是使用大量的类去填满方法区,可以借助反射或动态代理等等,但比较麻烦

笔者借助了CGLib直接操作字节码运行时生成了大量的动态类。

当前的很多主流框架,如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术

在JDK7之前,包括JDK7使用此方法再加上限制持久代大小很容易使得方法区抛出OOM,但是在JDK8使用了元空间,上述代码很难使得虚拟机产生方法的溢出异常了(会自动进行类的卸载和装载)

4.直接内存溢出

主要是NIO操作本地内存导致的

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

小结:

本章讲述了虚拟机内存划分以及可能导致的内存溢出异常

下一章讲解垃圾回收机制为了避免这些异常做了哪些努力

第三章、垃圾收集器与内存分配策略

垃圾回收(Garbage Collection,GC)并不是Java独有的,早在1960年Lisp语言就开始使用了

现在了解垃圾回收机制的必要性:排查内存溢出,内存泄漏等问题,或者当垃圾收集器称为并发性能的瓶颈时候,我们需要手动调整和监控垃圾收集器的参数

在线程私有的程序计数器,虚拟机栈和本地方法栈三部分中,与线程的生命周期相同,栈中的栈帧随着方法的进入而生成,随着方法的退出而毁灭,且每个方法需要分配的栈帧是已知的(在编译器即可知道),当方法结束时候,内存也就自然而然跟着回收了(相对来说动态性没那么强,大部分信息在编译期已经可以知道,其余的来源于编译器的优化,影响不大)

但堆和方法区则相对而言会具有显著的不确定性(动态性),任何时间任何类的任何方法都可能发出一个new指令来创建对象,无法预先确定对象生成的时间与需要销毁的时间以及对象的大小,垃圾收集器的回收重点也是这一部分

垃圾收集器需要保证堆内的对象已死(再无法被任何途径获取并使用)才进行回收,保证对象已死的方法如下:

1.引用计数算法(Java主流虚拟机都没有采取)

给每个对象添加一个引用计数器,当对象被引用就加一,引用失效了就减一,为零的时候对象就不可能再被使用了

优点:原理简单,判断效率高

缺点:占用额外的内存来计数,需要大量的额外处理才能保证可用性(例如对象之间的循环引用问题,如下):

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

假设ReferenceCountingGC有一个Object类型的instance字段,由于objA和objB相互引用,外界已经不可能访问到他了,但是计数器不为零,就是一个很好的引用计数算法不可解决的例子(需要额外处理)

2.可达性分析算法

当前主流的商业语言(Java,C#)都通过可达性分析来判断对象是否存活的

基本思路:通过一系列称为"GC Roots"的节点作为起始节点集,从这些节点开始向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,或者GC Roots到这个对象不可达,那么这个对象不可能再被使用(可回收)

有效避免了相互引用的问题,Object5,6,7就是例子

可作为GC Roots的对象包括以下几种:

  • 虚拟机栈中本地变量表中引用的对象(主要是入参,局部变量,临时变量)

  • 方法区中静态属性引用的对象

  • 方法区中常量引用的对象,如字符串常量池中的引用

  • 本地方法栈引用的变量

  • Java虚拟机内部的引用

  • 被锁的对象(Synchronized(Obj))

  • 反映Java虚拟机内部情况的对象

除了固定的GC Roots外,可能还会有其他对象临时的加入,构成完整的GC Roots。例如局部回收的GC Roots不能只由该回收部分决定,其他地方可能也存在这些待回收对象的引用,也需要加入进来。

目前最新的几款垃圾收集器无一例外使用了局部回收的特征。

上面两种垃圾回收算法和对象的引用都有关系

JDK1.2之前的引用只是代表另外一块存储地址,对象只有被引用和未被引用两种状态,譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象则显得有些无力

JDK1.2之后,对引用的定义进行了扩充:强引用,软引用,弱引用和虚引用四种,强度依次减弱:

  • 强引用:传统的引用的定义,Object obj = new Object();,只要对象存在强引用就不会被回收

  • 软引用:描述一些还有用,但非必须的对象,如果系统在垃圾回收过后还是即将发生内存溢出,会在内存溢出之前先回收所有软引用对象,使用SoftReference来实现软引用

  • 弱引用:描述非必须的对象,比软引用更弱,会在垃圾回收过程中直接被回收,使用WeakSoftReference来实现

  • 虚引用:最弱的一种引用存在,什么事儿都干不了,为一个对象设置虚引用的唯一目的就是在该对象被垃圾回收时收到一个系统通知,使用PlantomReference来实现

WeakReference构造函数和注释截图:

/**
     * Creates a new weak reference that refers to the given object.  The new
     * reference is not registered with any queue.
     *
     * @param referent object the new weak reference will refer to
     */
public WeakReference(T referent) {
    super(referent);
}

对象在可达性算法中被判定为不可达的对象,也不会非死不可。一个对象的死亡要经历两次标记过程,如果在可达性分析中被视为不可达的对象,会被标记一次。另外,如果对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用了,会被标记第二次,垃圾回收算法会直接回收两次标记了的对象。

而如果对象被判定为不可达的对象,在finalize()方法中与某个可达对象搭上了线(例如传递this对象使得成为某个类或对象的成员变量),那么会取消第一次的标记,取消回收该对象。

finalize方法最多被执行一次,且虚拟机只保证触发该方法,不保证会执行完该方法(如果方法是死循环或者阻塞的会影响整个系统)

例如:

/**
 * @author Lixiang(LuckyCurve)
 * @date 2020/4/23 17:41
 * @Desc 演示方法逃逸GC
 */
public class FinalizeEscapeGC {
    public static FinalizeEscapeGC flag = null;

    public void alive() {
        System.out.println("逃脱成功!");
    }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("被GC了,进入finalize方法,能逃出去吗?");
        flag = this;
    }

    public static void main(String[] args) throws InterruptedException {
        flag = new FinalizeEscapeGC();
        flag = null;
        //    建议虚拟机调用GC
        System.gc();
        //    finalize优先级低,多等一会儿,如果不等的话主线程就结束了
        TimeUnit.MILLISECONDS.sleep(500);
        if (flag == null) {
            System.out.println("哦豁,逃出失败");
        } else {
            flag.alive();
        }

    //    再来一次:
        flag = null;
        //    建议虚拟机调用GC
        System.gc();
        //    finalize优先级低,多等一会儿,如果不等的话主线程就结束了
        TimeUnit.MILLISECONDS.sleep(500);
        if (flag == null) {
            System.out.println("哦豁,逃出失败");
        } else {
            flag.alive();
        }
    }
}

输出:

被GC了,进入finalize方法,能逃出去吗?
逃脱成功!
哦豁,逃出失败

结论:同一个对象的finalize方法只能被系统调用一次

不推荐使用,完全是为了使得C++程序员适应析构函数。因为其运行成本高昂,且不会保证运行成功,官方也不推荐,建议大家完全忘记这个方法

方法区的垃圾回收

规范中并没有指定虚拟机在方法区实现垃圾回收

在Java堆中通常进行垃圾回收可以回收到70~90%的内存,但由于方法区的回收苛刻判定条件,回收效率极低

主要回收两部分:废弃的常量和不再使用的类型

以字符串常量举例:如果常量在当前系统中没有被引用,这个常量就会被清出常量池常量池的其他类/接口/方法/字段也都是如此

不再使用的类型的判断:

  • 该类所有实例已经被回收,在Java堆区不存在该类及子类的对象

  • 类加载器被回收,防止可替换类加载,懒加载等操作

  • 无法在任何地方通过反射访问到该类

满足这三个条件就允许被回收

垃圾收集算法:

从如何判断对象消亡的角度上出发,垃圾收集算法可以划分为"引用计数式垃圾收集"和"追踪式垃圾收集",也被称为"直接垃圾收集"和"间接垃圾收集"两部分,对应着"引用计数式"和"可达性分析"两种判断对象是否消亡的算法。

由于Java虚拟机都是"可达性分析"算法,所以对应着的是"追踪式垃圾收集"算法,讨论的也是这个,只重点介绍分代收集理论和几种算法思想及其发展过程。

分代收集理论

对普遍的垃圾回收机制(不限于Java)

建立在两个分代假说之上(是相互补充的,不冲突):

1.弱分代假说:绝大多数对象都是朝生夕灭的

2.强分代假说:熬过多次垃圾回收过程的对象就越难以消亡

从这个角度看,收集器应该将Java堆划分成不同的区域,一个区域存储的是朝生夕灭的对象可分配部分资源来进行(容易收集的部分,很容易就回收到了内存),另一部分区域可以选择存放难以消亡的对象,让虚拟机使用较少的资源去慢慢回收这个区域(这方面是吃力不讨好的事儿,用时间来磨)。这就保证了垃圾回收的时间开销和空间的有效利用。

在划出了不同的区域之后,就可以使用定制的GC去回收特定区域的垃圾内存了,以及对应区域的一些垃圾回收算法,如"标记-复制算法""标记-清除算 法""标记-整理算法"等

商用的Java虚拟机一般至少把Java堆划分成为新生代和老年代两个区域,分别对应上面的两种。

存在着明显的困难,判断新生代的对象哪些是已经凋亡的,除了遍历GC Roots,还不得不去老年代中的所有对象来确保可达性分析的正确性

为了解决上述问题,还不得不提出以下假说:

3.跨代引用假说:跨代引用相对于同代引用仅占极少数

很容易推导出,在经过垃圾回收时候,因为新生代和老年代之间存在关系,新生代不会被回收,而会进入老年代,减少了跨代的比例

根据这个假说,就应该在新生代上建立一种数据结构(记忆集,remembered Set)来记录跨代引用情况,当发生GC时候数据就会动态的改变,虽然维护数据的正确性需要开销,但对比扫描整个老年代还是值得的。

垃圾收集的几个专有名词:

  • 部分收集(Partial GC):不完整的Java堆的垃圾收集

    • 新生代收集(Minor GC/Young GC)

    • 老年代收集(Major GC/Old GC)

    • 混合收集(Mixed GC)

  • 整堆收集(Full GC):整个堆和方法区的收集

讨论完分代收集理论,再来讨论下分代收集算法,主要分为:"标记-复制算法""标记-清除算法""标记-整理算法"

1.标记-清除算法

出现最早,也是最基础的算法。

标记出需要回收的对象,回收所有被标记的对象(也可以标记所有不需要回收的对象),标记过程就是我们前面讨论的两种对象标记判定算法。

主要缺点:

  • 随着对象数量的增加标机效率和清除效率都会变得比较低

  • 清理完之后内存空间碎片化的问题,导致可能程序无法获取足够大的内存块而提前触发一次垃圾回收(是比较消耗性能的)

2.标记-复制算法

为了解决标记-清除算法中存在大量对象时候执行效率低的问题

将内存分成两等份,每次只使用其中的一份,当当前这一份快使用完成之后,标记存活的对象,并将标记的对象复制到另外一份内存当中,并整体回收刚才一半的内存。

优点:

  • 回收简单,直接移动堆顶指针,再按需分配即可

  • 不会出现碎片化的内存分布

缺点:

  • 可用内存缩小为原来的一半

这种算法非常适合回收新生代,IBM公司曾对新生代的朝生夕灭做了量化------98%的对象熬不过第一轮收集

后面对其进行了改进,称为Appel式回收:

将内存划分成为一块较大的Eden空间和两块较小的Survivor空间,比例为8:1,每次只使用Eden和其中一个Survivor,当空间满了,就复制存活对象到另一个Survivor中。内存利用率也变高了,非常适合新生代。当然如果当前对象实在太多不可回收,Survivor无法同时存下,会将多余的对象暂存在老年代中。

3.标记-整理算法

适用于老年代的算法,由于标记-复制算法不适用于对象存活率高的情况,且会吞50%内存,一般老年代不会采取这种算法

也是标记可存活对象,但是将存货对象移向堆的一端(移动是一种有缺并存的方法,会造成负重,但清理工作很容易做。但如果不移动,分配内存的任务会更难做)

从整体上看:移动对象回收时困难,不移动对象分配时困难

从停顿时间上看:不移动对象停顿时间会更短

从吞吐量上看:移动对象吞吐量更高

这里不移动的算法就是 标记-清除算法

有一种综合的处理方式:平时使用标记-清除算法,容忍碎片化,一旦由于碎片化过多而触发垃圾收集器的时候,使用标记-整理算法收集一次,保证规整

对上面举例的对象存活判定算法和垃圾收集算法介绍HotSpot的细节实现

以可达性分析算法中的GC Roots

固定可作为GC Roots的节点主要是全局性的引用(常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但是工作量相当大

1.根节点枚举

迄今为止,所有收集器枚举所有的根节点会"Stop thr World",即使是号称时间可控,或者(几乎)不会发生停顿的CMS,G1,ZGC等收集器,枚举根节点都是必须要停顿的。

目前主流的Java虚拟机都是准确式垃圾收集,虚拟机在类加载完成之后会自动记录对象的引用到一个OopMap里面,而不会直接从方法区中使用GC Roots查找

2.安全点

在OopMap的帮助下,HotSpot可以快速的完成GC Roots枚举

但是OopMap的时效性非常重要(因为数据会一直变化),所以程序设置了一些安全点,让只有到达安全点上才能收集信息存储仅OopMap里面。所以安全点的选择非常 重要,如果太少,会导致收集器一直等待OopMap的数据,如果太多,则会频繁的去修改OopMap造成内存负荷过大

问题:如何在垃圾回收发生的时候让所有线程都跑向最近的安全点然后挂起呢(因为OopMap收集根节点也需要stop the world)

方案一(几乎没有虚拟机采用):抢先式中断,直接中断所有线程,如果发现某些线程不在安全点上,再恢复这些线程一段时间,再暂停,重复操作直到全部线程都到了安全点上。

方案二:主动式中断,设置一个标志位,线程运行时候会不断检查这个标志位,一旦发现标志位为真就跑去最近的安全点上去

3.安全区域

安全点对运行的线程是非常完美的,但是对于那些没有分配到CPU时间片,处于sleep或者block的状态的线程,则无法响应,并运行到安全点了。虚拟机不可能等待线程分配到时间片再执行到安全点了,就必须引入安全区域了

安全区域可以被看成拉伸了的安全点,在这一区域上引用关系不会发生变化。每个线程都会保证在垃圾回收的时候自己处于安全区域或者安全点上。当其需要离开的时候,会先检查虚拟机是否通过OopMap完成了根节点枚举(或者其他需要暂停用户线程的阶段)如果没完成则会等待。

4.记忆集与卡集

只要涉及到部分区域收集行为的垃圾收集器(最典型的就是新生代和老年代的收集问题),就会面临别的域对现有域的对象有引用的情况。新生代建立了名为记忆集的数据结构来避免将整个老年代都直接加入到了GC Roots的扫描范围,增大负担

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

在不考虑效率和成本的情况下,最简单实现记忆集的方式就是给每个非收集区域中含有跨代引用的对象分配一个对象数组来实现

成本非常高昂,而且在回收过程中虚拟机没必要了解到是哪个非收集区域对象引用了当前对象,只需要了解到该对象被引用了即可

要选择颗粒更粗的记录方式,提供了以下几种精度可选:

  • 字长精度:每个记录精确到一个机器字长,该字长存储跨代指针

  • 对象精度:每个记录精确到一个对象,该对象有跨代指针

  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针

第三种方式用的最多,卡表就是在卡精度层面上对记忆集的实现。

内存区域被划分成为等大小的卡页,大小为2的N次幂,HotSpot好像是2的9次幂,而卡表只需要存储卡页的首地址即可

在回收新生代的时候只需要记录老年代到新生代的引用即可,新生代到老年代的引用无所谓的。

卡表记录着整个堆内存,卡页是对整个堆内存的划分

5.写屏障

问题:卡页何时变脏,谁来把他变脏?

何时变脏是很明确的:有其他分代区域中对象引用了本区域对象,其对应的卡表就会变脏。问题是如何变脏,理论上只要给一个引用赋值,指向这个卡页了,该卡页就需要变脏,但如何保证呢

主要是通过写屏障技术来实现的

在给引用赋值的时候,给这个赋值操作做一个AOP切面,在赋值前进行部分操作,在赋值后进行部分操作,分别称为写前屏障和写后屏障。可以在写后屏障中完成对卡表的更新操作。

一旦对象引用发生了更新,就对卡表进行相应的操作,这个开销远比直接扫描整个堆来的划算

卡表还存在并发问题

在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

6.并发的可达性分析

目前主流虚拟机标记对象存活都会使用可达性分析算法,理论上需要保证全过程一致,即需要冻结用户线程的运行,在GC Roots的根节点遍历中,经过了OopMap技术的加持,随着根节点数量增多,速率已经非常短暂且相对固定了。但遍历下面的对象会随着对象的增多,Java堆内存的增大速率会逐渐降低。

先要了解为什么要冻结,可能出现原本判定为可达的对象此时不可达了(不影响程序运行,顶多是少回收一点内存而已),也可能在并发线程中被标记为不可达的对象又重新可达了,那么此时的垃圾回收机制会带来错误。

引入三色图:

  • 白色:未被垃圾收集器访问过,或者是访问过后的不可达对象

  • 黑色:对象呗垃圾收集器访问过,且该对象的所有字段引用都已经扫描过。表示可达的对象(不可能直接连接到白色,至少需要灰色过度)

  • 灰色:被垃圾收集器访问过,这个对象上存在至少一个引用没有被扫描

把扫描视为一股灰流,流过GC Roots对象

当且仅当满足下列条件,会出现可达对象被回收的错误(即黑色对象被误标白色):

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

破坏其中一条即可:

第一条破坏:增量更新

这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了(需要重新扫描字段,因为灰色对象字段没有扫描完全)

第二条破坏:原始快照

这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

以上对引用关系的插入还是删除都是通过写屏障来实现的。

以上两种都有各自的垃圾回收器实现

垃圾回收算法告一段落,下面介绍垃圾算法的实现------垃圾收集器

下图列出几款经典的垃圾回收器

如果存在 连线,就说明可以搭配使用。

图中也划分出了区域,用来区别几款回收器的作用域

后面将介绍垃圾回收器的作用,使用场景等等,以及重点分析CMS和G1两款复杂但广泛的收集器

上面标记的是表示即将取消支持的组合。

1.Serial收集器

非常经典,在JDK3之前是HotSpot新生代收集器的唯一选择

Serial(顺序排列的,序列号),强调必须暂停其他的工作,只由垃圾回收线程来工作

到如今,HotSpot虚拟机的开发团队仍然在为降低用户线程因垃圾回收导致的停顿而做出努力

尽管Serial看上去很古老,但仍然作为虚拟机客户端的新生代垃圾回收器,因为他简单而高效,占用内存最少,且客户机的回收体积较小,一般一两百兆的新生代最多一百毫秒就可以完成,在不需要频繁的垃圾回收的客户机这边还是非常适用的

2.ParNew收集器

实质上就是Serial收集器的并行版本,大量复用Serial的代码,除了适用多线程进行垃圾回收之外,其余和Serial完全一致。

不少HotSpot,尤其是JDK7之前的服务器端虚拟机都会使用它,因为除了Serial之外,目前只有他能和CMS配合工作了

直到CMS出现才使得PerNew出现了辉煌,CMS是第一款支持垃圾回收线程和用户线程并发的回收器,遗憾的是CMS不能组合Parallel Scavenge配合工作,但这也造就了PerNew的辉煌,在激活CMS后默认的新生代收集器。

可惜随着发展,G1收集器登上了历史的舞台,这是一个全栈收集器。

且JDK9不再维护Serial和CMS,PerNew与Serial Old之前的组合。这意味着PerNew只能和CMS搭配着使用了。可以理解为从此以后,PerNew会合并入CMS,成为CMS新生代的处理器,并成为第一款退出历史舞台的收集器。

3.Parallel Scavenge收集器

基于标记-复制算法实现的收集器,类似于PerNew收集器,有什么特别之处呢?

专注点不同,更加专注于可控的吞吐量而不是用户线程的停顿时间

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数(都是有代价的,都服务与吞吐量)。

4.Serial Old收集器

是Serial的老年代版本,主要意义也是在客户端的老年代上使用,使用标记-整理算法。

如果在服务器端下可能有两种使用情况:

  • 作为CMS的备选

  • 在JDK5之前搭配Parallel Scavenge使用

5.Parallel old收集器

Parallel Scavenge的老年代实现,在JDK6才实现

导致在JDK6之前,Parallel Scavenge的位置很尴尬,只能搭配Serial Old来使用,无法发挥性能,又无法搭配CMS。

在单线程条件下不一定比CMS+Serial的组合来的优秀。

吞吐量优先的全站回收器组合就此诞生

6.CMS(Concurrent Mark Sweep)收集器

JDK5发布的

专注于获取最短的回收停顿时间的收集器,很适合搭载到BS系统的服务器上,要求较高的响应速度

基于标记清除算法,但是运作过程会更加复杂一些:

  1. 初始标记

  2. 并发标记

  3. 重新标记

  4. 并发清除

其中步骤1,3仍然需要stop the world(采用增量更新算法来实现的避免错误)

初始标记仅仅只是标记下与GC Roots直接关联的对象,速度非常快(GC Roots会经过了OopMap的优化,速度已经很快了)。进入并发标记,这个阶段需要遍历整个对象图,但是不会停止用户线程,允许用户线程并发行驶。进入重新标记阶段,会修正并发标记期间用户用户修改的对象,时间可能比初始标记稍微长一点,但比并发标记短很多,最后是并发清除,清除被标记了的对象即可

由于最费时的并发标记和并发清除实现了并发,所以整体上来说,CMS收集器的内存收集过程与用户线程是并发执行的

缺点:

  • 吞吐量降低,因为垃圾回收线程会以资源来换取运行时间,导致系统吞吐量降低

  • 由于浮动垃圾的堆积导致过早的产生了下一次的垃圾清理,浪费资源

如果线程在并发标记过后又产生了垃圾,CMS无法处理,只能等待下一次的清理。而CMS设置了内存阈值来保证有足够的资源去运行垃圾回收操作(-XX:CMSInitiatingOccu-pancyFraction可以通过这个参数设置来调整),导致可能会频繁的发生垃圾回收操作,如果设置的阈值过高,无法使用CMS,虚拟机使用备选方案Serial Old来回收老年代的垃圾,全程Stop the World,这样时间就更长了。

  • CMS采用的是标记-清除算法,会存在大量的内存碎片,提前触发GC

7.Garbage First收集器

简称G1,是垃圾回收器里程碑式的发展,集成到了JDK7,认为可以商用,在JDK8中完善所有功能

JDK9成为默认的垃圾回收器,CMS沦为备用选项

如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告息,提示CMS未来将会被废弃(JDK14废弃)

因为CMS从JDK5开始到JDK9都被作为默认的垃圾回收器,与底层耦合太多,使用G1区替代的时候也困难重重,因此HotSpot决定制定统一垃圾回收接口来保证垃圾回收器更容易的变更和移除

设计人员对GC的期望就是:在指定M毫秒的时间内,大概率能在N秒内完成垃圾回收。为了完成这个目标,做出了改变

对比以前的GC,要么是回收整个新生代/老年代,要么是整体回收。GC跳出了这个牢笼,能对堆内任何部分组成回收集(Collection Set,CSet)进行回收,这就是GC独有的Mixed GC模式

G1将堆内存划分成多个大小相等的独立区域(Region)。每个Region可以扮演新生代的Eden空间,Survivor空间,或者老年代空间。G1还考虑到了大对象的问题,存在特殊的Region存储大对象(超过Region一半的对象),名为Humongous,Region的大小可以通过-XX:G1HeapRegionSize设置,大小在1~32M之间,且为2的N次幂,对于超过了Region的对象(少见),存储在多个连续的Humongous Region之中,G1大多数操作会将Humongous Region当做老年代来看待。

将堆内存划分成一个个Region,然后评估每个Region的价值(垃圾的多少),首先回收价值高的GC,保证GC在有效时间内收益最高

需要解决的细节问题:

  • 每个Region需要维护自己的卡表,JVM会有着更大的负担。根据经验,G1大概会花费10~20%的内存来进行维持收集器的工作

  • 如何保证并发标记不会出现错误?CMS使用的是第一种方法(增量更新),而G1采用了第二种方法(原始快照)来解决,前面已经讲过。

大体的执行步骤如下:

  1. 初始标记

  2. 并发标记

  3. 最终标记

  4. 筛选回收

除了2都是需要Stop The World的。并非纯粹的追求低延迟,而是在保证延迟可控的前提下保证足够的并发量,更加符合实际场景

(要关注在各个阶段是否有用户线程)

通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结 果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,

缺点:

1.占用内存高,维护卡表多

2.光比速度比不过CMS

几款经典的垃圾回收器已经介绍完。

低延迟垃圾回收器

收集器最看重的三要素:内存占用,吞吐量和延迟

随着硬件资源的普及和发展,内存占用和吞吐量会愈发的更容易实现,但是延迟或许会增加(需要回收的内存增大了,有可能会花费更高的时间),延迟的关注度非常高

下图浅色是必须挂起用户线程的,深色则可以并发执行

浅色越少,stop the world操作越少,延迟越低

最后两款是还在试验的产物,延迟很低(了解即可)

ZGC在JDK14中处于建议定位阶段

Shenandoah相当于是第二代的G1,只不过开发者不是Oracle

Shenandoah的工作流程可分为九个阶段

1.初始标记:与G1一样,标记GC Roots直接关联的对象,这个过程是Stop thr world的

2.并发标记:与G1一样,遍历对象图,是Concurrent的,时间长短取决于堆内对象的数量

3.最终标记:统计出回收价值最高的Region,并将这些Region构成一组回收集Collection Set,会有stop the world的停顿

4.并发清理:清理那些一个存活对象都没有的Region(只要挪动指针即可)

5.并发回收:核心差异,将回收集Collection Set的存活对象复制到未被使用的Region里面去,Shenandoah通过读屏障和被称为Brooks Pointers的转发指针来解决用户并发操作带来的修改问题和内存中指针的一次性指向问题

6.初始引用更新:准备将堆内所有指向旧对象的引用更新指向新对象。这个阶段只是确保所有线程的并发回收都完成而已,会产生一个短暂的Stop the world

7.并发引用更新:真正进入指针引用的转移,Concurrent的,只需要按照内存物理地址的顺序,把旧值改变成新值即可

8.最终引用更新:修改GC Roots的引用,这个阶段是Shenandoah最后一次Stop the world了

9.并发清理:再次回收没有存活对象的Region,即Collection Set里面的Region

Brooks Pointers的转发指针类似于前面提到的句柄定位,都是间接性的对象访问方式,区别是句柄存储在句柄池里面,而转发指针存储在对象头里面

都有间接访问带来的弊端:两次寻址过程,虽然已经优化到了只有一条汇编指令了

但指针转发会有并发问题,如果出现以下情况:

  1. 收集器线程复制了对象副本

  2. 用户线程修改了对象

  3. 收集器更正转发指针的引用到新的地址

就会出现问题,Shenandoah是采取CAS操作来避免这个问题的

对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。

Shenandoah是第一款使用读屏障的收集器,由于对象读取次数会远远多于对象的写入次数,所以读屏障的设计要极其小心且高效。

使用ES对200G的维基百科进行索引时候Shenandoah的性能表现

弱项:高运行负担使得吞吐量下降

强项:低时间延迟

由于使用了空间去换取时间,同时存储的对象数量会急剧减少,并发量自然下降的厉害

第一款由非Oracle开发的收集器就此诞生

ZGC:(Z Garbage Collector)

由Oracle研发的低延迟垃圾收集器

目标和Shenandoah都是极其相似的,在对吞吐量影响不大的情况下,实现对任意堆内存大小下都可以把垃圾收集的停顿时间(Stop the world)限制在十毫秒以内的低延迟

这里的吞吐量下降不大是指相较于G1而言,性能不会下降超过15%,但在实际应用中来看,ZGC的吞吐量比G1还高,已经接近了Parallel Scavenge的成绩

给ZGC下一个这样的定义来概括它的主要特征:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

这里的Region有特殊性,具有动态创建和销毁,以及动态的区域容量大小,主要的大小为:

  • 小型Region:

固定容量为2M,用于放置小于256KB的对象

  • 中型Region:

容量固定为32M,用于存放大于256KB,小于4M的对象

  • 大型Region:

自定义内存容量,必须是2M的整数倍,存放4M以及以上的大对象,每个Region只存放一个大对象(有可能比中型Region还小)

ZGC回收器的核心问题,并发整理算法的实现:

Shenandoah使用转发指针和读屏障来实现并发整理

ZGC使用读屏障、染色指针和内存多重映射

染色指针技术:

主要需要解决的场景就是:需要知道对象的某些信息,但根本不需要访问对象的场景。例如前面讲到的------追踪式收集算法的标记阶段。

而其他收集器是怎么实现追踪式收集算法的标记阶段的呢?

Serial使用的是直接标记对象头的方式

G1,Shenandoah使用了相当于堆内存大小的1/64来记录信息

ZGC使用染色指针,直接把标记信息记录在引用对象的指针上。这时候,只需要遍历"引用图"来标记而取代了遍历"对象图"来标记

指针能够存储数据是来源于AMD64的架构的信息存储,会预分配指针一些位数来使得随着内存的扩展指针能够存下完整的内存地址,但实际上目前不会应用完全,染色指针技术就瞧中了这些空的位

使用染色指针来代替写屏障,需要的记录可以直接加在指针上,而不用做AOP再加到对象头上去了

内存多重映射:

主要解决的就是要操作指针的未被使用的位

而Java虚拟机作为一个普普通通的进程,是没有权利修改指针上哪些位存储标志位,哪些位存储地址。需要使用虚拟内存映射技术

操作系统提供虚拟地址和物理地址的映射关系,映射关系可以是一对一,一对多,多对多的多种关系

ZGC可以使用内存多重映射,将虚拟内存地址映射到物理内存地址,实现对内存的全面读写控制权

ZGC主要分为四个阶段,都可以并发执行,仅在两个阶段中间会存在短暂的停顿小阶段 Stop the world:

1.并发标记:也会经历初始标记,最终标记的stop the world,但仅仅需要更新的是染色指针的Marked0,Marked1标志位

2.并发预备重分配:统计出哪些Region需要重分配,并组合成为重分配集(Relocation Set),类似于G1的Collection Set,但还是有区别的,并不记录各个Region的回收价值,仅仅只是记录哪些Region是无用的,可以重新分配的

3.并发重分配:移动存活的对象,每次转发对象都会存储一张转发表,对象移动完成后立马释放原来的Region,一旦旧指针被使用,会立马指向新的内存地址上来,即具有"自愈"功能

4.并发重映射:不必迫切的去将所有旧引用更新成新引用,因为指针有自愈功能。一旦转发表里面的某一行数据被读取了(即可理解指针指向的地址发生了改变),这条记录即可被删除了。

实现了任何停顿都少于10毫秒的目标

ZGC的缺点:虽然停顿时间很短,但并发收集周期很长,会导致大量的浮动垃圾的堆积,目前最好的解决办法就是增大堆内存的大小

讲完了低延迟垃圾回收器(主要是停顿时间短,并发收集时间通常不短)

下面讲讲垃圾回收器的选择

在介绍一款不会进行垃圾回收操作的Epsilon收集器

随着微服务的发展,JVM的占用内存大,容器启动时间长,即时编译需要缓慢优化的特点,导致有一些先天不足。

但在最近的JDK版本中出现了提前编译,面向应用的类数据共享等特性的支持、

Epsilon也有着同样的目标,如果程序仅仅需要运行数分钟甚至数秒,即可以避免在Java堆内存分配完之前完成退出操作。那么负载小,无垃圾回收行为的Epsilon就非常好。

加上补充了的Epsilon,已经了解很多的收集器了,具体如何选择呢?

主要三个因素:

  • 应用程序的关注点是什么,如果是数据分析等需要尽快得出结论的,吞吐量(并发收集时间)就是关注点,如果是服务应用,那么延迟(stop

    the

    world)是主要关注点,如果是客户端应用或者是嵌入式应用,那么垃圾回收的内存占用就是主要关注点

  • JDK的发行厂商

  • 机器的物理配置

==具体还得根据实际的性能表现而定,不可纸上谈兵==

JDK9之前,Java虚拟机没有实现统一的日志框架

178页详细介绍,要使用的时候查阅即可.

实战:内存分配与回收策略

上面已经详细介绍了内存的回收策略,而JVM的另一个重要作用:内存分配也讨论下

1.对象优先在Eden分配

代码:

public static final int _1MB = 1024 * 1024;

    /**
     * 对象优先在Eden分配
     * -verbose:gc          相当于是-X:logs gc
     * -Xms20M              Java堆起始内存大小,也是最小值,设置成Xmx一样防止JVM内存扩展
     * -Xmx20M              Java堆最大可用内存为20M
     * -Xmn10M              给Java堆新生代的内存大小
     * -XX:+PrintGCDetails  详细信息
     * -XX:SurvivorRatio=8  设置Eden和Survivor比例为8:1,新生代包括一个Eden和两个Survivor
     */
    public static void testAllocation() {
        byte[] allocationw1, allocationw2, allocationw3, allocationw4;
        allocationw1 = new byte[2 * _1MB];
        allocationw2 = new byte[2 * _1MB];
        allocationw3 = new byte[2 * _1MB];
        allocationw4 = new byte[3 * _1MB];
    }

运行结果:

触发了GC

情况分析:

此时Eden占用了大约3M的内存存放allocationw4

最考试存入的allocationw1~3均无法存入到Survivor里面去,被送到了老年代,老年代此时6M,完美印证

如果最后存入的是4M的对象,则结果如下:

并没有触发垃圾回收,直接存入到了老年代里面去了,这也是下面要讨论的

如果要模拟这种情况,就加大大对象的标准,代码如下:

public static final int _1MB = 1024 * 1024;

    /**
     * 对象优先在Eden分配
     * -verbose:gc          相当于是-X:logs gc
     * -Xms20M              Java堆起始内存大小,也是最小值,设置成Xmx一样防止JVM内存扩展
     * -Xmx20M              Java堆最大可用内存为20M
     * -Xmn10M              给Java堆新生代的内存大小
     * -XX:+PrintGCDetails  详细信息
     * -XX:SurvivorRatio=8  设置Eden和Survivor比例为8:1,新生代包括一个Eden和两个Survivor
     * -XX:PretenureSizeThreshold=5242880   自定义大对象为5M
     */
    public static void testAllocation() {
        byte[] allocationw1, allocationw2, allocationw3, allocationw4;
        allocationw1 = new byte[2 * _1MB];
        allocationw2 = new byte[2 * _1MB];
        allocationw3 = new byte[2 * _1MB];
        allocationw4 = new byte[3 * _1MB];
    }

发生GC操作,且结果合理

2.大对象直接进入老年代

写程序要避免出现大对象,特别是朝生夕灭的大对象,很容易提前触发垃圾收集操作

上面的例子已经有了,就是直接添加4M的对象,虚拟机直接将其加入到老年代当中去,没有发生垃圾回收

3.长期存活的对象将进入老年代

对象头会存储一个Age字段,新生对象一般都会加入Eden(除了大对象),并且在发生一次垃圾收集的时候能被Survivor容纳的话,该对象就进入Survivor并且Age设置为1,并在每进行一次垃圾回收的时候age就加一,默认age为15就进入了老年代,可以通过-XX:MaxTenuringThreshold自定义年龄阈值

/**
     * @SuppressWarnings: 抑制编译器的变量未使用检查
     * -verbose:gc
     * -Xms20M
     * -Xmx20M
     * -Xmn10M
     * -XX:+PrintGCDetails
     * -XX:SurvivorRatio=8
     * -XX:MaxTenuringThreshold=15      老年代的年龄阈值,默认为15
     * -XX:PretenureSizeThreshold=5242880
     */
    //@SuppressWarnings("unused")
    public static void testTenuringThreshold() {
        byte[] allocationw1, allocationw2, allocationw3;
        allocationw1 = new byte[_1MB / 4];
        allocationw2 = new byte[4 * _1MB];
        allocationw3 = new byte[4 * _1MB];
        allocationw3 = null;
        allocationw3 = new byte[4 * _1MB];
    }

上述实验失败了,因为JDK的版本不同,8和14的都无法达到效果,各个版本对垃圾回收期的优化不同

4.如果Survivor空间中相同年龄所有对象大小的总和大于Survivor的一半,就会直接进入老年代,不用等待到达年龄阈值

5.空间分配担保

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。

==但是在jdk1.6 update 24之后-XX:-HandlePromotionFailure 不起作用了,只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行MonitorGC,否则FullGC==

==难怪之前发生的全是Full GC==

出现这个的原因是因为,在极端情况下,Eden空间满了,没有可回收对象,对象也无法加入到Survivor里面去,就会往老年代里面传。

可以设置HandlePromotionFailure参数的开关来避免Full GC过于频繁,但要承担一定风险

小结:

本章介绍了垃圾回收算法,各种垃圾收集器的特点与运作原理

虚拟机之所以提供多种收集器和大量的调节参数就是为了根据实际需求,根据实际情况来得出最佳的收集器,参数组合

接下来两章将会介绍内存分析工具和具体调优的案例

第四章、虚拟机性能监控能、故障处理工具

JDK打包的工具,类似于Java.exe,Javac.exe

JDK5之前需要手动开启JMX功能 -Dcom.sun.management.jmxremote,大部分工具都是依赖于JMX的,JDK6及以上就是默认开启了

需要切换到JAVA_HOME/bin目录下

[]:可选参数 \<>:参数描述

1.Jps:虚拟机进程状况工具

jps的命令格式为:(基于JDK8)

usage: jps [-help]
       jps [-q] [-mlvV] [<hostid>]

可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)。

大部分命令都是支持远程调试的,考虑到运作的机器可能与实际隔阂开来

options的取值:

选项 作用

-q        只输出LVMID省略主类的名称
-m    输出虚拟机启动时候传给main的参数
-l    主类的全名,如果是JAR包则是全路径
-v            启动时候的JVM参数

2.jstat:虚拟机统计信息监视工具

用于显示本地或者远程虚拟机中的类加载、内存、垃圾收集、即时编译等运行数据,在没有GUI的情况下,常用工具

用法:

Usage: jstat -help|-options
       jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]

参数interval和count表示查询间隔和次数

例如:每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,命令如下

jstat -gc 2764 250 20

option可选项众多:

3.jinfo:Java配置信息工具

作用:查看和调整虚拟机的各项参数

jinfo相较于jps的虚拟机参数打印,可以打印出默认的参数值

用法:

Usage:
    jinfo [option] <pid>
        (to connect to running process)
    jinfo [option] <executable <core>
        (to connect to a core file)
    jinfo [option] [server_id@]<remote server IP or hostname>
        (to connect to remote debug server)

4.jmap:Java内存映射工具

用于生成堆内存快照,还可以查询finalize执行队列、Java堆和方法区的 详细信息,如空间使用率、当前用的是哪种收集器等

用法:

Usage:
    jmap [option] <pid>
        (to connect to running process)
    jmap [option] <executable <core>
        (to connect to a core file)
    jmap [option] [server_id@]<remote server IP or hostname>
        (to connect to remote debug server)

5.jhat:虚拟机堆存储快照分析工具

配合jmap,一般都不愿意用

6.jstack:Java堆栈追踪工具

作用:生成虚拟机当前时刻的线程快照,可以进行并发分析

上面讲述了6个最基本的命令,随着JDK的发展,很多都有了替代品。但学习最基础的命令仍然是有必要的

除了上述的命令行工具外,JDK还提供了几个集成度更高的可视化工具,这类工具主要包括JConsole、JHSDB、VisualVM和JMC四个。

现在VisualVM成为了一个独立的开源项目

1.JHSDB:基于服务性代理的调试工具

改用JDK11 TLS了

运行事例代码:测试JHSDB的调试功能,验证staticObj,instanceObj,localObj存放位置

package cn.luckycurve.vmdemo;

/**
 * @author LuckyCurve
 * @date 2020/5/1 16:59
 * 测试JHSDB的调试功能,验证staticObj,instanceObj,localObj存放位置
 * -Xmx10m
 * -XX:-UseCompressedOops       抑制指针压缩,因为JHSDB对指针压缩的处理存在缺陷
 */
public class JHSDB_TestCase {
    static class Test {
        static JHSDB_TestCase.ObjectHolder staticObj = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();

        void foo() {
            ObjectHolder localObj = new ObjectHolder();
-            System.out.println("done");
        }
    }

    private static class ObjectHolder{
        private String message = "hello world";
    }

    public static void main(String[] args) {
        Test test = new Test();
        test.foo();
    }
}

1.在17行打上断点,让JVM停留在此刻。(如果没有使用集成IDE,可以使用死循环或者线程挂起来避免代码继续向后运行)

2.命令行jps,查看具体运行的JVM的进程ID

3.使用命令jhsdb hsdb来打开JHSDB的Swing程序,并输入进程ID

5.查看堆的内存分布

6.查找堆区内的所有ObjectHolder对象

scanoops 0x000001f04ac00000 0x000001f04b600000 cn.luckycurve.vmdemo.JHSDB_TestCase$ObjectHolder

==一定是全限定类名(考虑到类重名的情况)==

7.使用Inspector工具来搜索地址对应的类的详细信息

之所以在ObjectHolder里面加入了一个String字段就是为了看下是否是这个类的实例化对象

这里面还包括了Java类型的名字、继承关系、实现接口关系,字段信息、方法信息、运行时常量池的指针、内嵌的虚方法表(vtable)以及接口方法表(itable)等。

8.找出引用这些对象的指针

可以使用Tools菜单下的Compute Reverse Ptrs来完成(有BUG,会卡Swing)

使用JHSDB内置命令行来完成

指令:revptrs 0x000001f04b3b5250

==revptrs可根据对象地址查看引用该对象的活跃对象的地址,这里的引用是指通过类全局属性而非局部变量引用==

再使用Inspector来查看这个引用信息

这个对象就是staticObj

结论:Class为类信息,《Java虚拟机规范》要求所有Class信息都应该存储在方法区之中,但方法区如何实现,并没有规范,在JDK7以后的HotSpot虚拟机将静态变量与类型在Java语言一端的映射Class对象存储在了Java堆区当中

查看第二个对象的情况

就是主类,第二个对象就是staticObj

查询第三个对象

无法查询到,看来是无法查询到栈上面的对象引用了

人肉查看栈内存,在Java Thread查找

还好栈不大,一下就能找到

和第三个地址完全吻合。第三个对象就是localObj

实验完毕

JConsole:Java监视与管理控制台

它的主要功能是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整。

。JMX是一种开放性的技术,不仅可以用在虚拟机本身的管理上,还可以 运行于虚拟机之上的软件中,典型的如中间件大多也基于JMX来实现管理与监控。虚拟机对JMX MBean的访问也是完全开放的,可以使用代码调用API、支持JMX协议的管理控制台,或者其他符合JMX规范的软件进行访问。

1.启动

直接jconsole命令启动,会自动搜索本机的所有虚拟机进程,不需要JHSDB那样通过jps来查看进程id

随便连接进了一个虚拟机进程

实验:看内存变化

代码:

/**
 * @author LuckyCurve
 * @date 2020/5/2 20:24
 * 使用JConsole来检测资源占用情况
 * -Xms100m
 * -Xmx100m
 * -XX:+UseSerialGC
 */
public class MonitoringTest {

    static class OOMObject {
        public byte[] placeHolder = new byte[64 * 1024];
    }

    public static void fillHeap(Integer num) throws InterruptedException {
        TimeUnit.SECONDS.sleep(5);
        LinkedList<OOMObject> list = new LinkedList<>();
        for (int i = 0; i < num; i++) {
            Thread.sleep(50);
            list.add(new OOMObject());
        }
        System.gc();
    }

    public static void main(String[] args) throws InterruptedException {
        fillHeap(1000);
        //防止程序立马退出
        TimeUnit.SECONDS.sleep(10);
    }
}

使用JConsole怎么测都测不准,使用JProfiler了

堆内存变化如图所示:

Eden区:

Survivor:

Gen区:

测得就准的多。

随着分配对象的增多,Eden区逐渐满,当快满的时候,触发GC,将一部分移动到Survivor,更大的一部分直接移动到了Gen中。

Eden:Survivor=8:1,且同一时间只能存在一个Survivor

最后手动触发GC,全部回收到了Survivor和Gen,Eden清空

:question::如果最后要清空Gen,怎么做

把GC操作移动到方法体外面来即可

public static void main(String[] args) throws InterruptedException {
    fillHeap(1000);
    System.gc();
    //防止程序立马退出
    TimeUnit.SECONDS.sleep(10);
}

堆内存变动如图:

Eden:

Survivor:

Gen:

离开方法区,List已经变成不可达对象,直接被回收

但接下来测试线程检测的时候,JConsole表现和JProfiler差不多,都可以进行死锁的判断

JConsole:

JProfile:

线程状态:

锁的获取情况以及要获取那些琐:

3.VisualVM:多合-故障处理工具

对VisualVM的高度赞扬:功能最强大的运行监视和故障处理程序之一,能进行故障处理,性能分析。Oracle主推

VisualVM的性能分析功能比起JProfiler、YourKit等专业且收费的Profiling工具都不遑多让。而且相比这些第三方工具,VisualVM还有一个很大的优点:不需要被监视的程序基于特殊Agent去运行,因此它的通用性很强,对应用程序实际性能的影响也较小,使得它可以直接应用在生产环境中。这个优点是JProfiler、YourKit等工具无法与之媲美的。

visualVM基于NetBeans平台开发工具,可以通过插件扩展功能(就包括上面的JConsole工具都被以插件形式集成上去了)

在JDK6~8的时候存在,在JDK9中从Oracle中移除

​ 1.性能分析

​ 2.在线调优(不推荐在生产环境下,对性能影响有点大,如果一定要在生产环境下就使用JDK自带的JMC)

​ 3.插件推荐

​ 1.BTrace动态日志追踪:在不中断目标程序运行的情况下加入原本并不存在的调试代码

4.推荐最后一个工具------JMC(Java Mission Control)可持续在线的监控工具

JMC也是一个独立的程序,是基于HotSpot内嵌的JFR(监控和基于事件的信息收集框架),而且JFR监控过程的开始,停止都是完全动态的,不需要重启应用

JMC一方面作为JMX的控制台,显示来自虚拟机MBean提供的数据,另一方面也作为JFR的分析工具

MBean的部分与JConsole和VisualVM的效果是一样的,重点讲解JFR部分

左侧飞行记录仪,指定记录时间(由于是商用的,会弹出警告(对个人用户是免费的))

直接点完成,等待指定时间即可弹出飞行报告(这里即依赖于JFR的记录模式的随时开启和关闭了),且采集过程对性能几乎无影响

HotSpot插件推荐

HSDIS插件:JIT生成代码反汇编

当我们想了解HotSpot源码时候,因为其中很大一部分被即时编译器编译成了机器语言,非常不便于调试,于是这款插件出现了

HSDIS的作用是让HotSpot的-XX:+PrintAssembly指令来将即时编译器编译的代码还原为汇编代码输出,还会产生注解,便于调试。

要单独下载

可以和JITWatch一起使用便于代码阅读

小结:

六个命令行工具

jps
jstat
jinfo
jmap
jhat
jstack

四个可视化的故障处理工具

JHSDB
JConsole
VisualVM
Java Mission Control

第五章、调优案例分析与实战

对绝大多数Java程序员来说,都很少有机会直接接触生产环境的服务器

案例分析:

服务器端程序调优分析:255~270页

调优实战:IDEA调优

1.启动时间(不会编写时间记录插件,暂时无法执行)

2.编译时间

3.GC时间

主要可以通过:

1.升级JDK版本,每个版本的JDK都会有提升

2.调整内存布局来防止GC频率过高

3.根据GC频率(Full GC,Minor GC)指定垃圾收集器,提高收集效率

具体的回收效果可以通过VisualVM的VisualGC插件看出

会记录每个片段发生的垃圾收集的次数和总用时

也可以通过主页的CPU图来看GC活动情况

小结:

关于虚拟机内存管理部分到此就结束了,下一章我们将要开始Class文件与虚拟机执行子系统方面的知识

第三部分、虚拟机执行子系统

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步

第六章、类文件结构

随着语言的发展,大部分语言建立起了自己的运行虚拟机,将代码编译成二进制恩地机器码(Native Code)进行存储已不是唯一的选择,越来越多的语言选用平台中立的格式作为编译后的存储格式(如.class文件)

Java虚拟机提供的特性:

实现平台无关性的基石------字节码(Byte Code),当然,字节码的作用远不止此,他还是实现语言无关性的基石

Java虚拟机只是与class文件绑定,并不与Java语言绑定,任何语言编译出来的class文件都可以在Java虚拟机上运行

而本章的主要内容就是解析Class文件

Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没

在随后发布的《Java虚拟机规范》中几乎没有对class文件格式进行改动,改动基本上只是在原有结构基础上新增内容,扩充功能,并未对已经定义的内容进行修改

Class文件是一组以8个字节为基础单位的二进制流,存储密度非常高(没有分隔符)

Class文件里面只有两种类似C语言结构体的伪结构来存储数据,分别是:

  • 无符号数:属于基本数据类型,用u1,u2,u4,u8分别代表占位1248的无符号数,无符号数可以用来描述数字,索引引用,数量值或者按照UTF-8编码构成的字符串信息

  • 表:由多个无符号数或者其他表作为数据项来构成的复合数据类型,一般以"_info"结尾

当Class文件内部需要描述同一类型但数量不定的无符号数或表时候,会使用一个前置的容量计数器加若干个连续的数据项的形式,被称为某一类型的"集合"

由于Class文件没有分隔符,所以每一位所表示的含义非常重要

1.每个Class文件的前四个字节被称为魔数(Magic Number),用来进行身份识别Class文件的魔数在Java被称为Oak的时候就被定了下来,为"0xCAFEBABE",或许其中根Oak改名为Java有一定联系

2.紧跟着的四个字节存储的是Class文件的版本号,第5,6个字节存储的是次版本号(Minor Version),第7,8个字节存储的是主版本号(Major Version)。随着JDK的升级,Class文件版本号也进行了升级。版本号存在的一个必要就是------让虚拟机拒绝运行版本号过高的class文件。

使用JDK11编译一个Java文件,源码如下:

public class TestClass{
    private int m;

    public int inc () {
        return m+1;
    }
}

Class文件如下:

CA FE BA BE 00 00 00 37 00 13 0A 00 04 00 0F 09 
00 03 00 10 07 00 11 07 00 12 01 00 01 6D 01 00 
01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 
56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 
75 6D 62 65 72 54 61 62 6C 65 01 00 03 69 6E 63 
01 00 03 28 29 49 01 00 0A 53 6F 75 72 63 65 46 
69 6C 65 01 00 0E 54 65 73 74 43 6C 61 73 73 2E 
6A 61 76 61 0C 00 07 00 08 0C 00 05 00 06 01 00 
17 63 6E 2F 6C 75 63 6B 79 63 75 72 76 65 2F 54 
65 73 74 43 6C 61 73 73 01 00 10 6A 61 76 61 2F 
6C 61 6E 67 2F 4F 62 6A 65 63 74 00 21 00 03 00 
04 00 00 00 01 00 02 00 05 00 06 00 00 00 02 00 
01 00 07 00 08 00 01 00 09 00 00 00 1D 00 01 00 
01 00 00 00 05 2A B7 00 01 B1 00 00 00 01 00 0A 
00 00 00 06 00 01 00 00 00 03 00 01 00 0B 00 0C 
00 01 00 09 00 00 00 1F 00 02 00 01 00 00 00 07 
2A B4 00 02 04 60 AC 00 00 00 01 00 0A 00 00 00 
06 00 01 00 00 00 07 00 01 00 0D 00 00 00 02 00 
0E 

两个字符(十六进制)=一字节

可以看到版本号为37H = 55

对应关系(完美印证):

3.常量池计数器,版本号之后的两个字节存储的是常量池的入口:常量池的计数器,由于常量池相当大(基本是Class文件空间最大的数据项之一)且存放的常量是不固定的,需要使用常量池计数器来指定容量大小

这里的常量池计数器为0x13 = 19,所以常量池的索引是1~18(确实有点怪,索引不是从0开始的,只存储了18个常量,计数器却是19)【因为此时计数器是从1开始计数的】,和Java里面常见计数器有区别

4.常量池:主要存储:

  • 字面量

主要是如:文本字符串,被声明为final的常量值等

new String("hello world")

会在常量池中创建hello world字符串,再在堆中创建字符串

  • 符号引用

主要包括以下常量:

  1. 被模块导出或者开放的包(Package)

  2. 类和接口的全限定名(Fully Qualified Name)

  3. 字段的方法和描述(Description)

  4. 方法的名称和描述

  5. 方法句柄和方法类型(Method Handler,Method Type,Invoke Dynamic)

  6. 动态调用点和动态常量(Dynamically-Computed Call

    Site、Dynamically-Computed Constant)

由于不像C/C++具有连接这一步骤,在生成的Class文件中都无法确定各个常量的内存布局(内存地址),只有通过JVM加载过后,这些符号引用最终才能确定内存入口地址

常量池的具体传送信息:可以根据开头的标志位u1来确定数据类型,而每种数据类型都有指定的存储空间(u1,u2),即可指定出所有的对象数据。

如:

这里的tag表示类型标识,用于识别数据类型,下面的name_index则是索引号,指向了下面的数据类型

下面这个数据类型就是方法名/类名/变量名等标识符。使用2字节的length存储总长度,所以标识符的长度不应该超出2^16^ = 65536长度

需要详细对照信息的可以看《深入理解Java虚拟机》 p307页

不过Javap命令和ClassViewer都可以直接看到各个字段的对应信息

可以使用javap -v命令输出反汇编详细(-v参数表示详细信息)

javap -v "TestClass.class

常量池好像有点堆的味道了,存在大量数据被字段表,方发表,属性表所引用

5.访问标志

占两个字节,access_flags

用于识别接口或者类的访问信息,主要如下所示:

例如:标志位为0x0021表示ACC_PUBLIC,ACC_SUPER

实际上0x0021 = 0x0020 | 0x0001

6.接下来是类索引,父类索引,接口索引集合

索引的数据都处于常量池中,且每个索引都是一个u2类型的数据(两个字节)

类索引指向的是存放全限定类名的一个索引

父类索引也是指向父类全限定类名的索引

接口索引集合前面存放了一个计数器用于记录实现了多少个父类的方法(而不是重写了多少方法)也是一个u2

接口索引集合存放的是一堆u2数据类型的索引,指向常量池,从左到右读取各个implement的接口的方法实现(如果本身是接口,则从左到右读取extends的)

7.再往后走就是字段表集合,同样存在一个计数器,从0开始,记录的即是字段表的数目

只有常量池的计数器比较奇葩,从1开始,记录的数量等于常量池里的个数减一

字段表包括类级别变量以及实例级变量,不包括方法的局部变量

主要修饰有:字符的作用域(public、private、protected),是实例变量还是类变量(static修饰),可变性、并发可见性(volatile,是否强制从主内存读写),是否被序列化(transient修饰符)、数据类型、字段名称等等

一般都是布尔类型,要么就是对对常量池的引用

例如对一个private int m的描述

descriptor_index 存储的是0x0006,指向的是常量池里的I字符,表示的是int类型

全限定类名:如:Ljava/lang/Object:

用L表示对象,;表示全类名的结束

如果是数组,例如被定义为 \" java.lang.String \"类型的,对应的描述符为:[[Ljava/lang/String;

以上是对字段使用描述符的描述过程,如果是对方法的描述符,则遵守如下规则:

如int inc();方法的描述符为:()I

对boolean equals(Object o)的描述为:(Ljava/lang/Object;)Z

方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符为:

([CII[CIII)I记录效率非常的高,这些内容存储在常量池中,在描述符中存储一个偏移地址指向常量池里即可

后面还有一个attributes集合用来存储额外的信息(以后会讲到)

字段表中不会出现从父类或者父接口继承而来的字段,即使这些字段对子类是公开的

8.方法表集合

方法的详细信息:

方法修饰符:

其余的和前面的字段信息存储采用一样的存储模式,attribute也存储一些额外信息

方法体存储到哪儿去了呢?就存储到了每个方法的attribute域中

这里的Code即是方法inc的方法体

方法表集合也不会出现父类的方法,会出现例如<clinit>{=html}和<init>{=html}方法(类构造器,实例构造器)

最后一块,也是最麻烦的一块------属性方法表

他会出现在每个field中,每个method中,以及每个class文件的末尾

这里面的限定条件比较宽松,不要求严格的长度限制和顺序限制

里面存储着预设定的属性,如常见的:

属性名称               含义
  Code      Java代码编译成的字节码指令

SourceFile 记录源文件名称

等等。。。

由于属性表的自定义度极高,每个属性表都有自己的内存布局,只要满足以下要求即可:

name存储的是指向常量池的一个索引(从1开始的),里面存储着这个属性的名字(例如Code等等)

常见属性解析:

1.Code属性

Code里面存储方法体,存储在方法的attribute中(抽象方法和接口则没有)

参数说明:

  • max_stack:操作数栈深度的最大值

  • max_locals:局部变量表所需的存储空间,单位是变量槽。对于不满32kb的基础数据类型,每个变量占用一个变量槽,方法参数,抛出的异常都会占用局部变量表,也会包含在内。

  • code_length和code:存储字节码指令(字节码指令:一条指令对应一个字节码)。(注意:code_length理论上最大值可以为2^32^,但实际上最大值只能为2^16^,为u~2~)。

2.Exception属性

依次分析即可

字节码指令简介:

字节码组成:操作码、操作数

Java虚拟机面向操作数栈而不是面向寄存器的架构。大多数操作都只有操作码,没有操作数,操作数都存放在操作数栈中。

字节码劣势:

最多只能存在255个指令,超过一个长度的数据需要分成多个单字节存储起来【Class文件的存储高密度性】(这就导致了Long变量的存储过程不是线程安全的)

操作码助记符一般都包含其操作所对应的数据类型信息,如iload,fload。

reference的缩写是a

由于操作码的限制,Java虚拟机在编译期或运行期将byte和short扩展成带符号的int类型,将boolean和char扩展成相应的int类型,减少了指令的条数

对指令的加载和存储,数据会在栈帧中的局部变量表和操作数栈中来回传输

​ Java虚拟机算数指令只存在int、long、float、double

只有除法指令和求余指令会抛出异常,其余的都不会抛出异常(即使是数位溢出)

由于Java的默认数据类型转换,使得指令可以重用

默认的转换规则(自动小范围向大范围的安全转换)

与之对应的显式转换(不安全转换)可能导致精度丢失,符号错误

  • Float和Double的Nan转换成int的值为0

虽然类实例和数组都是对象,但Java虚拟机对类实例的创建和数组的创建和操作使用了不同的字节码指令

Java同步指令的实现

管程(Moniter)

public void get() {
    synchronized(this) {
        System.out.println("hello world!");
    }
}

输出的class文件里的Code为:

通过moniterenter和monitorexit实现控制

会发现无论如何monitorexit都会执行

Java虚拟机的重要观点:鼓励自己去实现Java虚拟机,只要能够正确读取Class文件的语义(结果)即可,如何实现无所谓(里面的指令重拍就是一个很好的观点的体现:结果不变即可,实现无所谓)

小结:

这一章讲述了Class文件结构的组成部分

下一章讲述字节码流在虚拟机执行引擎中是如何被==解释执行==的

第七章、虚拟机类加载机制

本章将讲述虚拟机如何加载Class文件,Class文件进入虚拟机后会发生哪些变化

虚拟机类加载机制:把Class文件加载到内存,并对数据进行校验,解析转换和初始化,最终形成可以被虚拟机直接使用的Java类型的过程。

Java语言的特点:动态的类加载和动态链接。

一个类型的生命周期主要包括:

这里的类型指的是一个Class文件,可能对应着一个类或者一个接口

在一个类要实现动态绑定的时候,类解析操作可以在类初始化之后执行

类初始化触发时机:

  • 遇到new,getstatic,putstatic,invokestatic这四条字节码指令时候

  • 该类被反射调用的时候

  • 当子类被加载的时候

  • 当该类是主类的时候

  • 当实现类需要调用父类或接口的default方法时候(JDK1.8)

《Java虚拟机规范》中明确指出,只有以上几种情况会触发类初始化。

Demo:

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static Integer value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}

输出:

即使是创建该类数组,也不会触发类的初始化

就复用上面的Demo即可模仿

静态常量也不会触发类初始化,因为在类加载的时候就已经记载到CountClass的常量池中去了,对HELLOWORLD的引用也直接指向了常量池中

Demo:

public class NotInitialization2 {
    public static void main(String[] args) {
        System.out.println(CountClass.HELLOWORLD);
    }

}

class CountClass {
    static {
        System.out.println("CountClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}

上面已经讨论了发生类初始化时候"有且仅有"的条件。(为什么没有讨论其他阶段?《Java虚拟机规范》只是对类初始化有严格要求,对其他过程没有限制)

接下来详细讨论每个过程:

  • 加载

加载(Loading)是类加载(Class Loader)的一个过程

做的几件事情

  1. 通过类的全限定类名来获取此类的二进制字节流(可以从压缩文件,例如:JAR,WAR中读取)

  2. 将该类的数据结构加载到方法区中去

  3. 在内存(堆)中生成一个代表这个类的java.lang.Class对象,作为方法区的数据访问入口(在前面碰到过)

该阶段可控性最强,因为开发者可以指定自己的类加载器去完成,重写findClass和loadClass方法(仅对非数组类型),对于数组类,不会动用类加载器,会直接交给JVM分配内存(前面也看到了,创建数组不会加载对应的元数据类型,元数据的大小在编译器就已经可以确定了【引用类型和基本数据类型】)

加载阶段结束后,二进制文件流被存储到了方法区之中,格式完全由虚拟机自定义(连方法区的实现都直接交给了虚拟机),并在堆内创建一个Class对象供外界访问

加载阶段发生时候可能混杂连接阶段的一些操作,但两个阶段开始时间仍然保持着固定的先后顺序

  • 验证

验证是连接阶段的第一步,保证Class文件符合《Java虚拟机规范》,保证不会危害到虚拟机自身(会混杂在加载阶段)

之所以存在这个阶段,也有class文件标准开源的一部分原因,如果仅仅只是有Java代码生成的class文件,使用一些危险的操作编译器会直接抛出异常,不会去编译,但class文件可能是直接由人工手撸出来的,带有破坏性的代码,Java虚拟机不得不去检查(Java层面的类继承关系也要进行重新检查,即使编译器已经检查过)。验证阶段在Java虚拟机的类加载过程中占据了相当大的比重。

在Java6之后,Java的设计者们也考虑到了这个问题,于是在编译好的方法体Code中存放了一个StackMapTable属性来存放已经通过java编译器的部分,但是还是存在风险:StackMapTABLE也有可能被篡改

校验阶段还包括:对虚拟机将符号引用转换成直接引用的时候(连接的第三个阶段------解析阶段)的校验

验证不是必须存在的,如果对运行的Class文件足够放心,可以使用-Xverify:none参数来关闭大部分的验证措施以缩短时间(非常不推荐,毕竟类加载只会发生一次)

  • 准备阶段

为类变量分配空间并设置类变量初始值(零值)的阶段

即使是private static int value = 123,value的值也会先置为零,然后在类构造器<clinit>{=html}()方法中再将value的值置为123

置零操作也有意外,例如以下情况:

private static final int value = 123,value的值会直接被设置成为123

置零很好理解,因为方法区的实现(元空间)位于堆区,在堆区分配内存的时候都会执行置零操作(在栈区则不会),所以全局变量(静态和非静态)都会有默认的初值,而局部变量则不赋值会报错。

  • 解析阶段

将Java虚拟机内对常量池的符号引用改成直接引用的过程,因为类已经分配空间到方法区,常量池也已经定了下来,可以直接替换地址了。

解析发生的时间《规范》里面没有规定死,只要在使用到之间解析即可

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行

  • 初始化阶段

类加载过程中的最后一个阶段,在此阶段,开始真正执行Class文件代码(前面都是在检查,加载Class文件到虚拟机)将控制权交给应用程序

初始化阶段:执行类的<clint>{=html}()类构造器,这个方法是由Javac程序自己生成的。

只包括:对所有类变量的赋值动作和静态语句块的语句的执行,且执行的顺序是依赖于语句在源文件中的顺序而定的,即静态代码块可以对类变量进行赋值操作,却无法使用在它之后定义的变量.

static {
    a = 1;
    System.out.println(a);
}

static Integer a = 1;

这个代码是违法的

<clint>{=html}()执行之前,父类的<clint>{=html}方法必定是执行完了的

(非常容易验证,在static块中输出信息即可)

如果一个类没有对初始类变量的赋值和静态代码块操作,编译器就不会为这个类去生成<clint>{=html}

Clint方法在多线程下的表现:

阻塞一个线程clint方法,看另一个线程时候会调用

public class BlockingClassInit {

    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                new BlockingClass();
            }
        };

        new Thread(runnable).start();
        new Thread(runnable).start();
    }
}

class BlockingClass {
    static {
        //不加的话编译器不让过
        if (true) {
            System.out.println(Thread.currentThread() + "init BlockingClass");
            while (true){
            }
        }
    }
}

调用类的构造方法会阻塞别的线程调用这个类的构造方法。

当然,如果这个类加载在一个线程中完成,其他线程就不会阻塞了。

类加载器(Class Loader):Java虚拟机设计团队有意将类加载阶段的"通过一个类的全限定类名来获取该类的二进制字节流"这个动作放到了Java虚拟机外部去实现,实现这个动作的代码被称作类加载器。

对任意一个类,都必须由加载他的类加载器和这个类本身共同确立其在Java虚拟机中的唯一性。即使是同一个CLass文件,如果被两个不同的类加载器加载,也会被认为是两个毫不相关的类,测试Demo:

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException, NoSuchMethodException, InvocationTargetException {
        //自定义类加载器
        ClassLoader loader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    //获取类的class文件
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    //获取文件字节长度
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name,b,0,b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Class loadClass = loader.loadClass("cn.luckycurve.classloaderdemo.ClassLoaderTest");
        Object o = loadClass.getDeclaredConstructor().newInstance();
        System.out.println(o.getClass());
        Object obj = new ClassLoaderTest();
        System.out.println(o instanceof ClassLoaderTest);
        System.out.println(obj instanceof ClassLoaderTest);
    }
}

输出结果为:

class cn.luckycurve.classloaderdemo.ClassLoaderTest
false
true

使用自定义的类加载器加载出来的实例与默认类无关(通过instance关键字判断出来的)

从JVM角度,类加载器大概分为两块:启动类加载器BootStrap Class Loader(默认,JVM自带),其他类加载器(由Java语言实现)

从Java开发人员角度,可以分为三层类加载器的结构和双亲委派的类加载结构

JDK8之前,绝大多数Java程序都是用以下三个系统来提供类加载进行加载

  • 启动类加载器:用C++实现,无法被Java程序直接引用。如果class类的getClassLoader返回null,就默认使用启动类加载器

  • 扩展类加载器:用Java实现,继承sun.misc.Launcher$ExtClassLoader,负责加载JAVA_HOME\lib{=tex}\ext目录中的自定义拓展类{=tex},不过很快被模块化带来的天然拓展能力所取代

  • 应用程序类加载器:用Java实现,继承sun.misc.Launcher$AppClassLoader,负责加载用户路径上所有的类库,如果开发者没有指定类加载器,通常使用的就是这个

类加载器的协同关系:

也就是所谓的双亲委派模型

双亲委派模型要求除了顶层的启动类加载器外,所有的类加载器都需要有自己的父类加载器,只不过这种父子关系通常不是由继承(Interitance)来表现的,而是由组合的关系来复用父加载器的代码

双亲委派模型的工作过程:将类加载请求先传递到父加载器(所以所有的类加载请求都会传到启动类加载器),如果父加载器解决不了,再使用子加载器。

双亲委派模型的优势:保证同一个类只能由唯一的类加载器去加载,避免了上面提到的因为加载类的虚拟机不同而造成错误的问题(越基础的类由越上层的加载器进行加载)。

Java9中引入的模块化系统是对Java技术的一次重要升级,除了定义了封装隔离机制(想代替传统类路径依赖的Java SE标准类),还定义了以下内容:

从而避免了很大一部分由于类型依赖而引发的运行时异常。

小结:

本章介绍了类加载过程的加载、验证、准备、解析和初始化五个阶段的基本操作

下一章我们将探讨Java虚拟机的执行引擎,看看Java虚拟机是如何执行Class文件所定义的字节码的

第八章、虚拟机字节码执行引擎

执行引擎是Java虚拟机的核心组件之一

虚拟机与物理机的区别:

  • 物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的

  • 虚拟机的执行引擎则是由软件自行实现的,能够执行那些不被硬件直接支持的指令集模式

《Java虚拟机规范》定义了Java虚拟机执行引擎的概念模型,即所有执行引擎都具有同一外观。在引擎执行字节码操作时候,通常会有两种选择:解释执行(通过解释器执行)、编译执行(通过即时编译器编译成本地代码)【虚拟机中至少有一个种类的执行引擎,可以同时具有,如HotSpot】

Java虚拟机以方法作为最基本的执行单元,方法调用和执行的背后的数据结构是"栈帧",存储于虚拟机栈中。其中主要存储方法的局部变量表、操作数栈、动态链接和方法返回地址等信息,对方法的调用和方法的结束对应着一个栈帧进栈和出栈的操作。

每个方法需要的栈容量和深度在Class文件中的method的code里面已经记录了。

随着方法的逐级调用,栈中可能存储了大量的栈帧,只有位于顶部的栈帧对应的方法才是运行着的

栈的逻辑结构:

Class文件里的方法存储:

接下来详细介绍栈帧的组成部分:

  • 局部变量表

存储的是方法参数和方法内定义的局部变量。在max_locals里确定了该方法局部变量表所需的最大空间

局部变量表是以变量槽为最小单位,前面有讲过,在Class文件的code属性讲解的时候,可以认为一个变量槽为32字节,不足32字节的如byte short boolean reference returnAddress(很古老的虚拟机才有,做异常跳转,现在基本被异常表取代了)等等则占用一个变量槽,如果是double和long则占用两个变量槽。

由于long和double占据两个变量槽,无法同时读取写入,很有可能产生并发问题,但在这里不会,这里是线程私有的空间。

Java内存模型对使用volatile修饰的long和double的get和set方法在虚拟机层面实现了同步

当调用一个方法,Java虚拟机会使用局部变量表将实参传递到形参,如果是实例方法(非静态的),则会在局部变量表索引为0的位置存储该方法实例对象的引用,然后就是其余参数,最后则是方法体内部定义的变量顺序。

变量槽是可以重用的,当pc计数器的值脱离了变量的作用域,该变量的变量槽就可以被重用,但存在一定副作用:最糟糕的就是影响系统的垃圾回收行为

Demo1(不会发生内存回收,即使bytes已经超过了其作用域):

public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(1);
        {
            Byte[] bytes = new Byte[64 * 1024 * 1024];
        }
        System.gc();
        TimeUnit.SECONDS.sleep(5);
    }

Demo2:

public static void main(String[] args) throws InterruptedException {
    TimeUnit.SECONDS.sleep(1);
    {
        Byte[] bytes = new Byte[64 * 1024 * 1024];
    }
    int a = 0;
    System.gc();
    TimeUnit.SECONDS.sleep(5);
}

加入了第6行,发生了垃圾回收

原因分析:尽管bytes脱离了作用域,但是bytes的变量槽没有被复用,GC Roots的一部分------布局变量表,对bytes仍然保持着关联,判定为可达对象,不会回收,而在第六行中使用了int a = 0使得bytes的变量槽被重用了,自然对应的堆内存就可以回收了(这里是变量少,可以保证a占用了bytes的变量槽,如果实际使用过程中建议直接使用bytes = null)即可

局部变量表不存在两次赋值的情况,不会像堆上的内存分配,先全部置零,再使用类构造器或者对象构造器去赋初值,如果局部变量表中的变量没有初值就会报错

  • 操作数栈

通常被称为操作栈,是一个后进先出(Last In First Out,LIFO)的栈

在编译成class文件的时候栈的最大深度就已经存储了,为max_stacks

当两个操作数需要进行操作的时候,会先将这两个操作数压入栈,然后再执行对应的字节码指令从操作数栈中取出数据,然后将计算结果重新压入栈中

Java虚拟机的解释执行引擎被称作"基于栈的执行引擎",里面的栈就是操作数栈。

  • 动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

  • 返回地址

方法调用结束就相当于栈帧出栈,出栈后(无论是正常返回还是抛出异常),都会继续重置pc计数器,继续执行。

一般会把动态连接,方法返回地址与其他附加信息归为一类,称为栈帧信息。

方法调用

一切方法的信息全部都以符号的形式存储到了Class文件里面,而不是内存的入口地址,即所谓的直接引用,有些方法需要在类加载过程中才能确定方法的直接引用,有些方法甚至需要到运行阶段才能确定方法的入口地址。

解析(静态编译的味道):

在类加载阶段,方法中的一部分符号引用可以转化为直接引用。如果调用目标的程序代码写好,编译器进行编译的那一刻就已经确定了下来,这类方法的调用被称为解析(Resolution)

在Java中完美满足上面要求"编译期可知,运行期不可变"的主要就是:静态方法和私有方法两大类,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

Java调用方法的五条字节码指令:

invokestatic和invokespecial调用的方法在解析阶段就可以确定方法的入口地址了,除了静态方法,私有方法,还有实例构造器,父类方法也可以确定下来,当然还有final方法(尽管他在invokevirtual指令中),以上五种方法被称为"非虚方法"(Non-Virtual Method),在类加载的时候就可以将对该方法的符号引用解析为直接引用,其他方法称为"虚方法"(Virtual Method)

之所以其他方法(如实例方法)无法确定下来,自我感觉,主要是------多态

如以下代码:

Object obj = new String("hello world");
obj.hsahcode();

在类加载的时候hashcode方法无法解析(因为实际上调用的是String的hashcode方法)

分派(动态编译的味道):

分派调用过程将会揭示多态性特征的一些最基本的体现。关注的是虚拟机层面如何正确的确定目标方法。

静态分配(直接根据当前对象的类型进行分配方法,典型的重载案例)

书上的"简单"Demo:

public class OverLoadDemo {

    static class SuperObject1 {

    }

    static class SuperObject2 {

    }

    public static void hello(Object o) {
        System.out.println("hello Object");
    }

    public static void hello(SuperObject1 superObject) {
        System.out.println("hello SuperObject1");
    }

    public static void hello(SuperObject2 superObject) {
        System.out.println("hello SuperObject");
    }

    public static void main(String[] args) {
        Object o1 = new Object();
        Object o2 = new SuperObject1();
        Object o3 = new SuperObject2();
        hello(o1);
        hello(o2);
        hello(o3);
    }
}

输出:

hello Object
hello Object
hello Object

都调用Object版本的理由如下:

Object o2 = new SuperObject1()中的Object称为变量o2的静态类型(Static Type),或者是外观类型(Apparent Type),后面的SuperObject1被称为"实际类型"(Actual Type)或者"运行时类型"(Runtime Type)。对象的静态类型在编译器是可知的,而对象的实际类型在编译器不可知的。

编译器在重载时候是通过参数的静态类型而不是实际类型作为标准判断的,可以直接在编译成class文件期间就可以把这个方法的符号引用写入invokevirtual指令中。

依赖于静态类型来决定所执行的方法的分派动作,都称为静态分派,发生在编译期间

动态分派

与多态的一个重要体现------方法重写有关

Java虚拟机通过变量的实际类型来判断需要调用的方法(在运行时候确定)

使用javap -v 指令来反编译一个类(-v显示详细信息),会发现对方法的调用都是直接使用invokevirtual 静态类型的方法,但是运行出来的结果却不是静态类型的方法,看来是invokevirtual指令的问题:

根据《Java虚拟机规范》,由以下四步:

  1. 获取操作数栈栈顶的对象的实际类型(通过操作数栈的对象指针找到对象的堆内存,再通过对象的对象头找到对象所对应的Class对象),计做C;

  2. 从C中查找方法名和参数都符合的方法,如果查找到,则进行权限校验,如果权限校验通过,则返回方法的直接引用,如果权限校验不通过则报错IllegalAccessError异常(正常情况下Javac就不会让编译通过,主要还是担心人为的Class文件破坏)

  3. 按照继承关系从下往上搜索C的父类,并重复进行第二步骤

  4. 始终没有合适的方法,抛出AbstractMethodError异常

这些检查步骤都是为了防止人为编写class破坏java虚拟机

我们把这种在运行期间根据实际类型确定执行方法的分派过程称为动态分派

可以总结出Java的多态是依赖于invokevirtual方法的执行逻辑,而invokevirtual只能执行方法,所以字段是不支持多态的

Demo如下:

public class Test {

    public int a = 1;

    static class Test2 extends Test {

        public int a = 3;

        public Test2() {
        }
    }

    public static void main(String[] args) {
        Test test2 = new Test2();
        System.out.println(test2.a);
    }
}

输出的结果时1,字段只支持静态分派

现在虚拟机的具体实现一般都是为类型在方法区创建一个虚方法表,使用虚方法表索引来代替元数据查找以此提高性能

虚方法表存放各个方法的实际入口地址(应该是类加载的链接阶段创建的)

使用虚方法表可以在获取对象的实际类型之后直接访问实际类型的虚方法表,而不用再去查询对应的Class文件了,提升性能

Java虚拟机的字节码指令集非常稳定,到如今只有在JDK7增加了一条指令invokedynamic指令,是为了实现动态类型语言支持而进行的改进

动态类型语言:对象的类型检查的主体过程是在运行期而不是编译期进行的,如JavaScript、Python

静态类型语言:类型检查的主体在编译期,如C、C++、Java

动态类型语言的一个核心特征:"变量无类型而变量值有类型"

动态类型语言和动态语言、弱类型语言不是一个概念,需要区别对待

由于Java虚拟机需要提供多种语言的运行平台,而且已经有很多动态类型语言运行在了Java虚拟机上,Java虚拟机得去实现动态类型语言的运行支持

本节讲述的是Java虚拟机对动态类型语言的支持和优化

Java虚拟机的执行引擎为解释执行和编译执行

下面分析解释执行的概念模型

中间是解释执行的实现过程,下面是经过编译执行的过程

Javac直接完成了抽象语法数这一步,生成了class文件,至于后续走哪条路,只有Java虚拟机自己知道。

Java虚拟机使用栈的指令集,而不是基于寄存器的指令集

两者优缺点(既然都能共同发展,必然存在优缺点):

栈的指令集架构:

  • 优点:可移植性高,不依赖硬件上的寄存器

  • 缺点:执行速度稍慢,是在解释执行的条件下,如果都编译成了汇编指令流,就和哪种指令集架构无关了。

小结:

本章讲述虚拟机如何找到正确的方法入口,如何执行Class文件伪指令,以及执行代码时候涉及到的内存结构(栈模型)

6~8章讲述了程序的存储,载入,执行的过程,第九章讲述实际开发中的经典案例

第九章、类加载及执行子系统的案例与实战

本章看看所学知识在实际中如何运用

Tomcat的类加载器架构:

这里的Catalina类加载器加载的就是server类库,也被称为shared类加载器

common:类库可以被Tomcat和所有Web应用程序共享

server:类库只能被Tomcat访问

shared:类库只能被Web应用程序共同访问

WebApp/WEB-INF:该类库只能被该web应用程序访问

越上面的类加载器加载出来的类,可以被后续的类加载器获取

OSGI(Open Service Gateway Initialive)是OSGI联盟制定的一个基于Java语言的动态爱模块化规范。现在已经成为Java世界中"事实上"的动态模块化标准。最知名的实现可能就是Eclipse IDE了,Eclipse中 安装,更新,删除插件而不需要重新启动程序,正是使用了这项技术。

每个类都会指定Import-package和Export-package。使得类之间的上层模块依赖转变为平级模块依赖,一个模块中只有Export的package才能被访问。实现了独特的类加载:

如 (OSGI每个模块称为一个Bundle):

BundleA:声明发布了PackageA,依赖java.*包

BundleB:声明依赖PackageA和PackageC,依赖Java.*包

BundleC:声明发布PackageC,依赖PackageA

动态代理技术的JVM实现层面研究(建议去开个坑了解下):

/**
 * @author LuckyCurve
 * @date 2020/5/12 14:29
 * 模拟Spring的动态代理并查看字节码Java是如何实现的
 * 原始代码逻辑打印hello world,代理类的逻辑是
 * 在原始方法执行之前打印一句Welcome
 */
public class DynamicProxyTest {

    interface IHello {
        void syaHello();
    }

    static class Hello implements IHello {
        @Override
        public void syaHello() {
            System.out.println("hello world");
        }
    }

    //JDK层面提供的代理类
    static class DynamicProxy implements InvocationHandler {

        Object originalObj;

        Object Bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(),originalObj.getClass().getInterfaces(),this);
        }

        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("welcome");
            return method.invoke(originalObj,args);
        }
    }

    public static void main(String[] args) {
        IHello iHello = (IHello) new DynamicProxy().Bind(new Hello());
        iHello.syaHello();

    }
}

实验:有点看不懂,先省去了。

小结:

6~9章讲述了Class文件格式,类加载和虚拟机执行引擎这几部分,都是概念模型

从第十章开始,我们把目光从概念模型转换为具体实现,如何进行内部优化的

第四部分、程序编译与代码优化

从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰

第十章、前端编译与优化

在Java技术中泛泛地谈编译是很含糊的表述,因为可能指的是前端编译器把java文件编译成class文件,也有可能是Java的即时编译器(JIT编译器,Just In Time Compiler)在运行期把字节码转换为本地机器码的过程,还有可能是静态的提前编译器(AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标指令集相关的二进制代码的过程,主要代表性的实现如下:

  • 前端编译器:JDK的javac,Eclipse JDT中的增量式编译器(ECJ)

  • 即时编译器:HotSpot的C1、C2编译器,Grual编译器

  • 提前编译器:JDK的Jaotc、GCJ

程序员常见的编译过程就是第一类,也是本章讨论的重点

Java虚拟机团队把对性能的优化完全关注到了Java虚拟机中,这样可以直接提速其他在Java虚拟机上运行的程序(即时编译器)。而Javac的性能优化基本没有,不过javac不断支持着新出现的语法糖,提高了程序员的编码效率。

即时编译器的优化:程序运行效率的不断提高

前端编译器的优化:程序员编码效率和幸福感的提高

了解Javac编译器,使用java语言实现(只是包含了少量C,不像HotSpot大量使用C++)

JDK6将javac加入标准API上

Javac主要有四个过程:

  1. 准备过程:初始化插入式注解处理器。

  2. 解析与填充符号表过程

  3. 插入式注解处理器的注解处理过程

  4. 分析与字节码生成过程

Javac源码调试直接看书上的。

Java语法糖,可以看做前端编译器提供的"小把戏",能够提升编程效率,我们应该了解"小把戏"的幕后。

1.泛型

泛型让程序员能够针对泛化的数据类型编写相同的算法,极大地提升了编程语言的类型系统及抽象能力

Java与C#在同年的同一个大版本更新出了泛型,很多人诟病Java的泛型不如C#的泛型,确实存在缺陷,不过是当时语言现状的权衡,而不是语言设计者不如C#

Java选择泛型的实现是"类型擦除式泛型",C#的泛型实现是"具现化式泛型"

C#的泛型无论是在任何时期都是存在的

Java泛型只有在源码中存在,在编译期即被替换为原来的裸类型,并且在相应的地方自动完成了类型转换,所以在运行期看来,ArrayList\<>

Java泛型实实在在存在的限制:

public class Test<E> {

    public static void main(String[] args) {
        Object o = new Object();
        if (o instanceof E) {
            System.out.println();
        }
        E e = new E();
        E[] es = new E[10];
    }
}

第五行,第八行,第九行报错

无法用泛型来进行实例判断,无法创建泛型对象,无法使用泛型创建数组

源于Java使用拆箱和装箱在编译的时候实现了替换(这也降低了效率)

唯一的优势:影响范围小。因为如果要将泛型提升到虚拟机层面上来,那么在JDK5.0之后的代码将无法在JDK5.0之前复用,考虑到Java复杂的技术体系和沉淀,选择了在编译期来进行泛型的自动拆箱和装箱,牺牲了泛型的使用效果和运行速率。

引入泛型后又出现了一个新的问题:泛型大量使用在容器中,那么老的容器类如何解决呢(因为Java规定JDK必须向后兼容):

1.原有的类不变,新增一套泛化版本的容器

2.直接把原有的类型泛化

C#选择第一条路,Java选择第二条路(因为Java的庞大生态)

在JDK1.2时期,Java的态度就选择了第一条路(对线程不安全的容器,直接新增一套线程安全的容器,如Vector 和 HashTable),但是到JDK5,如果选用第一条路,ArrayList Vector HashMap HashTable都需要再实现一套,麻烦,何况这些还广泛使用了。

使用ArrayList介绍Java泛型是如何实现的

引入裸类型的概念,来保证子类到父类的安全转化

public static void main(String[] args) {
    ArrayList<String> list1 = new ArrayList<>();
    ArrayList list = list1;
}

保证ArrayList<String>{=html}可以类型转换成为list1。

为了实现这个效果,可以让虚拟机运行时候真实构造ArrayList<String>{=html}类,继承自ArrayList,或者就把其看做ArrayList,在元素访问修改的时候加入强制转换和类型检查。这就直接导致了容器中不能存储基本数据类型,因为基本数据类型无法和Object之间进行强制转换,只能存储包装类,读取数据的时候进行无数次的拆箱装箱

还导致了方法无法重载

//Error
public void test(List<String> list) {

}

public void test(List<Integer> list) {

}

14年Oracle建立了Valhalla的语言改进项目改进Java遗留问题,泛型就是主要目标

2.自动装箱、拆箱,循环遍历

这些语法糖比泛型的实现和思想就要简单得多

拆箱装箱主要是依赖:valueOf()和xxxValue()方法来实现

循环遍历forEach:底层使用迭代器

变长参数String...:new String[](变长参数);

3.条件编译

在编译阶段确定一些条件为常量的if语句,并在编译成class文件的时候就优化调这些语句

Source:

public static void main(String[] args) {
    if (true) {
        System.out.println("hello world");
    } else {
        System.out.println("world is broken");
    }
}

Target:

public static void main(String[] args) {
    System.out.println("hello world");
}

只有条件为常量的if语句可以保证每次都进行优化,如果是其他条件判断,可能连编译都过不了,如:

//false
while (false) {
    System.out.println("hello");
}

条件编译的实现也是一种语法糖,省去必定不会执行的代码,只支持if语句

除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、数值字面量、对枚举和字符串的switch支持、try语句中定义和关闭资源(这3个从JDK 7开始支持)、Lambda表达式(从JDK 8开始支持,Lambda不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作),等等。

实战:Javac过程中对字段的命名规则进行检查。

小结:

了解了从Java源码编译到字节码的过程,分析了多种语法糖的前因后果。即前端编译器的工作。

在下一章中,将探讨后端编译器的运作和优化过程。

第十一章、后端编译与优化

本章将探索Java虚拟机的后端编译器的运作过程和原理,《Java虚拟机规范》没有具体的约束规则去限定后端编译器的实现。本章以HotSpot的后端编译器为例子。

1.即时编译器

最初的Java程序都是通过虚拟机的解释器进行解释执行的,随着对效率的追求,出现了即时编译器,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认为是热点代码(Hot Spot Code),并在运行期间将这些字节码编译成为本地机器码,并且尽可能的去优化,负责这部分任务的后端编译器被称为即时编译器。

HotSpot内部实现了多个即时编译器(两个或者三个)

目前主流的商务虚拟机都同时包含解释器和编译器

解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。

当然,也有可能出现一些激进优化的编译器去优化大概率情况下不会发生错误的代码(也就是说:优化过后有小概率出现错误),于是出现了逆优化来作为代码的逃生门

HotSpot的编译器:Client/Server Compiler 也叫C1,C2。第三个是JDK10出现的Graal,为了取代C2,目前还在实验阶段。

至于启动哪个,虚拟机自动根据客户端的硬件条件选择,也可以使用-client -server来指定虚拟机的启动模式。

可以使用-Xint来禁止编译器的运行,使得只运行在解释器的环境下

或者-Xcomp强制虚拟机尽量使用编译方式执行程序,当然无法编译的字节码还得解释器运行。

可以直接在命令行验证结果(手动加上虚拟机参数):

虚拟机进行了分层编译(后期应该会改变)

热点代码出现的类型:

  • 多次调用的方法

  • 多次执行的循环体

但都是直接编译整个方法

判断是否为热点的方法,被称为热点探测,主要有两种:

  • 基于采样的热点探测

周期性的检查各个线程的调用栈的栈顶,如果经常看到一个方法,就是热点方法。

优点:简单高效

缺点:线程被阻塞的时候会一直处于栈顶

  • 基于计数器的热点探测

为每个方法或者代码块建立一个计数器,运行时候就加一,超过阈值就认为是热点方法(客户端------1500,服务器------10000)热度会在超过一定时间时候(发生垃圾回收时候)减半。优点:准确

J9采用了第一种,HotSpot采用了第二种

编译过程:了解即可

2.提前编译器

提前编译在JDK1.0时期就已经出现,不过由于Java的"一次编译,随处运行"的口号,导致这种与平台无关性相冲突的技术迅速变得悄无声息。但在随后的发展(Android中)得到了广泛的使用

提前编译器也出现了两条路线:

  • 类似于C/C++,完全编译成机器二进制码

  • 将即时编译器需要做的事情现在做完并且保存到本地,在需要的时候直接加载(本质是给即时编译器做缓存加速)

提前编译器的优劣得失:

损失了平台独立性,极限榨取性能的手段,且被JDK官方关注

实战:Jaotc的提前编译(JDK9以后才有):

编译之后就失去了平台无关性了,甚至和虚拟机的配置都息息相关(影响到垃圾回收器的工作了)

小结:

学习了提前编译器和即时编译器两大后端编译器,以及编译器对代码的优化。

第五部分、高效并发

第十二章、Java内存模型与线程

随着计算机的运算性能的越来越快,会发现大量的性能都花费在了磁盘IO,网络通信,数据库访问上,为了让计算机性能得以释放,使用并发技术很有必要。

除了更好的利用计算机性能,增大服务器的每秒事务处理数(Transactions Per Second,TPS)也是重要的指标,也与并发数有关:

TPS(QPS) = 并发数/访问时间

高效并发是最后一个部分,会向读者讲述虚拟机如何实现多线程,多线程之间的数据竞争和共享数据的问题和解决方案。

硬件基础:

为了减少CPU和内存之间的速度 差异,使用了高速缓存(Cache)来作为内存和处理器之间的缓冲。

但出现了缓存一致性(Cache Coherence)的问题,每个处理器都有自己的高速缓存,如果同时修改了一片位置,那么主存会选择哪一个缓存进行同步呢?于是在缓存合并到主存的时候增加了缓存一致性协议(各个厂商都有自己的实现,在外部把它看做一个黑盒子就行了)来解决一致性问题

Java内存模型

《Java虚拟机规范》中定义Java内存模型就是为了屏蔽硬件和操作系统的内存访问差异,让java程序能够很轻易的达到一致的内存访问效果。

C/C++直接依赖硬件基础和操作系统的内存模型,有可能一个并发程序在当前环境下运行的很好,在其他环境下却运行不起来的情况,具有强的平台约束性

直到JDK5,内存模型才真正走向成熟。要同时保证定义的严谨:防止Java程序的并发访问操作存在歧义,也必须足够宽松:让虚拟机有足够的空间去利用硬件的各种特性和一些其他骚操作(指令重排等等)

Java内存模型主要关注在虚拟机中把变量存储到内存和从内存取出变量的底层细节,此时的变量只是包括:静态变量,普通变量和构成数组的元素,但不包括局部变量和方法参数

Java内存模型规定所有变量都存储在主内存中(可以与物理机的主内存进行类比,但物理机的主内存仅仅只是这里的一个子集),每个线程还有自己的工作内存(类似物理机的缓存)其中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据(volatile变量依然有工作内存的拷贝,只是由于他的操作特殊性的规定,使得看起来就像直接在主内存中读写一般),每个线程也无法访问其他线程的工作内存,变量之间的传递需要使用主内存来完成。

这里的主内存,工作内存与Java内存区域中的Java堆、栈、方法区等不是同一个层次的对内存的划分。

主内存和工作内存的交互主要通过以下八种操作来完成的:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态

  • unlock(解锁):作用于主内存的变量,把一个锁定状态的变量释放出来,以供其他线程锁定

  • read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便load动作使用

  • load(载入):作用于工作内存的变量,把一个read操作从主存中得到的变量值放入工作内存的变量副本中

  • use(使用):作用于工作内存的变量,把工作内存中的一个变量的值传递给执行引擎

  • assgin(赋值):作用于工作内存的变量,把一个从执行引擎接收的值赋值给工作内存的变量

  • store(存储):作用于工作内存的变量,把工作内存的一个变量的值送回主内存中,以便后续write操作使用

  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量放入主内存的变量中

如果需要把一个变量拷贝到工作内存,就需要按顺序执行read和load操作,如果把变量从工作内存同步回主内存,就需要按照顺序执行store和write操作。==Java内存模型只要求两操作是顺序执行的,但不要求是连续执行的==,除此之外,还必须满足以下八条规则:

  • 不允许read和load,store和write操作之一单独出现,即不允许出现:变量从主内存读取,工作内存不接受

    或者工作内存发起了写入,但主内存不接受的情况出现

  • 不允许一个线程丢弃他最近的assign操作,即变量在工作内存改变之后必须把该变量同步回主内存(置于同步时间则没有要求)

  • (有点没搞懂)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use,store操作之前,必须先执行assign或load操作

简单理解:创建一个对象在虚拟机角度只是完成内存分配即可(还会置零),但是如果这个分配的对象没有被赋值就直接被执行引擎使用了或者直接同步到了主内存当中去,就会出现问题。以上这条规则就是为了保证变量必然被初始化

  • 一个变量在同一时刻只能允许被同一条线程对其进行lock操作,但lock操作可以被一条线程执行多次(重入)

  • 如果对一个变量执行lock操作,会清空工作内存中该变量的数据,在执行引擎使用之前,保证使用load或assign操作来初始化这个变量(保证工作内存中这个变量的值是具有时效性的)

  • 如果对象没有被lock,就不能被unlock,也不能unlock其他线程lock的对象

  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(store和write操作)

这是站在虚拟机角度来分析并发问题的,了解技术内幕

volatile的特殊规则

volatile是java虚拟机提供的最轻量级的同步机制了

volatile的作用:

第一个、保证变量对所有线程的可见性,即满足happen-before规则,修改的对后续读取的是可见的

但是volatile变量的运算在并发条件下不是线程安全的

Demo:

/**
 * @author LuckyCurve
 * @date 2020/5/16 14:02
 * volatile变量在并发条件下的运算不是线程安全的
 */
public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    public static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        for (int i = 0; i < THREADS_COUNT; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    increase();
                }
            }).start();
        }

        //等待所有线程结束
        while (Thread.activeCount()>2) {
            Thread.yield();
        }

        System.out.println(race);
    }
}

理论结果:200000,实际结果:110107。volatile在进行算数运算的时候不是线程安全的,可以通过对race++进行加锁,即可得到正确的结果。

为什么会出现上述情况呢?

其实IDEA已经给出答案了:

因为race++操作操作不是原子性的,会先读取race,在进行++操作,再写回去。典型的读取-修改-写入错误。

volatile只是保证在读取变量的时候会自动将工作内存中的数据刷新(因此也就可以认为不存在一致性问题)

改进方法:1.对非原子操作加锁。==2.使用原子类,可以不要volatile关键字了,也可以得出正确结论==

上述方法等待所有线程结束也可以使用CountDownLatch来做

Thread.activeCount()会返回当前线程组的活跃线程数目,具体的详细信息可以使用Thread.currentThread().getThreadGroup().list();来查看,因为这里使用IDEA运行,会多一个Monitor线程,在加上原本的主线程,所以一共会有两个,判断条件大于2,如果使用eclipse或者java命令行运行,只需要大于1即可。

使用volatile的第二个语义是:禁止指令重排优化

实际应用场景:

假设线程A进行配置信息的读取,读取完毕之后将标志位设置成true

线程B等待标志位变成true,一旦为true,就开始解析A读取的配置文件

如果指令重排,可能线程A提前将标志位设置成true,此时线程B解析配置文件,出错。使用volatile修饰标志位能抑制指令的重排

更常见的是在单例模式中防止构造函数执行时候this逸出,提前发布未完成构造函数的对象。

选取使用volatile和锁不能完全根据性能,即使volatile能够保证一定概率比锁更快(也有可能不是这样,因为Java虚拟机对锁进行了消除和优化技术),volatile读取和普通变量没有什么差别,写入的时候稍微慢一点,要插入内存屏障防止指令乱序。我们在选取volatile仅仅只是根据语义是否满足实际需求,不必太过顾及锁和volatile性能的开销。

满足一下规则(依据上面8个指令,不详细介绍了):

  • 修改后会立即同步回主内存中,在使用时候会进行内存同步

  • volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序

long和double型变量的特殊规则

在内存模型中定义了一条相对宽松的规范:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来完成(感觉有点照顾栈局部变量表里边的变量槽,每个变量槽最多只能存储32位的数据,而每个变量槽只能保证自身读写操作的原子性,但是在堆中就不会有这种情况出现)。上述就是所谓的"long和double的非原子性协定"。

但这种情况是非常罕见的,在64位虚拟机上不会出现。在低版本的JDK中32位机器可能出现,如今也不会了。

在实际开发中不必太过重视,一般不需要因为这个原因去刻意的把long和double变量专门声明为volatile。

Java内存模型是围绕着如何处理:原子性,可见性,有序性来建立的

原子性:

由Java内存模型直接保证的原子性变量操作包括:read,load,assign,use,store和write,大致可以认为:基本数据类型的访问和读写是具备原子性的(==例外就是long和double,但只要知道有这回事儿就好了,无需太过在意这种不太可能发生的情况==)

为了保证更大范围的原子性操作,可以使用虚拟机层面上的lock和unlock,但是没有直接对用户开放,而是封装成为了更高级的字节码指令monitorenter和monitorexit来隐式的使用这两个操作,这两个字节码指令映射到Java代码就是同步块------Synchronized关键字

可见性:

当一个线程修改了共享变量的值,其他线程能够立即直到这个修改。

就如上面的volatile变量:与普通变量的区别是volatile的特殊规则能够保证新值能够立马同步到主内存,以及每次使用前立即从主内存中刷新,从而保证可见性,而普通变量则无法保证这一点(只是无法保证,不是不会发生)

除了volatile能保证可见性,还有两个------Synchronized和final

Synchronized的可见性是依赖于java内存模型层面上的unlock操作,在一个变量执行unlock操作之前,必须把此变量同步回主内存中

final的可见性是依赖于:被final修饰的字段在构造器中一旦被初始化完成,且构造器中没有出现this逸出,那么在其他线程就可以看到final字段的值(只保证构造函数这一次)。

有序性:

保证有序性的方法之一就是volatile------依赖其禁止指令重排的功能。

当然也可以使用Synchronized实现:保证所有访问对象操作的串行执行,从而使得同步代码块满足Happen Before的第一个规则

由于上述的很多规则非常复杂,Java提出了Happen-Before规则,可以判断线程是否安全的重要手段

A happen before B 指的是A产生的影响对B是可见的

一些天然的Happen Before关系(如果不在这里面,或者无法推导出来的,虚拟机就可以对他们进行随意的重排序):

  1. 程序次序规则:一个线程内,按照流程控制,前面的操作对后面的操作是可见的。

  2. 管程锁定原则:一个unlock操作happen before后面的lock操作(同一把锁)

  3. volatile变量规则:对一个volatile变量的写操作happen

    before后面对这个变量的读操作

  4. 线程启动规则:Thread的start方法happen before与此线程的每一个动作

  5. 线程终止规则:线程中所有操作均happen before于线程的终止操作

  6. 线程中断操作:调用中断的线程的Interrupt()happenbefore于被中断线程的检测InterruptException

  7. 对象终结规则:对象的初始化完成happen before与finalize方法的开始

  8. 传递性:A happen before B B happen before C = > A happen before C

指令执行时间上的先后关系 无法得到 happen before的关系

happen before关系 也无法得到 指令执行时间的先后关系:

最简单的例子:指令重排:

int i = 1;
int j = 2;

很明显 i=1 happen before与j=2 但是指令重排过后 很有可能j=2先执行,并不违背happen before规则。

得出结论:==语句执行时间次序 与 happen before 没有任何关系==

Java与线程

只有是调用了start方法且还没有结束的Thread类的实例才能代表一个线程。

Thread所有关键方法都被声明成为了Native。使用了Native意味着这个方法没有或者无法使用平台无关的手段来实现(也可能有效率因素,因为平台关联性越强,效率越容易提升)

实现线程的三种方式:使用内核线程实现(1:1实现)、使用用户线程实现(1:N实现)、使用用户线程加轻量级进程混合实现(N:M实现)。

1.内核线程实现:

也被称为1:1实现,内核线程(KIL)直接由操作系统内核支持的线程。每个内核线程可以视为内核的一个分身,支持多线程的内核就被称为多线程内核。

一般不直接使用内核线程,而是使用一种高级接口------轻量级进程(LWP),也就是我们常说的线程。每个轻量级进程由一个内核线程所支持,构成1:1的关系

局限性:基于操作系统的调用的线程结构,代价相对较高。其次,单个系统支持的内核线程的个数是有限制的,因此支持的轻量级进程的个数也是有限制的。

2.用户线程实现

也被称为1:N,只要不是内核线程,就可以被称为用户线程(UT),因此轻量级进程都是用户线程

缺点:没有系统内核的支援(也是优点:消耗非常低),需要用户自己完成线程的创建,销毁,切换以及调度,极其复杂,有时候甚至是不可能实现的,一般都不会使用它。

3.混合实现

基于前两种,混合依赖于系统内核和用户线程,也被称作N:M模型

许多UNIX系列的操作系统,如Solaris都提供了N:M的线程模型实现,在这上面使用这种模型非常方便

主流的Java虚拟机如HotSpot都基于操作系统原生线程模型来实现,即采用1:1线程模型(基于Solaris的HotSpot虚拟机还支持N:M模型)

线程调度

指的是系统为线程分配处理器使用权的过程,主要有两种方式:

协同式线程调度和抢占式线程调度

协同式:将执行时间主动权交给线程,让线程去执行,执行完之后主动通知操作系统去调用下一个线程。

优点:实现简单,因为线程的切换是已知的,基本不会出现线程同步问题

缺点:主动权交给线程了,不稳定,要是一个线程一直阻塞怎么办,多个线程共同阻塞,程序直接挂了。

抢占式:执行时间由系统分配,线程处于被动的一方(在线程内部可以调用yield去主动让出一部分时间,但是想要获取时间?不存在的)

Java就使用了抢占式的线程调度模式

当然可以设置线程的优先级建议操作系统多执行一些优先级高的线程

不能太过依赖于线程的优先级,因为有可能操作系统会对线程进行优化,优先级是会改变的。

Java规定的线程状态:

Java线程的局限(内核线程的局限):

如今都是微服务时代,需要每个服务器的高速响应,而Java采取的这种模式直接映射到操作系统内核线程上,会在线程的切换,调度上花费大量成本,能同时处理的线程数也有限,在这种请求时间短的情况下更是会造成大量的浪费

微服务时代的系统架构从以前的单机变成了现在的一个请求要访问多个机器。以前的单机时代,线程切换和调度的性能开销几乎可以被忽略,在现在则成本高昂。

那么切换到用户线程,还是会有这些问题,只不过现在的主动权交到了程序员的手上而不是像内核线程那样主动权还是在操作系统手上,可以更灵活的去保存现场。也就是协程(还以协同调度的方式工作)

Java的破局方案:纤程(Fiber),本质上就是有栈协程。被命名为Loom项目,还没有明确的发布日期,已经出现了Quasar协程库

历史总是惊人的相似:

  • JDK 5 把Doug Lea 的dl.util.concurrent引入,成为java.util.concurrent

  • JDK 9 把Attila Szegedi的dynalink项目引入,成为jdk.dy.nalink

    未来的期望:Loom项目的领导人Ron Pressler就是Quasar的作者

小结:

了解了Java内存模型的结构以及操作,介绍happen before规则,线程的创建过程。

关于高效并发问题,本章讲述的是并发

下一章将讲解虚拟机如何实现高效,以及虚拟机对我们编写的并发代码提供了何种优化

第十三章、线程安全与锁优化

首先要保证并发的正确性,然后在此基础上来实现高效。本章从如何保证并发的正确性以及如何实现线程安全说起

线程安全极难定义,普遍的定义是:"如果一个对象可以安全的被多个线程使用,那么它就是线程安全的"。但是毫无可实现性

《Java并发编程实战》里面这样定义线程安全:"当多个线程访问一个对象时,如果不用考虑这些线程从在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协同操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的"

深入的理解线程安全,不要把Java里的线程安全当做一个非真即假的二元排他选项来看待。而可以按照线程安全的强弱程度由强到弱排序:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.不可变

在JDK5之后(即java内存模型被提出来之后),final对象就可以保证是线程安全的了,只要一个不可变的对象被正确的构造了出来(没有this逸出),那其外部的可见性永远可以保证。

"不可变"带来的安全性是最直接、最纯粹的。

对于基本数据类型,只要在定义时候加上final就能保证是不可变的。如果共享数据是一个对象,Java语言暂时还没有提供对对象的支持,就需要对象自行保证其行为不会对其状态产生任何影响才行(自己的内部变量也应该被视为不可变的才行,《Java并发编程实战》中推荐如果一个变量必然不可能改变,建议直接声明成fina,这里就可以显示的声明为final,因为final对象和普通对象的资源消耗差不多,非常推荐这样做)。

推荐的原因:

Java的包装类和String都大量使用了final关键字。而这些对象基本上就是所有对象的基础,必然会大量使用,可见final对性能的影响几乎没有,但是能提供很好的线程安全性。

:question::但AtomicInteger和AtomicLong则是可变的

内部变量声明成了volatile,保证变量的可见性。提供了get和set方法,值需要改变

2.绝对线程安全

绝对的线程安全能够完全满足Brain Goetz给出的线程安全的定义,在任何条件下。这个绝对是非常绝对的。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全,例如类Vector:

Demo:

/**
 * @author LuckyCurve
 * @date 2020/5/17 10:58
 * 验证Vector不满足绝对线程安全
 */
public class VectorSecurity {

    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }


            //移除线程
            new Thread(()->{
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            },"removeThread").start();


            //打印
            new Thread(()->{
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println(vector.get(i));
                }
            },"printThread").start();

            //控制线程数量,防止操作系统假死
            while (Thread.activeCount()>20);
        }
    }
}

会报错:

Exception in thread "removeThread" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
    at java.base/java.util.Vector.remove(Vector.java:874)
    at cn.luckycurve.concurrent.security.VectorSecurity.lambda$main$0(VectorSecurity.java:24)
    at java.base/java.lang.Thread.run(Thread.java:834)

虽然get,remove,size方法都是同步的,但由于外部的错误引用(满足了竞态条件的先检查后执行,出现错误)。而需要使用锁机制:

//移除线程
new Thread(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            vector.remove(i);
        }
    }
}, "removeThread").start();


//打印
new Thread(() -> {
    synchronized (vector) {
        for (int i = 0; i < vector.size(); i++) {
            System.out.println(vector.get(i));
        }
    }
}, "printThread").start();

如果真要维持绝对的线程安全,需要Vector维持一组一致性的快照,每次修改产生新的快照,代价非常大。(感觉和CopyOnWriteArrayList原理很像,去试了下,也不是绝对线程安全的)

3.相对线程安全

也就是通常意义上的线程安全,保证对对象的单次操作都是线程安全的,但对于一系列的连续调用,就需要程序员提供同步手段了

在Java中,大部分声明为线程安全的类属于这种类型(使用Java的线程安全容器时候全部就当做这种使用就好了)

4.线程兼容

本身不是线程安全的(也就是上面的相对线程安全),但在调用端通过特殊手段可以保证对象在并发环境下安全的使用,如ArrayList和HashMap

5.线程对立

无论调用端采取了什么同步机制,都无法在多线程环境下并发的使用

在Java中基本不存在,因为Java天生就支持多线程,即使出现了也很快就被废弃了,显示不支持,并被其他方法或类型取代。

这种线程安全级别的划分方法是由Java首席架构师Brian Goetz的一篇论文中提出的

如何实现线程安全:

主要有以下两个方面:利用Java语法去进行CAS操作,使用算法实现

虚拟机层面提供同步和锁机制,重点讲虚拟机层面的

1.互斥同步

最常见最主要的并发正确性的保障手段。

使用互斥这种方法(如:临界区、互斥量、信号量等常见互斥手段)以达到同步(在多个线程访问共享数据的时候保障共享数据在同一时刻只能被一条或者是一些(当使用信号量的时候)线程使用)的目的。互斥是手段,同步是目的

Java中最常见的互斥同步手段就是Synchronized,经过javac编译形成moniterenter和moniterexit两个字节码指令,这两条字节码指令就对应着虚拟机内存模型中提供的lock和unlock方法

会为每个锁去分配一个锁计数器,只有当锁计数器为0时候才能获取对象(当前持有锁的线程除外,可重入的)。

synchronized是重量级锁,对线程的操作 直接映射到操作系统对线程的阻塞和唤醒操作。更糟糕的是当很多线程一起阻塞在一个锁上的时候,而这个站有锁的线程在进行IO等费时操作,会造成大量的线程切换带来的性能消耗。虚拟机也进行了一些优化,如会在通知操作系统之前让线程先进行一段自旋操作。

JDK5之后JUC包带来了Lock接口,提供了高级功能:

  • 线程可中断

  • 公平锁

  • 锁绑定多个对象

在JDK5之前确实ReentrantLock比Synchronized好,但是JDK6之后对Synchronized的优化使得性能基本持平

《Java虚拟机》:笔者仍然推荐在synchronized与ReentrantLock都可满足需要时优先使用synchronized(估计是借鉴了《Java并发编程实战》的,支持Synchronized的原因都基本一模一样)

2.非阻塞同步

互斥同步的主要问题是线程的阻塞和唤醒带来的消耗,也被称为阻塞同步。属于悲观的并发策略,认为不去做正确的同步措施(如加锁)就会出现问题。所以每次进行操作都需要获取释放锁,线程阻塞和运行态的切换

基于指令集的发展,有另一种设计:基于冲突检测的乐观并发策略。核心思想就是:不管风险,先进行操作,如果没有其他线程争抢,操作成功。如果数据存在争抢,产生冲突,再进行其他补救措施------最常见的补救措施:不断重试。不会把线程挂起,这种同步称为无阻塞同步。

主要是基于指令集的发展,保证多次操作的原子性,主要操作有:

  • 测试并设置(Test And Set)

  • 获取并增加(Fetch And Increment)

  • 交换(Swap)

  • 比较并交换(Compare And Swap,CAS)

  • 加载链接/条件存储(Load Linked/Store Conditional,LL/SC)

前三条是20世纪处理器的指令集大多有的,后两条是现代处理器新增的

Java在JDK层面提供了jdk.internal.misc.Unsafe类来支持CAS操作,但是不支持外部调用,直到JDK9的VarHandle可以让用户调用,实现CAS

但存在ABA问题,尽管解决思路已经提出来了,版本号,JDK层面也有了实现类AtomicStampedReference,但是仍然处在一个比较尴尬的位置上,大部分ABA问题不会出现问题,如果需要解决,使用互斥同步可能比原子类更加高效

锁优化

高效并发是从JDK5升级到JDK6后一项重要的改进项,虚拟机开发团队在这个版本更替上花费了大量的精力去进行锁优化:适应性自旋,锁清除,锁膨胀,轻量级锁,偏向锁等等等等。

自旋锁与适应性自旋

在很多情况下,共享数据的锁定状态只会持续很短的一段时间。如果线程A获取锁了,线程B也尝试获取锁,但获取失败,不会立马被挂起,而是使得线程B执行一个忙循环(自旋),看看锁是否会立即释放,如果执行完之后还没有释放,那么线程B就挂起了。

当获取锁之后的操作是短时间的,自旋锁的效果很好,但随着锁占用时间的增长,自旋锁只会白白的消耗CPU资源,不会有任何价值。默认自旋次数是十次,可以通过-XX:PreBlockSpin=<n>指定,不过我失败了

但是设置了自选次数是对所有共享数据而言的,有可能有些共享数据占有时间长短不同,设置了也不会有太大的作用,JVM于是有了适应性自旋,会根据上一次锁的自旋时间以及锁的状态来决定,随着程序运行时间的增长,虚拟机变得越来越"聪明"了

锁消除

指代码在即时编译器运行的时候,有加锁指令,但经过逃逸分析(在堆上的数据不会逃逸出去被其他线程访问到)该共享数据不可能存在竞争,就可以对锁进行消除。

锁消除的最广泛的应用往往不是程序员显式声明的synchronized,而是许多技术背后的默默加锁机制。如:

public String count(String s1,String s2,String s3){
    return s1 + s2 + s3;
}

在JDK5之后会转换为StringBuilder 【JDK1.5出现,取消了StringBuffer的加锁操作,提供单线程条件下高效访问】(JDK5之前是StringBuffer),如果是JDK5之前,转化得到的代码如下所示:

public String count(String s1,String s2,String s3){
    StringBuilder sb = new StringBuilder();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

StringBuilder内部存在大量的Synchronized,而s123很显然是不会逃逸的,于是可以优化掉了(仅限于即时编译执行,解释执行仍然会加锁)。而这些在程序员眼里是不可见的。

补充说明(确实也有这个疑惑):

锁粗化:

理论上我们总是希望锁的作用范围越小越好,但是也有例外------如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在了循环体当中,即使不存在线程竞争,也会因为频繁的互斥同步操作消耗性能

对上述字符串拼接就是一个最好的例子。虚拟机会直接将加锁操作置于所有append之前,解锁操作置于所有append之后

轻量级锁:

重量级锁的优化措施,而不是单独的一种技术实现去取代重量级锁。

JDK1.6加入的(很容易和ReentrantLock混淆,ReentrantLock是随着JUC包在1.5加入的,显然不是)。

轻量级锁不是为了代替重量级锁而提出的解决方案,设计的初衷是在没有多线程竞争的情况下,减少传统的重量级锁的性能消耗,减少多线程进入互斥的几率

和传统的重量级锁使用的本质一样:

当锁的标志位为01的时候,表示对象没有被锁定

JVM层的实现大概类似于CopyOnWriteArrayList的原理,需要修改的时候(获取锁)去复制对象头到栈,并修改引用(乐观锁机制,如果失败了就膨胀为重量级锁),修改完成后再将栈对象头回写,如果失败了也膨胀为重量级锁

具体细节:https://blog.csdn.net/qq_35124535/article/details/70312553

偏向锁

也是JDK6引入的,目的是:

如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互 斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

会永远偏向于第一个获得它的第一个获取它的线程。如果在执行过程中,没有其他线程去尝试获取锁,则持有偏向锁的线程永远不需要再进行同步

由于Mark Word的位置有限,每次对象持有偏向锁的时候会花费大量空间记录线程ID,会占用哈希码的位置,因此只要计算过一致性哈希的对象(调用了Object的hashcode方法,而不是重写之后的hashcode方法)或者处于偏向状态要计算一致性哈希的时候,偏向状态会立马被撤除,锁升级成重量级锁,腾出空间记录一致性哈希码

小结:

介绍了线程安全的概念和分类、虚拟机的一系列优化措施。

能够写出高性能,高伸缩性的并发程序是一门艺术,而了解并发在系统底层是如何实现的,则是掌握这门艺术的前提条件,也是成为高级程序员的必备知识之一

笔记完结,芜湖~~

最后更新于