Radio

一个小小程序员

0%

在说java内存模型之前,我们先来聊一聊什么是内存模型,和为什么要有内存模型。

内存模型(Memory Model) 是一个计算机概念,在早期普通计算机在工作中,每条指令都是在CPU中执行的,执行过程中呢,又要操作数据,而数据是保存在内存中的,也就是物理磁盘。随着技术的发展,CPU的执行速度就越来越快,但是内存却一直停滞不前,没啥进展。慢慢的CPU的执行速度和操作内存数据的速度差距就越来越大,这就导致CPU每次操作内存的时候,都要耗费很多时间,所以问题就来了。总不能因为内存慢,就影响我CPU的执行速度吧。

所以缓存就出现了,缓存出现后,之前的操作就由了从内存操作数据,变成了先从内存中复制一份数据到缓存,在缓存中操作数据,然后缓存再写会内存。随着CPU的能力越来越强,一级缓存慢慢的也没法满足CPU的需求了,慢慢的二级缓存,三级缓存都出现了。操作流程就变成了从一级缓存读写,发现一级缓存中没有再从二级缓存中读写,如果还没有再从三级缓存中或者直接从内存中读写。

但是CPU的发展越来越快,从之前的单核慢慢变成了多核,也同时带来了多线程。

原来的单核CPU,只有一套缓存,现在多核了,每个核都有一套缓存。

单核CPU中,多个线程都同时访问共享数据,虽然会发生线程切换,但是依然操作的是同一个缓存。

多核CPU中,每个核都有一套缓存,多线程这时候访问,就有可能会在多个核上来回切换了,这时候就会出现每个核中的缓存数据不一致。

由此引发了一个新的问题,缓存一致性,除了这个明显的问题以外,还有一些处理器之间的问题,比如为了提高效率,处理器会对输入指令进行处理器优化来保证运算单元能够充分被使用,同时还可能会对代码进行指令重排来达到最优的执行效果,这些看似友好的优化,对于多线程并发来说,处理不好,就会带来致命的问题。

所以为了解决这一系列的问题,内存模型就诞生了。

内存模型很显然是一种解决方案,在一开始,是通过对总线进行加锁的方式实现的(LOCK#),即保证同时只有一个CPU能够使用,其他的都处于等待状态,这种做法很显然不符合CPU的发展,是一种倒退方式。

另一个方案就是缓存一致性协议(Cache Coherence Protocol),其中以Intel 的MESI协议最为出名,它的主要实现原理就是,当CPU在操作数据的时候,发现数据是公共数据(共享数据),它会发出信号,通知其他的CPU将自己缓存的数据置为无效,然后当其他CPU在读取数据后,发现数据状态为无效,就会从内存中重新读取。

缓存行有4种不同的状态:

  • 已修改Modified (M)

    缓存行是脏的(dirty),与主存的值不同。如果别的CPU内核要读主存这块数据,该缓存行必须回写到主存,状态变为共享(S).

  • 独占Exclusive (E)

    缓存行只在当前缓存中,但是干净的(clean)–缓存数据同于主存数据。当别的缓存读取它时,状态变为共享;当前写数据时,变为已修改状态。

  • 共享Shared (S)

    缓存行也存在于其它缓存中且是干净的。缓存行可以在任意时刻抛弃。

  • 无效Invalid (I)

    缓存行是无效的

对上面出现的缓存问题,我们可以总结出三个特性:原子性,可见性,有序性。

  • 原子性:CPU在一个操作中,不可中断,要么都执行,要么都不执行。
  • 可见性:当一个线程修改了某个共享变量的时候,其他线程都能看见最新的值。
  • 有序性:程序要按照代码的先后顺序来执行。

内存模型是多线程下的重要规范,对于java语言来说,其实现方式即java内存模型(Java Memory Model ,JMM),感兴趣的可以看看http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf。

内存之间的交互操作:

一个变量是如何从主内存拷贝到工作内存(这里我们可以类比计算器的内存和缓存),又是如何从工作内存同步到主内存,java内存模型定义了以下8种操作。

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

从主内存拷贝数据到工作内存,即执行read和load操作,从工作内存同步回主存,即执行stroe和write操作。注意,java内存模型要求这两个操作必须按顺序执行,但是不要求是连续的,即中间可以穿插别的操作。比如对主存中的变量a和b进行拷贝时,可能会出现read a,read b,load a,load b。此外java内存模型还对这8种操作制定了相应的规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。

  • 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 assign)的变量,换句话说就是对一个变量实施use、store操作之前,必须先执行assign和load操作。

  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作以初始化变量的值。

  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

这种定义非常严谨,但是也很烦琐,操作起来也很麻烦。往往会让我们产生恐惧感,后来java设计团队大概也意识到了这个问题,将java内存模型的操作简化为了read,write,lock和unlock这四种,这种简化只是描述上的简化,基础设计却未改变。

从上面的read、load、assign、use、store和write原子操作来看,我们大致可以认为基本数据类型的访问和读写都是具备原子性的。如果还需要更大范围的原子性,还有lock和unlock,但是java虚拟机并未把这两个操作提供给我们,却提供了更 高层次的字节码指令monitorentermonitorexit来隐式地使用这两个操作。这两个操作即java中的同步块关键字—synchronized。由此我们可以知道,**synchronized关键字是具备原子性(Atomicity)**的。

根据java内存模型对原子操作指定的规则中的最后一条“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”。synchronized还具备了可见性(Visibility),同时java还提供了两个具备可见性的关键字volatilefinal。volatile关键字通过对变量记录版本号,如果变量发生变化,将变量值和版本号同步回主存,其他线程在访问变量时发现版本号不一致,即放弃当前工作内存的拷贝,从主存直接获取。由于java的运算符操作是非原子性的,这也导致了volatile在运算状态下是线程不安全的,其不具备原子性。而final关键字的实现方式是:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值。

根据“一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。”这条规则,synchronized还保证了有序性(Ordering),其对变量进行加锁后,其他线程再无法访问该变量,直到获取到锁。这条规则说的很明确,lock操作也可以被同一条线程重复多次。比如下面这个示例:

1
2
3
4
5
6
7
public synchronized void aaa(){
System.out.println("aaa");
this.bbb();
}
public synchronized void bbb(){
System.out.println("bbb");
}

根据这条规则,线程在获取到锁之后, 先执行了aaa,然后执行bbb的时候又要获取锁,此时如果线程无法获取bbb锁,就会造成死锁。所以synchronized还是一个可重入锁。其执行原理是:被锁的对象有一个计数器,当线程获取到锁的时候,计数器+1,同一线程再次获取锁的时候,计数器再+1,释放的时候再做自减操作,当计数器为0时,表示锁释放,其他线程就可以获得锁进行操作。除了synchronize外,volatile关键字也具备有序性。其实现原理是:volatile会对修饰的变量加一个内存屏障,指令重排时,无法将后面的指令重排到内存屏障之前,由此来保证有序性。

通过上面的分析,我们发现synchronized关键字好像是万能的,并发操作都可以用这个关键字来搞定,这也间接的导致了他被大量滥用的现象。而这种滥用也引发了更大了隐患。JDK6之后,synchronized已被大量优化,性能已和ReentrantLock持平,其性能问题已不再是我们是否选择它的主要因素,但是除了性能以外,synchronized还具有一些我们目前无法控制它的因素:

  • 它是个非公平锁,其获取锁的先后无法控制,很有可能一个线程等待了很久也无法获取到锁,而一个新的线程刚进来就获取到了锁;
  • 它无法等待可中断,即无法强制已获取锁的线程释放锁,也无法强制正在等待锁的线程中断等待或超时退出;
  • 无法设置锁超时时间,无条件执行;
  • 锁释放条件不足,其只有正常执行完或者抛出异常才会主动释放锁;
  • 其切换锁状态是在内核中完成,状态切换要消化大量处理器资源,是一个重量级锁;
  • ….

其诟病层出不穷,似乎在实际应用中很难有立身之地。实际上,synchronized也有其特点,比如语法简单,使用者无需考虑手动释放锁,对于一些合适的场景,synchronized的使用要比Lock方便好理解的多。

Java 8 API添加了一个新的抽象称为流Stream,可以让你以一种声明的方式处理数据。什么意思呢,就是说Stream是一个把集合转换为流来进行操作的一个抽象概念。Stream为我们提供了高效,整洁的代码,不再像之前各种遍历,各种判断,校验。

Stream使用了一种类似于sql查询的方式来对集合进行抽象,他把我们的集合数据当做一个流,放在管道里面,并在每个节点对集合进行操作,比如limit,skip等等。

java 8 为我们提供了两种流

  • Stream:串行流
  • parallelStream:并行流,因为使用parallelStream要开放多线程和进行多线程间的切换,会消耗额外的资源,而且不是线程安全的,要进行额外的同步加锁操作。所以遍历时如果没有数据库操作的话,是不建议使用并行流的,还是使用stream和普通遍历吧。

为了给下面的例子做准备,我们先定义好一个集合类,下面对这个集合进行操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@AllArgsConstructor
@Data
static class People {
private Integer id;
private String name;
private String englishName;
private Integer age;
private String sex;
private Integer orderNo;

}

