
欢迎来到预见猿份,本站项目均为站长原创,学习中有问题可直接提交给站长老苗解决(微信:mrt_0607)。
苗润土老师,20余年一线项目经验,2014年加入黑马,星辰wms、云岚到家、学成在线项目作者,历任高级讲师、教学主管及课程研究员。 b站老苗
1、JVM调优实战
通过前面的课程学习我们已经掌握了JVM调优的基本知识,下面我们进行实战,通过在Linux平台运行web项目,然后进行对其进行压力测试,我们通过对JVM的调优来观察是否对服务的性能有了提升。
最后我们将总结优化的经验以及一些建议。
1.1、环境准备
测试环境需要1台Linux支持。
| IP | 开放端口 | 内存 | 说明 |
|---|---|---|---|
| 192.168.31.133 | 8080 | 4GB | 运行web项目 |
jdk使用的版本为jdk8,需要在Linux服务器中安装jdk8:
#将资料中的jdk-8u144-linux-x64.tar.gz上传到/usr/local/src下
tar -xvf jdk-8u144-linux-x64.tar.gz
#修改环境变量
vim /etc/profile
#set java environment
#JAVA_HOME=/usr/local/src/jdk-11.0.7
JAVA_HOME=/usr/local/src/jdk1.8.0_144
CLASSPATH=.:$JAVA_HOME/lib.tools.jar
PATH=$JAVA_HOME/bin:$PATH
export JAVA_HOME CLASSPATH PATH
#使环境变量立即生效
source /etc/profile
#测试
[root@itcast src]# java -version
java version "1.8.0_144"
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)1.1.1、安装tomcat
tomcat服务器在JavaEE项目中使用率非常高,所以在生产环境对tomcat的优化也变得非常重要了。
对于tomcat的优化,主要是从2个方面入手,一是,tomcat自身的配置,另一个是tomcat所运行的jvm虚拟机的调优。
cd /gc
wget http://mirrors.tuna.tsinghua.edu.cn/apache/tomcat/tomcat-8/v8.5.34/bin/apache-tomcat-8.5.34.tar.gz
tar -xvf apache-tomcat-8.5.34.tar.gz
cd apache-tomcat-8.5.34/conf
#对tomcat进行优化配置
vim server.xml
#优化一:禁用AJP服务,一般是使用Nginx+tomcat的架构,所以用不着AJP协议,所以把AJP连接器禁用。
#将下面的配置注释掉
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />
#优化二:设置线程池,并且调整最大并发线程数
<!--将注释打开-->
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="500" minSpareThreads="50" prestartminSpareThreads="true" maxQueueSize="500"/>
<!--
参数说明:
maxThreads:最大并发数,默认设置 200,一般建议在 500 ~ 1000,根据硬件设施和业务来判断
minSpareThreads:Tomcat 初始化时创建的线程数,默认设置 25
prestartminSpareThreads: 在 Tomcat 初始化的时候就初始化 minSpareThreads 的参数值,如果不等于 true,minSpareThreads 的值就没啥效果了
maxQueueSize,最大的等待队列数,超过则拒绝请求
-->
<!--在Connector中设置executor属性指向上面的执行器-->
<Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
#优化三:设置tomcat运行模式为nio2,tomcat的运行模式有3种:bio、nio、apr,其中nio2是nio的升级版,在tomcat8中才支持的,建议采用nio2模式。
<Connector executor="tomcatThreadPool" port="8080" protocol="org.apache.coyote.http11.Http11Nio2Protocol"
connectionTimeout="20000"
redirectPort="8443" />
#启动tomcat
cd /gc/apache-tomcat-8.5.34/bin/
./startup.sh && tail -f ../logs/catalina.out
#打开浏览器进行测试访问
http://192.168.31.133:8080/
1.1.3、部署web项目
为了方便测试性能,我们将部署一个java web项目,这个项目本身和本套课程没有什么关系,仅仅用于测试。
在资料中找到itcast-gc-demo-1.0.war,上传到linux服务器,进行部署安装。
cd /gc/apache-tomcat-8.5.34/webapps
rm -rf *
mkdir ROOT
cd ROOT/
rz上传war包
jar -xvf itcast-gc-demo-1.0.war
rm -rf itcast-gc-demo-1.0.war重新启动tomcat。
查看是否已经启动成功:http://192.168.31.133:8080/movie/query

