第一章 走进java
1.2 Java技术体系
- Java程序设计语言
- Java虚拟机
- Class文件格式
- Java API类库
- 第三方Java类库
第二章 自动内存管理
2.2 运行时数据区域
- 程序计数器:可看作是当前线程锁执行的字节码的行号指示器。循环、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。该区域是唯一一个没有OOM的区域。
- 虚拟机栈:存放了编译器可知的各种基本数据类型。会有SOF和OOM。
- 本地方法栈:会有SOF和OOM。
- 堆:是GC的主要区域。会有OOM。
- 方法区:会有OOM。
- 运行时常量池:存储被虚拟机加载的类信息、常量、静态变量等。是方法区的一部分。
- 直接内存:NIO,可以使用native函数直接分配对外内存。
2.3 HotSpot虚拟机对象探秘
2.3.1 对象创建过程:
其中3.分配内存,有两种方式:
- 指针碰撞:堆内存是规整的,则指针移动一定距离即可。这个指针位移的操作是存在线程安全问题的,解决办法是CAS,或者给每个内存分配一块本地内存分配缓冲区TLAB。
- 空闲列表:堆内存不是规整的,则虚拟机维护一个列表记录那些内存可用。
2.3.2 对象的内存布局
对象 的内存布局可以分为三个部分:对象头、实例数据、对齐填充。
对象头分为两个部分:一部分存储自身运行时数据,另一部分是类型指针。
2.3.3 对象的访问定位 - 句柄。堆中划分出一块作为对象句柄池。优点在于移动对象不需要改变reference,只需要改变句柄的指向。
- 指针。优点在于节省一次指针定位的开销。
2.4 OOM异常
OOM场地 | 异常信息 | 重要参数 | 备注 |
---|---|---|---|
堆区 | -Xms4096m -Xmx4096m -XX:HeapDumpOnOutOfMemoryError | 要区分内存溢出,内存泄漏(通过内存分析工具看引用) | |
堆外 | oom | -XX:MaxDirectMemorySize | 现象:如果oom后dump文件很小,而且程序中又直接或者间接使用了NIO,就需要考虑一下这方面的问题 |
栈区 | 这个区域可能发生oom和sof | -Xss(每个线程分配的内存大小) | Oom的场景:多线程场景下,每创建一个线程都需要申请一块独立的内存空间,如果-Xss设置的过大,则可创建的线程数就越少,则越容易oom,解决办法就是减少-Xss,或者减少-Xmx |
方法区 | -XX:PermSize -XX:MaxPermSize | 发生场景:大量JSP,大量CGLib |
第三章 垃圾收集器与内存分配策略
3.1 GC需要完成的三件事情
- 哪些内存需要回收
- 什么时候回收
- 如何回收
3.2 哪些对象可以回收
判断对象是“死”是“活”
方法 | 方法描述 | 优点 | 缺点 | 总结 |
---|---|---|---|---|
引用计数法 | 记录每个对象的引用数量,引用为0的对象可以被回收 | 无法解决循环引用的问题 | java不采用这种方法,python采用 | |
可达性分析法 | 回收通过GC Roots不可达的对象(GC root不可达的对象还有一次听过finalize拯救自己的机会) | Java采用的方法 |
再谈对象引用
引用类型 | 描述 | 使用场景 |
---|---|---|
强引用 | new 出来的 | 地球人都知道 |
软引用 | SoftReference,OOM之前会回收掉这些引用 | 缓存对象 |
弱引用 | WeakReference,下一次GC就会被回收掉 | WeakHashMap中的key就是弱引用 |
虚引用 | PhantomReference,不对对象的生存时间构成影响,存在的唯一目的就是在对象被系统回收时收到一个系统通知 |
3.3 垃圾收集算法
算法 | 优点 | 缺点 | 总结 |
---|---|---|---|
标记-清除 | 存在碎片问题 | ||
复制 | 在对象存活率较高时进行比较多的复制,效率变低 | 现在的商业虚拟机都采用这种算法处理新生代,Eden:s1:s2=8:1:1 | |
标记-整理 | 应用老年代 |
3.4 HotSpot的算法实现
3.4.1 枚举根节点
可作为GC Roots的节点主要在全局性的引用(常量或类静态属性)与执行上下文(栈帧中的本地变量表)中。
如果要逐个检查这些引用一定会耗费很多时间。JVM通过OopMap来避免遍历。
OopMap中记录着全局性的引用与执行上下文中的对象引用。
3.4.2 安全点
JVM在安全点产生OopMap
3.4.3 安全区域
安全区域是指在一段代码片段中引用关系不会发生变化。
3.5 垃圾收集器
3.5.1 CMS收集器
四个阶段:
- 初始标记:标记GC-root直接关联的类
- 并发标记:gc线程和用户线程同时运行,进行gc-root tracing
- 重新标记:标记并发标记过程中产生变化的对象引用
- 清除
需要stw的阶段:初始标记,重新标记
最耗时的阶段:并发标记,清除
CMS缺点:
- 并发标记,清除两个阶段占用cpu,导致cpu吞吐降低
- 浮动垃圾问题。所以要预留一部分给浮动垃圾,参数-XX:CMSInitiatingOccupancyFranction,设置老年出发GC的比例,
- 碎片问题。-XX:+useCMSCompactAtFullCollection
-XX:CMSFullGCBeforeCompaction
3.5.2 G1收集器
G1特点:
- 空间整合
- 可预测的停顿。优先回收价值最大的region
在G1收集器中,Region之间的对象引用以及其他收集器的新生代与老年代之间的对象引用,虚拟机都是使用rememberedSet来避免全堆扫描的,
G1中没个region都对应一个rememberedSet。虚拟机发现程序对reference类型的数据进行写操作的时候,会产生一个中断,检查reference指向的对象是否在不同的region中,然后记录rememberedSet。rememberedSet是GC Roots的一部分,
G1四个步骤:
- 初始标记
- 并发标记:做可达性分析。把这期间变化的引用记录到rememberedSet Log。
- 最终标记:把rememberedSet Log的数据合并到rememberedSet中。
- 筛选回收:对各region的回收价值和成本排序,结合用户期望的GC时间执行回收计划。
需要stw的阶段:初始标记,最终标记
最耗时的阶段:并发标记,筛选回收
3.5.3 对象内存的分配
分配原则:
- 优先在Eden
- 大对象直接在老年代。-XX:PretenureSizeThreshold
长期存活的对象将进入老年代:-XX:MaxTenuringThreshold=15表示到达15岁的对象将进入老年代。在特殊情况下对象不到15岁也进入老年代:Survivor空间中相同年龄的对象的大小综合大于Survivor空间的一半,则大于等于该年龄的对象直接进入老年代。
空间分配担保
第四章 虚拟机性能监控与故障处理工具
4.1 jps
jps -v 查看虚拟机启动时的JVM参数
jps -v
175 MyApplication -Xmx4g -Xms4g -Xmn1g -Xss256k -XX:+CMSClassUnloadingEnabled -XX:PermSize=256M -XX:MaxPermSize=512M -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+UseG1GC
4.2 jstat
4.2.1 jstat -class 可查看加载的类有多大空间,可用于分析permGen Space OOM。
jstat -class 171
Loaded : 已经装载的类的数量
Bytes : 装载类所占用的字节数
Unloaded:已经卸载类的数量
Bytes:卸载类的字节数
Time:装载和卸载类所花费的时间
4.2.2 jstat -gc 可查看gc统计
jstat -gc 171
S0C:年轻代中第一个survivor(幸存区)的容量 (kb)
S1C:年轻代中第二个survivor(幸存区)的容量 (kb)
S0U :年轻代中第一个survivor(幸存区)目前已使用空间 (kb)
S1U :年轻代中第二个survivor(幸存区)目前已使用空间 (kb)
EC :年轻代中Eden(伊甸园)的容量 (kb)
EU :年轻代中Eden(伊甸园)目前已使用空间 (kb)
OC :Old代的容量 (kb)
OU :Old代目前已使用空间 (kb)
MC:metaspace(元空间)的容量 (kb)
MU:metaspace(元空间)目前已使用空间 (kb)
YGC :从应用程序启动到采样时年轻代中gc次数
YGCT :从应用程序启动到采样时年轻代中gc所用时间(s)
FGC :从应用程序启动到采样时old代(全gc)gc次数
FGCT :从应用程序启动到采样时old代(全gc)gc所用时间(s)
GCT:从应用程序启动到采样时gc用的总时间(s)
4.2.3 jinfo [ option ] pid 查看虚拟机信息
option
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+|-]name 开启或者关闭对应名称的参数
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
jinfo -flags 171 //输出全部JVM参数
4.2.4 jmap
分析OOM的通常做法:
-1.首先配置JVM启动参数,让JVM在遇到OutOfMemoryError时自动生成Dump文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path
-2.如果没有上面的配置,则jmap 生成堆文件。注意这个操作是stop the world的。
jmap -dump:format=b,file=/path/heap.bin <pid>
-3.用MAT分析工具分析堆文件
4.2.4 jstack
将JVM堆栈信息dump到/tmp/jstack20190316这个文件
jstack <pid> >/tmp/jstack20190316
jstack20190316中的内容如下图
参考:cpu持续高的案例分析.
第五章 调优案例分析与实战
5.1 高性能硬件上的程序部署策略
案例描述:
一个15万PV/天左右的在线文档网站,硬件是32位系统1.5G堆内存。
某天升级了硬件系统,新的硬件为4个cpu,16GB物理内存,64为CentOS 5.4,Resin为Web服务器。整个服务器没有部署其他应用。管理员为了尽量利用硬件资源使用了64位的JDK1.5,并通过-Xmx和-Xms将堆固定为12GB。是使用情况是,网站总是不定期的出现长时间失去响应的情况。
分析:
网站长时间失去响应是由于GC导致的,回收12G的堆,一次Full GC停顿高达12s。并且由于程序设计关系,文档从磁盘加载到内存,反序列化文档产生的大对象很对进入老年代,这导致12G很快被用完。导致频繁FULL GC。
暂且不说代码的问题。程序部署上的问题是,过大堆内存回收带来长时间停顿。
科普:
高性能硬件上部署程序主要有两种方式:
- 通过64位JDK来使用大内存
- 使用若干32位虚拟机建立逻辑集群来利用硬件资源
部署方式 | 部署实践 | 面对的问题 |
---|---|---|
64位JDK | 有把握控制FULL GC的频率,比如可以通过深夜定时任务触发FullGC或者定时重启来保持内存可用空间在一个稳定水平。 | 1.内存回收长时间停顿。2.64位性能低于32位。3.这种程序几乎违法在oom的时候生成堆快照,即便生成也很难分析。4.内存消耗比32位快。 |
32位虚拟机集群 | 在一个物理机上启动多个应用进程,每个进程对应一个端口号,再搭建一个前端的负载均衡器,以反向代理的方式分配请求 | 1.尽量避免节点竞争全局资源,典型的就是磁盘竞争。2.很难高效的利用某些资源,例如连接池。3.32位windows平台内存受限最多2GB,Linux系统中受限最多4GB。4.大量使用本地缓存的应用比较浪费内存,可以考虑使用集中式缓存。 |
5.2 集群间同步导致的内存溢出
案例描述:
一个MIS系统,硬件为两台2个cpu、8GB内存的小型机,每台机器启动3个实例构成了一个6个节点的集群。
有一些共享数据需要在各个节点直接共享,开始这些数据放在mysql中,但是读写竞争激烈影响性能,后来构建了一个全局缓存,需要共享的数据线放在缓存中,共享完成才清除缓存。共享的过程是把数据用tcp发送给各个节点。
分析:
由于tcp存在失败的可能,需要重发,所以在没有确认所有节点都收到信息前,发送的数据必须在内存中保留。当个节点直接的网络交互非常频繁时,如果网络情况不能满足要求,重发数据在内存中不断堆积,很快就oom了。
5.3 对外内存溢出
案例描述:
一小学电子考试系统,32位系统,1.6GB内存,服务器不定时OOM,加入-XX:+HeapDumpOnOutOfMemoryError,但是在OOM的时候并没有生成堆文件,挂着jstat一直紧盯着屏幕发现GC并不频繁,各区都表示“压力不大”,但就是不停的oom。
分析:
32位windows平台最多利用2GB内存,其中1.6GB给了堆,Direct Memory只有0.4GB可用。Direct Memory这块内存只有等老年代满了Full GC的时候顺便把他回收一把,否则他得不到回收的机会就只能抛出oom。
- Direct Memory:可以通过-XX:MaxDirectMemorySize调整大小
- 线程堆:可通过-Xss调整
- Socket缓存区:每个链接都receive和send两个缓存区,分别占37k,25k,如果链接多的话这块占内存也很可观。
5.4 外部命令导致系统慢
案例描述:
一个数字校园系统,在系统压测时发现请求时间比较长,通过操作系统的mpstat工具发现cpu使用率很高,并且占用大部分cpu资源的程序不是应用系统本身,这是不正常的。
通过工具查看哪些系统调用话费了最多的cpu资源,发现竟然是“fork”系统调用,众所周知,”fork“是你linux用来产生新进程的。
系统开发人员最终找到可答案:每个用户请求处都需要执行一个外部shell脚本活的系统的一些信息,执行这个shell脚本是通过java的Runtime.getRuntime().exec()来调用的。
JVM执行这个命令的过程是:首先clone一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后退出这个进程。
如果频繁执行这个操作,系统的消耗会很大。
第六章 类文件结构
6.2 JVM平台无关性的基石- .class
JVM可以运行在各种平台的原因就是他可以执行字节码(.class),JVM支持任何语言编译成的字节码。
6.3 class文件结构
class文件是以8位字节为单位的二进制流,个数据项之间没有分隔符。文件中用类似c语言结构体的伪结构来存储数据,数据类型只有两种:无符号数和表。
无符号数属于基本数据类型,用u1、u2、u4、u8来表示1、2、4、8字节的无符号数。无符号数可以用以描述数字、索引引用、数量之或者按照utf-8编码构成的字符串值。
表是由无符号数和其他表构成的复合类型。表习惯以"_info"结尾。整个class文件本质上就是一张表。
无论是无符号数还是表,当需要描述多个数据时,会使用前置计数器+若干数据项的形式,称这种描述形式为集合。
6.3.1 魔数与class文件版本
每个class文件的头4个字节称为魔数,它的唯一作用是确定这个文件是否是虚拟机可接受的class文件。
class文件的魔数值固定为:0xCAFEBABY
紧接着魔数的是class文件的版本号:5、6字节是次版本号(minor version)。7、8字节是主版本号(major version),该版本号和jdk版本有对应关系,低版本的class不能运行在高版本的jdk。class版本号从45.0开始,对应JDK1.1。jdk1.7对应class版本51.0。
6.3.2 常量池
紧挨着版本号之后是常量池,是占用class空间最大的项目之一。
常量池的数据项目数量不固定,所以采用集合描述,所以在常量池的入口放一个计数器记录常量池常量的数量。
常量池中放两类常量:
- 字面量:如文本字符串,声明为final的常量值等。
- 符号引用:如类和接口的全限定名、字段的名称和描述符、方法的名称和描述。
java代码在javac编译的时候不像c或者c++有“链接”这一步骤,而是虚拟机在加载class文件的时候动态链接。所以这些常量只有在虚拟机运行时用到时才会 被翻译到具体的内存。
常量池中每一项常量都是一个表,第一位是u1类型的标志位,表示这个常量是那种类型。共14中取值:
javap -verbose命令可用于输出字节码内容
public class Person {
private String name;
private String sex;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSex() {
return sex;
}
public void setSex(String sex) {
this.sex = sex;
}
}
~ javap -verbose Person.class
Classfile Person.class
Last modified 2019-4-5; size 523 bytes
MD5 checksum b91fd6abd4007d7cb376e5a44c455a4b
Compiled from "Person.java"
public class Person
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#22 // Person.name:Ljava/lang/String;
#3 = Fieldref #4.#23 // Person.sex:Ljava/lang/String;
#4 = Class #24 // Person
#5 = Class #25 // java/lang/Object
#6 = Utf8 name
#7 = Utf8 Ljava/lang/String;
#8 = Utf8 sex
#9 = Utf8 <init>
#10 = Utf8 ()V
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 getName
#14 = Utf8 ()Ljava/lang/String;
#15 = Utf8 setName
#16 = Utf8 (Ljava/lang/String;)V
#17 = Utf8 getSex
#18 = Utf8 setSex
#19 = Utf8 SourceFile
#20 = Utf8 Person.java
#21 = NameAndType #9:#10 // "<init>":()V
#22 = NameAndType #6:#7 // name:Ljava/lang/String;
#23 = NameAndType #8:#7 // sex:Ljava/lang/String;
#24 = Utf8 Person
#25 = Utf8 java/lang/Object
{
public Person();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 5: 0
public java.lang.String getName();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field name:Ljava/lang/String;
4: areturn
LineNumberTable:
line 11: 0
public void setName(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #2 // Field name:Ljava/lang/String;
5: return
LineNumberTable:
line 15: 0
line 16: 5
public java.lang.String getSex();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #3 // Field sex:Ljava/lang/String;
4: areturn
LineNumberTable:
line 19: 0
public void setSex(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #3 // Field sex:Ljava/lang/String;
5: return
LineNumberTable:
line 23: 0
line 24: 5
}
SourceFile: "Person.java"
6.3.3 访问标志位
在常量池之后紧接着的两个字节是访问标志位(access_flag),用于标识一些类或接口层次的访问信息,是否是public,是否是abstract等。
共有16个字节可以使用,当前只使用了8个,没有使用到的一律设置为0。
6.3.4 类索引、父类索引、与接口索引集合
类索引、父类索引分别是一个u2类型的数据,接口索引集合是一组u2类型数据的集合,class文件由这三项确定类的继承关系。
类索引用于确定这个类的全限定名,父类索引用于确定父类的全限定名。
6.3.5 字段表集合
字段表用于描述接口或类中声名的变量。字段包含类级别变量以及实例级别变量,但不包含方法内的局部变量。
6.3.6 属性表集合
字段表和方法表都可以携带自己的属性表集合,
6.4 字节码指令
java虚拟机指令是由一个字节长度、代表着某种特定操作含义的数字(称为操作码),以及跟随其后的多个参数(称为操作数)构成。
由于java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码。
6.4.1 字节码与数据类型
在java虚拟机的指令集中,大多数的指令都包含了起操作的数据类型信息,例如,iload是加载int类型的数据,fload是加载float类型的数据。 这两条指令在class文件中必须拥有独立的操作码。
对于大部分数据类型的指令,他们的操作码中包含特殊的字符来表示到底为哪种数据类型服务。其中i代表int、l代表long、s代表short、b代表byte、c带表char、f代表float、d代表double、a代表refrence。
由于java虚拟机操作码只有一个字节长度(8位),也就是说操作码最多256个,这就导致,操作码不能支持所有数据类型,其实,大部分指令都不支持byte、char、short,甚至没有任何指令支持 boolean,编译器会在编译期或者运行期将其扩展为相应的int类型,使用int指令。
6.4.2 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
6.4.3 运算指令
大体上算术指令可以分为两种:堆整形数据进行运算,堆对浮点类型数据运算。
6.4.4 类型转换指令
java虚拟机直接支持宽化类型转换,类型转换指令一般用于处理窄化类型转换和处理字节码指令集没有直接支持相关类型的问题。
6.4.5 对象创建和访问指令
6.4.6 操作数栈指令
6.4.7 控制转移指令
6.4.8 方法调用和返回指令
6.4.9 异常处理指令
6.4.10 同步指令
第七章 虚拟机类加载机制
7.1 概述
在java语言中,类型的加载、连接和初始化过程都是在程序运行期完成的。这种策略会有一定的性能开销,但是多了更多的灵活性。例如一个面向接口的应用程序,在运行期才指定具体的实现类。
7.2 类加载的时机
类从被加载到虚拟机内存到卸载出内存为止,他的整个生命周期包含以下七个部分:
虚拟机规定有且只有5种情况必须立即对类进行初始化:
- 遇到new、getstatic、putstatic或invokestatic这4条指令时
- 使用java.lang.reflect对类反射时
- 初始化一个类时,先初始化其父类
- 虚拟机启东时先初始化主类(带有main方法的类)
- 使用JDK1.7动态语言支持时
7.3 类加载的过程
类加载的全过程包括:加载、验证、准备、解析、初始化
7.3.1 加载
“加载”是类加载过程的一个阶段。在加载阶段JVM需要完成三件事情:
- 通过一个类的全限定名获取类的二进制流
- 将这个二进制流的静态存储结构转化为方法区的动态数据结构
- 在内存生成一个这个类的对象,作为方法区这个对象的各种数据的访问入口
7.3.2 验证
这个阶段大致上会完成4个阶段的验证动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
文件格式验证:
- 魔数是否以0xCAFFBABY开头
- 主次版本号是否在本虚拟机的处理范围
- 常量池的常量中是否有不支持的类型
- …
这个阶段的验证还有很多。这个阶段是基于二进制流进行的验证,通过这个阶段的验证才会将二进制流存入方法区。
元数据验证:
- 是否有父类(除了object之外,都应该有父类)
- 这个类的父类是否继承了不允许被继承的类(被finial修饰的类)
- 如果这个类不是抽象类,是否实现了其必须要实现的方法
- 类中的字段、方法是否与父类产生矛盾
- …
这个阶段主要是验证java语言规范
字节码验证:
这个阶段是最复杂的阶段,通过分析数据流和控制流,确定程序语义合法,符合逻辑。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
- 保证方法体中的类型转换时有效的(例如父类对象赋值给子类引用)
- …
JDK1.6引入了“StackMapTable”,其描述了方法体中的所有基本块(按照控制流拆分的基本块)开始时本地变量表和操作栈应有的状态。在字节码验证阶段就不需要按照程序推导验证,只要检查StackMapTable就ok。
符号引用验证:
这个验证发生在将符号引用转化为直接引用的时候、发生在解析阶段。需要验证的内容:
- 符号引用中通过全限定名能否找到指定的类
- 验证可访问性(public、private、protected)
如果所运行的全部代码被反复使用和验证过,那么在实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机加载类的时间。
7.3.2 准备
这个阶段正式为类变量(static修饰的变量)分配内存 并赋值初始值。这些变量使用的内存在方法区分配。
两点说明: - 这个阶段给类变量分配内存不包含实例变量,实例变量会在对象实例化时在堆中分配内存。
- 赋值初始值并不是真正的初始化,例如public static int value = 123;在该阶段只是赋值为value=0;
7.3.4 解析
将虚拟机常量池中的符号引用替换为直接引用的过程。
7.3 初始化
7.4 类加载器
7.4.1 类与类加载器
同一个类,被不同的类加载器加载也是不相等的。
7.4.2 双亲委派模型
从JVM角度看,类加载器大体上可以分为两类:启动类加载器(c++实现的,是JVM的一部分) && 其他类加载器(java实现的,独立于JVM外部)。
从程序员角度看,可分为三类:
- 启动类加载器:负责将<JAVA_HOME>/lib中或者-Xbootclasspath指定的路径中类库加载到虚拟机中。
- 扩展类加载器:负责将<JAVA_HOME>/lib/ext中或者被java.ext.dirs系统变量指定的路径中类库加载到虚拟机中。程序员可以直接使用扩展类加载器。
- 应用程序类加载器:是ClassLoader中getSystemClassLoader()的返回值。负责加载用户类路径上所指定的类库。
双亲委派模型的工作过程:
一个类加载器接收到类加载请求,首先父类去加载,父类加载失败才会自己去加载。
7.4.3 破坏双亲委派模型
双亲委派模型并不是一个强制约束,在java世界中大部分类加载器都遵循这个规模,但也有例外。这一模型有3次被破坏。
-双亲委派是在jdk1.2加入的,为了兼容老版本,提供了findClass()方法。jdk1.2之前都是直接重写loadClass(),jdk1.2之后是将双亲委派实现在loadClass()中的。
- 双亲委派很好的解决了各个加载器“基础”的问题,但是如果基础类又要调用用户的代码怎么办?这几需要打破双亲委派模型,如JDBC。
- 代码热部署的需求
第八章 虚拟机字节码执行引擎
8.1 概述
本章从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
每一个方法的调用都对应一个栈帧在JVM里出入栈。
栈帧的内容包含:局部变量表、操作数栈、动态链接、方法返回地址等。
8.2.1 局部变量表
用于存储方法参数和方法内部的局部变量。
在编译期就能确定局部变量表的最大容量。
局部变量表以变量槽为最小单位。
8.2.2 操作数栈
栈的最大深度在编译期确定。
方法刚开始的时候操作数栈是空的,在运行时,通过操作数栈传递参数或者进行算数运算。
理论上两个栈帧是完全独立的,但是有一些优化情况会让两个栈帧出现重叠。
8.2.3 动态链接
每个栈帧都包含 一个执行运行时常量池的引用,该引用是为了支持动态链接。
class文件的常量池中有大量的符号引用,这些符号引用有些在静态解析中转化为直接引用,有些在运行时转化为直接引用,称为动态链接。
8.2.4 方法返回地址
方法退出的过程:
1.把当前栈帧出栈
2.恢复上层方法的局部变量表和操作数
3.把返回值压入调用者栈的操作数栈
4.调整pc计数器指向下一条指令
8.3 方法调用
所有的方法调用在class文件中都是常量池中的符号引用,在类加载的解析阶段会把一些符号变为直接引用,还有一些要在执行器才能确定具体的方法。
8.3.1 解析
有些方法调用在编译期就可以确定调用入口,比如静态方法、私有方法。
jvm中提供了5条方法调用的指令:
- invokestatic:调用静态方法
- invokespecial:调用构造方法、私有方法
- invokevirtual:调用虚方法
- invokeinterface:调用接口
- invokedynamic
8.3.2 分派
这里讲“重载”和“重写”在JVM中是如何实现的
1.静态分派
依据静态类型类定位方法执行版本称为静态分派。静态分配的典型应用是方法重载。静态分派发生在编译期。
2.动态分派
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3.单分派与多分派
8.3.3 动态类型语言支持
8.4 基于栈的字节码解释执行引擎
8.4.1 解释执行
本节探讨JVM如何执行方法中字节码指令的。JVM执行java代码分为解释执行和编译执行。本章探讨解释执行。
8.4.2 基于栈的指令集和基于寄存器的指令集
Java编译期输出的指令流,是基于栈的指令集架构。
pc机中直接支持的指令集架构是寄存器指令集。
栈指令集的优势是可移植,缺点是执行速度会稍慢。
8.4.3 基于栈的解释器执行过程
第九章 类加载及执行子系统的案例与实践
9.1 概述
在class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不多。
能通过程序进行操作的,主要是字节码生成与类加载器这两部分功能。
本章几个例子,是字节码生成和类加载器的经典活用。
9.2 案例分析
9.2.1 Tomcat:正统的类加载器架构
主流的web服务器都实现了自己的类加载器,因为一个健全的web服务器要解决以下问题:
- 部署在同一个服务器上的两个web应用所使用的java类库要相互隔离。
- 部署在同一个服务器上的两个web应用所使用的java类库要相互共享。
- 服务器使用的类库与应用程序使用的类库隔离。
- 支持jsp的服务器,大多要支持hotSwap功能。
下面以tomcat为例,看一下为了支持上面这些问题,tomcat是如何规划用户类库结构和类加载器的。
tomcat目录结构中有三组目录:/common/、/server/、/shared/*可以存放java类库,另外再加上web应用程序自身的/WEB-INF/*一共四组: - /common/*:tomcat和所有web应用共享
- /server/*:tomcat独享
- /shared/*:所有web应用共享
- /WEB-INF/*:web独享
为了支持这套目录,并对目录里的类库加载隔离,tomcat自定义了多个类加载器,这些类加载器按照双亲委派模型实现。
9.2.2 字节码生成技术与动态代理的实现
动态代理是可以在运行时产生一个描述代理类的字节码byte[]数组,大致的生成过程就是根据class文件的格式规范去拼装字节码。,但是在实际开发中以byte为单位直接拼装出字节码的场景很少见,这种方式也只能产生高度模板化的方法。
对于用户程序来说,如果有大量操作字节码的需求,还是适应封装好的字节码类库比较合适。
9.3自己动手实现远程执行功能
第十章 编译期优化
10.2 javac编译器
10.2.1
javac的编译过程大概可以分为3个过程
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程
这三个部分的交互顺序如图:
javac的整个编译过程最重要的就是下面8个方法:
10.2.2 解析与填充符号表
包含此法分析和语法分析两个部分。
词法分析是把源代码变为标记(token)集合,单个字符是编码过程的最小单元,而标记是编译过程的最小单元。如”int a = b+2“这句代码包含6个标记分别是:“int”、“a”、"="、“b”、"+"、“2”.
语法分析根据token序列构造抽象语法树(AST)的过程,是描述程序语法结构的树形表达方式。每个节点都代表一个语法结构,如:包、类型、修饰符、运算符、接口、返回值、甚至是注释。
完成了词法分析和语法分析之后,接下来就是填充符号表。
符号表是由符号地址和符号信息构成的表格。其内容在编译的不同阶段都会用到,在语义分析中,可用于语义检查和产生中间代码、在目标代码生成阶段,当堆符号进行地址分配时,可作为地址分配的依据。
10.2.3 注解处理器
解析注解处理器就是不断修改语法树。
10.2.4 语义分析与字节码生成
语法分析之后,编译器获得了程序的语法树,这保证程序结构的正确。
而语义分析用于保证程序逻辑正确,其主要任务是对语法树进行上下文有关性质的检查。
语义分析过程包含标注检查、数据和控制流分析两部分。
- 标注检查:检查变量在使用前是否被生明、变量与赋值之间的数据类型是否匹配。
- 数据及控制流分析:检查方法每条路径是否都有返回值、受检异常是否都被处理
10.2.5 解析语法糖
10.2.6 字节码生成
根据语法树,生成class文件。
10.3 语法糖的味道
10.3.1 泛型与类型擦除
泛型是类型擦除的,也就是说编译成class文件后,泛型会被擦除。
但是元数据中还是有泛型信息的,这也是我们可以通过反射得到参数化类型的根本依据。
10.4 自动拆箱、装箱与遍历循环
拆箱、装箱在编译之后被转化成对应的包装盒还原方法、如Integer.valueof()、Integer.initValue()。
遍历循环则是把代码还原成了迭代器的实现,这也是为什么遍历循环需要被遍历的类实现iterable接口。
10.5 条件编译
第十一章 运行期优化
11.1 概述
即时编译器(JIT)的作用:提高热点代码的执行效率,在运行期,将这些代码编译成与本地平台相关的机器代码,并进行个种层次的优化。
热点代码是:摸个方法或者代码块,频繁使用。
JIT并不是JVM必须的部分,但却是最核心最能体现JVM技术水平的部分。
本章以hotSpot虚拟机为例讲JIT.围绕以下几个问题展开:
- 为何hotSpot要使用解释器和编译器并存的架构
- 为何hotSpot要使用两个不同的即时编译器
- 程序何时使用解释器,何时使用编译器
- 哪些程序会被编译成本地代码?如何编译成本地代码?
- 如何观察JIT的编译过程和编译结果
为何hotSpot要使用解释器和编译器并存的架构
当程序运行环境中内存限制较大时(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译器提高效率。
为何hotSpot要使用两个不同的即时编译器
hotSpot内置两个编译器,Client Compiler和Server Compiler(C1、C2),默认解释器与其中一个编译器配合使用,使用哪个编译器取决于JVM运行的模式。
解释器与编译器搭配的方式成为”混合模式“
使用参数”-Xint“强制JVM运行在”解释模式“,这是编译器完全不介入工作
使用参数”-Xcomp“强制JVM运行在”编译模式“ ,这是将优先使用编译器执行程序
java -version命令查看当前JVM运行的模式
~ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)
➜ ~
程序何时使用解释器,何时使用编译器
JIT需要占用程序运行时间,要编译出优化程度高的代码则需要的时间也更多。而且也需要解释器手机性能监控信息,这导致解释器运行时间长。
为了在程序启动速度和运行效率之间达到平衡,hotSpot采用分层编译的策略。
-
第0层 程序解释执行 不开启性能监控
-
第1层 C1编译,进行简单可靠的优化
-
第2层 C2编译,开启一些耗时较长的优化
这种分层策略C1、C2同时运行,C1可以获得更好的编译速度。C2可以获得更好的编译质量。
哪些程序会被编译成本地代码?如何编译成本地代码
热点代码有两类: -
被多次调用的方法
-
被多次执行的循环体
那么这里的”多次“怎么定义和衡量呢?JVM有两种热点探测的方式: -
基于采样的热点探测:方法是定期检查各个线程的栈顶方法,经常出现的栈顶的方法就是热点方法。优点是简单,高效而且可以取得方法的调用关系。缺点是不精确。
-
基于计数器的热点探测:每个方法一个计数器统计执行次数,超过一定阈值就是热点方法。这种方式得到的结果准确但是操作麻烦而且不能得到调用关系。
hotSpot采用第二种-基于计数器的热点探测方法,它为每个方法准备两个计数器,方法调用计数器和回边计数器。两个计数器都有阈值,
方法调用计数器
client模式下阈值是1500、server模式下是10000次,可通过虚拟机参数-XX:CompileThreshold设置阈值。方一个方法被调用时,先检查该方法是不是被JIT优化过的方法,如果不是则计数器+1,然后判断调用计数器+回边计数器之和是否超过阈值。
如果不做任何设置,方法计数器统计的并不是绝对次数,超过一段时间还没到达阈值则计数器值减半,称为热度衰减,这段时间称为半衰周期。热度衰减实在GC时顺便进行的,可通过参数-XX:UseCounterDecay来关闭热度衰减,通过-XX:CounterHalfLifeTime设置半衰周期时间。
回边计数器
用于统计一个方法中循环体方法的执行次数。
当字节码中遇到指令向后跳转称为“回边”。
没有热度衰减。
第十二章 java内存模型与线程
12.2 硬件的效率与一致性
计算机的存储设备与处理器的运算速度存在几个数量级的差距,所以加入告诉缓存作为内存与处理器之间的缓冲。
在多处理器中每个处理器都有自己的高速缓冲,如何保证缓存一致性?这就需要各处理器都遵循一些协议。
除了高速缓存之外,代码可能会被乱序执行,JVM中也有指令重排序。
12.2 Java内存模型
JVM试图用内存模型来屏蔽各种硬件和操作系统的内存访问差异,让Java程序在各个平台达到一致的内存访问效果。C、C++直接使用硬件和操作系统的内存模型,有可能在一个平台上运行正常的系统在另一套系统上却运行出错。
主内存与工作内存
JVM内存模型主要目标是定义程序中各个变量的访问规则,即存储虚拟机中的变量和读取内存中变量。
JVM内存模型规定所有变量都存储在主存,但是每个线程还有自己的工作内存。
线程、主存、工作内存三者之间的交互关系如图:
内存交互操作
工作内存与主存之间的交互细节,JVM内存模型定义了8个操作,每个操作都是原子的。
- lock:作用于主存,把一个变量标识为线程独占。
- unlock
- read。作用于主存,把一个变量值从主存传输到工作线程,以便随后的load。
- load。作用于工作内存,把read的变量值放入工作内存的副本中
- use。作用于工作内存,把工作内存的变量值传递给执行引擎
- assign。作用于工作内存,给工作内存赋值操作。
- store。作用于工作内存,给主存赋值操作,以便随后的write。
- write
在这8个基本操作的基础上,JVM还规定了如下规则: - read和load、store和write必须成对出现。
- 不允许线程丢assign的结果。
- 没有assign,不允许store。
- 同一线程可以对一个变量多次lock吗,同时需要多次unlock。
volatile关键字
只有内存可见性,没有原子性。
Long和Double变量的特殊规则
JVM内存的8个基本操作都具有原子性,但允许JVM将没有被volatile修饰的64位数据的读写操作划分为两次32位操作来进行。
即虚拟机实现可以选择不保证64位变量的read、load、store、write操作的原子性。
但是目前商用的JVM中都实现将64位变量读写操作实现为原子操作。
12.4 java 与线程
实现线程的三种方式
-
使用内核线程实现。由内核完成线程调度和切换,缺点是需要占用内核资源,这导致能创建的线程数量有限。而且切换线程需要用户态和内核态切换,耗费大。
-
使用用户线程实现
进程和用户线程1:N。
优点是不需要内核的支援、缺点也是没有内核支援。
这就使得其实现 比较复杂,java中已经放弃使用这种实现。
-
使用用户线程加轻量级进程实现
用户线程和轻量级进程比例不定,N:M。
用户线程的创建、切换 还是在用户空间进行。
线程调度与系统调用通过轻量级内核线程来完成,降低了整个进程被阻塞风险。
java线程调度’
有两种方式: -
协同式线程调度。A线程执行完主动通知系统切换到另一个线程上。优点是实现简单缺点是执行时间不可控。
-
抢占式线程调度。每个线程由系统分配时间片。java使用这种方式。
可以通过设置线程优先级来给一些线程更多的执行时间。
状态转换
第十三章 线程安全与锁优化
13.2 线程安全的实现方法
- 互斥同步。synchronized。缺点是存在线程阻塞和唤醒带来的性能问题。
- 非阻塞同步。(CAS)
13.3 锁优化
- 自旋锁 && 自适应自旋
- 锁消除
- 锁粗化
- 轻量级锁
- 偏向锁