private static List<People> list = new ArrayList<>(Arrays.asList(
new People(1, "名字1", "Radio", 10, "男", 1),
new People(2, "名字2", "MaLi", 20, "男", 3),
new People(3, "名字3", "Filter", 10, "男", 2),
new People(4, "名字4", "Abc", 10, "男", 6),
new People(5, "名字5", "Name", 13, "男", 5),
new People(6, "名字6", "English", 20, "男", 4),
new People(1, "名字1", "Radio", 10, "男", 1)
));
forEach : 输出
1
2
3
4
5
6
7
@Test
public void testForEach() {
list.stream().forEach(a -> System.out.println(a.toString()));
//forEach用来对流处理完后,输出流;如果仅仅是使用循环功能,不建议使用stream.forEach;使用原生的forEach即可,即list.forEach。
Random random = new Random();
random.ints().limit(10).forEach(System.out::println);
}
fileter:过滤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Test
public void testFilter() {
List<Integer> ids = Arrays.asList(1, 2);
List<People> list1 = list.stream().filter(a -> ids.contains(a.getId())).collect(Collectors.toList());
System.out.println(list1);
//注意其和removeIf的区别,removeIf内部的表达式如果成立,则删除掉,而filter表达式成立则输出。这也是stream流操作的特点
//list.removeIf(a -> !ids.contains(a.getId()));
//查询第一个

People people1 = list.stream().filter(a -> ids.contains(a.getId())).findFirst().get();
System.out.println(people1);
//查询任意一个
People people2 = list.stream().filter(a -> ids.contains(a.getId())).findAny().get();
System.out.println(people2);
//注意一般这种写法有风险,get很可能会报空指针,如果list为空的话,所以我们推荐下面这种方法
Optional<People> people3 = list.stream().filter(a -> ids.contains(a.getId())).findFirst();
//people3.isPresent()用来判断是否存在有效数据
if (people3.isPresent()) {
People people = people3.get();
}
//是否包含
boolean flag = list.stream().anyMatch(a -> ids.contains(a.getId()));
System.out.println(flag);

//查询数量
long num = list.stream().filter(a -> ids.contains(a.getId())).count();
System.out.println(num);
}
max/min:最大/最小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testCount() {
//查询年龄最大的人
Optional<People> people1 = list.stream().max(Comparator.comparingInt(People::getAge));
if (people1.isPresent()) {
System.out.println(people1.get());
}

//查询年龄最小的人
Optional<People> people2 = list.stream().min(Comparator.comparingInt(People::getAge));
if (people2.isPresent()) {
System.out.println(people2.get());
}
}
reduce:归纳
1
2
3
4
5
6
7
8
9
10
@Test
public void testReduce() {
//求所有人的年龄和
Optional<Integer> sum1 = list.stream().map(People::getAge).reduce(Integer::sum);
sum1.isPresent();
System.out.println(sum1);
//和固定值比较大小,输出最大值
Integer sum2 = list.stream().map(People::getAge).reduce(10, Integer::max);
System.out.println(sum2);
}
sorted:排序
1
2
3
4
5
6
7
8
@Test
public void testSorted() {
//正序
List<People> list1 = list.stream().sorted(Comparator.comparingInt(People::getOrderNo)).collect(Collectors.toList());
//倒序
List<People> list2 = list.stream().sorted(Comparator.comparingInt(People::getOrderNo).reversed())
.collect(Collectors.toList());
}
limit:限制(这俩结合使用,可以做假分页)
1
2
3
4
5
6
7
@Test
public void testLimit() {
//获取指定数量
List<People> list1 = list.stream().limit(2).collect(Collectors.toList());
//跳过指定数量
List<People> list2 = list.stream().skip(2).collect(Collectors.toList());
}
count:统计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Test
public void testAvg() {
//前提list不能为null
long count = list.stream().collect(Collectors.counting());
long count1 = list.stream().count();
long count2 = list.size();
System.out.println(count + "," + count1 + "," + count2);
//年龄平均值
double avg = list.stream().collect(Collectors.averagingInt(People::getAge));
System.out.println(avg);
//最大值
Optional<Integer> max1 = list.stream().map(People::getAge).collect(Collectors.maxBy(Integer::compareTo));
Optional<Integer> max2 = list.stream().map(People::getAge).max(Integer::compareTo);
System.out.println(max1 + "," + max2);
//求和
Integer sum1 = list.stream().collect(Collectors.summingInt(People::getAge));
int sum2 = list.stream().mapToInt(People::getAge).sum();
System.out.println(sum1 + "," + sum2);
//统计
DoubleSummaryStatistics statistics = list.stream().collect(Collectors.summarizingDouble(People::getAge));
statistics.getMax();
statistics.getMin();
statistics.getCount();
statistics.getAverage();
statistics.getSum();
}
grouping:分组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void testGrouping() {
//分组,按年龄分组
Map<Integer, List<People>> peopleMap1 = list.stream().collect(Collectors.groupingBy(People::getAge));
//按是否符合要求来分组,例子为按年龄是否大于10来分组
Map<Boolean, List<People>> peopleMap2 = list.stream().collect(Collectors.partitioningBy(a -> a.getAge() > 10));

//按年龄分组 只取ID
Map<Integer, List<Integer>> peopleMap3 = list.stream().collect(
Collectors.groupingBy(People::getAge, Collectors.mapping(People::getId, Collectors.toList())));
//复杂分组,先按年龄分组,再按性别分组
Map<Integer, Map<String, List<People>>> peopleMap4 =
list.stream().collect(Collectors.groupingBy(People::getAge, Collectors.groupingBy(People::getSex)));

}
joining:合并
1
2
3
4
5
6
@Test
public void testJoining() {
//合并
String str = list.stream().map(People::getName).collect(Collectors.joining(","));
System.out.println(str);//名字1,名字2,名字3,名字4,名字5,名字6,名字1
}
map:映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Test
public void testMap() {
//map:接收一个函数作为参数,该函数会被应用到每个元素上,并将其映射成一个新的元素
List<Integer> ids1 = list.stream().map(People::getId).collect(Collectors.toList());
//去重复
List<Integer> ids2 = list.stream().map(People::getId).distinct().collect(Collectors.toList());
//转set去重复
Set<Integer> ids3 = list.stream().map(People::getId).collect(Collectors.toSet());

//给每个人的年龄加10岁
list.stream().map(a -> {
a.setAge(a.getAge() + 10);
return a;
}).collect(Collectors.toList());
//上面方法等价于下面这行代码
//peek的操作是返回一个新的stream的,且设计的初衷是用来debug调试的,因此使用steam.peek()必须对流进行一次处理再产生一个新的stream。
list.stream().peek(a -> a.setAge(a.getAge() + 10)).collect(Collectors.toList());

List<String> flatList = Arrays.asList("a,b,c,d", "e,f,g,h");
//flatMap:接收一个函数作为参数,将流中的每个值都换成另一个流,然后把所有流连接成一个流
List<String> newFlatList = flatList.stream().flatMap(a -> {
String[] strArr = a.split(",");
return Arrays.stream(strArr);
}).collect(Collectors.toList());
System.out.println(flatList.toString());
System.out.println(newFlatList.toString());
}
collect:收集
1
2
3
4
5
6
7
@Test
public void testCollect() {
//该合并函数有两个参数,第一个参数为当前重复key 之前对应的值,第二个为当前重复key 现在数据的值。
Map<String, People> nameMap = list.stream().collect(Collectors.toMap(People::getName, b -> b,
(oldValue, newValue) -> newValue));
System.out.println(nameMap.toString());
}

iterate:遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Test
public void testDistinct() {
String[] arr1 = {"a", "b", "c", "d"};
String[] arr2 = {"d", "e", "f", "g"};

List<String> strList = new ArrayList<>(Arrays.asList(arr1));

//of方法:有两个overload方法,一个接受变长参数,一个接口单一值
Stream<String> stream1 = Stream.of(arr1);
Stream<String> stream2 = Stream.of(arr2);

// concat:合并两个流
// distinct:去重
List<String> list = Stream.concat(stream1, stream2).distinct().collect(Collectors.toList());
System.out.println(list);

// limit:限制从流中获得前n个数据
List<String> list2 = strList.stream().limit(2).collect(Collectors.toList());
System.out.println(list2.toString());

// skip:跳过前n个数据
List<String> list3 = strList.stream().skip(1).limit(2).collect(Collectors.toList());
System.out.println(list3.toString());

//generator方法:生成一个无限长度的Stream,其元素的生成是通过给定的Supplier(这个接口可以看成一个对象的工厂,每次调用返回一个给定类型的对象)
Stream.generate(() -> Math.random());
Stream.generate(Math::random);

// iterate:遍历
// iterate方法:也是生成无限长度的Stream,和generator不同的是,其元素的生成是重复对给定的种子值(seed)调用用户指定函数来生成的。
//其中包含的元素可以认为是:seed
//先获取一个无限长度的正整数集合的Stream,然后取出前10个打印。
// 切记一定要使用limit方法,不然会无限打印下去。
List<Integer> list4 = Stream.iterate(1, x -> x + 2).skip(1).limit(10).collect(Collectors.toList());
System.out.println(list4);
}

说起逃逸分析(Escape Analysis),我们可能会觉得很陌生,之所以陌生的原因是因为这项技术还不是很成熟,其内部分析过程的复杂度也超乎我们的想象,早在JDK6,HotSpot就已经支持了逃逸分析,由于逃逸分析的计算成本之高,甚至不能保证带来的性能优化收益高于它的消耗。一直到JDK7,这项优化配置才被默认打开。

首先我们可以明确逃逸分析是一项优化技术。也许你曾经听说过,但凡new出来的对象肯定都在堆上,但是有了逃逸分析之后,就不一定了。

其优化原理是:分析对象的动态作用域,当一个对象在函数里面定义后,它可能会被外部的函数所引用,例如对象返回值作为参数再传递到其他函数中,这种对象逃出了自己函数的行为,称为方法逃逸;再有除了被别的函数引用外,可能还会被别的线程所访问,比如可以赋值给其他线程中访问的实例变量,这种称为线程逃逸。从不逃逸,方法逃逸到线程逃逸,我们称为对象由低到高的不同程度逃逸。

如果一个对象不会逃逸到方法或者线程外,换句话说别的方法或线程无法通过任何途径访问到这个对象,或者逃逸程度比较低(只逃出了方法,但是没有逃出线程),那么可能会对这个对象进行不同程度的优化:

栈上分配

在java虚拟机中,创建的对象都被分配在java堆中,堆中的对象是各个线程共享和可见的。等到没人引用对象后,虚拟机的垃圾回收子系统就会回收该对象,但是无论是标记筛选还是标记整理,垃圾回收都需要消耗大量的资源。如果我们可以确定一个对象不会逃逸出线程,那么这个对象在栈上分配也是一个不错的选择。对象所占用的内存就会随着栈帧出栈而销毁。一般情况下,这种不逃逸的情况所占比例是非常大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而销毁了,垃圾收集的压力也会下降很多。这种分配方式支持方法逃逸,但是不支持线程逃逸。

比如:

1
2
3
4
5
6
//这种情况,对象就是没有逃逸出方法
public void getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
}
1
2
3
4
5
6
7
//这种情况,对象就逃逸出方法了
public Point getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
return point;
}
标量替换

如果一个数据已经无法再分成更小的数据来表示了,比如原始类型(int,long等数值类型以及reference类型等)都不能再分解了,这种数据就可以称为标量。相对的,如果一个数据还可以被分解,那就是聚合量。对象就是一个典型的聚合量。如果我们根据程序情况,把一个对象分解,将用到的成员变量都替换为原始类型,这个过程就称为标量替换。如果一个对象没有逃逸出方法,而且还可以被分解,那么我们程序在执行过程中就可以不去创建这个对象。标量替换可以被视作栈上分配的一种特例,他要求对象不能逃逸出方法外。

比如:

1
2
3
4
5
6
7
//这种情况,就可以使用标量替换。
public String getPoint(){
Point point = new Point();
point.setX(1);
point.setY(1);
reutn "x:"+point.getX()+";y:"+point.getY();
}
1
2
3
4
5
6
//这种情况,就可以使用标量替换。
public String getPoint(){
int x = 1;
int y = 1;
reutn "x:"+point.getX()+";y:"+point.getY();
}
同步消除

如果我们可以确定一个变量不会逃逸出线程,即线程私有,那么这个变量的读写肯定就不会有竞争,对这个变量实施同步的措施也就可以安全的消除掉。

在这里我们引用一下锁消除的相关知识点。我们看下面这个代码:

1
2
3
public String concatStr(String s1, String s2, String s3, String s4) {
return s1 + s2 + s3 + s4;
}

现在我们都已经知道,由于String是个final类型的不可变类。对字符串的操作总是会生成出新的String对象来进行的。因为javac会对String的字符串操作进行优化,加法会自动转为StringBuilder对象的连续append()操作。但是在JDK5之前,并不是这样的,这个操作会被转成StringBuffer。即下面这样的代码