1.1.4、测试项目
实现的功能:
- 读取电影文件movies.dat,载入到内存
- 查询电影数据时,随机返回1w ~ 10W个数据
代码:
package cn.itcast.gc.controller;
import cn.itcast.gc.service.MovieService;
import cn.itcast.gc.vo.Movie;
import cn.itcast.gc.vo.MyResult;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("movie")
public class MovieController {
@Autowired
private MovieService movieService;
@GetMapping("query")
public MyResult queryMovieList(){
MyResult myResult = new MyResult();
List<Movie> movies = this.movieService.queryMovieList();
myResult.setCount(movies.size());
myResult.setTop10(movies.subList(0, 9)); //只返回前10条数据,不要将性能损耗在传输上
return myResult;
}
}package cn.itcast.gc.service;
import cn.itcast.gc.vo.Movie;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.RandomUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
@Service
public class MovieService {
private static final List<Movie> MOVIE_LIST = new ArrayList<>();
@Autowired
private ResourceLoader resourceLoader;
@PostConstruct
public void init() {
try {
//读取电影数据文件到内存
Resource resource = resourceLoader.getResource("classpath:movies.dat");
InputStream inputStream = resource.getInputStream();
List<String> lines = IOUtils.readLines(inputStream, "UTF-8");
for (String line : lines) {
String[] ss = StringUtils.split(line, "::");
Movie movie = new Movie();
movie.setId(Long.valueOf(ss[0]));
movie.setTitle(ss[1]);
movie.setPeoples(ss[2]);
//将数据放入到集合中
MOVIE_LIST.add(movie);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public List<Movie> queryMovieList() {
//随机1w ~ 10w条数据
int count = RandomUtils.nextInt(10000, 100000);
List<Movie> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
int index = RandomUtils.nextInt(0, MOVIE_LIST.size() - 1);
Movie movie = MOVIE_LIST.get(index);
Movie newMovie = new Movie(); //为了加大内存的损耗,创建新的对象
newMovie.setId(movie.getId());
newMovie.setPeoples(movie.getPeoples());
newMovie.setTitle(movie.getTitle());
list.add(newMovie);
}
return list;
}
}1.2、压力测试
下面我们通过jmeter进行压力测试,先测得在初始状态下的并发量等信息,然后我们在对jvm做调优处理,再与初始状态测得的数据进行比较,看调好了还是调坏了。
首先需要对jmeter本身的参数调整,jmeter默认的的内存大小只有1g,如果并发数到达300以上时,将无法正常执行,会抛出内存溢出等异常,所以需要对内存大小做出调整。
修改jmeter.bat文件:
set HEAP=-Xms1g -Xmx4g -XX:MaxMetaspaceSize=512m
在该文件中可以看到,jmeter默认使用的垃圾收集器是G1.
Defaults to '-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1ReservePercent=20'
1.2.1、添加gc日志相关参数
#内存设置较小是为了更频繁的gc,方便观察效果,实际要比此设置的更大
JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms64m -Xmx128m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"重新启动tomcat。
1.2.2、创建测试用例


1.2.3、初始测试
初始测试使用200的并发进行测试,每个线程执行100次,总数为20000次请求。
测试结果:

平均响应时间:3033ms
吞吐量:32.4/sec (每秒的请求量)
错误率:0.87%

测试过程中的cpu、内存等信息。
1.2.4、GC日志解析
在初始测试中,将生成的gc日志上传到gceasy进行分析,我们将看到一些非常有价值的信息,对我们的后续调优有着重要的指导作用。

在jvm内存统计中可以看出,年轻代与老年代在高峰时,基本将可用空间都占满了,说明内存空间不足,需要调整内存大小。

关键指标中,吞吐量为98.694%,gc平均停顿时间为8.19ms,最大停顿时间为180ms。
可以看出,在初始状态时,吞吐量并不高,最大停顿时间较长,平均停顿时间表现不错。

在图表中可以看出,gc之后堆内存的使用基本是在60m ~ 80m之间。

在图表中可以看出,gc之前的堆内存的使用在高峰时,将占用到80m ~ 120m之间。

在gc持续时间统计中,可以看出full gc的时间要远高于younggc的时间,在调优时应当尽量的减少full gc。

在清理垃圾的统计中,可以看出gc清理的垃圾基本维持在40m左右,最多的一次是发生在full gc,可以推断此次是内存即将耗尽,发生了fullgc,释放了大量的内存空间,这也是在前面年轻代对象有部分进入到老年代,在此次fullgc时被清理了。

从年轻代的gc情况来看,gc之前与gc之后差较大,说明垃圾对象在年轻代被清理的比较多,就是说临时性的对象居多。

从老年代的gc情况来看,gc之前与之后的差并不大,说明老年代的垃圾对象并不是很多。

Meta Space空间充足,基本没有变化,占用空间40m左右。

从该图中看出,晋升到老年代的对象与可以分配对象相比,非常的少,也说明了上面我们看到的,对象主要集中在young区。

在GC统计中,可以看出:
- Minor GC清理掉的垃圾对象合计24.13gb,说明产生的临时对象非常的多
- Minor GC的执行间隔为636ms,说明发生gc的行为是比较频繁的
- Full GC发生了10次,较为频繁
- Full GC的平均持续时间为90ms,时间较长
- GC的暂停次数为690次,暂停次数将影响到服务的响应时间

在对象的统计中,可以看出对象的平均生成率:57.85m/s,平均的晋升率:610kb/s。

在GC原因统计中可以看出,大部分发生gc的原因都为分配失败,也就说内存不足导致;
需要说明的是虽然发生了4次Metadata GC,并不是Metaspace不足导致,前面我们看到Metaspace空间充足,而是该gc发生在最开始时间,说明初始的Metaspace不足,导致了Metaspace扩容,并发生了GC,可以适当调整Metaspace初始大小以减少Metadata GC次数。
1.3、调优一:调整内存
在jvm调优中,调整内存大小是调优手段中最为基本的一种手段,但是需要注意的是,内存的调整并不是简单的加大内存,而是需要结合业务特性、gc类型等内容进行调整。
对于我们目前测试的应用而言,属于及时响应、低延迟的应用,这样的应用在jvm堆内存中,对象的存活时间较短,所以应该将年轻代的内存调大些。
#设置堆内存为1024m
#设置年轻代大小为512m,默认是堆内存的1/3
#设置初始的Metaspace大小为64m
JAVA_OPTS="-XX:+UseParallelGC -XX:+UseParallelOldGC -Xms1024m -Xmx1024m -XX:NewSize=512m -XX:MetaspaceSize=64m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"测试结果:


可以看到内存基本够用,从结果来看,可以进一步扩大年轻代的大小。

吞吐量与停顿时间都比较理想。

可以看出,没有发生Full GC,Minor GC的次数明显下降。情况有了很大的改善。

GC原因中,也只有一种原因,这是属于正常的情况。
总体来讲,通过调整内存大小,对于服务的性能有了显著的提升。
下面尝试下,加大并发到400,看下表现怎么样?

可以看到,在400并发的情况下,吞吐量为395,平均响应时间为945ms,相比较200并发下的结果,稍微差了一下,整体还算ok。
可以按照这个方法,测试500、800、1000或者更高并发。
关于jmeter测试时,可能出现的java.net.BindException: Address already in use: connect异常,解决方案如下:
https://blog.csdn.net/zhangxiaohui4445/article/details/100162317
1.4、调优二:更换G1收集器
选择性能更优的垃圾收集器也是调优的手段之一,在jdk8中,使用率最高的当属G1收集器了,下面我们就尝试切换成G1收集器,来看下它的表现。
JAVA_OPTS="-XX:+UseG1GC -Xmx1024m -XX:MetaspaceSize=64m -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"测试结果:

这个结果要比同等内存大小的ParallelGC性能稍好一些,但是提升并不明显。

自分配的内存区域大小,也基本符合需求。

在吞吐量指标中,有所下降,但是停顿时间都有所减少。
总结:
更换G1垃圾收集器后,其性能有所提升,但是并不明显,原因有两个:
- 调优一中并未出现明显的问题,所以相对比,不是很明显
- G1垃圾收集器适合大内存低延迟的场景,比如设置6G、8G内存的场景下保持低延迟
1.5、调优三:更换ZGC收集器
下面我们将垃圾收集器换成ZGC,需要注意的时,jdk需要切换到jdk11版本。
JAVA_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx1024m -XX:MetaspaceSize=64m -Xlog:gc*:../logs/gc.log -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"测试结果:

测试结果中的吞吐量与平均响应时间要比G1稍差一些,差距并不太大。

在GC方面的表现,无论是吞吐量还是停顿时间均有不俗的表现。
综合起来看的话,ZGC的表现还是很不错的,如果给其设置大内存,依然可以得到较短的停顿时间。
1.6、调优建议
对于JVM的调优,给出大家几条建议:
- 生产环境的JVM一定要进行参数设定,不能全部默认上生产。
- 对于参数的设定,不能拍脑袋,需要通过实际并发情况或压力测试得出结论。
- 对于内存中对象临时存在居多的情况,将年轻代调大一些。如果是G1或ZGC,不需要设定。
- 仔细分析gceasy给出的报告,从中分析原因,找出问题。
- 对于低延迟的应用建议使用G1或ZGC垃圾收集器。
- 不要将焦点全部聚焦jvm参数上,影响性能的因素有很多,比如:操作系统、tomcat本身的参数等。
1.7、PerfMa
PerfMa提供了JVM参数分析、线程分析、堆内存分析功能,界面美观,功能强大,我们在做jvm调优时,可以作为一个辅助工具。官网:https://www.perfma.com/

1.7.1、XXFox
XXFox是Java虚拟机参数分析工具,可以对JVM参数进行查询、检查、优化等。
参数优化:

参数生成:

1.7.2、XSheepdog
XSheepdog是Java线程Dump分析工具,通过jstack生成线程的dump文件,进行分析。

1.7.3、XElephant
XElephant是Java内存Dump分析工具。
获取内存 Dump 文件的命令:
jmap -dump:format=b,file=生成的文件名 进程号生成 Dump 文件后,就可以通过 控制台 > Java内存Dump分析 ,选择合适的途径上传到服务器进行分析。

2、百亿级流量电商大促高并发系统下JVM如何调优?

结合前面我们所学习到知识,我们来看个实际的案例,百亿流量电商大促高并发,如何调优?
面对这个问题,首先不要被标题吓到,需要我们冷静、仔细的分析,才能解决这个问题。

百亿流量的电商网站,一般日活跃用户可能在1亿左右,大促期间的参与人较平常会增加很多,比如5亿左右。
电商系统的业务线非常多,大促期间压力最大的属订单、支付业务,我们聚焦到订单系统。
假设,部署了订单系统的微服务数量为100,每台机器4核8G内存。
一般情况下,电商网站的付费转化率为10%左右,所以,1亿的活跃用户会产生1000w订单。
- 一天24小时,以12小时下单时间计算,每小时产生83w订单,每秒钟产生230左右个订单。
- 平均到100台机器,每台机器2~3个订单/秒,可以说毫无压力。
大促期间,转化量会提升,假设提升到50%左右,所以,5亿活跃用户会产生2.5亿订单。
- 大促期间的用户下单与平常的有所不同,这里我们假设,一天下单时间以20小时计算,前1小时,下单30%,其它时间段产生70%的订单。
- 前一小时产生30%订单,也就是7千500万订单,每秒钟将会产生20833个订单,分摊到100台服务器,每台服务器208个/秒订单的产生。
- 其它时间段产生70%的订单,也就是1亿7千5百万个订单,每小时产生920万订单,每秒产生2558个订单,分摊到100台机器,每天机器26个/秒订单的产生。
通过上面的分析,一般情况和大促的非集中时间段,产生的订单量较少,每台机器的压力并不大,所以我们只需要关注大促开始的1小时的压力即可。
假设每个订单对象的大小为1KB,考虑到每个订单的产生会有复杂的业务流程,我们将订单对象大小扩大100倍,也就是,每产生一个订单对象,会占用100KB的内存空间。
也就是说,在大促期间,前一小时,每秒钟占用的内存空间为:100KB * 208 = 20800KB 也就是 20M左右空间。
每台机器的内存是8G,除去操作系统、监控等内存开销,配置到jvm虚拟机的堆内存空间为6G。
考虑到订单对象在内存中创建后,会很快被持久化到数据库,随后就会GC回收,所以属于短暂性存活对象,尽可能的让其待在年轻代,避免过多的对象进入老年代,从而导致Full GC。
在JDK8中,建议使用G1垃圾收集器,下面是给出的jvm主要参数建议:
- shell
-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -Xmx6G -XX:MetaspaceSize=128m -XX:MaxGCPauseMillis=100 -XX:G1NewSizePercent=50 -XX:G1MaxNewSizePercent=80 -XX:G1NewSizePercent
- 设置要用作年轻代大小最小值的堆百分比。默认值是 Java 堆的 5%。(实验性质参数)
-XX:G1MaxNewSizePercent
- 设置要用作年轻代大小最大值的堆大小百分比。默认值是 Java 堆的 60%。(实验性质参数)
注意:该参数仅供参考,因为每个人的环境、业务等均不相同,大家需领悟其意义,不可生搬硬套。
3、类文件结构
了解Java的类文件结构,可以帮助我们进一步的对jvm的理解,在做jvm调优时,可以做到知其所以然。
Java程序最终是转换成class文件执行在虚拟机上的,那么class文件是什么样的的结构,虚拟机又是如何处理去执行class文件里面的内容呢?
3.1、MyDemo
package cn.itcast.jvm;
public class MyDemo {
private int a = 1;
private int b = 2;
public int sum() {
int c = a + b;
return c;
}
public static void main(String[] args) {
MyDemo myDemo = new MyDemo();
int sum = myDemo.sum();
System.out.println(sum);
}
}上面的java文件编译得到的class文件,class文件是一个16进制字节码的二进制文件,我们通过winhex软件打开如下:

3.2、字节码
要想读懂上面的字节码内容,就需要通过字节码表来查询对应的含义了。
3.2.1、字节码表
需要说明的是,class文件只有两种数据类型:无符号数 和 表。
- 无符号数
- 无符号数可以用来描述数字、索引引用、数量值或按照utf-8编码构成的字符串值。
- 其中无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节。
- 表
- 表是由多个无符号数或其他表构成的复合数据结构。
- 所有的表都以“_info”结尾,由于表没有固定长度,所以通常会在其前面加上个数说明。
| 类型 | 名称 | 说明 | 长度 |
|---|---|---|---|
| u4 | magic | 魔数,识别Class文件格式 | 4个字节 |
| u2 | minor_version | 副版本号 | 2个字节 |
| u2 | major_version | 主版本号 | 2个字节 |
| u2 | constant_pool_count | 常量池计算器 | 2个字节 |
| cp_info | constant_pool | 常量池 | n个字节 |
| u2 | access_flags | 访问标志 | 2个字节 |
| u2 | this_class | 类索引 | 2个字节 |
| u2 | super_class | 父类索引 | 2个字节 |
| u2 | interfaces_count | 接口计数器 | 2个字节 |
| u2 | interfaces | 接口索引集合 | 2个字节 |
| u2 | fields_count | 字段个数 | 2个字节 |
| field_info | fields | 字段集合 | n个字节 |
| u2 | methods_count | 方法计数器 | 2个字节 |
| method_info | methods | 方法集合 | n个字节 |
| u2 | attributes_count | 附加属性计数器 | 2个字节 |
| attribute_info | attributes | 附加属性集合 | n个字节 |
3.2.2、魔数
在class文件中,前4个字节就是魔数,如下:

魔数是用来区分文件类型的一种标识,0XCAFEBABE (咖啡豆/咖啡宝贝)表示就是class文件,之所以不使用文件后缀名区别就是后缀名太容易被改了,不安全。
3.2.3、版本号
魔数后面的4位就是版本号了,同样也是4个字节,其中前2个字节表示副版本号,后2个字节表示主版本号。

前面两个字节是0x0000,也就是其值为0; 后面两个字节是0x0034,也就是其值为52; 所以上面的代码就是52.0版本来编译的,也就是jdk1.8.0。

3.3、常量池
在版本号的后面就是常量池了。
3.3.1、常量池容量计数值

由于常量池的数量不固定,所以需要通过2个字节来记录常量池的大小,其值为0X002E,用十进制表示就是46,需要注意的是,常量池中只有45个常量。
与Java中语言习惯不同,这个容量计数是从1而不是0开始的,在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。
3.3.2、常量类型和结构
常量池中的每一项都是一个表,其项目类型共有14种,如下表格所示:
| 类型 | 标志 | 描述 |
|---|---|---|
| CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
| CONSTANT_Integer_info | 3 | 整形字面量 |
| CONSTANT_Float_info | 4 | 浮点型字面量 |
| CONSTANT_Long_info | 5 | 长整型字面量 |
| CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
| CONSTANT_Class_info | 7 | 类或接口的符号引用 |
| CONSTANT_String_info | 8 | 字符串类型字面量 |
| CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
| CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
| CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
| CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
| CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
| CONSTANT_MothodType_info | 16 | 标志方法类型 |
| CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
这14种类型的结构各不相同,如下表格所示:

注:此表格的类型的单位不对,不是bit,应该是byte(字节)。
从上面的表格可以看到,虽然每一项的结构都各不相同,但是他们有个共同点,就是每一项的第一个字节都是一个标志位,标识这一项是哪种类型的常量。
3.3.3、第一个常量

可以看到,第一个常量的值为10,对应到表中的标志位,找到常量为 CONSTANT_Methodref_info (类中方法的符号引用),它的结构为:

后面的4个字节都是它的内容,记录着2个索引值:

第一个索引值为:0X0009,也就是9,指向常量池中第9项的索引。
第二个索引值为:0X001E,也就是30,指向常量池中第30项的索引。
3.3.4、第二个常量

可以看到,第一个常量的值为9,对应到表中的标志位,找到常量为 CONSTANT_Fieldref_info(字段的符号引用),它的结构为:

后面的4个字节都是它的内容,记录着2个索引值:

第一个索引值为:0X0004,也就是4,指向常量池中第4项的索引。
第二个索引值为:0X001F,也就是31,指向常量池中第31项的索引。
3.3.5、javap 编译字节码
通过javap命令就可以将class文件转化为可读的字节码指令。
javap -v MyDemo.class > MyDemo.txt生成的字节码内容如下,可以看到与我们前面分析的是一致的。
Classfile /F:/code/itcast-jvm/out/production/itcast-jvm/cn/itcast/jvm/MyDemo.class
Last modified 2020-5-18; size 743 bytes
MD5 checksum 871ca2c3441314943f47358a3a709d3c
Compiled from "MyDemo.java"
public class cn.itcast.jvm.MyDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#30 // java/lang/Object."<init>":()V
#2 = Fieldref #4.#31 // cn/itcast/jvm/MyDemo.a:I
#3 = Fieldref #4.#32 // cn/itcast/jvm/MyDemo.b:I
#4 = Class #33 // cn/itcast/jvm/MyDemo
#5 = Methodref #4.#30 // cn/itcast/jvm/MyDemo."<init>":()V
#6 = Methodref #4.#34 // cn/itcast/jvm/MyDemo.sum:()I
#7 = Fieldref #35.#36 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Methodref #37.#38 // java/io/PrintStream.println:(I)V
#9 = Class #39 // java/lang/Object
#10 = Utf8 a
#11 = Utf8 I
#12 = Utf8 b
#13 = Utf8 <init>
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 LocalVariableTable
#18 = Utf8 this
#19 = Utf8 Lcn/itcast/jvm/MyDemo;
#20 = Utf8 sum
#21 = Utf8 ()I
#22 = Utf8 c
#23 = Utf8 main
#24 = Utf8 ([Ljava/lang/String;)V
#25 = Utf8 args
#26 = Utf8 [Ljava/lang/String;
#27 = Utf8 myDemo
#28 = Utf8 SourceFile
#29 = Utf8 MyDemo.java
#30 = NameAndType #13:#14 // "<init>":()V
#31 = NameAndType #10:#11 // a:I
#32 = NameAndType #12:#11 // b:I
#33 = Utf8 cn/itcast/jvm/MyDemo
#34 = NameAndType #20:#21 // sum:()I
#35 = Class #40 // java/lang/System
#36 = NameAndType #41:#42 // out:Ljava/io/PrintStream;
#37 = Class #43 // java/io/PrintStream
#38 = NameAndType #44:#45 // println:(I)V
#39 = Utf8 java/lang/Object
#40 = Utf8 java/lang/System
#41 = Utf8 out
#42 = Utf8 Ljava/io/PrintStream;
#43 = Utf8 java/io/PrintStream
#44 = Utf8 println
#45 = Utf8 (I)V
{
public cn.itcast.jvm.MyDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_1
6: putfield #2 // Field a:I
9: aload_0
10: iconst_2
11: putfield #3 // Field b:I
14: return
LineNumberTable:
line 3: 0
line 5: 4
line 6: 9
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this Lcn/itcast/jvm/MyDemo;
public int sum();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: aload_0
5: getfield #3 // Field b:I
8: iadd
9: istore_1
10: iload_1
11: ireturn
LineNumberTable:
line 9: 0
line 10: 10
LocalVariableTable:
Start Length Slot Name Signature
0 12 0 this Lcn/itcast/jvm/MyDemo;
10 2 1 c I
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #4 // class cn/itcast/jvm/MyDemo
3: dup
4: invokespecial #5 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #6 // Method sum:()I
12: istore_2
13: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
16: iload_2
17: invokevirtual #8 // Method java/io/PrintStream.println:(I)V
20: return
LineNumberTable:
line 14: 0
line 15: 8
line 16: 13
line 17: 20
LocalVariableTable:
Start Length Slot Name Signature
0 21 0 args [Ljava/lang/String;
8 13 1 myDemo Lcn/itcast/jvm/MyDemo;
13 8 2 sum I
}
SourceFile: "MyDemo.java"内容大致分为4个部分:
第一部分:显示了生成这个class的java源文件、版本信息、生成时间等。
第二部分:显示了该类中所涉及到常量池,共45个常量。
第三部分:显示该类的构造器,编译器自动插入的。
第四部分:显示了sum、main方的信息。(这个是需要我们重点关注的)
3.4、描述符
3.4.1、字段描述符
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.2
| FieldType term | Type | Interpretation |
|---|---|---|
B | byte | signed byte |
C | char | Unicode character code point in the Basic Multilingual Plane, encoded with UTF-16 |
D | double | double-precision floating-point value |
F | float | single-precision floating-point value |
I | int | integer |
J | long | long integer |
LClassName; | reference | an instance of class ClassName |
S | short | signed short |
Z | boolean | true or false |
[ | reference | one array dimension |
3.4.2、方法描述符
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html#jvms-4.3.3
示例:
The method descriptor for the method:
Object m(int i, double d, Thread t) {...}is:
(IDLjava/lang/Thread;)Ljava/lang/Object;3.5、实例:字符串拼接
字符串的拼接在开发过程中使用是非常频繁的,常用的方式有三种:
- +号拼接: str+"456"
- StringBuilder拼接
- StringBuffer拼接
StringBuffer是保证线程安全的,效率是比较低的,我们更多的是使用场景是不会涉及到线程安全的问题的,所以更多的时候会选择StringBuilder,效率会高一些。
那么,问题来了,StringBuilder和“+”号拼接,哪个效率高呢?接下来我们通过字节码的方式进行探究。
首先,编写个示例:
package cn.itcast.jvm;
public class TestString {
public static void main(String[] args) {
new TestString().m1();
new TestString().m2();
}
public void m1(){
String s1 = "123";
String s2 = "456";
String s3 = s1 + s2;
System.out.println(s3);
}
public void m2(){
String s1 = "123";
String s2 = "456";
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
String s3 = sb.toString();
System.out.println(s3);
}
}通过字节码的方式进行查看:
#命令:javap -c TestString.class
Compiled from "TestString.java"
public class cn.itcast.jvm.TestString {
public cn.itcast.jvm.TestString();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class cn/itcast/jvm/TestString
3: dup
4: invokespecial #3 // Method "<init>":()V
7: invokevirtual #4 // Method m1:()V
10: new #2 // class cn/itcast/jvm/TestString
13: dup
14: invokespecial #3 // Method "<init>":()V
17: invokevirtual #5 // Method m2:()V
20: return
public void m1();
Code:
0: ldc #6 // String 123
2: astore_1
3: ldc #7 // String 456
5: astore_2
6: new #8 // class java/lang/StringBuilder
9: dup
10: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
13: aload_1
14: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: aload_2
18: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: astore_3
25: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
28: aload_3
29: invokevirtual #13 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
32: return
public void m2();
Code:
0: ldc #6 // String 123
2: astore_1
3: ldc #7 // String 456
5: astore_2
6: new #8 // class java/lang/StringBuilder
9: dup
10: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
13: astore_3
14: aload_3
15: aload_1
16: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: pop
20: aload_3
21: aload_2
22: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
25: pop
26: aload_3
27: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
30: astore 4
32: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
35: aload 4
37: invokevirtual #13 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
40: return
}从解字节码中可以看出,m1()方法源码中是使用+号拼接,但是在字节码中也被编译成了StringBuilder方式。
所以,可以得出结论,字符串拼接,+号和StringBuilder是相等的,效率一样。
小结:可以通过查看字节码的方式对代码的底层做研究,探究其原理。
4、类加载机制
Java源代码经过编译器编译成字节码之后,最终都需要加载到虚拟机之后才能运行。虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
4.1、类加载时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示。

上图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
需要注意的是,按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。
但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
- 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令;
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候;
- 当初始化一个类的时候,发现其父类还没有进行初始化的时候,需要先触发其父类的初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类;
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有初始化。
- 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。
比如如下几种场景就是被动引用:
- 通过子类引用父类的静态字段,不会导致子类的初始化;

- 通过数组定义来引用类,不会触发此类的初始化;

- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化;

4.2、类加载过程
4.2.1、加载
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
4.2.2、验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段大致上会完成下面 4 个阶段的检验动作:
- 文件格式验证
- 第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能够被当前版本的虚拟机处理。验证点主要包括:
- 是否以魔数 0xCAFEBABE 开头;
- 主、次版本号是否在当前虚拟机处理范围之内;
- 常量池的常量中是否有不被支持的常量类型;
- Class 文件中各个部分及文件本身是否有被删除的或者附加的其它信息等等。
- 第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能够被当前版本的虚拟机处理。验证点主要包括:
- 元数据验证
- 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段的验证点包括:
- 这个类是否有父类;
- 这个类的父类是否继承了不允许被继承的类;
- 如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法;
- 类中的字段、方法是否与父类产生矛盾等等。
- 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求,这个阶段的验证点包括:
- 字节码验证
- 第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
- 符号引用验证
- 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段--解析阶段中发生。
- 符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
4.2.3、准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段。
4.2.4、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
4.2.5、初始化
类初始化阶段是类加载过程中的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全是由虚拟机主导和控制的。
到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。
4.3、类加载器
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
4.3.1、类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
4.3.2、双亲委派模型
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 来实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由 Java 来实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。
从 Java 开发者的角度来看,类加载器可以划分为:
- 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在 <java_home>\lib 目录中的类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,那直接使用 null 代替即可;
- 扩展类加载器(Extension ClassLoader):这个类加载器由
sun.misc.Launcher$ExtClassLoader实现,它负责加载 <java_home>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器; - 应用程序类加载器(Application ClassLoader):这个类加载器由
sun.misc.Launcher$App-ClassLoader实现。getSystemClassLoader()方法返回的就是这个类加载器,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
我们的应用程序都是由这 3 种类加载器互相配合进行加载的,在必要时还可以自己定义类加载器。它们的关系如下图所示:

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
双亲委派模型的工作过程是:
- 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类
- 而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此
- 因此所有的加载请求最终都应该传送到最顶层的启动类加载器中
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
这样做的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。
相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基本的行为也就无法保证了。
双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中:
protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 首先,检查请求的类是不是已经被加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父类抛出 ClassNotFoundException 说明父类加载器无法完成加载
}
if (c == null) {
// 如果父类加载器无法加载,则调用自己的 findClass 方法来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}5、编译优化
如何使我们编写的代码可以更加高效的执行,除了源码层面的优化外,虚拟机中的编译器也是在背后默默的为代码做着优化工作,下面我们就来了解下编译器做的一些优化行为。
5.1、前端编译器
前端编译器就是将*.java文件编译成*.class文件的过程。
前端编译器能够做的优化是非常有限的,主要的优化工作在后端编译器完成。
5.1.1、javac编译过程
javac的编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示。
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
- 符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。
- 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码。

上图是Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类主要执行流程,页反应出javac的执行流程。
5.2、后端编译与优化
后端编译器是指把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程。
后端编译器一般分为即时编译器与提前编译器:
- 即时编译器
- 在HotSpot中有三种编译器
- 客户端编译器”(Client Compiler)
- 服务端编译器”(Server Compiler)
- Graal编译器(JDK10中出现)
- 提前编译器
- Android中的ART就是提前编译器
5.2.1、编译器的优化
编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。
OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表,地址:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex

一般来讲,最具代表性的优化技术有四种,分别是:
- 方法内联
- 逃逸分析
- 公共子表达式消除
- 数组边界检查消除
5.2.2、方法内联
方法内联,是指JVM在运行时将调用次数达到一定阈值的方法调用替换为方法体本身,从而消除调用成本,并为接下来进一步的代码性能优化提供基础,是JVM的一个重要优化手段之一。
举个例子:
static class B {
int value;
final int get() {
return value;
}
}
//未内联的代码
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
//内联优化的代码
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
//可以继续优化
//冗余存储消除的代码
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}
//复写传播的代码
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}
//进行无用代码消除的代码
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}需要注意的是,以上优化是在编译器内部完成,而不是在代码中完成的,只是用代码来说明问题。
5.2.3、逃逸分析
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
逃逸分析的基本原理是:
- 分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;
- 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
- 从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低,则可能为这个对象实例采取不同程度的优化。
从JDK 7时服务端编译器默认开启了这项优化服务。
示例(伪代码):
// 完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42); //包含x和y坐标的POJO
return p.getX();
}
//第一步,将Point的构造函数和getX()方法进行内联优化:
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
p.x = xx; // Point构造函数被内联后的样子
p.y = 42
return p.x; // Point::getX()被内联后的样子
}
//第二步,经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42
return px;
}
//第二步的优化,将内存的开销从堆转移到栈,内存空间会随着线程销毁而释放,省去了堆中垃圾回收等操作
//第三步,通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果
public int test(int x) {
return x + 2;
}5.2.4、公共子表达式消除
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术。
它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。
对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。
如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
//源码
int d = (c * b) * 12 + a + (a + b * c);
//编译器检测到c*b与b*c是一样的表达式,而且在计算期间b与c的值是不变
int d = E * 12 + a + (a + E);
//还可以进一步优化
int d = E * 13 + a + a;5.2.5、数组边界检查消除
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。
如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的范围检查,即i必须满足“i>=0&&i<foo.length”的访问条件,否则将抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException。
对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。
对于编译器而言,根据数据流分析来确定是否越界,如果没有越界,那么执行时就不需要再判断了。
如果在循环中,本身就是通过循环变量来控制对数组的访问,执行时也就不用再判断了,
这样就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