1
2
3
4
5
6
7
8
public String concatString(String s1, String s2, String s3, String s4) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
sb.append(s4);
return sb.toString();
}

这段代码其实是有同步的,即每一次append都会对sb进行锁定,经过逃逸分析后,我们发现sb的引用其实根本无法逃出线程,所以,这个地方虽然是有锁的,但是这个锁是完全没有必要的。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。

这里我们在扩展一个知识点,对于StringBuffer的append,如果频繁的进行互斥同步操作,甚至加锁操作在循环内部,这会导致很多不必要的性能消耗。所以虚拟机会对锁进行粗化,就是扩展到第一个append操作之前直至最后一个append操作之后,这样只需要加锁一次就可以了。

最后我们说一下逃逸分析的历史

引自深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)(周志明)

关于逃逸分析的研究论文早在1999年就已经发表,但直到JDK 6,HotSpot才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一个弱项。在现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型,目的是实现与C#中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。

对于栈上分配这种优化,由于复杂度等原因,HotSpot中目前暂时还没有做这项优化,但一些其他的虚拟机(如Excelsior JET)使用了这项优化。

尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。

泛型的诞生

在2004年,Java和C#两门语言于同一年更新了一个重要的大版本,即Java 5.0和C#2.0,在这个大版本中,两门语言又不约而同地各自添加了泛型的语法特性。不过,两门语言对泛型的实现方式却选择了截然不同的路径。本来Java和C#天生就存在着比较和竞争,泛型这个两门语言在同一年、同一个功能上做出的不同选择,自然免不了被大家对比审视一番,其结论是Java的泛型直到今天依然作为Java语言不如C#语言好用的“铁证”被众人嘲讽。我们介绍Java泛型时,并不会去尝试推翻这个结论,相反甚至还会去举例来揭示Java泛型的缺陷所在,但同时也必须向不了解Java泛型机制和历史的读者说清楚,Java选择这样的泛型实现,是出于当时语言现状的权衡,而不是语言先进性或者设计者水平不如C#之类的原因。

Java与C#的泛型

Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),而C#选择的泛型实现方式是“具现化式泛型”(Reified Generics)。具现化和特化、偏特化这些名词最初都是源于C++模版语法中的概念,如果我们本身不使用C++的话,在阅读中可不必太纠结其概念定义,把它当一个技术名词即可,只需要知道C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的,List与List就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。而Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList与ArrayList其实是同一个类型,由此我们可以想象“类型擦除”这个名字的含义和来源。

读者虽然无须纠结概念,但却要关注这两种实现方式会给使用者带来什么样的影响。Java的泛型确实在实际使用中会有一些限制,如果读者是一名C#开发人员,可能很难想象下面的代码在在Java中都是不合法的。

1
2
3
4
5
6
7
8
9
public class TypeErasureGenerics<E> {
public void doSomething(Object item) {
if (item instanceof E) { // 不合法,无法对泛型进行实例判断
...
}
E newItem = new E(); // 不合法,无法使用泛型创建对象
E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组
}
}

上面这些是Java泛型在编码阶段产生的不良影响,如果说这种使用层次上的差别还可以通过多写几行代码、方法中多加一两个类型参数来解决的话,性能上的差距则是难以用编码弥补的。C#2.0引入了泛型之后,带来的显著优势之一便是对比起Java在执行性能上的提高,因为在使用平台提供的容器类型(如List,Dictionary<TKey,TValue>)时,无须像Java里那样不厌其烦地拆箱和装箱,如果在Java中要避免这种损失,就必须构造一个与数据类型相关的容器类(譬如IntFloatHashMap这样的容器)。显然,这除了引入更多代码造成复杂度提高、复用性降低之外,更是丧失了泛型本身的存在价值。

Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型,而它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。但这种听起来节省工作量甚至可以说是有偷工减料嫌疑的优势就显得非常短视,真的能在当年Java实现泛型的利弊权衡中胜出吗?答案的确是它胜出了,但我们必须在那时的泛型历史背景中去考虑不同实现方式带来的代价。

泛型的历史背景

泛型思想早在C++语言的模板(Template)功能中就开始生根发芽,而在Java语言中加入泛型的首次尝试是出现在1996年。Martin Odersky(后来Scala语言的缔造者)当时是德国卡尔斯鲁厄大学编程理论的教授,他想设计一门能够支持函数式编程的程序语言,又不想从头把编程语言的所有功能都再做一遍,所以就注意到了刚刚发布一年的Java,并在它上面实现了函数式编程的3大特性:泛型、高阶函数和模式匹配,形成了Scala语言的前身Pizza语言。后来,Java的开发团队找到了Martin Odersky,表示对Pizza语言的泛型功能很感兴趣,他们就一起建立了一个叫作“Generic Java”的新项目,目标是把Pizza语言的泛型单独拎出来移植到Java语言上,其最终成果就是Java 5.0中的那个泛型实现,但是移植的过程并不是一开始就朝着类型擦除式泛型去的,事实上Pizza语言中的泛型更接近于现在C#的泛型。Martin Odersky自己在采访自述中提到,进行Generic Java项目的过程中他受到了重重约束,甚至多次让他感到沮丧,最紧、最难的约束来源于被迫要完全向后兼容无泛型Java,即保证“二进制向后兼容性”(Binary Backwards Compatibility)。二进制向后兼容性是明确写入《Java语言规范》中的对Java使用者的严肃承诺,譬如一个在JDK 1.2中编译出来的Class文件,必须保证能够在JDK 12乃至以后的版本中也能够正常运行。这样,既然Java到1.4.2版之前都没有支持过泛型,而到 Java 5.0突然要支持泛型了,还要让以前编译的程序在新版本的虚拟机还能正常运行,就意味着以前没有的限制不能突然间冒出来。

举个例子,在没有泛型的时代,由于Java中的数组是支持协变(Covariant)的,对应的集合类也可以存入不同类型的元素,类似于下面这样的代码尽管不提倡,但是完全可以正常编译成Class文件。

1
2
3
4
5
Object[] array = new String[10];
array[0] = 10; // 编译期不会有问题,运行时会报错
ArrayList things = new ArrayList();
things.add(Integer.valueOf(10)); //编译、运行时都不会报错
things.add("hello world");

为了保证这些编译出来的Class文件可以在Java 5.0引入泛型之后继续运行,设计者面前大体上有两条路可以选择:

  • 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。
  • 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于 已有类型的泛型版。

在这个分叉路口,C#走了第一条路,添加了一组System.Collections.Generic的新容器,以前的System.Collections以及System.Collections.Specialized容器类型继续存在。C#的开发人员很快就接受了新的容器,倒也没出现过什么不适应的问题,唯一的不适大概是许多.NET自身的标准库已经把老容器类型当作方法的返回值或者参数使用,这些方法至今还保持着原来的老样子。

但如果相同的选择出现在Java中就很可能不会是相同的结果了,要知道当时.NET才问世两年,而Java已经有快十年的历史了,再加上各自流行程度的不同,两者遗留代码的规模根本不在同一个数量级上。而且更大的问题是Java并不是没有做过第一条路那样的技术决策,在JDK 1.2时,遗留代码规模尚小,Java就引入过新的集合类,并且保留了旧集合类不动。这导致了直到现在标准类库中还有Vector(老)和ArrayList(新)、有Hashtable(老)和HashMap(新)等两套容器代码并存,如果当时再摆弄出像Vector(老)、ArrayList(新)、Vector(老但有泛型)、ArrayList(新且有泛型)这样的容器集合,可能叫骂声会比今天听到的更响更大。

到了这里,相信我们已经能稍微理解为什么当时Java只能选择第二条路了。但第二条路也并不意味着一定只能使用类型擦除来实现,如果当时有足够的时间好好设计和实现,是完全有可能做出更好的泛型系统的,否则也不会有今天的Valhalla项目来还以前泛型偷懒留下的技术债了。下面我们就来看看当时做的类型擦除式泛型的实现时到底哪里偷懒了,又带来了怎样的缺陷。

类型擦除

我们继续以ArrayList为例来介绍Java泛型的类型擦除具体是如何实现的。由于Java选择了第二条路,直接把已有的类型泛型化。要让所有需要泛型化的已有类型,譬如ArrayList,原地泛型化后变成了ArrayList,而且保证以前直接用ArrayList的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如ArrayList、ArrayList这些全部自动成为ArrayList的子类型才能可以,否则类型转换就是不安全的。由此就引出了“裸类型”(Raw Type)的概念,裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type),只有这样,像下面代码示例(裸类型赋值)的赋值才是被系统允许的从子类到父类的安全转型。

1
2
3
4
5
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;

接下来的问题是该如何实现裸类型。这里又有了两种选择:一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList这样的类型,并且自动实现从ArrayList派生自ArrayList的继承关系来满足裸类型的定义;另外一种是索性简单粗暴地直接在编译时把ArrayList还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令,这样看起来也是能满足需要,这两个选择的最终结果大家已经都知道了。下面代码是一段简单的Java泛型例子,我们可以看一下它编译后的实际样子是怎样的。

1
2
3
4
5
6
7
8
//泛型擦除前
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}

把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从Object到String的强制转型代码,如下所示:

1
2
3
4
5
6
7
8
//泛型擦除后
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}

类型擦除带来的缺陷前面已经提到过一些,为了系统性地讲述,我们在此再举3个例子,把前面与 C#对比时简要提及的擦除式泛型的缺陷做更具体的说明。

首先,使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦,譬如下面这段代码:

1
2
3
4
5
6
//原始类型的泛型(目前的Java不支持)
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;

这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持int、long与Object之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴:既然没法转换那就索性别支持原生类型的泛型了吧,你们都用ArrayList、ArrayList,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了得了。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为Java泛型慢的重要原因,也成为今天Valhalla项目要重点解决的问题之一。

第二,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦,譬如上面罗列的几种Java不支持的泛型用法,都是由于运行期Java虚拟机无法取得泛型类型而导致的。像下面代码示例这样,我们去写一个泛型版本的从List到数组的转换方法,由于不能从List中取得参数化类型T,所以不得不从一个额外参数中再传入一个数组的组件类型进去,实属无奈。

1
2
3
4
public static <T> T[] convert(List<T> list, Class<T> componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}

最后,我们认为通过擦除法来实现泛型,还丧失了一些面向对象思想应有的优雅,带来了一些模棱两可的模糊状况,例如下面的例子:

1
2
3
4
5
6
7
8
9
//当泛型遇见重载1
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
}

今天我们都知道这段代码是不能被编译的,因为参数List和List编译之后都被擦除了,变成了同一种的裸类型List,类型擦除导致这两个方法的特征签名变得一模一样。初步看来,无法重载的原因已经找到了,但是真的就是如此吗?其实这个例子中泛型擦除成相同的裸类型只是无法重载的其中一部分原因,请再接着看一看下面这个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//当泛型遇见重载2 JDK6可以执行
public class GenericTypes {
public static String method(List<String> list) {
System.out.println("invoke method(List<String> list)");
return "";
}
public static int method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
return 1;
}
public static void main(String[] args) {
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
}

运行结果:

1
2
invoke method(List<String> list)
invoke method(List<Integer> list)

这两种重载方式的区别,是两个method()方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行(JDK6)了。这是我们对Java语言中返回值不参与重载选择的基本认知的挑战吗?

示例2中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个method()方法加入了不同的返回值后才能共存在一个Class文件之中。方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在Class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个Class文件中的。

由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,如在泛型类中如何获取传入的参数化类型等。所以JCP组织对《Java虚拟机规范》做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别49.0以上版本的Class文件的虚拟机都要能正确地识别Signature参数。

从上面的例子中可以看到擦除法对实际编码带来的不良影响,由于List和List擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案,并且存在一定语意上的混乱,譬如上面提到的,必须用JDK 6的Javac才能编译成功,其他版本或者是ECJ编译器都有可能拒绝编译。

另外,从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

值类型与未来的泛型

在2014年,刚好是Java泛型出现的十年之后,Oracle建立了一个名为Valhalla的语言改进项目,希望改进Java语言留下的各种缺陷(解决泛型的缺陷就是项目主要目标其中之一)。原本这个项目是计划在JDK 10中完成的,但是直到今日也只有少部分目标(譬如VarHandle)顺利实现并发布出去。它现在的技术预览版LW2(L-World 2)是基于未完成的JDK 14 EarlyAccess来运行的,所以在泛型很可能在将来会发生变动。

在Valhalla项目中规划了几种不同的新泛型实现方案,被称为Model 1到Model 3,在这些新的泛型设计中,泛型类型有可能被具现化,也有可能继续维持类型擦除以保持兼容(取决于采用哪种实现方案),即使是继续采用类型擦除的方案,泛型的参数化类型也可以选择不被完全地擦除掉,而是相对完整地记录在Class文件中,能够在运行期被使用,也可以指定编译器默认要擦除哪些类型。相对于使用不同方式实现泛型,目前比较明确的是未来的Java应该会提供“值类型”(Value Type)的语言层面的支持。

说起值类型,这点也是C#用户攻击Java语言的常用武器之一,C#并没有Java意义上的原生数据类型,在C#中使用的int、bool、double关键字其实是对应了一系列在.NET框架中预定义好的结构体(Struct),如Int32、Boolean、Double等。在C#中开发人员也可以定义自己值类型,只要继承于ValueType类型即可,而ValueType也是统一基类Object的子类,所以并不会遇到Java那样int不自动装箱就无法转型为Object的尴尬。

值类型可以与引用类型一样,具有构造函数、方法或是属性字段,等等,而它与引用类型的区别在于它在赋值的时候通常是整体复制,而不是像引用类型那样传递引用的。更为关键的是,值类型的实例很容易实现分配在方法的调用栈上的,这意味着值类型会随着当前方法的退出而自动释放,不会给垃圾收集子系统带来任何压力。

在Valhalla项目中,Java的值类型方案被称为“内联类型”,计划通过一个新的关键字inline来定义,字节码层面也有专门与原生类型对应的以Q开头的新的操作码(譬如iload对应qload)来支撑。现在的预览版可以通过一个特制的解释器来保证这些未来可能加入的字节码指令能够被执行,要即时编译的话,现在只支持C2编译器。即时编译器场景中是使用逃逸分析优化来处理内联类型的,通过编码时标注以及内联类实例所具备的不可变性,可以很好地解决逃逸分析面对传统引用类型时难以判断(没有足够的信息,或者没有足够的时间做全程序分析)对象是否逃逸的问题。

泛型的使用
泛型类

将泛型定义在类上,使用的时候才确定下来

最常见的就是DAO

1
2
3
4
5
6
public interface BaseMapper<T> extends Mapper<T> {
/**
* 根据 ID 查询
*/
T select(Serializable id);
}
泛型方法

将泛型定义在方法上

比如上面提到的将List转为Array的示例

1
2
3
4
public static <T> T[] convert(List<T> list, Class<T> componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}
泛型通配符

如下代码:

1
2
3
public void show(List list){
list.get(1);
}

如上面所说,泛型的开发者们为了兼容历史,这段代码是允许的,但是这段代码并不优雅。那怎么办呢,改成List<Object> list吗?首先我们需要明确,泛型中的Object并没有继承关系 所以List<Object> listList<String> list是没有任何关系的。那我们怎么办呢,List list 表示并不指定集合中的元素,既不是Object也不是String,于是泛型给我们提供了通配符?,即List<?> list。了解了通配符后,我们再来看下面这个示例:

1
2
3
4
public static void show(List<?> list){
list.size();
list.add(2);
}

这段代码看上去没有问题,但是实际上是无法编译的。问题出在了add方法上,如果我们去掉add方法,那这段代码是没有问题的,那为什么add不行,size就没问题呢。这里就对通配符有一个约定了只能调用对象与类型无关的方法,不能调用对象与类型有关的方法

因为我们并不清楚list到底是个什么类型,所以我们无法往list中添加一个Integer类型的数据。

通配符上限

有了通配符后,我们的函数就变的很危险了,因为无法预知泛型的具体类型,这时我们就用到了通配符上限了,我让这个list中只能使用Integer,Float等类型的数字。

1
2
3
public static void show(List<? extends Number> list){
...
}

这个代码就表示:list集合的元素只能是Number或者Number的子类

通配符下限

有了上限后,对应的java还给我们提供了下限。

比如我们要给一个Integer的集合进行排序,那么我们可以这么写

1
2
List<Integer> list = new ArrayList<>(Arrays.asList(9,6,5,4,1,8,7,2,3));
list.sort(Comparator.comparingInt(a -> a));

我们来看一下sort方法, 里面的Comparator<? super E> c参数,E是我们List集合的类型,当前是Integer,那这个就可以表示为Comparator<? super Integer> c,这个代码就表示Comparator的元素只能是E或者E的父类,这里的E就是Integer了。

1
2
3
4
5
6
7
8
9
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray();
Arrays.sort(a, (Comparator) c);
ListIterator<E> i = this.listIterator();
for (Object e : a) {
i.next();
i.set((E) e);
}
}

所以,我们除了使用Comparator给我们提供的一些默认方法外,还可以这么写。

1
2
3
4
5
6
static class DescCompare implements Comparator<Integer>{
@Override
public int compare(Integer i1, Integer i2) {
return i1 - i2;
}
}

因为Comparator的元素可以是Integer的父类,那么我们还可以写成Comparator<Number>。但是如果我们写成Comparator<Double>就不行了,不符合我们的泛型约定。

类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

image-20201126193219983

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里我们说的是按部就班地“开始”,而不是按部就班地“进行”或按部就班地“完成”,强调这点是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了==有且只有六种情况==必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    1. 使用new关键字实例化对象的时候。
    2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    3. 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

被动引用的例子之一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package effectiveJava.classloading;
/**
* 被动使用类字段演示一:
* 通过子类引用父类的静态字段,不会导致子类初始化
**/
public class SuperClass {
public static int value = 123;
static {
System.out.println("Super.......");
}
}


package effectiveJava.classloading;

public class SubClass extends SuperClass{
static {
System.out.println("Sub......");
}
}


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

运行结果:

1
2
Super.......
123

上述代码运行之后,只会输出“Super…….”,而不会输出“Sub……”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。至于是否要触发子类的加载和验证阶段,这点取决于虚拟机的具体实现。对于HotSpot虚拟机来说,可通过-XX:+TraceClassLoading参数观察到此操作是会导致子类加载的。

被动引用的例子之二:

1
2
3
4
5
6
7
8
9
10
11
package effectiveJava.classloading;
/**
* 被动使用类字段演示二:
* 通过数组定义来引用类,不会触发此类的初始化
**/

public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}

运行结果:空

运行之后发现没有输出“Super…….”,说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。但是这段代码里面触发了另一个名为“[Lorg.fenixsoft.classloading.SuperClass”的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。Java语言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问,而C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

准确地说,越界检查不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中。

被动引用的例子之三:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package effectiveJava.classloading;
/**
* 被动使用类字段演示三:
* 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的
类的初始化
**/
public class SuperClass {
public static final String HELLOWORLD = "hello world";

static {
System.out.println("Super.......");
}
}

package effectiveJava.classloading;

public class SubClass extends SuperClass{
static {
System.out.println("Sub......");
}
}

package effectiveJava.classloading;
public class NotInitialization {
public static void main(String[] args) {

System.out.println(SuperClass.HELLOWORLD);
}
}

运行结果:hello world

上述代码运行之后,也没有输出“Super…….”,这是因为虽然在Java源码中确实引用了ConstClass类的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”直接存储在NotInitialization类的常量池中,以后NotInitialization对常量ConstClass.HELLOWORLD的引用,实际都被转化为NotInitialization类对自身常量池的引用了。也就是说,实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class文件后就已不存在任何联系了。

接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:接口也有初始化过程,这点与类是一致的,上面的代码都是用静态语句块“static{}”来输出初始化信息的,而接口中不能使用“static{}”语句块,但编译器仍然会为接口生成“()”类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类加载的过程
加载

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段。在加载阶段,Java虚拟机需要完成以下三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

《Java虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是相当大的。例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流必须得从某个Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。仅仅这一点空隙,Java虚拟机的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础之上,例如:

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  • 从网络中获取,这种场景最典型的应用就是Web Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
  • 由其他文件生成,典型场景是JSP应用,由JSP文件生成对应的Class文件。
  • 从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
  • 可以从加密文件中获取,这是典型的防Class文件被反编译的保护措施,通过加载时解密Class文件来保障程序运行逻辑不被窥探。

相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,一个类型必须与类加载器一起确定唯一性)。
  • 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java语言本身是相对安全的编程语言(起码对于C/C++来说是相对安全的),使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译。但前面也曾说过,Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出Class文件在内的任何途径产生。上述Java代码无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。但是《Java虚拟机规范》的早期版本(第1、2版)对这个阶段的检验指导是相当模糊和笼统的,规范中仅列举了一些对Class文件格式的静态和结构化的约束,要求虚拟机验证到输入的字节流如不符合Class文件格式的约束,就应当抛出一个java.lang.VerifyError异常或其子类异常,但具体应当检查哪些内容、如何检查、何时进行检查等,都没有足够具体的要求和明确的说明。直到2011年《Java虚拟机规范(Java SE 7版)》出版,规范中大幅增加了验证过程的描述(篇幅从不到10页增加到130页),这时验证阶段的约束和验证规则才变得具体起来。从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

  1. 文件格式验证

    第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:

    • 是否以魔数0xCAFEBABE开头。
    • 主、次版本号是否在当前Java虚拟机接受范围之内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。
    • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。
    • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。
    • ……

    实际上第一阶段的验证点还远不止这些,上面所列的只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

  2. 元数据验证

    第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:

    • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
    • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
    • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
    • ……

    第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。

  3. 字节码验证

    第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,例如:

    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中”这样的情况。
    • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。
    • 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
    • ……

    如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。即使字节码验证阶段中进行了再大量、再严密的检查,也依然不能保证这一点。这里涉及了离散数学中一个很著名的问题——“停机问题”(Halting Problem),即不能通过程序准确地检查出程序是否能在有限的时间之内结束运行。在我们讨论字节码校验的上下文语境里,通俗一点的解释是通过程序去校验程序逻辑是无法做到绝对准确的,不可能用程序来准确判定一段程序是否存在Bug。

    由于数据流分析和控制流分析的高度复杂性,Java虚拟机的设计团队为了避免过多的执行时间消耗在字节码验证阶段中,在JDK6之后的Javac编译器和Java虚拟机里进行了一项联合优化,把尽可能多的校验辅助措施挪到Javac编译器里进行。具体做法是给方法体Code属性的属性表中新增加了一项名为“StackMapTable”的新属性,这项属性描述了方法体所有的基本块(Basic Block,指按照控制流拆分的代码块)开始时本地变量表和操作栈应有的状态,在字节码验证期间,Java虚拟机就不需要根据程序推导这些状态的合法性,只需要检查StackMapTable属性中的记录是否合法即可。这样就将字节码验证的类型推导转变为类型检查,从而节省了大量校验时间。理论上StackMapTable属性也存在错误或被篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验,则是虚拟机设计者们需要仔细思考的问题。

    JDK6的HotSpot虚拟机中提供了-XX:-UseSplitVerifier选项来关闭掉这项优化,或者使用参数XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。而到了JDK7之后,尽管虚拟机中仍然保留着类型推导验证器的代码,但是对于主版本号大于50(对应JDK6)的Class文件,使用类型检查来完成数据流分析校验则是唯一的选择,不允许再退回到原来的类型推导的校验方式。

    停机问题就是判断任意一个程序是否会在有限的时间之内结束运行的问题。如果这个问题可以在有限的时间之内解决,可以有一个程序判断其本身是否会停机并做出相反的行为。这时候显然不管停机问题的结果是什么都不会符合要求,所以这是一个不可解的问题。

  4. 符号引用验证

    最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
    • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。
    • ……

    符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java虚拟机将会抛出一个java.lang.IncompatibleClassChangeError的子类异常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。

关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为public static int value = 123;

那变量value在准备阶段过后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

image-20201209115833239

上面提到在“通常情况”下初始值是零值,那言外之意是相对的会有某些“特殊情况”:如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,假设上面类变量value的定义修改为public static final int value = 123;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。

解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用已经出现过多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic这17个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

类似地,对方法或者字段的访问,也会在解析阶段中对它们的可访问性(public、protected、private、)进行检查,至于其中的约束规则已经是Java语言的基本常识。

invokedynamic指令是在JDK7时加入到字节码中的,当时确实只为了做动态语言(如JRuby、Scala)支持,Java语言本身并不会用到它。而到了JDK8时代,Java有了Lambda表达式和接口的默认方法,它们在底层调用时就会用到invokedynamic指令,这时再提动态语言支持其实已不完全切合,我们就只把它当个代称吧。

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。

不过对于invokedynamic指令,上面的规则就不成立了。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符(Dynamically-Computed Call Site Specifier)”,这里“动态”的含义是指必须等到程序实际运行到这条指令时,解析动作才能进行。相对地,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码时就提前进行解析。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、 CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和 CONSTANT_InvokeDynamic_info 8种常量类型。下面我们将讲解前4种引用的解析过程,对于后4种,它们都和动态语言支持密切相关,由于Java语言本身是一门静态类型语言,在没有讲解清楚invokedynamic指令的语意之前,我们很难将它们直观地和现在的Java语言语法对应上。

  1. 类或接口的解析

    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:

    • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
    • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表该数组维度和元素的数组对象。
    • 如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

    针对上面第3点访问权限验证,在JDK9引入了模块化以后,一个public类型也不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限。

    如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:

    • 被访问类C是public的,并且与访问类D处于同一个模块。
    • 被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。
    • 被访问类C不是public的,但是它与访问类D处于同一个包中。

    在后续涉及可访问性时,都必须考虑模块间访问权限隔离的约束,即以上列举的3条规则。

  2. 字段解析

    要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:

    • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

    如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。

    以上解析规则能够确保Java虚拟机获得字段唯一的解析结果,但在实际情况中,Javac编译器往往会采取比上述规范更加严格一些的约束,譬如有一个同名字段同时出现在某个类的接口和父类当中,或者同时在自己或父类的多个接口中出现,按照解析规则仍是可以确定唯一的访问字段,但Javac编译器就可能直接拒绝其编译为Class文件。下面的示例,如果注释了Sub类中的“public static int A=4;”,接口与父类同时存在字段A,那Oracle公司实现的Javac编译器将提示“The field Sub.A is ambiguous”,并且会拒绝编译这段代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    package effectiveJava.classloading;

    public class FieldResolution {
    interface Interface0 {
    int A = 0;
    }
    interface Interface1 extends Interface0 {
    int A = 1;
    }
    interface Interface2 {
    int A = 2;
    }
    static class Parent implements Interface1 {
    public static int A = 3;
    }
    static class Sub extends Parent implements Interface2 {
    public static int A = 4;
    }
    public static void main(String[] args) {
    System.out.println(Sub.A);
    }
    }
  3. 方法解析

    方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:

    • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError异常。
    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

    最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

  4. 接口方法解析

    接口方法也是需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

    • 与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出java.lang.IncompatibleClassChangeError异常。
    • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。

    在JDK9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError异常。但在JDK9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError异常。

初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物,但我们非常有必要了解这个方法具体是如何产生的,以及<clinit>()方法执行过程中各种可能会影响程序运行行为的细节,这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作。

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,示例:

1
2
3
4
5
6
7
8
package effectiveJava.classloading;
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
// System.out.print(i); // 这句编译器会提示“非法向前引用” Illegal forward reference
}
static int i = 1;
}

<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<clinit>()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下示例,字段B的值将会是2而不是1。

1
2
3
4
5
6
7
8
9
10
11
12
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package effectiveJava.classloading;

public class DeadLoopClass {
static {
// 如果不加上这个if语句,编译器将提示“Initializer does not complete normally”
// 并拒绝编译
/**
* 解析1:因为static方法,是类初始化的时候进行的。如果没加if,那么就会进入while的死循环,虚拟机检查不通过。
* 解析2:没加if的时候,虚拟机只检查最外层代码,因为有while的死循环;如果加了if以后,就只单纯检查If语句,虚拟机不会检查到while的死循环语句块。
*/
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
}
class aa {
public static void main(String[] args) {
Runnable script = () -> {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}

运行结果,一条线程在死循环以模拟长时间操作,另外一条线程在阻塞等待:

1
2
3
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]init DeadLoopClass

jps:虚拟机进程状况工具

JDK的很多小工具的名字都参考了UNIX命令的命名方式,jps(JVM Process Status Tool)是其中的典型。除了名字像UNIX的ps命令之外,它的功能也和ps命令类似:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)。虽然功能比较单一,但它绝对是使用频率最高的JDK命令行工具,因为其他的JDK工具大多需要输入它查询到的LVMID来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(PID,Process Identifier)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就必须依赖jps命令显示主类的功能才能区分了。

jps命令格式:

jps [ options ] [ hostid ]

jps执行样例:

1
2
3
4
5
6
7
8
9
D:\workspace\hello-spring-boot>jps -l
23760 com.example.demo.DemoApplication
43120
90336 org.jetbrains.jps.cmdline.Launcher
74212 sun.tools.jps.Jps
78500 org.jetbrains.idea.maven.server.RemoteMavenServer36
90276 C:\Users\jiangpeng\Desktop\jd-gui-1.4.0.jar
45496 org.jetbrains.jps.cmdline.Launcher
93068 org.jetbrains.jps.cmdline.Launcher

jps还可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,参数hostid为RMI注册表中注册的主机名。jps的其他常用选项:

序号 选项 作用
1 -q 只输出LVMID,省略主类的名称
2 -m 输出虚拟机进程启动时传递给主类main()函数的参数
3 -l 输出主类的全名,如果进程执行的是jar包,输出jar包路径
4 -v 输出虚拟机进程启动时的JVM参数
jstat:虚拟机统计信息监视工具

jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时数据,在没有GUI图形界面、只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的常用工具。

jstat命令格式为:

jstat [ option vmid [interval[s|ms] [count]] ]

对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的;如果是远程虚拟机进程,那VMID的格式应当是:

[protocol:][//]lvmid[@hostname[:port]/servername]

参数interval和count代表查询间隔和次数,如果省略这2个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:

jstat -gc 2764 250 20

选项option代表用户希望查询的虚拟机信息,主要分为三类:类加载、垃圾收集、运行期编译状况。常用选项:

序号 选项 作用
1 -class 监视类装载、卸载数量、总空间以及类装载所耗费的时间
2 -gc 监视Java堆状况,包括Eden区、两个Survivor区、老年代、元空间等的容量、已用空间、GC时间合计等信息
3 -gccapacity 监视内容基本与-gc相同,但输出主要关注Java堆各个区域使用到的最大、最小空间
4 -gcutil 监视内容基本与-gc相同,但输出主要关注已使用的空间占总空间的百分比
5 -gccause 与-gcutil功能一样,但是会额外输出导致上一次GC产生的原因
6 -gcnew 监视新生代GC状况
7 -gcnewcapacity 监视内容基本与-gcnew相同,但输出主要关注使用到的最大、最小空间
8 -gcold 监视老年代GC状况
9 -gcoldcapacity 监视内容基本与-gcold相同,但输出主要关注使用到的最大、最小空间
10 -gcpermcapacity 输出永久代使用到的最大、最小空间(1.8已废弃这个选项,因为永久代被移除了)
11 -compiler 输出即时编译器编译过的方法、耗时等信息
12 -printcompilation 输出已经被即时编译的方法

我们用-gcutil来举个例子

1
2
3
D:\workspace\hello-spring-boot>jstat -gcutil 95804
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
40.63 0.00 68.56 53.81 93.47 91.87 2430 8.866 7 2.355 11.221

查询结果表明,新生代Eden区(E,表示Eden)使用了68.56%的空间,两个Survivor区(S0、S1,表示Survivor0、Survivor1)一个使用了40.63%,一个是0,老年代(O,表示Old)和元空间(M。表示MetaSpace)则分别使用了53.81%和93.47%的空间。程序运行以来共发生Minor GC(YGC,表示Young GC)2430次,总共耗时8.866秒;发生Full GC(FGC,表示Full GC)7次,Full GC共耗时(FGCT,Full GC Time)为2.355秒,所有GC总耗时(GCT,表示GC Time)11.221秒。CCS表示压缩使用比例。

jinfo:Java配置信息工具

jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了(如果只限于JDK6或以上版本的话,使用javaXX:+PrintFlagsFinal查看参数默认值也是一个很好的选择)。jinfo还可以使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来。这个命令在JDK5时期已经随着Linux版的JDK发布,当时只提供了信息查询的功能,JDK6之后,jinfo在Windows和Linux平台都有提供,并且加入了在运行期修改部分参数值的能力(可以使用-flag[+|-]name或者-flag name=value在运行期修改一部分运行期可写的虚拟机参数值)。在JDK6中,jinfo对于Windows平台功能仍然有较大限制,只提供了最基本的-flag选项。

jinfo命令格式:

jinfo [ option ] pid

jinfo执行样例:

1
2
D:\workspace\hello-spring-boot>jinfo -flag CMSInitiatingOccupancyFraction 95804
-XX:CMSInitiatingOccupancyFraction=-1
jmap:Java内存映像工具

jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。如果不使用jmap命令,要想获取Java堆转储快照也还有一些比较“暴力”的手段:譬如XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在内存溢出异常出现之后自动生成堆转储快照文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[Ctrl]+[Break]键让虚拟机生成堆转储快照文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“恐吓”一下虚拟机,也能顺利拿到堆转储快照。

jmap的作用并不仅仅是为了获取堆转储快照,它还可以查询finalize执行队列、Java堆和方法区的详细信息,如空间使用率、当前用的是哪种收集器等。

和jinfo命令一样,jmap有部分功能在Windows平台下是受限的,除了生成堆转储快照的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统中都可以使用之外,其余选项都只能在Linux/Solaris中使用。

jmap命令格式:

jmap [ option ] vmid

常用option选项:

序号 选项 作用
1 -dump 生成Java堆转储快照。格式为-dump:[live, ]format=b,file=,其中live自参数说明是否只dump出存活的对象
2 -finalizerinfo 显示在F-Queue中等待Finalizer线程执行finalize方法的对象。只在Linux和Solaris系统下有效
3 -heap 显示Java堆详细信息,如使用哪种收集器、参数配置、分代状况等。只在Linux和Solaris系统下有效
4 -histo 显示堆中对象统计信息,包括类、实例数量、合计容量
5 -permstat 以ClassLoader为统计口径显示永久代内存状态。只在Linux和Solaris系统下有效
6 -F 当虚拟机进行对-dump选项没有响应时,可使用这个选项强制生成dump快照。只在Linux和Solaris系统下有效

jmap执行样例:

1
jmap -dump:format=b,file=C:\Users\jiangpeng\Desktop\heap.hprof 95804 生dump文件
jhat:虚拟机堆转储快照分析工具

JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/Web服务器,生成堆转储快照的分析结果后,可以在浏览器中查看。不过实事求是地说,在实际工作中,除非手上真的没有别的工具可用,否则多数人是不会直接使用jhat命令来分析堆转储快照文件的,主要原因有两个方面。一是一般不会在部署应用程序的服务器上直接分析堆转储快照,即使可以这样做,也会尽量将堆转储快照文件复制到其他机器上进行分析,因为分析工作是一个耗时而且极为耗费硬件资源的过程,既然都要在其他机器上进行,就没有必要再受命令行工具的限制了。另外一个原因是jhat的分析功能相对来说比较简陋,后文将会介绍到的VisualVM,以及专业用于分析堆转储快照文件的Eclipse Memory Analyzer、IBM HeapAnalyzer等工具,都能实现 比jhat更强大专业的分析功能。

1
jhat C:\Users\jiangpeng\Desktop\heap.hprof 分析dump文件

我们最好在分析前,把hprof文件放到一个单独的文件夹中,因为分析过程中会产生很多文件

1
2
3
4
5
6
7
8
9
10
D:\workspace\hello-spring-boot>jhat C:\Users\jiangpeng\Desktop\11\heap.hprof
Reading from C:\Users\jiangpeng\Desktop\11\heap.hprof...
Dump file created Wed Nov 25 10:26:42 CST 2020
Snapshot read, resolving...
Resolving 50988 objects...
Chasing references, expect 10 dots..........
Eliminating duplicate references..........
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

image-20201125170633623

这个时候我们就可以用浏览器访问http://localhost:7000/了。

image-20201125170705645

分析结果默认以包为单位进行分组显示,分析内存泄漏问题主要会使用到其中的“Heap Histogram”(与jmap-histo功能一样)与OQL页签的功能,前者可以找到内存中总容量最大的对象,后者是标准的对象查询语言,使用类似SQL的语法对内存中的对象进行查询统计。

jstack:Java堆栈跟踪工具

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源。

jstack命令格式:

jstack [ option ] vmid

option选项:

序号 选项 作用
1 -F 当正常输出的请求不被响应时,强制输出线程堆栈
2 -l 除堆栈外,显示关于锁的附加信息
3 -m 如果调用到本地方法的时候,可以显示C/C++的堆栈

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
D:\workspace\hello-spring-boot>jstack -l 95804
2020-11-25 17:11:50
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.261-b12 mixed mode):

"Keep-Alive-Timer" #17065 daemon prio=8 os_prio=1 tid=0x000001299b327800 nid=0x81cc waiting on condition [0x0000001b941ff000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at sun.net.www.http.KeepAliveCache.run(KeepAliveCache.java:172)
at java.lang.Thread.run(Thread.java:748)

Locked ownable synchronizers:
- None

"XNIO-10 Accept" #1174 prio=5 os_prio=0 tid=0x000001299b32c000 nid=0x16258 runnable [0x0000001ba92fe000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.WindowsSelectorImpl$SubSelector.poll0(Native Method)
at sun.nio.ch.WindowsSelectorImpl$SubSelector.poll(WindowsSelectorImpl.java:296)
at sun.nio.ch.WindowsSelectorImpl$SubSelector.access$400(WindowsSelectorImpl.java:278)
at sun.nio.ch.WindowsSelectorImpl.doSelect(WindowsSelectorImpl.java:159)
at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
- locked <0x00000006c9f092e8> (a sun.nio.ch.Util$3)
- locked <0x00000006c9f092d8> (a java.util.Collections$UnmodifiableSet)
- locked <0x00000006c9efff80> (a sun.nio.ch.WindowsSelectorImpl)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
at org.xnio.nio.WorkerThread.run(WorkerThread.java:511)

Locked ownable synchronizers:
- None
·
·
·
"VM Thread" os_prio=2 tid=0x0000021afd95e000 nid=0xc5d8 runnable

"GC task thread#0 (ParallelGC)" os_prio=0 tid=0x0000021aeac4a000 nid=0x18ae8 runnable
·
·
·
"GC task thread#7 (ParallelGC)" os_prio=0 tid=0x0000021aeac55800 nid=0x15654 runnable

"VM Periodic Task Thread" os_prio=2 tid=0x0000021a87f30000 nid=0x17df4 waiting on condition

JNI global references: 1124

从JDK5起,java.lang.Thread类新增了一个getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码完成jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<%@ page import="java.util.Map" %>
<html>
<head>
<title>服务器线程信息</title>
</head>
<body>
<pre>
<%
for (Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStackTraces().entrySet()) {
Thread thread = (Thread) stackTrace.getKey();
StackTraceElement[] stack = (StackTraceElement[]) stackTrace.getValue();
if (thread.equals(Thread.currentThread())) {
continue;
}
System.out.print("\n线程:" + thread.getName() + "\n");
for (StackTraceElement element : stack) {
System.out.print("\t" + element + "\n");
}
}
%>
</pre>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
线程:http-nio-9000-exec-6
sun.misc.Unsafe.park(Native Method)
java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:107)
org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33)
java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
java.lang.Thread.run(Thread.java:748)

线程:RMI TCP Connection(2)-192.168.32.16
java.net.SocketInputStream.socketRead0(Native Method)
java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
java.net.SocketInputStream.read(SocketInputStream.java:171)
java.net.SocketInputStream.read(SocketInputStream.java:141)
java.io.BufferedInputStream.fill(BufferedInputStream.java:246)
java.io.BufferedInputStream.read(BufferedInputStream.java:265)
java.io.FilterInputStream.read(FilterInputStream.java:83)
sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:555)
sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:834)
sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:688)
sun.rmi.transport.tcp.TCPTransport$ConnectionHandler$$Lambda$93/795161234.run(Unknown Source)
java.security.AccessController.doPrivileged(Native Method)
sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:687)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
java.lang.Thread.run(Thread.java:748)
其他工具汇总

性能监控和故障处理:用于监控分析Java虚拟机运行信息

image-20201125172630917

附带两个常用命令

根据端口号查询 具体进程信息。
netstat -ano |findstr 8080
杀掉某个进程。(加-F强制杀除,英文意思为Force )。
taskkill -PID 7276 -F

随着jdk的更新,后面的jdk9以及之后的版本也逐渐出现各种可视化的性能监控工具,比如JHSDB:基于服务性代理的调试工具;JConsole:Java监视与管理控制台;VisualVM:多合-故障处理工具;Java Mission Control:可持续在线的监控工具等等等等。

在这一节开始前,我们先介绍总结一下垃圾收集器的一些参数

序号 参数 描述
1 -XX:+UseSerialGC Jvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
2 -XX:+UseParNewGC 打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收
3 -XX:+UseConcMarkSweepGC 使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用。
4 -XX:+UseParallelGC Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行回收
5 -XX:+UseParallelOldGC 使用Parallel Scavenge + Parallel Old的收集器组合进行回收 ==(JDK1.8默认)==
6 -XX:SurvivorRatio 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1
7 -XX:PretenureSizeThreshold 直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
8 -XX:MaxTenuringThreshold 晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代
9 -XX:UseAdaptiveSizePolicy 动态调整java堆中各个区域的大小以及进入老年代的年龄
10 -XX:+HandlePromotionFailure 是否允许新生代收集担保,进行一次minor gc后,另一块Survivor空间不足时,将直接会在老年代中保留
11 -XX:ParallelGCThreads 设置并行GC进行内存回收的线程数
12 -XX:GCTimeRatio GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge收集器时有效
13 -XX:MaxGCPauseMillis 设置GC的最大停顿时间,在Parallel Scavenge收集器下有效
14 -XX:CMSInitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70
15 -XX:+UseCMSCompactAtFullCollection 由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效
16 -XX:+CMSFullGCBeforeCompaction 设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用
17 -XX:+UseFastAccessorMethods 原始类型优化
18 -XX:+DisableExplicitGC 是否关闭手动System.gc
19 -XX:+CMSParallelRemarkEnabled 降低标记停顿
20 -XX:LargePageSizeInBytes 内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m

下面的例子我们都采用Parallel Scavenge + Parallel Old的收集器组合进行内存回收(即-XX:+UseParallelOldGC);真实情况,可能会各种各样,可以再具体分析。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

HotSpot虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际的问题排查中,收集器日志常会打印到文件后通过工具进行分析。

1
2
3
4
5
6
7
8
//VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
}
1
2
3
4
5
6
7
8
9
10
[GC (Allocation Failure) [PSYoungGen: 6738K->993K(9216K)] 6738K->1831K(19456K), 0.0029895 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
PSYoungGen total 9216K, used 7459K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 78% used [0x00000000ff600000,0x00000000ffc507b8,0x00000000ffe00000)
from space 1024K, 97% used [0x00000000ffe00000,0x00000000ffef8738,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 4933K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 48% used [0x00000000fec00000,0x00000000ff0d1640,0x00000000ff600000)
Metaspace used 3376K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 365K, capacity 388K, committed 512K, reserved 1048576K

我们来分析一下这个垃圾回收的日志信息

这里我们先说明下几个收集器日志

DefNew:是使用-XX:+UseSerialGC(新生bai代,老年代都使用串行回收收集器)。

并行收集器:

​ ParNew:是使用-XX:+UseParNewGC(新生代使用并行收集器,老年代使用串行回收收集器)或者-XX:+UseConcMarkSweepGC(新生代使用并行收集器,老年代使用CMS)。

​ PSYoungGen:是使用-XX:+UseParallelOldGC(新生代,老年代都使用并行回收收集器)或者-XX:+UseParallelGC(新生代使用并行回收收集器,老年代使用串行收集器)

​ garbage-first heap:是使用-XX:+UseG1GC(G1收集器)

1
2
     	回收前  回收后(新生代的总内存) 堆回收前  堆回收后(堆的总内存)
[PSYoungGen: 6738K->993K(9216K)] 6738K->1831K(19456K), 0.0029895 secs]

尝试分配三个2MB大小和一个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:Survivor-Ratio=8决定了新生代中Eden区与一个Survivor区的空间比例是8∶1,从输出的结果也清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

分配allocation4对象的语句时会发生一次Minor GC,这次回收的结果是新生代6738KB变为993KB,而总内存占用量则几乎没有减少(因为allocation1、2、3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。产生这次垃圾收集的原因是为allocation4分配内存时,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。垃圾收集期间虚拟机又发现已有的三个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

这次收集结束后,4MB的allocation4对象顺利分配在Eden中。因此程序执行完的结果是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、2、3占用)。

大对象直接进入老年代

大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组,例子中的byte[]数组就是典型的大对象。大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,我们写程序的时候应注意避免。在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。

1
2
3
4
5
6
//-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 
//-XX:+UseParNewGC
public static void main(String[] args) {
byte[] allocation1;
allocation1 = new byte[4 * _1MB]; // 出现一次Minor GC
}
1
2
3
4
5
6
7
8
Heap
par new generation total 9216K, used 7082K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 86% used [0x00000000fec00000, 0x00000000ff2ea820, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
concurrent mark-sweep generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 3455K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 373K, capacity 388K, committed 512K, reserved 1048576K
长期存活的对象将进入老年代

HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中(详见第2章)。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。

空间分配担保

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

解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

小结

垃圾收集器在许多场景中都是影响系统停顿时间和吞吐能力的重要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,就是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取最好的性能。没有固定收集器、参数组合,没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。

Serial收集器

Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。大家只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。“Stop The World”这个词语也许听起来很酷,但这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可知、不可控的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是不能接受的。读者不妨试想一下,要是你的电脑每运行一个小时就会暂停响应五分钟,你会有什么样的心情?Serial/Serial Old收集器的运行过程:

image-20201124145939547

对于“Stop The World”带给用户的恶劣体验,早期HotSpot虚拟机的设计者们表示完全理解,但也同时表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个工种,但实际上肯定还要比打扫房间复杂得多!

从JDK1.3开始,一直到现在最新的JDK13,HotSpot虚拟机开发团队为消除或者降低用户线程因垃圾收集而导致停顿的努力一直持续进行着,从Serial收集器到Parallel收集器,再到Concurrent Mark Sweep(CMS)和Garbage First(G1)收集器,最终至现在垃圾收集器的最前沿成果Shenandoah和ZGC等,我们看到了一个个越来越构思精巧,越来越优秀,也越来越复杂的垃圾收集器不断涌现,用户线程的停顿时间在持续缩短,但是仍然没有办法彻底消除(这里不去讨论RTSJ中的收集器),探索更优秀垃圾收集器的工作仍在继续。

看到这里,我们似乎已经把Serial收集器当成成一个最早出现,但目前已经老而无用,食之无味,弃之可惜的“鸡肋”了,但事实上,迄今为止,它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,有着优于其他收集器的地方,那就是简单而高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存,桌面应用甚少超过这个容量),垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。ParNew收集器的工作过程:

image-20201124150352263

ParNew收集器除了支持多线程并行收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前的遗留系统中首选的新生代收集器,其中有一个与功能、性能无关但其实很重要的原因是:除了Serial收集器外,目前只有它能与CMS收集器配合工作。

在JDK5发布时,HotSpot推出了一款在强交互应用中几乎可称为具有划时代意义的垃圾收集器——CMS收集器。这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程(基本上)同时工作。

遗憾的是,CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。

可以说直到CMS的出现才巩固了ParNew的地位,但成也萧何败也萧何,随着垃圾收集器技术的不断改进,更先进的G1收集器带着CMS继承者和替代者的光环登场。G1是一个面向全堆的收集器,不再需要其他新生代收集器的配合工作。所以自JDK9开始,ParNew加CMS收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了。官方希望它能完全被G1所取代,甚至还取消了ParNew加Serial Old以及Serial加CMS这两组收集器组合的支持(其实原本也很少人这样使用),并直接取消了XX:+UseParNewGC参数,这意味着ParNew和CMS从此只能互相搭配使用,再也没有其他收集器能够和它们配合了。我们也可以理解为从此以后,ParNew合并入CMS,成为它专门处理新生代的组成部分。ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器

ParNew收集器在单核心处理器的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。当然,随着可以被使用的处理器核心数量的增加,ParNew对于垃圾收集时系统资源的高效利用还是很有好处的。它默认开启的收集线程数与处理器核心数量相同,在处理器核心非常多(譬如32个,现在CPU都是多核加超线程设计,服务器达到或超过32个逻辑核心的情况非常普遍)的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

从ParNew收集器开始,后面还将会接触到若干款涉及“并发”和“并行”概念的收集器。在大家可能产生疑惑之前,有必要先解释清楚这两个名词。并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。

并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并未被冻结,所以程序仍然能响应服务请求,但由于垃圾收集器线程占用了一部分系统资源,此时应用程序的处理的吞吐量将受到一定影响。

除了一个面向低延迟一个面向高吞吐量的目标不一致外,技术上的原因是Parallel Scavenge收集器及后面提到的G1收集器等都没有使用HotSpot中原本设计的垃圾收集器的分代框架,而选择另外独立实现。

Parallel Scavenge收集器

Parallel Scavenge收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器……Parallel Scavenge的诸多特性从表面上看和ParNew非常相似,那它有什么特别之处呢?

Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
$$
吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}
$$
如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

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

-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的: 系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。如果我们对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。Serial Old收集器的工作过程:

image-20201124152153672

需要说明一下,Parallel Scavenge收集器架构中本身有PS MarkSweep收集器来进行老年代收集,并非直接调用Serial Old收集器,但是这个PS MarkSweep收集器与Serial Old的实现几乎是一样的,所以在官方的许多资料中都是直接以Serial Old代替PS MarkSweep进行讲解,这里我们也采用这种方式。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。这个收集器是直到JDK6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于相当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合,在注重吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。Parallel Old收集器的工作过程:

image-20201124152558530

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:初始标记(CMS initial mark),并发标记(CMS concurrent mark),重新标记(CMS remark),并发清除(CMS concurrent sweep)。

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

image-20201124152915603

CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:

首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从JDK7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK9发布后iCMS模式被完全废弃。

然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

还有最后一个缺点,CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

Garbage First收集器(G1)

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。早在JDK7刚刚确立项目目标、Oracle公司制定的JDK7 RoadMap里面,G1收集器就被视作JDK7中HotSpot虚拟机的一项重要进化特征。从JDK6 Update14开始就有Early Access版本的G1收集器供开发人员实验和试用,但由此开始G1收集器的“实验状态”(Experimental)持续了数年时间,直至JDK7 Update4,Oracle才认为它达到足够成熟的商用程度,移除了“Experimental”的标识;到了JDK8 Update40的时候,G1提供并发的类卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的G1收集器才被Oracle官方称为“全功能的垃圾收集器”(Fully-Featured Garbage Collector)。

G1是一款主要面向服务端应用的垃圾收集器。HotSpot开发团队最初赋予它的期望是(在比较长期的)未来可以替换掉JDK5中发布的CMS收集器。现在这个期望目标已经实现过半了,JDK9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。

但作为一款曾被广泛运用过的收集器,经过多个版本的开发迭代后,CMS(以及之前几款收集器)的代码与HotSpot的内存管理、执行、编译、监控等子系统都有千丝万缕的联系,这是历史原因导致的,并不符合职责分离的设计原则。为此,规划JDK10功能目标时,HotSpot虚拟机提出了“统一垃圾收集器接口”,将内存回收的“行为”与“实现”进行分离,CMS以及其他收集器都重构成基于这套接口的一种实现。以此为基础,日后要移除或者加入某一款收集器,都会变得容易许多,风险也可以控制,这算是在为CMS退出历史舞台铺下最后的道路了。

作为CMS收集器的替代者和继承人,设计者们希望做出一款能够建立起“停顿时间模型”(Pause Prediction Model)的收集器,停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RTSJ)的中软实时垃圾收集器特征了。

那具体要怎么做才能实现这个目标呢?首先要有一个思想上的改变,在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。

image-20201124154407777

虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

G1将堆内存“化整为零”的“解题思路”,看起来似乎没有太多令人惊讶之处,也完全不难理解,但其中的实现细节可是远远没有想象中那么简单,否则就不会从2004年Sun实验室发表第一篇关于G1的论文后一直拖到2012年4月JDK7 Update4发布,用将近10年时间才倒腾出能够商用的G1收集器来。G1收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:

譬如,将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?解决的思路我们已经知道:使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法我们已经讲过:CMS收集器采用增量更新算法实现,而G1收集器则是通过原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中 的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

譬如,怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的运作过程大致可划分为以下四个步骤:

  1. 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

从上述阶段的描述可以看出,G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。

从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。

image-20201124160239148

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。

G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”。在未来,G1收集器最终还是要取代CMS的,而当下它们两者并存的时间里,分个高低优劣就无可避免。

相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

不过,G1相对于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。

在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。

引用计数算法

很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;这种说法是错误的,

引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例,例如微软COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。

举个简单的例子:对象objA和objB都有字段instance,赋值令objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。所以Java虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

image-20201119170850993

如图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

引用

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。在JDK1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象——很多系统的缓存功能都符合这样的应用场景。

在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

  1. 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  2. 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。
  3. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。
  4. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。
finalize

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

下面这个例子我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package effectiveJava.functionTest;

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive() {
System.out.println("yes, i am still alive;");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
SAVE_HOOK = this;
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;

System.gc();

// 因为finalize方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no , i am dead");
}

}
}

运行结果:

1
2
finalize method executed!
yes, i am still alive;

如果我们接着在main方法后面再写一遍,那它就会被回收了

1
2
3
4
5
6
7
8
9
10
11
//下面这段代码与上面的完全相同,但是这次自救却失败了。
//这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临
//下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no , i am dead");
}

运行结果:

1
2
3
finalize method executed!
yes, i am still alive;
no , i am dead

有一点需要特别说明,上面关于对象死亡时finalize()方法的描述可能带点悲情的艺术加工,我们并不鼓励大家使用这个方法来拯救对象。相反,建议大家尽量避免使用它,因为它并不能等同于C和C++语言中的析构函数,而是Java刚诞生时为了使传统C、C++程序员更容易接受Java所做出的一项妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。有些教材中描述它适合做“关闭外部资源”之类的清理性工作,这完全是对finalize()方法用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉Java语言里面的这个方法。

回收方法区

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK11时期的ZGC收集器就不支持类卸载),方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集算法在主流Java虚拟机中均未涉及,所以我们暂不介绍,后面涉及到的算法均属于追踪式垃圾收集的范畴。

分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。这里我们需要知道,这一切的出现都始于分代收集理论。

把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一些名为“*Generation”的实现,如“DefNewGeneration”和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpot鼓励开发者尽量在这个框架内开发新的垃圾收集器,但除了最早期的两组四款收集器之外,后来的开发者并没有继续遵循。导致此事的原因有很多,最根本的是分代收集理论仍在不断发展之中,如何实现也有许多细节可以改进,被既定的代码框架约束反而不便。其实我们只要仔细思考一下,也很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

这里我们提到了一些垃圾收集的名词,我们整理一下

  1. 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
    3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  2. 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

值得注意的是,分代收集理论也有其缺陷,最新出现(或在实验中)的几款垃圾收集器都展现出了面向全区域收集设计的思想,或者可以支持全区域不分代的收集的工作模式。

新生代(Young)、老年代(Old)是HotSpot虚拟机,也是现在业界主流的命名方式。在IBM J9虚拟机中对应称为婴儿区(Nursery)和长存区(Tenured),名字不同但其含义是一样的。

通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集。

标记-清除算法

最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在上面讲述垃圾对象标记判定算法时其实已经介绍过了。

之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。它的主要缺点有两个:

  1. 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  2. 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如图所示:

image-20201119174808185

标记-复制算法

标记-复制算法常被简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

标记-复制算法的执行过程如图所示:

image-20201119174918095

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。

在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

image-20201119175401527

内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

这里需要说明一下,HotSpot中的这种分代方式从最初就是这种布局,和IBM的研究并没有什么实际关系。这里列举IBM的研究只是为了说明这种分代布局的意义所在。

标记-整理算法

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-整理算法的示意图如图所示:

image-20201119191802016

标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。

如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

最新的ZGC和Shenandoah收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行。

通常标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要来的短而已。

HotSpot的算法细节实现——-根节点枚举

我们以可达性分析算法中从GC Roots集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在Java应用越做越庞大,光是方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,若要逐个检查以这里为起源的引用肯定得消耗不少时间。

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。

由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

HotSpot的算法细节实现——-安全点

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。

实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。

HotSpot的算法细节实现——-安全区域

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

HotSpot的算法细节实现——-记忆集与卡表

上面我们再说分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。如代码:

1
2
3
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  1. 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  2. 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  3. 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:

1
CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如图所示:

image-20201119194852586

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

由Antony Hosking在1993年发表的论文《Remembered sets can also play cards》中提出。

之所以使用byte数组而不是bit数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的,没有直接存储一个bit的指令,所以要用bit的话就不得不多消耗几条shift+mask指令。

HotSpot的算法细节实现——-写屏障

我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

下面这段代码是一段更新卡表状态的简化逻辑:

1
2
3
4
5
6
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

1
2
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;

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

这个语境上的内存屏障(Memory Barrier)的目的是为了指令不因编译优化、CPU执行优化等原因而导致乱序执行,它也是可以细分为仅确保读操作顺序正确性和仅确保写操作顺序正确性的内存屏障的。

AOP为Aspect Oriented Programming的缩写,意为面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。后面提到的“环形通知”也是AOP中的概念。

HotSpot的算法细节实现——-并发的可达性分析

前面我们曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。

要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。

想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  1. 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  2. 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  3. 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

关于可达性分析的扫描过程,我们不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

下面演示了这样的致命错误具体是如何产生的:

image-20201119202121202

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

首先因为jdk1.7对该方法进行了改动,所以我们只分析1.7的现象。

在分析这个方法前,我们先来看看HotSpot的字符串常量池

他的存在,是为了减小对象的创建,我们不要把它想的过于复杂,比如我们在程序中写了一个String a = "abc";那么这个abc就会存储在字符串常量池中,下次再遇到这个,就不会再创建一个abc了,而是直接去字符串常量池中去找,所以String a = "abc";String b = "abc";这两个变量,a==btrue的。因为他们都指向同一个字符串常量池。(字符串常量池1.7之前在方法区,1.7之后被挪到了堆,方法区也被挪到了元空间)。

简单了解了一下字符串常量池后,我们再来看一个经典的面试题

String s1 = new String(“abc”);这句话创建了几个字符串对象?

答案是 1个或者2个,为啥呢,如果字符串常量池中已存在字符串常量(或者引用,后面再说)“abc”,则只会在空间创建一个字符串常量“abc”。如果池中没有字符串常量“abc”,那么它将首先在池中创建,然后在堆空间中创建,因此将创建总共 2 个字符串对象。

比如下面这个示例

1
2
3
4
5
6
@Test
public void test1() {
String s1 = new String("abc");// 堆内存的地址值
String s2 = "abc";
System.out.println(s1 == s2);// 输出 false,因为一个是堆内存,一个是常量池的内存,故两者是不同的。
}

这个如果比较好懂,我们就再接着看一个稍微复杂一点的

1
2
3
4
5
6
7
8
9
10
11
12
 @Test
public void test2() {
String str1 = "str";
String str2 = "ing";

String str3 = "str" + "ing";//常量池中的对象
String str4 = str1 + str2; //在堆上创建的新的对象
String str5 = "string";//常量池中的对象
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
}

我们来解读一下这个test,第一行str1和第二行str2 毫无疑问,在字符串常量池中产生,第三行和第四行有什么区别呢,第三行 两个字符串常量相加,依然在字符串常量池,第四行了两个变量相加,那么就会在堆中产生了。这里我们做一个说明:

String str3 = “str” + “ing”; 在编译期间,这种拼接会被优化,编译器直接帮你拼好,所以str3相当于直接赋值为“string”

而相加的过程中一旦出现了对象,就不会做优化,因为这是一个对象,内存不是确定的,没有写死,无法实现优化。而且在相加的过程中,java会先new出一个StringBuilder,然后调用append()方法来将+号两遍的字符串拼接起来,然后toString()之后返回给=号左边的变量,也就是说,最后得到的是一个new出来的字符串

这样一来,结果就很好理解了。

我们再看一个例子

1
2
3
4
5
6
@Test
public void intern4() {
String a="a";
String ab =a+"b";
System.out.println(ab == "ab");//false
}

我们分析一下,ab是堆,“ab”是常量池,所以false很合理,如果我们吧a变成final的,那么结果就是true了。

1
2
3
4
5
6
@Test
public void intern4() {
final String a="a";
String ab1 =a+"b";
System.out.println(ab1 == "ab");//true
}

这就是我们刚才所说的优化了,因为a是final不可变的,所以编译的时候,a+”b”=”a”+”b”=”ab”;

为啥说intern要先举一下上面的例子呢,因为String的intern方法就是针对这个来设计的优化。

intern是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

jdk1.7后,如果对象只存在堆中,会拷贝对象的引用到常量池中

了解了基本定义之后,我们再来看下面这个例子

1
2
3
4
5
6
7
8
@Test
public void test3(){
String s1 = new String("计算机");
String s2 = s1.intern();
String s3 = "计算机";
System.out.println(s1 == s2);//false,因为一个是堆内存中的String对象,一个是常量池中的 String 对象,
System.out.println(s3 == s2);//true,因为两个都是常量池中的String对象
}

第一行会在字符串常量池和堆中分别创建两个对象,s1指向堆中的引用,没有问题,s1.intern()指向常量池,此时常量池中已经有了“计算机”,所以s2此时就等于常量池中的引用,s3直接指向常量池。

看了这个简单的例子后,我们稍微看个复杂点的

1
2
3
4
5
6
@Test
public void intern3() {
String str1 = new String("abc") + new String("def");
System.out.println(str1.intern() == str1);//true
System.out.println(str1 == "abcdef");//true
}

这个时候我们可能会有疑问str1 == “abcdef” 怎么可能为true呢,一个是堆,一个是常量池。我们一步步看,第一句话,我们分析一下,创建了几个对象。

答案是5个,常量池中有两个,分别是abc和def,堆中有三个,abc,def和abcdef,这个没啥疑问吧,然后第二行,调用了str1.intern()。这个时候发生了什么,它去常量池中找abcdef,发现没有,做了个什么操作呢,把堆中的引用存到了常量池中,(1.7之前是拷贝值,1.7之后是拷贝引用,因为都在堆里面了,为了优化,没必要存两份了)。这个时候,常量池中存在了abcdef,这个abcdef就是str1中堆的值。所以 这个时候我们再来比较str1和abcdef,其实都是指的堆。我们把这个例子加一句话

1
2
3
4
5
6
7
@Test
public void intern3() {
String str2 = "abcdef";
String str1 = new String("abc") + new String("def");
System.out.println(str1.intern() == str1);//false
System.out.println(str1 == "abcdef");//false
}

我们只在第一行加一句话,其他的都不动。结果却是翻天覆地的。如果你对intern有了一个差不多的了解后,我们再来分析一下,第一句话,在常量池中创建了一个abcdef,第二句话效果不变,我们再来看第三句话,此时str1.intern()去常量池中找,发现有了一个常量了,这个时候 它就指向了常量池中的abcdef了,所以结果不难理解了。

了解了这个之后,我们再来看一个有趣的例子

1
2
3
4
5
6
7
8
@Test
public void intern() {
String str1 = new StringBuilder("计算机").append("软件").toString();
String str2 = new StringBuilder("ja").append("va").toString();

System.out.println(str1.intern() == str1);
System.out.println(str2.intern() == str2);
}

根据我们刚才的推测,这俩应该都是true,因为都是指向的堆中的引用。但是实际上,结果是:

1
2
true
false

为啥第二个是false呢,这是因为“java”是个关键字, 这个字符串在执行StringBuilder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

最后,我们再看一个例子

1
2
3
4
5
6
7
8
9
10
@Test
public void intern2() {
String a="a";
String b="b";
String ab1 = new String(a+b);
String ab2 = new String(a+b);
System.out.println(ab1.intern() == ab1);//true
System.out.println(ab2.intern() == ab2);//false
System.out.println(ab1.intern() == ab2.intern());//true
}

这个例子再次给我们证明了,如果常量池中不存在,存储对象的引用,所以第一个是true,第二个ab2.intern()的时候,发现已经存在了ab1的引用,所以指向了ab1,这两行,谁在前面,谁是true。