This the multi-page printable view of this section. Click here to print.
编程语言
- 1: Java 编程
- 1.1: Java 基础
- 1.1.1: CH01-面向对象
- 1.1.2: CH02-基本知识
- 1.1.3: CH03-基本图谱
- 1.1.4: CH04-泛型机制
- 1.1.5: CH05-注解机制
- 1.1.6: CH06-异常机制
- 1.1.7: CH07-反射机制
- 1.1.8: CH08-SPI 机制
- 1.2: Java 集合
- 1.2.1: CH01-集合结构
- 1.2.2: CH02-ArrayList
- 1.2.3: CH03-LinkedList
- 1.2.4: CH04-Stack-Queue
- 1.2.5: CH05-PriorityQueue
- 1.2.6: CH06-HashSet-Map
- 1.2.7: CH07-LinkedHashSet-Map
- 1.2.8: CH08-TreeSet-Map
- 1.2.9: CH09-WeakHashMap
- 1.2.10: CH10-Stream
- 1.3: Java 并发
- 1.3.1: CH01-并发体系
- 1.3.2: CH02-理论基础
- 1.3.3: CH03-线程基础-1
- 1.3.4: CH04-线程基础-2
- 1.3.5: CH05-Synchronized
- 1.3.6: CH06-Volatile
- 1.3.7: CH07-Final
- 1.3.8: CH08-并发概览
- 1.3.9: CH09-底层支撑
- 1.3.10: CH10-LockSupport
- 1.3.11: CH11-AQS-1
- 1.3.12: CH12-AQS-2
- 1.3.13: CH13-AQS-3
- 1.3.14: CH14-AQS-4
- 1.3.15: CH15-ReentrantLock
- 1.3.16: CH16-ReentrantReadWriteLock
- 1.3.17: CH17-ConcurrentHashMap
- 1.3.18: CH18-ConcurrentLinkedQueue
- 1.3.19: CH19-BlockingQueue
- 1.3.20: CH20-FutureTask
- 1.3.21: CH21-ThreadPoolExecutor
- 1.3.22: CH22-ScheduledThreadPoolExecutor
- 1.3.23: CH23-ForkJoin.md
- 1.3.24: CH24-CountDownLatch
- 1.3.25: CH25-CyclicBarrier
- 1.3.26: CH26-Semaphore
- 1.3.27: CH27-Phaser
- 1.3.28: CH28-Exchanger
- 1.3.29: CH29-ThreadLocal
- 1.3.30: CH30-AllLocks
- 1.3.31: CH31-AllQueues
- 1.3.32: CH32-AllPools
- 1.4: Java I/0
- 1.4.1: CH01-IO分类
- 1.4.2: CH02-装饰模式
- 1.4.3: CH03-InputStream
- 1.4.4: CH04-OutputStream
- 1.4.5: CH05-常用操作.md
- 1.4.6: CH06-IO实现
- 1.4.7: CH07-IO模型
- 1.4.8: CH08-BIO原理
- 1.4.9: CH09-NIO基础
- 1.4.10: CH10-NIO原理
- 1.4.11: CH11-AIO原理
- 1.4.12: CH12-零拷贝
- 1.4.13: CH13-内存映射
- 1.5: Java Effective
- 1.6: Java Debug
- 2: JVM 核心
- 2.1: JVM Core
- 2.1.1: CH01-JVM概览
- 2.1.2: CH02-JVM字节码
- 2.1.3: CH03-JVM类加载
- 2.1.4: CH04-JVM内存结构
- 2.1.5: CH05-JVM内存模型-1
- 2.1.6: CH06-JVM内存模型-2
- 2.1.7: CH07-JVM垃圾回收
- 2.1.8: CH08-JVM-G1
- 2.1.9: CH09-JVM-ZGC
- 2.1.10: CH10-JVM调优参数
- 2.1.11: CH11-JVM-OOM
- 2.1.12: CH12-JVM线程Dump
- 2.1.13: CH13-JVM调试命令
- 2.1.14: CH14-JVM调试工具
- 2.1.15: CH15-JVM动态调试
- 2.2: JSR 133
- 2.3: JMM 规范
- 2.3.1: CH01-JMM规范
- 2.3.2: CH02-JMM Explain
- 2.3.3: CH03-JMM原则
- 2.3.4: CH04-JSR133-FAQ
- 2.3.5: CH05-JSR133-Cook
- 2.4: JVM 深入理解 V2
- 2.4.1: CH01-走近 Java
- 2.4.2: CH02-内存区域与溢出
- 2.4.3: CH03-垃圾收集与分配策略
- 2.4.4: CH04-性能监控与故障处理
- 2.4.5: CH05-调优案例
- 2.4.6: CH06-类文件结构
- 2.4.7: CH07-类加载
- 2.4.8: CH08-字节码执行引擎
- 2.4.9: CH09-案例与实战
- 2.4.10: CH10-编译期优化
- 2.4.11: CH11-运行时优化
- 2.4.12: CH12-内存模型与线程
- 2.4.13: CH13-线程安全与锁优化
- 2.4.14: Endix-B-字节码指令
- 2.4.15: Endix-C-虚拟机参数
- 2.5: JVM 深入理解 V3
- 3: JVM 并发
- 3.1: Java 并发实战
- 3.1.1: CH01-简介
- 3.1.2: CH02-线程安全性
- 3.1.3: CH03-对象共享
- 3.1.4: CH04-对象组合
- 3.1.5: CH05-基础构建块
- 3.1.6: CH06-任务执行
- 3.1.7: CH07-取消关闭
- 3.1.8: CH08-线程池
- 3.1.9: CH09-GUI应用
- 3.1.10: CH10-活跃性危险
- 3.1.11: CH11-性能与伸缩
- 3.1.12: CH12-测试
- 3.1.13: CH13-显式锁
- 3.1.14: CH14-自定义扩展
- 3.1.15: CH15-原子与非阻塞同步
- 3.1.16: CH16-内存模型
- 3.2: Java 并发模式
- 3.3: 深入理解并行
- 3.3.1: CH01-关于本书
- 3.3.2: CH02-简介
- 3.3.3: CH03-硬件特性
- 3.3.4: CH04-并行工具
- 3.3.5: CH05-计数
- 3.3.6: CH06-分割同步设计
- 3.3.7: CH07-锁
- 3.3.8: CH08-数据所有权
- 3.3.9: CH09-延后处理
- 3.3.10: CH10-数据结构
- 3.3.11: CH11-验证
- 3.3.12: CH12-形式验证
- 3.3.13: CH13-综合应用
- 3.3.14: CH14-高级同步
- 3.3.15: CH15-高级同步-内存序
- 3.3.16: ENDIX-C-内存屏障
- 3.4: 七并发模型
- 3.4.1: CH01-概述
- 3.4.2: CH02-线程与锁
- 3.4.3: CH03-函数式编程
- 3.4.4: CH04-分离标识与状态
- 3.4.5: CH05-Actor
- 3.4.6: CH06-CSP
- 3.4.7: CH07-数据并行
- 3.4.8: CH08-Lambda 架构
- 3.4.9: CH09-未来方向
- 4: JVM 拆解
- 4.1: CH01-开始运行
- 4.2: CH02-基本类型
- 4.3: CH03-类加载
- 4.4: CH04-方法调用-上
- 4.5: CH05-方法调用-下
- 4.6: CH06-异常处理
- 4.7: CH07-实现反射
- 4.8: CH08-invokedynamic-上
- 4.9: CH09-invokedynamic-下
- 4.10: CH10-对象内存布局
- 4.11: CH11-垃圾回收-上
- 4.12: CH12-垃圾回收-下
- 4.13: CH13-内存模型
- 4.14: CH14-Synchronized
- 4.15: CH15-语法糖
- 4.16: CH16-即时编译-上
- 4.17: CH17-即时编译-下
- 4.18: CH18-即时编译中间表达
- 4.19: CH19-字节码基础
- 4.20: CH20-方法内联-上
- 4.21: CH21-方法内联-下
- 4.22: CH22-HotSpot-intrinsic
- 4.23: CH23-逃逸分析
- 4.24: CH24-字段访问优化
- 4.25: CH25-循环优化
- 4.26: CH26-向量化
- 4.27: CH27-注解处理器
- 4.28: CH28-JMH-上
- 4.29: CH29-JMH-下
- 4.30: CH30-诊断-CLI
- 4.31: CH31-诊断-GUI
- 4.32: CH32-JNI
- 4.33: CH33-Agent
- 4.34: CH34-GraalVM
- 4.35: CH35-Truffle
- 4.36: CH36-SubstrateVM
- 4.37: CH37-常用工具
- 5: JVM 性能
- 6: Scala 编程
- 6.1: Scala 精要
- 6.1.1: Case Class
- 6.1.2: 类型系统
- 6.1.3: 函数式-基础
- 6.1.4: 函数式-用例
- 6.1.5: Future-基础
- 6.1.6: Future-用例
- 6.1.7: Future-集合
- 6.1.8: Future-Promise
- 6.1.9: Implicits-基础
- 6.1.10: Implicits-进阶
- 6.1.11: Trait-基础
- 6.1.12: Trait-用例
- 6.1.13: Try
- 6.1.14: Type-用例
- 6.1.15: Type-进阶
- 6.1.16: Primitive
- 6.1.17: Numbers
- 6.1.18: String
- 6.1.19: Control
- 6.1.20: Pattern Match
- 6.1.21: Class Object: 基础
- 6.1.22: Class Object: 类
- 6.1.23: Class Object: 方法
- 6.1.24: Class Object: 对象
- 6.1.25: 函数式对象
- 6.1.26: 整体类层级
- 6.1.27: 组合继承
- 6.1.28: Package
- 6.1.29: SBT
- 6.1.30: Predef
- 6.1.31: I/O
- 6.1.32: 保留字
- 6.1.33: Exception
- 6.1.34: Inject Type
- 6.1.35: Type Class
- 6.1.36: Map Flatmap
- 6.1.37: 泛型型变
- 6.1.38: 常见问题
- 6.1.39: 占位符
- 6.1.40: 多变量赋值
- 6.1.41: 构造器
- 6.1.42: 函数式入门
- 6.1.43: Monoid
- 6.1.44: 函数式与类型类
- 6.1.45: 异步编程
- 6.1.46: 战略Scala风格
- 6.2: Scala 函数式
- 7: Rust 编程
- 8: Golang 编程
- 9: 前端技术栈
- 9.1: 浏览器
- 9.2: HTML5
- 9.3: CSS3
- 9.4: JavaScript
- 9.5: TypeScript
- 9.6: Vue 3
- 9.7: React
- 10: Dart 编程
- 10.1: 基本认识
1 - Java 编程
Java 9
接口私有方法
支持在接口内声明私有默认方法。
匿名内部类类型推断
List<Integer> numbers = new ArrayList<>() {
// ..
}
try-with-resources 新语法
BufferedReader br1 = new BufferedReader(...);
BufferedReader br2 = new BufferedReader(...);
try (br1; br2) {
System.out.println(br1.readLine() + br2.readLine());
}
弃用下划线标识符
int _ = 10; // Compile error
警告提升
私有方法支持 @SafeVarargs。
引入废弃类型时不再警告。
Java 11
本地变量类型推断
省略类型声明并不表示动态类型,而是更加智能的类型推断:
var greetingMessage = "Hello!";
var date = LocalDate.parse("2019-08-13");
var dayOfWeek = date.getDayOfWeek();
var dayOfMonth = date.getDayOfMonth();
Map<String, String> myMap = new HashMap<String, String>(); // Pre Java 7
Map<String, String> myMap = new HashMap<>(); // Using Diamond operator
Java 14
Switch 表达式
新的 switch 语句:
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
default -> {
String s = day.toString();
int result = s.length();
yield result;
}
};
可以只用作为表达式使用:
int k = 3;
System.out.println(
switch (k) {
case 1 -> "one";
case 2 -> "two";
default -> "many";
}
);
每个 case 都拥有自己的域:
String s = switch (k) {
case 1 -> {
String temp = "one";
yield temp;
}
case 2 -> {
String temp = "two";
yield temp;
}
default -> "many";
}
switch 的 case 必须详尽,这意味着 String、原始类型及其包装类型的 default 必须提供:
int k = 3;
String s = switch (k) {
case 1 -> "one";
case 2 -> "two";
default -> "many";
}
对于枚举来说,要么匹配所有子类,要么提供 default case:
enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
Day day = Day.TUESDAY;
switch (day) {
case MONDAY -> ":(";
case TUESDAY, WEDNESDAY, THURSDAY -> ":|";
case FRIDAY -> ":)";
case SATURDAY, SUNDAY -> ":D";
}
Java 15
文本块
String html = """
<html>
<body>
<p>Hello, world</p>
</body>
</html>
""";
System.out.println(html);
每个换行尾部都会自动追加 \n
标识,如果在尾部显式添加一个 \
字符,则表示不换行,但又增加了可读性:
String singleLine = """
Hello \
World
""";
额外缩进会被自动移除:
String indentedByToSpaces = """
First line
Second line
""";
String indentedByToSpaces = """
First line
Second line
""";
目前还不支持插值语法,但可以使用 formatted 方法:
var greeting = """
hello
%s
""".formatted("world");
NPE 更有意义
原有异常栈:
node.getElementsByTagName("name").item(0).getChildNodes().item(0).getNodeValue();
Exception in thread "main" java.lang.NullPointerException
at Unlucky.method(Unlucky.java:83)
新的异常栈:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "org.w3c.dom.Node.getChildNodes()" because
the return value of "org.w3c.dom.NodeList.item(int)" is null
at Unlucky.method(Unlucky.java:83)
Java 16
Record 类
与 Scala 中 case class 类似。
用于声明一个不可变的数据类。
public record Point(int x, int y) { }
var point = new Point(1, 2);
point.x(); // returns 1
point.y(); // returns 2
该声明的含义如下:
- two
private
final
fields,int x
andint y
- a constructor that takes
x
andy
as a parameter x()
andy()
methods that act as getters for the fieldshashCode
,equals
andtoString
, each takingx
andy
into account
其限制如下:
- 不能含有任何非 final 字段
- 默认构造器需要包含所有字段,可以声明额外构造器以提供默认字段值
- 不能继承其他类
- 不能声明 native 方法
- 隐式 final,不能声明为 abstract
提供隐私无参构造器,也可以显式声明无参构造器以实现参数校验:
public record Point(int x, int y) {
public Point {
if (x < 0) {
throw new IllegalArgumentException("x can't be negative");
}
if (y < 0) {
y = 0;
}
}
}
声明额外构造器必须委托给其他构造器:
public record Point(int x, int y) {
public Point(int x) {
this(x, 0);
}
}
访问器可以被覆写,其他隐私的方法如 hashCode、equals、toString 也可以被覆写:
public record Point(int x, int y) {
@Override
public int x() {
return x;
}
}
能够声明静态或实例方法:
public record Point(int x, int y) {
static Point zero() {
return new Point(0, 0);
}
boolean isZero() {
return x == 0 && y == 0;
}
}
可以实现 Serializable 接口,且不需要提供 serialVersionUID:
public record Point(int x, int y) implements Serializable { }
public static void recordSerializationExample() throws Exception {
Point point = new Point(1, 2);
// Serialize
var oos = new ObjectOutputStream(new FileOutputStream("tmp"));
oos.writeObject(point);
// Deserialize
var ois = new ObjectInputStream(new FileInputStream("tmp"));
Point deserialized = (Point) ois.readObject();
}
可以直接在方法体内声明 Record 类:
public List<Product> findProductsWithMostSaving(List<Product> products) {
record ProductWithSaving(Product product, double savingInEur) {}
products.stream()
.map(p -> new ProductWithSaving(p, p.basePriceInEur * p.discountPercentage))
.sorted((p1, p2) -> Double.compare(p2.savingInEur, p1.savingInEur))
.map(ProductWithSaving::product)
.limit(5)
.collect(Collectors.toList());
}
模式匹配:instanceof
instanceof 语法支持自动 cast:
if (obj instanceof String s) {
// use s
}
新的 instanceof 检查与原来的逻辑类似,但如果检查总是通过,将会直接抛出错误:
// "old" instanceof, without pattern variable:
// compiles with a condition that is always true
Integer i = 1;
if (i instanceof Object) { ... } // works
// "new" instanceof, with the pattern variable:
// yields a compile error in this case
if (i instanceof Object o) { ... } // error
模式检查通过则提取出一个模式变量,该变量为常规的 non-final 变量类似:
- 可以被修改
- 覆盖字段声明
- 如果有相同名称的本地变量,则编译失败
模式变量可以直接用于后置的检查逻辑:
if (obj instanceof String s && s.length() > 5) {
// use s
}
模式变量的作用域也不仅限于检查内部:
private static int getLength(Object obj) {
if (!(obj instanceof String s)) {
throw new IllegalArgumentException();
}
// s is in scope - if the instanceof does not match
// the execution will not reach this statement
return s.length();
}
Java 17
Sealed 类
与 Scala 中 sealed trait 类似。
用于声明一个边界清晰的抽象层级。Sealed 类的子类可以选择 3 种修饰符,以约束抽象边界:
- final:子类无法再被继承
- sealed:子类仅能被允许的类继承
- non-sealed:子类可以被自由继承
public sealed class Shape {
public final class Circle extends Shape {}
public sealed class Quadrilateral extends Shape {
public final class Rectangle extends Quadrilateral {}
public final class Parallelogram extends Quadrilateral {}
}
public non-sealed class WeirdShape extends Shape {}
}
替代 Enum
在支持 Sealed 类之前,只能在单个类文件内通过 Enum 建模固定类型,Sealed 则更加灵活,而且能够用于模式匹配。
Enum 类可以通过 values 方法遍历子类,而 Seaed 类的子类也可以通过 getPermittedSubclasses 来遍历。
模式匹配:switch
之前的 switch 表达式是有限的,仅能用于判定完全相等性,且仅支持有限的类型:数字、枚举、字符串。
该预览特性支持 switch 表达式走用于任意类型,以及更加复杂的匹配模式。
原来的模式:
var symbol = switch (expression) {
case ADDITION -> "+";
case SUBTRACTION -> "-";
case MULTIPLICATION -> "*";
case DIVISION -> "/";
};
增强后支持类型模式语法:
return switch (expression) {
case Addition expr -> "+";
case Subtraction expr -> "-";
case Multiplication expr -> "*";
case Division expr -> "/";
};
比如:
String formatted = switch (o) {
case Integer i && i > 10 -> String.format("a large Integer %d", i); // 引用了 i
case Integer i -> String.format("a small Integer %d", i);
default -> "something else";
};
同时支持 null 值,不再是抛出 NPE:
switch (s) {
case null -> System.out.println("Null");
case "Foo" -> System.out.println("Foo");
default -> System.out.println("Something else");
}
如果匹配语句没有包含所有可能的输入,编译器则会直接报错:
Object o = 1234;
// OK
String formatted = switch (o) {
case Integer i && i > 10 -> String.format("a large Integer %d", i);
case Integer i -> String.format("a small Integer %d", i);
default -> "something else";
};
// Compile error - 'switch' expression does not cover all possible input values
String formatted = switch (o) {
case Integer i && i > 10 -> String.format("a large Integer %d", i);
case Integer i -> String.format("a small Integer %d", i);
};
// Compile error - the second case is dominated by a preceding case label
String formatted = switch (o) {
case Integer i -> String.format("a small Integer %d", i);
case Integer i && i > 10 -> String.format("a large Integer %d", i);
default -> "something else";
};
1.1 - Java 基础
1.1.1 - CH01-面向对象
三大特性
封装
利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体。数据被保护在抽象数据类型的内部,尽可能的隐藏内部的细节,只保留一些对外接口使其与外部发生关系。用户无需知道对象内部的细节,但可以通过对象所提供的接口来访问对象。
优点:
- 减少耦合:可以独立的开发、测试、优化、使用、理解、修改
- 减少维护负担:可以更容易的被其他开发者理解,并且在调试的时候可以不影响其他模块
- 有效的调节性能:可以通过剖析确定哪些模块影响了系统的性能
- 提高软件的可重用性
- 降低了构建大型系统的风险:即使整个系统不可用,但是这些独立的模块却有可能是可用的
继承
继承实现了 IS-A 关系,比如 Cat 和 Animal 就是一种 IS-A 关系,因此 Cat 可以继承自 Animal,从而获得属于 Animal 的非私有的属性和方法。
继承应该遵循里氏替换原则,子类对象必须能够替换掉父类对象(可以直接将子类对象看做是父类对象)。
Cat 可以当做 Animal 来使用,也就是说可以使用 Animal 引用 Cat 对象。父类引用指向子类对象称为 向上转型。
多态
多态分为编译时多态和运行时多态:
- 编译时多态主要指方法的重载
- 运行时多态指程序中定义的对象引用所指向的具体类型在运行期才确定
运行时多态有三个条件:
- 继承
- 覆写
- 向上转型
类图
泛化关系
Java extend。
实现关系
Java implement。
聚合关系
表示整体由部分组成,但是整体和部分不是强依赖,整体不存在了部分还会存在。
组合关系
和聚合不同,组合中整体和部分是强依赖的,整体不存在了部分也不存在了。比如公司和部门,公司没了部门就不存在了。
关联关系
表示不同类对象之间有关联,这是一种静态关系,与运行过程的状态无关,在最开始就是可以确定的。因此也可以用一对一、一对多、多对一、多对多这种表述来表示。
比如学生和学校就是一种关联关系,一个学校可以有多个学生,但是一个学生只属于一个学校,因此这是一种多对一关系,在运行开始就能确定。
依赖关系
依赖关系是在运行中起作用的。A 类对 B 类的依赖关系主要有三种形式:
- A 类是 B 类中的局部变量
- A 类是 B 类方法中的参数
- A 类向 B 类发送消息,从而影响 B 类
Vihicle 的 move 方法接收一个 MoveBehavior 对象作为参数来实现移动逻辑,而 MoveBehavior 可能的实现是向上或向下移动。
参考资料
1.1.2 - CH02-基本知识
数据类型
包装类型
八个基本类型:
- boolean:1
- byte:8
- char:16
- short:16
- int:32
- float:32
- long:64
- double:64
基本类型都拥有对应的包装类型,它们之间的赋值使用自动装箱与拆箱完成:
Integer x=2; // 装箱
int y=x; // 拆箱
缓存池
new Integer(21)
与 Integer.valueOf(21)
的区别在于:
- 前者每次都会创建一个新的对象
- 后者会使用缓存池中的对象,多次调用会获得同一个对象的引用
Integer x = new Integer(21);
Integer y = new Integer(21);
System.out.println(x == y); // false
Integer m = Integer.valueOf(21);
Integer n = Integer.valueOf(21);
System.out.println(m == n); // true
valueOf()
方法的实现比较简单,它会先判断值是否在缓存池中,有则返回否则新建并返回。
public static Integer valueOf(int i) {
if(i >= IngegerCache.low && i <= Integer.high){
return IntegerCache[i + (-IngegerCache.low)];
}
return new Integer(i);
}
在 Java 8 中,Integer 缓存池的大小默认为 -128 至 127。
编译器会在缓冲池范围内的基本类型自动装箱过程中调用 valueOf
方法,因此多个取值相同的 Integer 实例在使用自动装箱来创建时,会引用相同的对象。
Integer m = 123;
Integer n = 123;
System.out.println(m == n); // true
基本类型对应的缓冲池如下:
boolean: true, false
all byte values
short values between -128 to 127
int values between -128 to 127
char in range \u0000 to \u007F
String
概览
String 被声明为 final,因此不可被继承。
其内部使用 char 数组存储数据,该数据也被声明为 final,表示该 value 数组初始化之后便不能再被修改。并且没有提供修改该数组的方法,因此可以保证 String 不可变。
不可变的好处
- 可以缓存哈希值
因为 String 的 hash 值经常被使用,比如 String 作为 HashMap 的 key。不可变使得其 hash 值也不会变,因此仅需计算一次。
- String Pool 的需要
如果一个 String 对象已经被创建过了,那么就会从 String Pool 中取得引用。只有 String 是不可变的,才可能使用 String Pool。
- 安全性
String 经常被作为参数类型,String 不可变性可以保证参数不可变。
- 线程安全
不可变特性天生具备线程安全性,可以在多个线程中并发使用。
String, StringBuffer, String Builder
- 可变性
- String 不可变
- StringBuffer、StringBuilder 可变
- 线程安全
- String 不可变,即线程安全
- StringBuilder 非线程安全
- StringBuffer 保证线程安全,内部使用 synchronized 实现同步
Stirng.intern()
使用 Stirng.intern()
可以保证相同内容的字符串变量引用同一个内存对象。
String s1 = new String("aaa");
String s2 = new String("aaa");
System.out.println(s1 == s2); // false
String s3 = s1.intern();
System.out.println(s1.intern() == s3); // true
如果直接使用 "bbb"
这种形式而非 new 来创建字符串实例,会自动将其放入 String Pool 中。
String s4 = "bbb";
String s5 = "bbb";
System.out.println(s4 == s5); // true
在 Java 7 之前,字符串常量池被放在运行时常量池中,属于永久代。在 Java 7 中,字符串常量池被移到 Native Method 中。这是因为永久代的空间有限,在大量使用字符串的场景下会导致 OOM。
运算
参数传递
Java 中的参数是以值传递的形式传入方法中,而不是引用传递。(这里的值值得是引用的地址值)
以下代码中,Dog dog
是一个指针,存储的是对象的地址值。在将一个参数传入一个方法时,本质上是将对象的地址以值的形式传递到形参中。因此在方法中改变指针引用的对象,那么这两个指针将指向不同的对象,一方改变自身指向的对象不会对另一方产生影响。
public class Dog {
String name;
Dog(String name) {
this.name = name;
}
String getName() {
return this.name;
}
void setName(String name) {
this.name = name;
}
String getObjectAddress() {
return super.toString();
}
}
public class PassByValueExample {
public static void main(String[] args) {
Dog dog = new Dog("A");
System.out.println(dog.getObjectAddress()); // Dog@4554617c
func(dog);
System.out.println(dog.getObjectAddress()); // Dog@4554617c
System.out.println(dog.getName()); // A
}
private static void func(Dog dog) {
System.out.println(dog.getObjectAddress()); // Dog@4554617c
dog = new Dog("B");
System.out.println(dog.getObjectAddress()); // Dog@74a14482
System.out.println(dog.getName()); // B
}
}
但是如果在方法中改变对象的字段值,则会改变原对象的字段值,因为改变的是同一个地址指向的内容。
class PassByValueExample {
public static void main(String[] args) {
Dog dog = new Dog("A");
func(dog);
System.out.println(dog.getName()); // B
}
private static void func(Dog dog) {
dog.setName("B");
}
}
StackOverflow: Is Java “pass-by-reference” or “pass-by-value”?
float 与 double
1.1
这样的字面量属于 double 类型,不能直接将其赋值给 float 变量,因为是向下转型。Java 不能隐式执行向下转型,因为会使得精度降低。
float f = 1.1; // error
float m = 1.1f; // right
隐式类型转换
因为字面量 1
是 int 类型,它比 short 类型精度要高,因此不能隐式的将 int 类型向下转型为 short 类型。
但是使用 +=
运算可以执行隐式类型转换:
short s1 = 1;
// s1 = s1 + 1;
s1 += 1;
这其实相当于将 s1 + 1
的计算结果执行了向下转型:
s1 = (short) (s1 + 1);
StackOverflow : Why don’t Java’s +=, -=, *=, /= compound assignment operators require casting?
switch
从 Java 7 开始,可以在 switch 条件判断语句中使用 String 对象。
Switch 不支持 long,是因为 switch 的设计初衷是对那些只有极少数的几个值进行等值判断,如果值的范围过大,那么还是使用 if 比较合适。
Why can’t your switch statement data type be long, Java? - Stack Overflow
继承
访问权限
Java 中有三个访问权限修饰符:private、protected、public,如果不加访问修饰符,则表示 package 范围内可见。
可以对类或类中的成员(字段/方法)添加修饰符。
- 类可见表示其他类可以用这类来创建实例对象。(即通过类来引用成员)
- 成员可见表示其他类可以用这个类的实例对象访问到该成员。(即通过类实例来引用成员)
protected 用于修饰成员,表示在继承体系中成员对于子类可见,但是该修饰符对于类没有作用。
设计良好的模块会隐藏所有实现细节,将其 API 和实现细节清晰的隔离开来。模块之间仅通过他们的 API 进行通信,一个模块不需要知道其他模块内部的具体细节,这个概念被称为信息隐藏或封装。因此访问权限应当尽可能的使每个类或成员不被外界访问。
如果子类的方法重写了父类的方法,那么子类中该方法的访问级别不允许低于父类的访问级别。这是为了确保可以在使用父类实例的地方都能使用子类实例,即子类实例提供了父类应该具体的能力,即里氏替换原则。
字段决不能是 public,因为这么做就是去了对这个字段修改行为的控制,外部可以对其执行任意修改。
如果是 package 范围或私有的嵌套类,那么直接暴露成员不会有太大的影响。
抽象类与接口
- 抽象类
抽象类和抽象方法使用 abstract 关键字声明。抽象类一般会包含抽象方法,抽象方法一定位于抽象类中。
抽象类和普通类之间最大的区别是,抽象类不能被实例化,需要继承抽象类并实例化子类。
- 接口
接口是抽象类型的延伸,在 Java 8 之前,可以看做是一个完全抽象的类,即不能拥有任何实现方法。
从 Java 8 开始,接口也可以拥有默认的实现方法,这是因为不支持默认方法的接口的维护成本太高了。在此之前,如果一个接口想要添加新方法,那么要修改所有实现了该接口的类。
接口的成员都是 public 可见,并且不允许定义为 private 或 protected。
接口的字段默认都是 static final 的,即静态的。
- 比较
- 从设计层面看,抽象类提供了 IS-A 关系,那么就必须满足里氏替换原则,即子类对象必须能够替换所有父类对象。
- 接口更像是 LIKE-A 关系,它只是提供一种方法实现契约,并不要求接口和实现接口的类具有 IS-A 关系。
- 从使用上来看,一个类可以实现多个接口,但不能同时继承多个抽象类。
- 接口的字段只能是 static final,抽象类没有该限制。
- 即可的成员只能是 public 可见,而抽象类的成员可以有多种范围可见。
- 选择
使用接口:
- 需要让不相关的类都都实现一个方法,比如无关的类都可以实现 Comparable 接口中的 compareTo 方法。
- 需要使用多重继承。
使用抽象类:
- 需要在几个相关的类中共享代码。
- 需要能够控制继承的成员的访问权限,而非均为 public。
- 需要集成非静态和非常量字段。
在很多情况下,接口优先于抽象类,因为接口没有抽象类严格的类层次结构要求,可以灵活的为一个类添加行为。并且从 Java 8 开始,接口也可以有默认的实现方法,使得修改接口的成本降低。
super
- 访问父类的构造函数:从而委托父类完成一些初始化工作。
- 方位父类的成员:如果子类重写了父类的某个方法实现,则可以通过 super 来引用父类原有的方法实现。
重写与重载
- 重写(overwrite)
存在与继承体系中,子类实现了与父类在方法声明上完全相同的一个方法。
为了满足里氏替换原则,重写必须有以下两个限制:
- 子类方法的访问权限不能低于父类方法
- 子类方法的返回类型必须是父类方法返回类型的原类型或其子类型。
使用 @Overwrite
注解可以通过编译器来检查这两个限制是否满足。
- 重载(overload)
存在与同一个类中,指多个方法的名称相同,但是参数类型、个数、顺序至少有一个不同。
如果仅返回值不同,其他都相同,不能认为是重载。
Object 通用方法
public final native Class<?> getClass()
public native int hashCode()
public boolean equals(Object obj)
protected native Object clone() throws CloneNotSupportedException
public String toString()
public final native void notify()
public final native void notifyAll()
public final native void wait(long timeout) throws InterruptedException
public final void wait(long timeout, int nanos) throws InterruptedException
public final void wait() throws InterruptedException
protected void finalize() throws Throwable {}
equals
等价关系
- 自反性:
x.equals(x)
为 true - 对称性:
x.equals(y) == y.equals(x)
为 true - 传递性:
x.equals(y) && y.equals(z)
则x.equals(z)
为 true - 一致性:多次调用 equals 方法的结果不会变化
- 与 null 比较:任何不为 null 的对象 x 调用
x.equals(null)
均为 false
- 自反性:
equals 与 ==
- 对于基本类型,== 判断值是否相等,基本类型没有 equals 方法
- 对于引用类型,== 判断两变量的引用是否相等,而 equals 则判断引用的对象是否等价
实现
- 检查是否为同一个对象的引用,如果是则直接返回 true
- 检查是否为同一个类型,不同直接返回 false
- 将 Object 转型
- 判断每个关键域是否相等
public class EqualExample {
private int x;
private int y;
private int z;
public EqualExample(int x, int y, int z) {
this.x = x;
this.y = y;
this.z = z;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EqualExample that = (EqualExample) o;
if (x != that.x) return false;
if (y != that.y) return false;
return z == that.z;
}
}
hashCode
hashCode 返回哈希值,而 equals 用于判断两个对象是否等价。等价的两个对象的哈希值也一定相等,但是哈希值相等的两个对象不一定等价。
在重写 equals 方法时应当总是重写 hashCode 以确保等价对象的哈希值也一定相等。
理想的哈希函数应该具有均匀性,不相等的对象应该均匀分布到所有可能的哈希值上。这就要求哈希函数能把所有域的值都考虑进来,可以将每个域都当做 R 进制的某一位,然后组成一个 R 进制的整数。R 一般取 31,因为它是一个奇素数,如果是偶数的话,当出现乘法溢出,信息就会丢失,因为与 2 相乘相当于左移一位。
一个数与 31 相乘可以转换成移位和减法操作:31*X == (X<<5)-X
,编译器会自动执行该优化。
@Override
public int hashCode() {
int result = 17;
result = 31 * result + x;
result = 31 * result + y;
result = 31 * result + z;
return result;
}
toString
默认返回的是对象的内存地址,如 ToStringExample@4554617c
,其中 @
符号后面的值为散列码的无符号十六进制表示。
Clone
- cloneable
clone()
是 Object 对象的 protected 方法,并非 public,如果一个类不显式的重新 clone 方法,其他类就无法直接去调用该类实例的 clone 方法。
public class CloneExample {
private int a;
private int b;
@Override
protected CloneExample clone() throws CloneNotSupportedException {
return (CloneExample)super.clone();
}
}
CloneExample e1 = new CloneExample();
CloneExample e2 = e1.clone(); // will throw: CloneNotSupportedException
直接调用重写后的 clone 方法会抛出以上异常,这是因为 CloneExample 并未实现 Cloneable 接口。
注意,clone 并非 Cloneable 接口的方法,而是 Object 的一个 protected 方法。Cloneable 只是规定了:如果一个类没有实现 Cloneable 接口,直接调用 clone 方法时就会抛出该异常。
- 浅拷贝
拷贝对象和原始对象的引用类型引用同一个对象。
// 像上面一样实现 Cloneable 接口并重写 clone 方法
ShallowCloneExample e1 = new ShallowCloneExample();
ShallowCloneExample e2 = e1.clone();
e2.setValue(11);
assert e1.getValue() == 11; // true
- 深拷贝
拷贝对象和原始对象的引用类型引用不同对象。
public class DeepCloneExample implements Cloneable {
private int[] arr;
@Override
protected DeepCloneExample clone() throws CloneNotSupportException {
DeepCloneExample cloned = (DeepCloneExample) super.clone();
result.arr = new int[arr.length];
for(int i=0;i<arr.length;i++){
cloned.arr[i]=arr[i];
}
return cloned;
}
}
- clone 的替代方案
使用 clone 方法来拷贝对象既复杂又有风险,可能会抛出异常,并且还需要类型转换。可以基于 Effective Java 中的建议,通过拷贝构造函数或拷贝工厂来拷贝一个对象。
public class CloneConstructorExample {
private int[] arr;
public CloneConstructorExample() {
arr = new int[10];
for (int i = 0; i < arr.length; i++) {
arr[i] = i;
}
}
public CloneConstructorExample(CloneConstructorExample original) {
arr = new int[original.arr.length];
for (int i = 0; i < original.arr.length; i++) {
arr[i] = original.arr[i];
}
}
public void set(int index, int value) {
arr[index] = value;
}
public int get(int index) {
return arr[index];
}
}
关键字
final
- 变量
声明数据为常量,可以为编译时常量,也可以是在运行时被初始化后不能修改的常量。
- 对于基本类型,fianl 使数值不变
- 对于引用类型,final 使引用不变,即不能被重新赋值为别的对象,但是所引用对象的值可以被修改
- 方法
声明的方法不能被子类重写。
被 private 修饰的方法隐式的被指定为 fianl,如果在子类中定义的方法和基类中某个 private 方法签名一样,此时子类的方法并被重写基类方法,而是在子类中重新定义了一个新的方法。
- 类
声明类不能被继承。
static
- 静态变量
静态变量:又称为类变量,该变量属于类,而非该类的实例,类的所有实例都共享该变量,并通过类名来引用该变量,在内存中仅有一份。
实例变量:每创建一个实例就会产生一个实例变量,它与该实例同生共死。
- 静态方法
静态方法在类加载的时候就存在了,它不依赖于任何实例。所以静态方法必须要实现,也就是说他不能抽象方法。
只能访问所属类的静态字段(变量)和静态方法,方法中不能有 this 和 super 关键字。
- 静态语句块
静态语句块将在类初始化时执行一次。
- 静态内部类
非静态内部类属于外部类的实例,而静态内部类属于外部类本身,通过外部类来引用该静态内部类。
- 静态导包
通过静态导包,可以在使用静态变量和方法时不用再逐个指明 ClassName,可一件简化代码:
import static com.xxx.ClassName.*;
- 初始化顺序
静态变量和语句块优先于实例变量和普通语句块,它们的顺序由代码中的编写顺序决定。然后才是构造函数。
存在集成的情况下,初始化顺序为:
- 父类(静态变量、静态语句块)
- 子类(静态变量、静态语句块)
- 父类(实例变量、普通语句块)
- 父类(构造函数)
- 子类(实例变量、普通语句块)
- 子类(构造函数)
反射
每个类都有一个 Class 对象,包含了与类有关的信息。当编译一个新类时,会产生一个同名的 class 文件,该文件内存保存着 Class 对象。
类加载相当于 Class 对象的加载。类在第一次使用时才会动态加载到 JVM 中,或者使用 Class.forName(...)
主动加载一个类,该方法会返回一个 Class 对象。
反射可以在运行时提供类的信息,并且该类可以在运行时才被加载进来,甚至在编译期该类的 class 尚不存在。
Class 和 java.lang.reflect 一起为反射提供了支持,java.lang.reflect 类库主要包括以下三个类:
- Field:可以使用 get 和 set 方法读取或修改 Field 对象关联的字段。
- Method:可以使用 invoke 方法滴啊用与 Method 对象关联的方法。
- Constructor:可以使用 Constructor 创建新的对象。
高级用法:
Extensibility Features : An application may make use of external, user-defined classes by creating instances of extensibility objects using their fully-qualified names.
Class Browsers and Visual Development Environments : A class browser needs to be able to enumerate the members of classes. Visual development environments can benefit from making use of type information available in reflection to aid the developer in writing correct code.
Debuggers and Test Tools : Debuggers need to be able to examine private members on classes. Test harnesses can make use of reflection to systematically call a discoverable set APIs defined on a class, to insure a high level of code coverage in a test suite.
反射的缺点:
反射虽然强大但不应被直接使用。如果能够在不使用反射的情况下完成操作,则应避免使用反射。使用反射时应注意以下几点:
- Performance Overhead : Because reflection involves types that are dynamically resolved, certain Java virtual machine optimizations can not be performed. Consequently, reflective operations have slower performance than their non-reflective counterparts, and should be avoided in sections of code which are called frequently in performance-sensitive applications.
- Security Restrictions : Reflection requires a runtime permission which may not be present when running under a security manager. This is in an important consideration for code which has to run in a restricted security context, such as in an Applet.
- Exposure of Internals :Since reflection allows code to perform operations that would be illegal in non-reflective code, such as accessing private fields and methods, the use of reflection can result in unexpected side-effects, which may render code dysfunctional and may destroy portability. Reflective code breaks abstractions and therefore may change behavior with upgrades of the platform.
异常
Throwable 可以用来表示任何可以作为异常抛出的类,分为两种:Error 和 Exception。其中 Error 用来表示 JVM 无法处理的错误,Exception 分为两种:
- 受检异常:需要 try…catch 语句捕获并进行处理,并且可以从异常中恢复。
- 非受检异常:程序运行时错误,比如除零操作会引起 ArithmeticException,这时程序崩溃且无法恢复。
泛型
注解
Java 注解是附加在代码中的一些元信息,用于一些工具在编译期、运行时执行解析并使用,起到说明、配置的功能。注解不会也不应该影响代码的实际逻辑,仅仅起到辅助性的作用。
特性
版本特性
Java 8
- Lambda Expressions
- Pipelines and Streams
- Date and Time API
- Default Methods
- Type Annotations
- Nashhorn JavaScript Engine
- Concurrent Accumulators
- Parallel operations
- PermGen Error Removed
Java 7
Strings in Switch Statement
Type Inference for Generic Instance Creation
Multiple Exception Handling
Support for Dynamic Languages
Try with Resources
Java nio Package
Binary Literals, Underscore in literals
Diamond Syntax
与 C++
- Java 是纯粹的面向对象语言,所有的对象都继承自 Object,C++ 为了兼容 C 同时支持面向对象和面向过程。
- Java 通过虚拟机实现跨平台特性,但是 C++ 依赖于特定的平台。
- Java 没有指针,其引用可以理解为安全指针,而 C++ 具有与 C 一样的指针。
- Java 支持自动垃圾回收,而 C++ 需要手动回收。
- Java 不支持多重继承,只能通过实现多个接口到达相同的目的,而 C++ 支持多重继承。
- Java 不支持操作符重载,虽然可以对两个 String 对象执行加法运算,但是是语言内置的操作,不属于操作符重载,而 C++ 可以。
- Java 的 goto 是保留字,不可以使用,C++ 可以使用。
- Java 不支持条件编译,C++ 通过
#ifdef #ifndef
等预处理制冷可以实现条件编译。
JRE 与 JDK
- JRE 是 JVM 程序,Java 应用需要运行在 JRE 之上。
- JDK 是 JRE 的超集,带有 JRE 和用于编写 Java 程序的工具,比如 javac。
参考资料
- Java 基础 - 知识点 (pdai.tech)
- Eckel B. Java 编程思想. 机械工业出版社, 2002
- Bloch J. Effective java. Addison-Wesley Professional, 2017
1.1.3 - CH03-基本图谱
概述
- 术语
- JDK:Java 开发者工具,Java Developer’s Kit
- JRE:Java 运行时环境,Java Runtime Environment
- JVM:Java 虚拟机,Java Vitual Machine
- API:应用程序编程接口,Application Programming Interface
- IDE
- Eclipse
- Intellij IDEA
- 程序构造块
- package
- import
- class
- main
- 大括号
- 语句
- 注释
- 工作方式
- 先编译后执行
- Java 扩平台的根本
- 编译生成中间代码
- JVM 加载执行中间代码
- 先编译后执行
语言核心
语言元素
- 关键字:有特殊含义的单词
- 48 个可用
- 2 个保留:goto,const
- 标识符
- 字符、数字、下划线、
$
- 不能以数字开头
- 不能有
!
等特殊字符 - 不能是关键字
- 大小写敏感
- 驼峰风格
- 字符、数字、下划线、
- 运算符
- 赋值:
=
- 算术:
+,-,*,/,%
- 关系:
>,<,>=,<=,!=,==
- 短路:
&&,||
- 三目:
..?..:..
- 逻辑:
&,|,!
- 自增/减运:
++,--
- 下标运:
[]
- 类型运:
(ClassName)
- 其他:
- new
- instanceOf
- 位与
- 访问成员
- 赋值:
- 字面量:
- 整形:122
- 实数:3.14,2.1E-3
- 字符:
'a'
- 字符串:
"b"
- 布尔:true
- 引用:null
- 类型:
int.class, String.class
- 分隔符
变量/常量
数据类型
- 基本类型:
- byte-1
- short-2
- int-4
- long-8
- float-4
- double-8
- boolean
- char-2
- 枚举类型:符号常量
- 引用类型:对象
字符串
- 创建
String str = new String("abc")
String str = "abc";
- 操作
数组
- 一维数组:
int[] a = [1,2,3]
- 二维数组:
int[][] a = [[1,2,3],[4,5,6]]
循环
for
while
do-while
分支
- if-else
- switch-case
FAQ
使用哪种数据类型表示价格
如果不是特别关心内存和性能的话,使用BigDecimal,否则使用预定义精度的 double 类型。
怎么将 byte 转换为 String
可以使用 String 接收 byte[] 参数的构造器来进行转换,需要注意的点是要使用的正确的编码,否则会使用平台默认编码,这个编码可能跟原来的编码相同,也可能不同。
Java 中怎样将 bytes 转换为 long 类型
String接收bytes的构造器转成String,再 Long.parseLong。
我们能将 int 强制转换为 byte 类型的变量吗
是的,我们可以做强制转换,但是 Java 中 int 是 32 位的,而 byte 是 8 位的,所以,如果强制转化是,int 类型的高 24 位将会被丢弃,byte 类型的范围是从 -128 到 127。
存在两个类,B 继承 A,C 继承 B,我们能将 B 转换为 C 么? 如 C = (C) B
可以,向下转型。但是不建议使用,容易出现类型转型异常.
哪个类包含 clone 方法? 是 Cloneable 还是 Object?
java.lang.Cloneable 是一个标示性接口,不包含任何方法,clone 方法在 object 类中定义。并且需要知道 clone() 方法是一个本地方法,这意味着它是由 c 或 c++ 或 其他本地语言实现的。
Java 中 ++ 操作符是线程安全的吗?
不是线程安全的操作。它涉及到多个指令,如读取变量值,增加,然后存储回内存,这个过程可能会出现多个线程交差。还会存在竞态条件(读取-修改-写入)。
a = a + b 与 a += b 的区别
+= 隐式的将加操作的结果类型强制转换为持有结果的类型。如果两这个整型相加,如 byte、short 或者 int,首先会将它们提升到 int 类型,然后在执行加法操作。
我能在不进行强制转换的情况下将一个 double 值赋值给 long 类型的变量吗?
不行,你不能在没有强制类型转换的前提下将一个 double 值赋值给 long 类型的变量,因为 double 类型的范围比 long 类型更广,所以必须要进行强制转换。
3*0.1 == 0.3 将会返回什么? true 还是 false?
false,因为有些浮点数不能完全精确的表示出来。
int 和 Integer 哪个会占用更多的内存?
Integer 对象会占用更多的内存。Integer 是一个对象,需要存储对象的元数据。但是 int 是一个原始类型的数据,所以占用的空间更少。
为什么 Java 中的 String 是不可变的(Immutable)?
Java 中的 String 不可变是因为 Java 的设计者认为字符串使用非常频繁,将字符串设置为不可变可以允许多个客户端之间共享相同的字符串。更详细的内容参见答案。
我们能在 Switch 中使用 String 吗?
从 Java 7 开始,我们可以在 switch case 中使用字符串,但这仅仅是一个语法糖。内部实现在 switch 中使用字符串的 hash code。
Java 中的构造器链是什么?
当你从一个构造器中调用另一个构造器,就是Java 中的构造器链。这种情况只在重载了类的构造器的时候才会出现。
枚举类
JDK1.5出现 每个枚举值都需要调用一次构造函数
什么是不可变对象(immutable object)? Java 中怎么创建一个不可变对象?
不可变对象指对象一旦被创建,状态就不能再改变。任何修改都会创建一个新的对象,如 String、Integer及其它包装类。
- 对象的状态在创建之后就不能发生改变,任何对它的改变都应该产生一个新的对象。
- 类的所有的属性都应该是final的。
- 对象必须被正确的创建,比如: 对象引用在对象创建过程中不能泄露(leak)。
- 对象应该是final的,以此来限制子类继承父类,以避免子类改变了父类的immutable特性。
- 如果类中包含mutable类对象,那么返回给客户端的时候,返回该对象的一个拷贝,而不是该对象本身(该条可以归为第一条中的一个特例)
我们能创建一个包含可变对象的不可变对象吗?
是的,我们是可以创建一个包含可变对象的不可变对象的,你只需要谨慎一点,不要共享可变对象的引用就可以了,如果需要变化时,就返回原对象的一个拷贝。最常见的例子就是对象中包含一个日期对象的引用。
有没有可能两个不相等的对象有有相同的 hashcode?
有可能,两个不相等的对象可能会有相同的 hashcode 值,这就是为什么在 hashmap 中会有冲突。相等 hashcode 值的规定只是说如果两个对象相等,必须有相同的hashcode 值,但是没有关于不相等对象的任何规定。
两个相同的对象会有不同的的 hash code 吗?
不能,根据 hash code 的规定,这是不可能的。
我们可以在 hashcode() 中使用随机数字吗?
不行,因为对象的 hashcode 值必须是相同的。
Java 中,Comparator 与 Comparable 有什么不同?
Comparable 接口用于定义对象的自然顺序,而 comparator 通常用于定义用户定制的顺序。Comparable 总是只有一个,但是可以有多个 comparator 来定义对象的顺序。
为什么在重写 equals 方法的时候需要重写 hashCode 方法?
因为有强制的规范指定需要同时重写 hashcode 与 equal 是方法,许多容器类,如 HashMap、HashSet 都依赖于 hashcode 与 equals 的规定。
“a==b”和”a.equals(b)”有什么区别?
如果 a 和 b 都是对象,则 a==b 是比较两个对象的引用,只有当 a 和 b 指向的是堆中的同一个对象才会返回 true,而 a.equals(b) 是进行逻辑比较,所以通常需要重写该方法来提供逻辑一致性的比较。例如,String 类重写 equals() 方法,所以可以用于两个不同对象,但是包含的字母相同的比较。
a.hashCode() 有什么用? 与 a.equals(b) 有什么关系?
hashCode() 方法是相应对象整型的 hash 值。它常用于基于 hash 的集合类,如 Hashtable、HashMap、LinkedHashMap等等。
它与 equals() 方法关系特别紧密。根据 Java 规范,两个使用 equal() 方法来判断相等的对象,必须具有相同的 hash code。
final、finalize 和 finally 的不同之处?
- final 是一个修饰符,可以修饰变量、方法和类。如果 final 修饰变量,意味着该变量的值在初始化后不能被改变。
- Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的,但是什么时候调用 finalize 没有保证。
- finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常。
Java 中的编译期常量是什么? 使用它又什么风险?
变量也就是我们所说的编译期常量,这里的 public 可选的。实际上这些变量在编译时会被替换掉,因为编译器知道这些变量的值,并且知道这些变量在运行时不能改变。这种方式存在的一个问题是你使用了一个内部的或第三方库中的公有编译时常量,但是这个值后面被其他人改变了,但是你的客户端仍然在使用老的值,甚至你已经部署了一个新的jar。为了避免这种情况,当你在更新依赖 JAR 文件时,确保重新编译你的程序。
静态内部类与顶级类有什么区别?
一个公共的顶级类的源文件名称与类名相同,而嵌套静态类没有这个要求。一个嵌套类位于顶级类内部,需要使用顶级类的名称来引用嵌套静态类,如 HashMap.Entry 是一个嵌套静态类,HashMap 是一个顶级类,Entry是一个嵌套静态类。
Java 中,Serializable 与 Externalizable 的区别?
Serializable 接口是一个序列化 Java 类的接口,以便于它们可以在网络上传输或者可以将它们的状态保存在磁盘上,是 JVM 内嵌的默认序列化方式,成本高、脆弱而且不安全。Externalizable 允许你控制整个序列化过程,指定特定的二进制格式,增加安全机制。
说出 JDK 1.7 中的三个新特性?
虽然 JDK 1.7 不像 JDK 5 和 8 一样的大版本,但是,还是有很多新的特性,如 try-with-resource 语句,这样你在使用流或者资源的时候,就不需要手动关闭,Java 会自动关闭。Fork-Join 池某种程度上实现 Java 版的 Map-reduce。允许 Switch 中有 String 变量和文本。菱形操作符(<>)用于泛型推断,不再需要在变量声明的右边申明泛型,因此可以写出可读写更强、更简洁的代码。另一个值得一提的特性是改善异常处理,如允许在同一个 catch 块中捕获多个异常。
说出 5 个 JDK 1.8 引入的新特性?
Java 8 在 Java 历史上是一个开创新的版本,下面 JDK 8 中 5 个主要的特性: Lambda 表达式,允许像对象一样传递匿名函数 Stream API,充分利用现代多核 CPU,可以写出很简洁的代码 Date 与 Time API,最终,有一个稳定、简单的日期和时间库可供你使用 扩展方法,现在,接口中可以有静态、默认方法。 重复注解,现在你可以将相同的注解在同一类型上使用多次。
下述包含 Java 面试过程中关于 SOLID 的设计原则,OOP 基础,如类,对象,接口,继承,多态,封装,抽象以及更高级的一些概念,如组合、聚合及关联。也包含了 GOF 设计模式的问题。
接口是什么? 为什么要使用接口而不是直接使用具体类?
接口用于定义 API。它定义了类必须得遵循的规则。同时,它提供了一种抽象,因为客户端只使用接口,这样可以有多重实现,如 List 接口,你可以使用可随机访问的 ArrayList,也可以使用方便插入和删除的 LinkedList。接口中不允许普通方法,以此来保证抽象,但是 Java 8 中你可以在接口声明静态方法和默认普通方法。
Java 中,抽象类与接口之间有什么不同?
Java 中,抽象类和接口有很多不同之处,但是最重要的一个是 Java 中限制一个类只能继承一个类,但是可以实现多个接口。抽象类可以很好的定义一个家族类的默认行为,而接口能更好的定义类型,有助于后面实现多态机制 参见第六条。
Object有哪些公用方法?
clone equals hashcode wait notify notifyall finalize toString getClass 除了clone和finalize其他均为公共方法。
11个方法,wait被重载了两次
equals与==的区别
- == 是一个运算符 equals是Object类的方法
- 比较时的区别
- 用于基本类型的变量比较时: ==用于比较值是否相等,equals不能直接用于基本数据类型的比较,需要转换为其对应的包装类型。
- 用于引用类型的比较时。==和equals都是比较栈内存中的地址是否相等 。相等为true 否则为false。但是通常会重写equals方法去实现对象内容的比较。
String、StringBuffer与StringBuilder的区别
第一点: 可变和适用范围。String对象是不可变的,而StringBuffer和StringBuilder是可变字符序列。每次对String的操作相当于生成一个新的String对象,而对StringBuffer和StringBuilder的操作是对对象本身的操作,而不会生成新的对象,所以对于频繁改变内容的字符串避免使用String,因为频繁的生成对象将会对系统性能产生影响。
第二点: 线程安全。String由于有final修饰,是immutable的,安全性是简单而纯粹的。StringBuilder和StringBuffer的区别在于StringBuilder不保证同步,也就是说如果需要线程安全需要使用StringBuffer,不需要同步的StringBuilder效率更高。
接口与抽象类
- 一个子类只能继承一个抽象类,但能实现多个接口
- 抽象类可以有构造方法,接口没有构造方法
- 抽象类可以有普通成员变量,接口没有普通成员变量
- 抽象类和接口都可有静态成员变量,抽象类中静态成员变量访问类型任意,接口只能public static final(默认)
- 抽象类可以没有抽象方法,抽象类可以有普通方法,接口中都是抽象方法
- 抽象类可以有静态方法,接口不能有静态方法
- 抽象类中的方法可以是public、protected;接口方法只有public abstract
抽象类和最终类
抽象类可以没有抽象方法, 最终类可以没有最终方法
最终类不能被继承, 最终方法不能被重写(可以重载)
异常
相关的关键字 throw、throws、try…catch、finally
- throws 用在方法签名上, 以便抛出的异常可以被调用者处理
- throw 方法内部通过throw抛出异常
- try 用于检测包住的语句块, 若有异常, catch子句捕获并执行catch块
关于finally
- finally不管有没有异常都要处理
- 当try和catch中有return时,finally仍然会执行,finally比return先执行
- 不管有木有异常抛出, finally在return返回前执行
- finally是在return后面的表达式运算后执行的(此时并没有返回运算后的值,而是先把要返回的值保存起来,管finally中的代码怎么样,返回的值都不会改变,仍然是之前保存的值),所以函数返回值是在finally执行前确定的
注意: finally中最好不要包含return,否则程序会提前退出,返回值不是try或catch中保存的返回值
finally不执行的几种情况: 程序提前终止如调用了 System.exit、断电
受检查异常和运行时异常
- 受检查的异常(checked exceptions),其必须被try…catch语句块所捕获, 或者在方法签名里通过throws子句声明。受检查的异常必须在编译时被捕捉处理,命名为Checked Exception是因为Java编译器要进行检查, Java虚拟机也要进行检查, 以确保这个规则得到遵守。
- 运行时异常(runtime exceptions), 需要程序员自己分析代码决定是否捕获和处理,比如空指针,被0除…
- Error的,则属于严重错误,如系统崩溃、虚拟机错误、动态链接失败等,这些错误无法恢复或者不可能捕捉,将导致应用程序中断,Error不需要捕获。
super出现在父类的子类中。有三种存在方式
- super.xxx(xxx为变量名或对象名)意思是获取父类中xxx的变量或引用
- super.xxx(); (xxx为方法名)意思是直接访问并调用父类中的方法
- super() 调用父类构造
this() & super()在构造方法中的区别
- 调用super()必须写在子类构造方法的第一行, 否则编译不通过
- super从子类调用父类构造, this在同一类中调用其他构造均需要放在第一行
- 尽管可以用this调用一个构造器, 却不能调用2个
- this和super不能出现在同一个构造器中, 否则编译不通过
- this()、super()都指的对象,不可以在static环境中使用
- 本质this指向本对象的指针。super是一个关键字
序列化
声明为static和transient类型的数据不能被序列化, 反序列化需要一个无参构造函数
Java移位运算符
<<
:左移运算符,x << 1
,相当于x乘以2(不溢出的情况下),低位补0>>
:带符号右移,x >> 1
,相当于x除以2,正数高位补0,负数高位补1>>>
:无符号右移,忽略符号位,空位都以0补齐
形参&实参
形式参数可被视为local variable.形参和局部变量一样都不能离开方法。只有在方法中使用,不会在方法外可见。 形式参数只能用final修饰符,其它任何修饰符都会引起编译器错误。但是用这个修饰符也有一定的限制,就是在方法中不能对参数做任何修改。不过一般情况下,一个方法的形参不用final修饰。只有在特殊情况下,那就是: 方法内部类。一个方法内的内部类如果使用了这个方法的参数或者局部变量的话,这个参数或局部变量应该是final。 形参的值在调用时根据调用者更改,实参则用自身的值更改形参的值(指针、引用皆在此列),也就是说真正被传递的是实参。
局部变量为什么要初始化
局部变量是指类方法中的变量,必须初始化。局部变量运行时被分配在栈中,量大,生命周期短,如果虚拟机给每个局部变量都初始化一下,是一笔很大的开销,但变量不初始化为默认值就使用是不安全的。出于速度和安全性两个方面的综合考虑,解决方案就是虚拟机不初始化,但要求编写者一定要在使用前给变量赋值。
Java语言的鲁棒性
Java在编译和运行程序时,都要对可能出现的问题进行检查,以消除错误的产生。它提供自动垃圾收集来进行内存管理,防止程序员在管理内存时容易产生的错误。通过集成的面向对象的例外处理机制,在编译时,Java揭示出可能出现但未被处理的异常,帮助程序员正确地进行选择以防止系统的崩溃。另外,Java在编译时还可捕获类型声明中的许多常见错误,防止动态运行时不匹配问题的出现。
1.1.4 - CH04-泛型机制
概览
Java 的泛型特性由 JDK 1.5 引入,因此为了兼容之前的版本,Java 泛型的实现采用了“伪泛型”策略。即 Java 在语法上支持泛型,但是在编译阶段会执行“类型擦除(Type Erasure)”,将所有的泛型表示都替换为实际的类型,就像完全没有泛型一样。
泛型的本质是为了参数化类型,在不创建新类型的情况下,通过泛型指定的不同类型来控制形式化参数所限制的具体类型。也就是说在泛型的使用过程中,操作的数据类型被指定为一个参数,该类型参数可以用在类、接口、方法中,分别被称为泛型类、泛型接口、泛型方法。
引入泛型的最直接意义在于:
- 适用于多种数据类型执行相同的代码逻辑(代码复用)。
- 泛型中的类型在使用时指定,不需要强制类型转换(类型安全,由编译器执行检查)。
基本使用
泛型类
class Point<T> {
private T var;
public T getVar() {
return var;
}
public void setVar(T var){
this.var = var;
}
}
Point<String> point = new Point<>();
pioint.setVar("value");
泛型接口
interface Info<T> {
T getVar();
}
class InfoImpl<T> implements Info<T> {
private T var;
}
泛型方法
class Caster {
public static <T> T castAs(Class<T> clazz, Object value) {
return clazz.cast(value);
}
}
泛型边界
- 参考 “泛型型变-Scala”。
泛型擦除
擦除原则:
- 消除类型参数声明,即删除
<>
及其包围的部分。 - 根据类型参数的上下界推断并替换所有的类型参数为具体类型:
- 如果类型参数是无限制通配符或没有上下界限定则替换为 Object。
- 如果存在上下界限定则根据子类替换原则取类型参数的最左侧限定类型,即父类。
- 为了保证类型安全,必要时插入强制类型转换代码。
- 自动产生“桥接方法”以保证类型擦除后的代码仍然具有泛型的“多态性”。
如何执行类型擦除:
参数类定义中的类型参数:无限制类型擦除
当类定义中类型参数没有任何限制时,直接将其替换为 Object,如
<T>
或<?>
会被直接替换为 Object。擦除类定义中的类型参数:有限制类型擦除
如果类定义中的类型参数有上下界限定,在擦除时将其替换为类型参数的上界或下界,如
<T extends Number>
和<? extends Number>
会被替换为 Number,<? super Number>
会被替换为 Object。擦除方法定义中的类型参数
原则和擦除类定义中的类型参数一致,这里仅擦除方法定义中的有限制类型参数为例。
如何证明类型擦除
原始类型相等
ArrayList<String> list1 = new ArrayList<>(); list1.add("abc"); ArrayList<Integer> list2 = new ArrayList<>(); list2.add(123); assert list1.getClass() == list2.getClass();
通过反射添加其他类型的元素
ArrayList<Integer> list = new ArrayList<>(); list.add(1); list.getClass().getMethod("add", Object.class).invoke(list, "abc");
类型擦除后的原生类型(Raw Type)
原生类型为擦除掉泛型信息后,保留在字节码中的类型变量的真正类型,无论何时定义一个泛型类型,对应的原始类型都会自动提供,类型变量擦除,并使用其限定类型或 Object 替换。
如何理解编译期检查
Java 编译器首先检查代码中泛型的类型,然后再执行类型擦除,然后再执行编译。
基于代码的类型检查是如何进行的呢?
比如以下代码:
ArrayList<String> list1 = new ArrayList(); //第一种 情况
ArrayList list2 = new ArrayList<String>(); //第二种 情况
以上代码可以编译通过,且不会出现变异警告。但是仅第一种可以实现与完全使用泛型参数一样的效果,第二种不行。
因为类型检查就是编译时完成的,new ArrayList()
只是在内存中开辟一个存储空间,可以存储任何类型的对象,而真正涉及类型检查的是它的引用,因为我们是通过引用 list1 来调用它的方法,比如调用其 add 方法,所以 list1 引用能能完成泛型类型的检查。而引用 list2 没有使用泛型,所以不行。
ArrayList<String> list1 = new ArrayList();
list1.add("1"); //编译通过
list1.add(1); //编译错误
String str1 = list1.get(0); //返回类型就是String
ArrayList list2 = new ArrayList<String>();
list2.add("1"); //编译通过
ist2.add(1); //编译通过
Object object = list2.get(0); //返回类型就是Object
new ArrayList<String>().add("11"); //编译通过
new ArrayList<String>().add(22); //编译错误
String str2 = new ArrayList<String>().get(0); //返回类型就是String
因此可以得出,类型检查就是针对引用执行的检查,谁是一个引用,通过该引用调用泛型方法,就会对这个引用调用的方法进行类型检测,而无关它真正引用的对象。
泛型多态/桥接方法
类型擦除会造成多态冲突,而 JVM 通过桥接方法类解决。
class Pair<T> {
private T value;
public T getValue() {
return value;
}
public void setValue(T value) {
this.value = value;
}
}
然后实现一个子类:
class DateInter extends Pair<Date> {
@Override
public void setValue(Date value) {
super.setValue(value);
}
@Override
public Date getValue() {
return super.getValue();
}
}
在这个子类中,我们设定父类的泛型类型为Pair<Date>
,在子类中,我们覆盖了父类的两个方法,我们的原意是这样的:将父类的泛型类型限定为 Date,那么父类里面的两个方法的参数都为 Date 类型。
实际上类型擦除后,父类的泛型类型全部变成了 Object,所以父类编译之后会成为如下形式:
class Pair {
private Object value;
public Object getValue() {
return value;
}
public void setValue(Object value) {
this.value = value;
}
}
这时子类中的两个重写方法:
@Override
public void setValue(Date value) {
super.setValue(value);
}
@Override
public Date getValue() {
return super.getValue();
}
首先是 setValue 方法,父类的类型是 Object,而子类的类型是 Date,参数类型不一样,如果是在普通的继承体系中,根本就不会是重写,而是重载。但是如果通过基于重载的形式以子类来调用父类的方法:
DateInter dateInter = new DateInter();
dateInter.setValue(new Date());
dateInter.setValue(new Object()); //编译错误
如果是重载,那么子类中会有两个 setValue 方法,一个参数为 Object,一个参数为 Date,但编译错误可以得出这里实际上是重写。
原因是,当我们集成父类并设定其类型参数为 Date,期望将泛型类变成如下形式:
class Pair {
private Date value;
public Date getValue() {
return value;
}
public void setValue(Date value) {
this.value = value;
}
}
然后在子类中重写参数类型为 Date 的两个方法,实现继承中的多态。
但是由于种种原因,JVM 并不能将泛型类型转换为 Date,只能将其擦除,称为原生类型 Object。这样我们本意是重写来实现多态,但类型擦除后只能变为了重载。从而导致类型擦除与多态的冲突。因此 JVM 决定通过桥接来解决该问题。
首先通过命令 javap -c className
的方式反编译 DateInter 子类的字节码,结果如下:
class com.tao.test.DateInter extends com.tao.test.Pair<java.util.Date> {
com.tao.test.DateInter();
Code:
0: aload_0
1: invokespecial #8 // Method com/tao/test/Pair."<init>":()V
4: return
public void setValue(java.util.Date); //我们重写的setValue方法
Code:
0: aload_0
1: aload_1
2: invokespecial #16 // Method com/tao/test/Pair.setValue:(Ljava/lang/Object;)V
5: return
public java.util.Date getValue(); //我们重写的getValue方法
Code:
0: aload_0
1: invokespecial #23 // Method com/tao/test/Pair.getValue:()Ljava/lang/Object;
4: checkcast #26 // class java/util/Date
7: areturn
public java.lang.Object getValue(); //编译时由编译器生成的巧方法
Code:
0: aload_0
1: invokevirtual #28 // Method getValue:()Ljava/util/Date 去调用我们重写的getValue方法;
4: areturn
public void setValue(java.lang.Object); //编译时由编译器生成的巧方法
Code:
0: aload_0
1: aload_1
2: checkcast #26 // class java/util/Date
5: invokevirtual #30 // Method setValue:(Ljava/util/Date; 去调用我们重写的setValue方法)V
8: return
}
从编译结果来看,我们原本要重写方法实际上成了 4 个方法,最后两个方法即为编译器自动生成的桥接方法。可以发现桥接方法的参数类型都是 Object,也就是说,子类中真正覆盖父类方法的就是这两个桥接方法。桥接方法的内部会执行类型转换并调用我们重写的那两个方法。
基本类型不能作为泛型参数
比如只能使用 List<Integer>
而不能使用 List<int>
。因为当类型擦除后,List 的原生类型变为 Object,但是 Object 不能存储 int 值,只能是引用类型 Integer 的值。
然后实际操作中使用的 list.add(1)
,则是基本类型的自动装箱机制。
泛型类型不能实例化
T t = new T();
以上代码会直接报错。因为 Java 编译期没法确定泛型参数的实际类型,也就找不到对应的类的字节码文件,所以自然就不能完成编译。此外由于 T 被擦除为 Object,如果可以执行 new T()
,则就变成了 new Object()
,失去了代码的本意。
泛型数组:通过具体类型参数初始化
List<String>[] lsa = new List<String>[10]; // Not really allowed.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Unsound, but passes run time store check
String s = lsa[1].get(0); // Run-time error ClassCastException.
由于擦除机制,上面代码可以给 oa[1]
赋值为 ArrayList 类型的变量也不会出现异常。但是在取出数据时要做一次类型转换,所以就会出现 ClassCastException。
如果可以执行泛型数组的声明则上面这种情况就不会出现任何警告和错误,只有在运行时才会报错,但是泛型的出现就是为了避免转型错误,所有 Java 支持泛型数组初始化则与初衷违背。
下面的代码是成立的:
List<?>[] lsa = new List<?>[10]; // OK, array of unbounded wildcard type.
Object o = lsa;
Object[] oa = (Object[]) o;
List<Integer> li = new ArrayList<Integer>();
li.add(new Integer(3));
oa[1] = li; // Correct.
Integer i = (Integer) lsa[1].get(0); // OK
所以说采用通配符的方式初始化泛型数组是允许的,因为对于通配符的方式是最后取出数据时才执行转型,符合预期逻辑。
Java 的泛型数组初始化时数组类型不能是具体的泛型类型,只能是通配符的形式,因为具体类型会导致可存入任意类型对象,在取出时会发生类型转换异常,会与泛型的设计思想冲突,而通配符形式本来就需要自己强转,符合预期。
泛型数组:正确的初始化数组实例
无论是 new ArrayList[10]
的形式还是通过泛型通配符的形式来初始化都存在转型异常风险。
在需要使用泛型数组时应尽量使用列表集合替代,此外也可以通过反射来创建一个具有指定类型的数组:
public class ArrayWithTypeToken<T> {
private T[] array;
public ArrayWithTypeToken(Class<T> type, int size) {
array = (T[]) Array.newInstance(type, size);
}
public void put(int index, T item) {
array[index] = item;
}
public T get(int index) {
return array[index];
}
public T[] create() {
return array;
}
}
//...
ArrayWithTypeToken<Integer> arrayToken = new ArrayWithTypeToken<Integer>(Integer.class, 100);
Integer[] array = arrayToken.create();
泛型类中的静态方法与静态变量
public class Test<T> {
public static T one; //编译错误
public static T show(T one){ //编译错误
return null;
}
}
因为泛型类中的类型参数是在定义对象时才指定的,而静态变量或静态方法不需要使用对象实例来调用。对象未被创建,如何确定该泛型参数的具体类型,所以错误。
public class Test<T> {
public static <T> T show(T one){ //这是正确的
return null;
}
}
注意这里 show 方法的类型参数 T 并非 Test 类的类型参数 T。
异常中使用泛型
- 不能抛出也不能捕获泛型对象。继承 Throwable 时使用泛型也是非法的。
- 因为异常仅在运行时抛出,编译期擦除参数类型后无法区分不同具体类型的泛型异常。
- 不能在 catch 子句中使用泛型变量
- 因为泛型信息会被擦除为原生类型,
catch(T e)
会变成catch(Throwable e)
- 根据异常捕获原则,如果下面还有别的 catch 语句来处理其他异常类型,导致冲突。
- 因为泛型信息会被擦除为原生类型,
- 可以在声明中抛出参数啊类型的异常,
<T extends Throwable> void do(T t) throws T
如何获取类型参数
java.lang.reflect.Type
是 Java 中所有类型的公共高级接口,代表了所有 Java 中的类型。Type 体系中的类型包括:
- 数组类型:GenericArrayType
- 参数化类型:ParameterizedType
- 类型变量:TypeVariable
- 通配符类型:WildcardType
- 原始类型:Class
- 基本类型:Class
1.1.5 - CH05-注解机制
概览
注解是 JDK 1.5 版本开始引入的一个特性,用于对代码进行说明,可以对包、类、接口、字段、方法参数、局部变量等进行注解。它主要的作用有以下四方面:
生成文档,通过代码里标识的元数据生成javadoc文档。
编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证。
编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码。
运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例。
比如:
- Java自带的标准注解,包括
@Override
、@Deprecated
和@SuppressWarnings
,分别用于标明重写某个方法、标明某个类或方法过时、标明要忽略的警告,用这些注解标明后编译器就会进行检查。 - 元注解,元注解是用于定义注解的注解,包括
@Retention
、@Target
、@Inherited
、@Documented
,@Retention
用于标明注解被保留的阶段,@Target
用于标明注解使用的范围,@Inherited
用于标明注解可继承,@Documented
用于标明是否生成javadoc文档。 - 自定义注解,可以根据自己的需求定义注解,并可用元注解对自定义注解进行注解。
内置注解
- @Override
- @Deprecated
- @SuppressWarnings
元注解
- @Target
- @Retention,@RetentionTarget
- @Documented
- @Inherited
- @Repeatable
- @Native
注解与反射接口
通过反射工具包 java.lang.reflect
中的 AnnotatedElement
可以访问注解信息。注意仅在注解被声明为 RUNTIME 时,才能在运行时获取注解信息,当 class 文件被加载时保存在 class 文件中的注解信息才会被 JVM 读取。
AnnotatedElement 接口是所有成语元素的父接口,所有程序通过反射获得了某个类的 AnnotatedElement 对象之后,就可以调用该对象的方法来访问具体的注解信息:
boolean isAnnotationPresent(Class<?extends Annotation> annotationClass)
<T extends Annotation> T getAnnotation(Class<T> annotationClass)
Annotation[] getAnnotations()
<T extends Annotation> T[] getAnnotationsByType(Class<T> annotationClass)
<T extends Annotation> T[] getDeclaredAnnotationsByType(Class<T> annotationClass)
Annotation[] getDeclaredAnnotations()
自定义注解
定义注解:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyMethodAnnotation {
public String title() default "";
public String description() default "";
}
使用注解:
public class TestMethodAnnotation {
@Override
@MyMethodAnnotation(title = "toStringMethod", description = "override toString method")
public String toString() {
return "Override toString method";
}
@Deprecated
@MyMethodAnnotation(title = "old static method", description = "deprecated old static method")
public static void oldMethod() {
System.out.println("old method, don't use it.");
}
@SuppressWarnings({"unchecked", "deprecation"})
@MyMethodAnnotation(title = "test method", description = "suppress warning static method")
public static void genericsTest() throws FileNotFoundException {
List l = new ArrayList();
l.add("abc");
oldMethod();
}
}
读取注解:
Method[] methods = TestMethodAnnotation.class.getClassLoader()
.loadClass(("com.pdai.java.annotation.TestMethodAnnotation"))
.getMethods();
for (Method method : methods) {
// 方法上是否有MyMethodAnnotation注解
if (method.isAnnotationPresent(MyMethodAnnotation.class)) {
try {
// 获取并遍历方法上的所有注解
for (Annotation anno : method.getDeclaredAnnotations()) {
System.out.println("Annotation in Method '"
+ method + "' : " + anno);
}
// 获取MyMethodAnnotation对象信息
MyMethodAnnotation methodAnno = method
.getAnnotation(MyMethodAnnotation.class);
System.out.println(methodAnno.title());
} catch (Throwable ex) {
ex.printStackTrace();
}
}
}
注解特性
Java 8 新特性
- @Repeatable
- @ElementType.TYPE_USE
- @ElementType.TYPE_PARAMETER
TYPE_USE 包括类型声明和类型参数声明,是为了方便设计者进行类型检查。
// 自定义ElementType.TYPE_PARAMETER注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_PARAMETER)
public @interface MyNotEmpty {
}
// 自定义ElementType.TYPE_USE注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE_USE)
public @interface MyNotNull {
}
// 测试类
public class TypeParameterAndTypeUseAnnotation<@MyNotEmpty T>{
//使用TYPE_PARAMETER类型,会编译不通过
// public @MyNotEmpty T test(@MyNotEmpty T a){
// new ArrayList<@MyNotEmpty String>();
// return a;
// }
//使用TYPE_USE类型,编译通过
public @MyNotNull T test2(@MyNotNull T a){
new ArrayList<@MyNotNull String>();
return a;
}
}
1.1.6 - CH06-异常机制
概览
Java 异常是 Java 提供的一种识别及响应错误的机制,Java 异常机制可以使程序中异常处理的代码和正常业务代码分离,保证程序代码更加优雅,并提高程序的健壮性。
层次结构
异常是指非预期的各种状况,如:文件找不到、网络连接失败、非法参数等。异常是一个事件,它发生在程序运行期间,干扰了正常的指令流程。Java 通过 API 中 Throwable 类的众多子类描述各种不同的异常。因此,Java 异常都是对象,是 Throwable 子类的实例,描述了出现在一段编码中的错误条件。当条件生成时,错误将引发异常。
Throwable
Throwable 是 Java 语言中所有错误与异常的超类。
Throwable 包含两个子类:Error(错误)和 Exception(异常),它们通常用于指示发生了异常情况。
Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。
Error
Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。
此类错误一般表示代码运行时 JVM 出现问题。通常有 Virtual MachineError(虚拟机运行错误)、NoClassDefFoundError(类定义错误)等。比如 OutOfMemoryError:内存不足错误;StackOverflowError:栈溢出错误。此类错误发生时,JVM 将终止线程。
这些错误是不受检异常,非代码性错误。因此,当此类错误发生时,应用程序不应该去处理此类错误。按照 Java 惯例,我们是不应该实现任何新的 Error 子类的!
Exception
程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常。
- 运行时异常
- 都是 RuntimeException 类及其子类异常,如 NullPointerException(空指针异常)、IndexOutOfBoundsException(下标越界异常)等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免这类异常的发生。
- 运行时异常的特点是 Java 编译器不会检查它,也就是说,当程序中可能出现这类异常,即使没有用 try-catch 语句捕获它,也没有用 throws 子句声明抛出它,也会编译通过。
- 编译期异常
- 是 RuntimeException 以外的异常,类型上都属于 Exception 类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如 IOException、SQLException 等以及用户自定义的 Exception 异常,一般情况下不自定义检查异常。
受检异常/非受检异常
- 受检异常:编译器妖气必须处理的异常
- 正确的程序在运行中,很容易出现的、情理可容的异常状况。可查异常虽然是异常状况,但在一定程度上它的发生是可以预计的,而且一旦发生这种异常状况,就必须采取某种方式进行处理。
- 除了 RuntimeException 及其子类以外,其他的 Exception 类及其子类都属于受检异常。这种异常的特点是 Java 编译器会检查它,也就是说,当程序中可能出现这类异常,要么用 try-catch 语句捕获它,要么用 throws 子句声明抛出它,否则编译不会通过。
- 非受检异常:编译器不强制要求检查的异常
- 包括运行时异常(RuntimeException与其子类)和错误(Error)。
异常基础
关键字
- try:监听异常
- catch:捕获异常
- finally:总是执行
- throw:抛出异常
- throws:声明异常
异常捕获
- try-catch
- try-catch-finally
- try-finally
- try-with-resource: AutoCloseable
常见异常
- RuntimeException
- ArrayIndexOutOfBoundsException:数组索引越界
- ArithmeticException:除零异常
- NullPointerException:空指针
- ClassNotFoundException:类加载
- NegativeArraySizeException:数组长度为负
- ArrayStoreException:数组元素类型不兼容
- SecurityException:安全性异常
- IllegalArgumentException:非法参数
- IOException
- IOException:输出输出流异常
- EOFException:文件结束
- FileNotFoundException:文件未找到
- Others
- ClassCastException
- SQLException
- NoSuchFieldException
- NoSuchMethodException
- NumberFormatException
- StringIndexOutOfBoundsException
- IllegalAccessException
- InstantiationException
应用实践
- 在恰当的级别处理异常,即知道该如何处理的地方及时处理异常。
- 解决问题并且重新调用产生异常的方法。
- 进行少许修补,然后绕过异常发生的地方继续执行。
- 用别的数据进行计算,以代替方法预计会返回的值。
- 把当前运行环境下能做的事尽量做完,然后把相同的异常重抛到更高层。
- 终止程序。
- 进行简化。
- 让类库和程序更安全。
- 不要通过异常来实现业务流程。
底层机制
try-catch
public static void simpleTryCatch() {
try {
testNPE();
} catch (Exception e) {
e.printStackTrace();
}
}
通过 javap 的带编译后的代码:
//javap -c Main
public static void simpleTryCatch();
Code:
0: invokestatic #3 // Method testNPE:()V
3: goto 11
6: astore_0
7: aload_0
8: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception
异常表中包含了一个或多个异常处理器的信息,内容如下:
- from:可能发生异常的起始点
- to:可能发生异常的结束点
- target:从 from 到 to 之间发生异常后异常处理器的位置
- type:异常处理器所处理的异常类型
当发生一个异常时:
- JVM 会在当前出现异常的方法中查找异常表,检查是否有合适的处理器
- 如果当前方法的异常表不为空,且异常符合表中 from to 的范围,同时 type 匹配,则 JVM 将调用位于 target 的处理器来处理异常
- 如果上一步没有找到处理器,则继续检查异常表中的下一项
- 如果当前异常表无法处理,则向上查找(弹栈)调用方法的调用点,并重复 1-3 步的操作。
- 如果所有栈帧都被弹出,仍然没有处理,则抛出异常给当前 Thread,Thread 会终止。
- 如果当前 Thread 为最后一个非守护线程,且异常未处理,则导致 JVM 终止运行。
try-catch-finally
public static void simpleTryCatchFinally() {
try {
testNPE();
} catch (Exception e) {
e.printStackTrace();
} finally {
System.out.println("Finally");
}
}
通过 javap 得到异常表部分:
public static void simpleTryCatchFinally();
Code:
0: invokestatic #3 // Method testNPE:()V
3: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
6: ldc #7 // String Finally
8: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
11: goto 41
14: astore_0
15: aload_0
16: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V
19: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
22: ldc #7 // String Finally
24: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: goto 41
30: astore_1
31: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
34: ldc #7 // String Finally
36: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
39: aload_1
40: athrow
41: return
Exception table:
from to target type
0 3 14 Class java/lang/Exception
0 3 30 any
14 19 30 any
- 如果 0-3 之间发生了 Exception 异常,调用 14 位置的处理器。
- 如果 0-3 之间无论发生哪种异常,都调用 30 位置的处理器。
- 如果 14-19(catch) 之间无论发生什么异常,都调用 30 位置的处理器。
注意异常表会代码中 finally 的部分同时在异常表的 catch 和 finally 部分注册,以实现无论是否发生异常都执行的逻辑。
catch 先后顺序
先 catch 类层级较高的异常类型会导致编译错误。
return 与 finally
public static String tryCatchReturn() {
try {
testNPE();
return "OK";
} catch (Exception e) {
return "ERROR";
} finally {
System.out.println("tryCatchReturn");
}
}
这里无论是否抛出一查,finally 部分都会执行。
性能损耗
创建一个异常对象是创建一个普通对象所需耗时的几十倍,抛出、捕获异常则是创建异常对象所需耗时的数倍。
1.1.7 - CH07-反射机制
反射基础
Runtime Type Identification(RTTI) 运行时类型识别,作用是在运行时识别一个对象的类型和类信息。主要有两种方式:
- 传统的的 RTTI,它假设我们在编译期就知道了所有的类型。
- 反射机制,它允许我们在运行时发现和使用类的信息。
Class 类
Class 类就像 String 类、Object 类一样,是一个实实在在的类,存在与 java.lang 包中。Class 类的实例表示 java 应用运行时的类(class ans enum)或接口(interface and annotation),可以通过 类名.class
、类型.class
、Class.forName(类名)
等方法获取 Class 类的对象实例。
数组同样也被映射为为 class 对象的一个类,所有具有相同元素类型和维数的数组都共享该 Class 对象。基本类型boolean,byte,char,short,int,long,float,double 和关键字 void 同样表现为 class 对象。
- Class 类也是类的一种,与 class 关键字是不一样的。
- 手动编写的类被编译后会产生一个 Class 对象,其表示的是创建的类的类型信息,而且这个 Class 对象保存在 同名.class 的文件中(字节码文件)
- 每个通过关键字 class 标识的类,在内存中有且只有一个与之对应的 Class 对象来描述其类型信息,无论创建多少个实例对象,其依据的都是用一个Class对象。
- Class 类只有私有构造函数,因此对应 Class 对象只能由 JVM 创建和加载。
- Class 类的对象作用是运行时提供或获得某个对象的类型信息。
类加载
类加载流程:
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
反射应用
在Java中,Class类与java.lang.reflect类库一起对反射技术进行了全力的支持。在反射包中,我们常用的类主要有Constructor类表示的是Class 对象所表示的类的构造方法,利用它可以在运行时动态创建对象、Field表示Class对象所表示的类的成员变量,通过它可以在运行时动态修改成员变量的属性值(包含private)、Method表示Class对象所表示的类的成员方法,通过它可以动态调用对象的方法(包含private),下面将对这几个重要类进行分别说明。
Class 对象
- 类名.class
- 对象.getClass()
- 完全限定名:Class.forName(全限定类名)
Class 方法
- forName()
- Object.getClass()
- getName():类的全限定名,可用于 Class.forName
- getSimpleName():仅类名
- getCanonicalName():更易理解的完全限定名,数组时表示不同
- isInterface()
- getInterfaces()
- getSupercalss()
- newInstance()
- getFields()
- getDeclaredFields()
- getConstructors()
- …
Constructor
Field
Method
反射流程
public class HelloReflect {
public static void main(String[] args) {
try {
// 1. 使用外部配置的实现,进行动态加载类
TempFunctionTest test = (TempFunctionTest)Class.forName("com.tester.HelloReflect").newInstance();
test.sayHello("call directly");
// 2. 根据配置的函数名,进行方法调用(不需要通用的接口抽象)
Object t2 = new TempFunctionTest();
Method method = t2.getClass().getDeclaredMethod("sayHello", String.class);
method.invoke(test, "method invoke");
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchMethodException e ) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
}
}
public void sayHello(String word) {
System.out.println("hello," + word);
}
}
反射获取类实例
通过 Class 的静态方法,获取类信息:
@CallerSensitive
public static Class<?> forName(String className) throws ClassNotFoundException {
// 先通过反射,获取调用进来的类信息,从而获取当前的 classLoader
Class<?> caller = Reflection.getCallerClass();
// 调用native方法进行获取class信息
return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
最后,JVM 会回调 ClassLoader 执行类加载:
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
// sun.misc.Launcher
public Class<?> loadClass(String var1, boolean var2) throws ClassNotFoundException {
int var3 = var1.lastIndexOf(46);
if(var3 != -1) {
SecurityManager var4 = System.getSecurityManager();
if(var4 != null) {
var4.checkPackageAccess(var1.substring(0, var3));
}
}
if(this.ucp.knownToNotExist(var1)) {
Class var5 = this.findLoadedClass(var1);
if(var5 != null) {
if(var2) {
this.resolveClass(var5);
}
return var5;
} else {
throw new ClassNotFoundException(var1);
}
} else {
return super.loadClass(var1, var2);
}
}
// java.lang.ClassLoader
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 先获取锁
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 如果已经加载了的话,就不用再加载了
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 双亲委托加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 父类没有加载到时,再自己加载
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Object getClassLoadingLock(String className) {
Object lock = this;
if (parallelLockMap != null) {
// 使用 ConcurrentHashMap来保存锁
Object newLock = new Object();
lock = parallelLockMap.putIfAbsent(className, newLock);
if (lock == null) {
lock = newLock;
}
}
return lock;
}
protected final Class<?> findLoadedClass(String name) {
if (!checkName(name))
return null;
return findLoadedClass0(name);
}
以下是 newInstance 的实现:
// 首先肯定是 Class.newInstance
@CallerSensitive
public T newInstance()
throws InstantiationException, IllegalAccessException
{
if (System.getSecurityManager() != null) {
checkMemberAccess(Member.PUBLIC, Reflection.getCallerClass(), false);
}
// NOTE: the following code may not be strictly correct under
// the current Java memory model.
// Constructor lookup
// newInstance() 其实相当于调用类的无参构造函数,所以,首先要找到其无参构造器
if (cachedConstructor == null) {
if (this == Class.class) {
// 不允许调用 Class 的 newInstance() 方法
throw new IllegalAccessException(
"Can not call newInstance() on the Class for java.lang.Class"
);
}
try {
// 获取无参构造器
Class<?>[] empty = {};
final Constructor<T> c = getConstructor0(empty, Member.DECLARED);
// Disable accessibility checks on the constructor
// since we have to do the security check here anyway
// (the stack depth is wrong for the Constructor's
// security check to work)
java.security.AccessController.doPrivileged(
new java.security.PrivilegedAction<Void>() {
public Void run() {
c.setAccessible(true);
return null;
}
});
cachedConstructor = c;
} catch (NoSuchMethodException e) {
throw (InstantiationException)
new InstantiationException(getName()).initCause(e);
}
}
Constructor<T> tmpConstructor = cachedConstructor;
// Security check (same as in java.lang.reflect.Constructor)
int modifiers = tmpConstructor.getModifiers();
if (!Reflection.quickCheckMemberAccess(this, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
if (newInstanceCallerCache != caller) {
Reflection.ensureMemberAccess(caller, this, null, modifiers);
newInstanceCallerCache = caller;
}
}
// Run constructor
try {
// 调用无参构造器
return tmpConstructor.newInstance((Object[])null);
} catch (InvocationTargetException e) {
Unsafe.getUnsafe().throwException(e.getTargetException());
// Not reached
return null;
}
}
newInstance 的主要逻辑:
- 权限检测,如果不通过则直接报错
- 查找无参构造器,并将其缓存
- 调用具体方法的无参构造方法,生成实例并返回
以下是获取构造器过程:
private Constructor<T> getConstructor0(Class<?>[] parameterTypes,
int which) throws NoSuchMethodException
{
// 获取所有构造器
Constructor<T>[] constructors = privateGetDeclaredConstructors((which == Member.PUBLIC));
for (Constructor<T> constructor : constructors) {
if (arrayContentsEq(parameterTypes,
constructor.getParameterTypes())) {
return getReflectionFactory().copyConstructor(constructor);
}
}
throw new NoSuchMethodException(getName() + ".<init>" + argumentTypesToString(parameterTypes));
}
获取构造器分为三步:
- 先获取所有的 constructors, 然后执行参数类型比较;
- 如果存在匹配,通过 ReflectionFactory copy 一份 constructor 返回;
- 否则抛出 NoSuchMethodException;
下面是获取所有构造器的过程:
// 获取当前类所有的构造方法,通过jvm或者缓存
// Returns an array of "root" constructors. These Constructor
// objects must NOT be propagated to the outside world, but must
// instead be copied via ReflectionFactory.copyConstructor.
private Constructor<T>[] privateGetDeclaredConstructors(boolean publicOnly) {
checkInitted();
Constructor<T>[] res;
// 调用 reflectionData(), 获取保存的信息,使用软引用保存,从而使内存不够可以回收
ReflectionData<T> rd = reflectionData();
if (rd != null) {
res = publicOnly ? rd.publicConstructors : rd.declaredConstructors;
// 存在缓存,则直接返回
if (res != null) return res;
}
// No cached value available; request value from VM
if (isInterface()) {
@SuppressWarnings("unchecked")
Constructor<T>[] temporaryRes = (Constructor<T>[]) new Constructor<?>[0];
res = temporaryRes;
} else {
// 使用native方法从jvm获取构造器
res = getDeclaredConstructors0(publicOnly);
}
if (rd != null) {
// 最后,将从jvm中读取的内容,存入缓存
if (publicOnly) {
rd.publicConstructors = res;
} else {
rd.declaredConstructors = res;
}
}
return res;
}
// Lazily create and cache ReflectionData
private ReflectionData<T> reflectionData() {
SoftReference<ReflectionData<T>> reflectionData = this.reflectionData;
int classRedefinedCount = this.classRedefinedCount;
ReflectionData<T> rd;
if (useCaches &&
reflectionData != null &&
(rd = reflectionData.get()) != null &&
rd.redefinedCount == classRedefinedCount) {
return rd;
}
// else no SoftReference or cleared SoftReference or stale ReflectionData
// -> create and replace new instance
return newReflectionData(reflectionData, classRedefinedCount);
}
// 新创建缓存,保存反射信息
private ReflectionData<T> newReflectionData(SoftReference<ReflectionData<T>> oldReflectionData,
int classRedefinedCount) {
if (!useCaches) return null;
// 使用cas保证更新的线程安全性,所以反射是保证线程安全的
while (true) {
ReflectionData<T> rd = new ReflectionData<>(classRedefinedCount);
// try to CAS it...
if (Atomic.casReflectionData(this, oldReflectionData, new SoftReference<>(rd))) {
return rd;
}
// 先使用CAS更新,如果更新成功,则立即返回,否则测查当前已被其他线程更新的情况,如果和自己想要更新的状态一致,则也算是成功了
oldReflectionData = this.reflectionData;
classRedefinedCount = this.classRedefinedCount;
if (oldReflectionData != null &&
(rd = oldReflectionData.get()) != null &&
rd.redefinedCount == classRedefinedCount) {
return rd;
}
}
}
- 首先阐释缓存中获取
- 如果没有缓存,则从 JVM 中重新加载,并存入缓存,缓存使用软引用保存,保证内存可用。
另外,使用 relactionData() 进行缓存保存;ReflectionData 的数据结构如下:
// reflection data that might get invalidated when JVM TI RedefineClasses() is called
private static class ReflectionData<T> {
volatile Field[] declaredFields;
volatile Field[] publicFields;
volatile Method[] declaredMethods;
volatile Method[] publicMethods;
volatile Constructor<T>[] declaredConstructors;
volatile Constructor<T>[] publicConstructors;
// Intermediate results for getFields and getMethods
volatile Field[] declaredPublicFields;
volatile Method[] declaredPublicMethods;
volatile Class<?>[] interfaces;
// Value of classRedefinedCount when we created this ReflectionData instance
final int redefinedCount;
ReflectionData(int redefinedCount) {
this.redefinedCount = redefinedCount;
}
}
比较构造是否是要查找构造器,其实就是比较类型完全相等性,有一个不相等则返回 false。
最终通过以下逻辑获得构造器:
private static boolean arrayContentsEq(Object[] a1, Object[] a2) {
if (a1 == null) {
return a2 == null || a2.length == 0;
}
if (a2 == null) {
return a1.length == 0;
}
if (a1.length != a2.length) {
return false;
}
for (int i = 0; i < a1.length; i++) {
if (a1[i] != a2[i]) {
return false;
}
}
return true;
}
// sun.reflect.ReflectionFactory
/** Makes a copy of the passed constructor. The returned
constructor is a "child" of the passed one; see the comments
in Constructor.java for details. */
public <T> Constructor<T> copyConstructor(Constructor<T> arg) {
return langReflectAccess().copyConstructor(arg);
}
// java.lang.reflect.Constructor, copy 其实就是新new一个 Constructor 出来
Constructor<T> copy() {
// This routine enables sharing of ConstructorAccessor objects
// among Constructor objects which refer to the same underlying
// method in the VM. (All of this contortion is only necessary
// because of the "accessibility" bit in AccessibleObject,
// which implicitly requires that new java.lang.reflect
// objects be fabricated for each reflective call on Class
// objects.)
if (this.root != null)
throw new IllegalArgumentException("Can not copy a non-root Constructor");
Constructor<T> res = new Constructor<>(clazz,
parameterTypes,
exceptionTypes, modifiers, slot,
signature,
annotations,
parameterAnnotations);
// root 指向当前 constructor
res.root = this;
// Might as well eagerly propagate this if already present
res.constructorAccessor = constructorAccessor;
return res;
}
然后只需调用对应构造器的 newInstance 方法即可返回实例:
// return tmpConstructor.newInstance((Object[])null);
// java.lang.reflect.Constructor
@CallerSensitive
public T newInstance(Object ... initargs)
throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, null, modifiers);
}
}
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
ConstructorAccessor ca = constructorAccessor; // read volatile
if (ca == null) {
ca = acquireConstructorAccessor();
}
@SuppressWarnings("unchecked")
T inst = (T) ca.newInstance(initargs);
return inst;
}
// sun.reflect.DelegatingConstructorAccessorImpl
public Object newInstance(Object[] args)
throws InstantiationException,
IllegalArgumentException,
InvocationTargetException
{
return delegate.newInstance(args);
}
// sun.reflect.NativeConstructorAccessorImpl
public Object newInstance(Object[] args)
throws InstantiationException,
IllegalArgumentException,
InvocationTargetException
{
// We can't inflate a constructor belonging to a vm-anonymous class
// because that kind of class can't be referred to by name, hence can't
// be found from the generated bytecode.
if (++numInvocations > ReflectionFactory.inflationThreshold()
&& !ReflectUtil.isVMAnonymousClass(c.getDeclaringClass())) {
ConstructorAccessorImpl acc = (ConstructorAccessorImpl)
new MethodAccessorGenerator().
generateConstructor(c.getDeclaringClass(),
c.getParameterTypes(),
c.getExceptionTypes(),
c.getModifiers());
parent.setDelegate(acc);
}
// 调用native方法,进行调用 constructor
return newInstance0(c, args);
}
返回实例之后,可以根据实际需要执行类型转化,以调用具体类型的方法。
反射获取方法
首先获取 Method:
// java.lang.Class
@CallerSensitive
public Method getDeclaredMethod(String name, Class<?>... parameterTypes)
throws NoSuchMethodException, SecurityException {
checkMemberAccess(Member.DECLARED, Reflection.getCallerClass(), true);
Method method = searchMethods(privateGetDeclaredMethods(false), name, parameterTypes);
if (method == null) {
throw new NoSuchMethodException(getName() + "." + name + argumentTypesToString(parameterTypes));
}
return method;
}
- 获取所有方法列表
- 更具方法名和方法列表,找到符合要求的方法
- 如果没有则抛出异常,有则返回方法
首先获取类的所有方法:
// Returns an array of "root" methods. These Method objects must NOT
// be propagated to the outside world, but must instead be copied
// via ReflectionFactory.copyMethod.
private Method[] privateGetDeclaredMethods(boolean publicOnly) {
checkInitted();
Method[] res;
ReflectionData<T> rd = reflectionData();
if (rd != null) {
res = publicOnly ? rd.declaredPublicMethods : rd.declaredMethods;
if (res != null) return res;
}
// No cached value available; request value from VM
res = Reflection.filterMethods(this, getDeclaredMethods0(publicOnly));
if (rd != null) {
if (publicOnly) {
rd.declaredPublicMethods = res;
} else {
rd.declaredMethods = res;
}
}
return res;
}
与构造器类似,首先读取缓存,没有缓存则从 JVM 中获取。
不同的是,方法列表执行过滤 Reflection.filterMethods。
// sun.misc.Reflection
public static Method[] filterMethods(Class<?> containingClass, Method[] methods) {
if (methodFilterMap == null) {
// Bootstrapping
return methods;
}
return (Method[])filter(methods, methodFilterMap.get(containingClass));
}
// 可以过滤指定的方法,一般为空,如果要指定过滤,可以调用 registerMethodsToFilter(), 或者...
private static Member[] filter(Member[] members, String[] filteredNames) {
if ((filteredNames == null) || (members.length == 0)) {
return members;
}
int numNewMembers = 0;
for (Member member : members) {
boolean shouldSkip = false;
for (String filteredName : filteredNames) {
if (member.getName() == filteredName) {
shouldSkip = true;
break;
}
}
if (!shouldSkip) {
++numNewMembers;
}
}
Member[] newMembers =
(Member[])Array.newInstance(members[0].getClass(), numNewMembers);
int destIdx = 0;
for (Member member : members) {
boolean shouldSkip = false;
for (String filteredName : filteredNames) {
if (member.getName() == filteredName) {
shouldSkip = true;
break;
}
}
if (!shouldSkip) {
newMembers[destIdx++] = member;
}
}
return newMembers;
}
然后根据方法名和参数类型过滤指定方法返回:
private static Method searchMethods(Method[] methods,
String name,
Class<?>[] parameterTypes)
{
Method res = null;
// 使用常量池,避免重复创建String
String internedName = name.intern();
for (int i = 0; i < methods.length; i++) {
Method m = methods[i];
if (m.getName() == internedName
&& arrayContentsEq(parameterTypes, m.getParameterTypes())
&& (res == null
|| res.getReturnType().isAssignableFrom(m.getReturnType())))
res = m;
}
return (res == null ? res : getReflectionFactory().copyMethod(res));
}
在执行匹配的过程将会尝试最精确的匹配,最后会通过 ReflectionFactory.copy 返回方法。
调用 method.invoke
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
通过 MethodAccessor 执行调用,而 MethodAccessor 为接口,在第一次使用时或调用 acquireMethodAccessor 新建实例。
// probably make the implementation more scalable.
private MethodAccessor acquireMethodAccessor() {
// First check to see if one has been created yet, and take it
// if so
MethodAccessor tmp = null;
if (root != null) tmp = root.getMethodAccessor();
if (tmp != null) {
// 存在缓存时,存入 methodAccessor,否则调用 ReflectionFactory 创建新的 MethodAccessor
methodAccessor = tmp;
} else {
// Otherwise fabricate one and propagate it up to the root
tmp = reflectionFactory.newMethodAccessor(this);
setMethodAccessor(tmp);
}
return tmp;
}
// sun.reflect.ReflectionFactory
public MethodAccessor newMethodAccessor(Method method) {
checkInitted();
if (noInflation && !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) {
return new MethodAccessorGenerator().
generateMethod(method.getDeclaringClass(),
method.getName(),
method.getParameterTypes(),
method.getReturnType(),
method.getExceptionTypes(),
method.getModifiers());
} else {
NativeMethodAccessorImpl acc =
new NativeMethodAccessorImpl(method);
DelegatingMethodAccessorImpl res =
new DelegatingMethodAccessorImpl(acc);
acc.setParent(res);
return res;
}
}
两种 Accessor 的细节:
// NativeMethodAccessorImpl / DelegatingMethodAccessorImpl
class NativeMethodAccessorImpl extends MethodAccessorImpl {
private final Method method;
private DelegatingMethodAccessorImpl parent;
private int numInvocations;
NativeMethodAccessorImpl(Method method) {
this.method = method;
}
public Object invoke(Object obj, Object[] args)
throws IllegalArgumentException, InvocationTargetException
{
// We can't inflate methods belonging to vm-anonymous classes because
// that kind of class can't be referred to by name, hence can't be
// found from the generated bytecode.
if (++numInvocations > ReflectionFactory.inflationThreshold()
&& !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) {
MethodAccessorImpl acc = (MethodAccessorImpl)
new MethodAccessorGenerator().
generateMethod(method.getDeclaringClass(),
method.getName(),
method.getParameterTypes(),
method.getReturnType(),
method.getExceptionTypes(),
method.getModifiers());
parent.setDelegate(acc);
}
return invoke0(method, obj, args);
}
void setParent(DelegatingMethodAccessorImpl parent) {
this.parent = parent;
}
private static native Object invoke0(Method m, Object obj, Object[] args);
}
class DelegatingMethodAccessorImpl extends MethodAccessorImpl {
private MethodAccessorImpl delegate;
DelegatingMethodAccessorImpl(MethodAccessorImpl delegate) {
setDelegate(delegate);
}
public Object invoke(Object obj, Object[] args)
throws IllegalArgumentException, InvocationTargetException
{
return delegate.invoke(obj, args);
}
void setDelegate(MethodAccessorImpl delegate) {
this.delegate = delegate;
}
}
执行 method.invoke(obj, args) 时,滴啊用 DelegatingMethodAccessorImpl.invoke(),最后被委托到 NativeMethodAccessorImpl.invoke():
public Object invoke(Object obj, Object[] args)
throws IllegalArgumentException, InvocationTargetException
{
// We can't inflate methods belonging to vm-anonymous classes because
// that kind of class can't be referred to by name, hence can't be
// found from the generated bytecode.
if (++numInvocations > ReflectionFactory.inflationThreshold()
&& !ReflectUtil.isVMAnonymousClass(method.getDeclaringClass())) {
MethodAccessorImpl acc = (MethodAccessorImpl)
new MethodAccessorGenerator().
generateMethod(method.getDeclaringClass(),
method.getName(),
method.getParameterTypes(),
method.getReturnType(),
method.getExceptionTypes(),
method.getModifiers());
parent.setDelegate(acc);
}
// invoke0 是个 native 方法,由jvm进行调用业务方法。从而完成反射调用功能。
return invoke0(method, obj, args);
}
其中,generateMethod 是生成具体类的方法:
/** This routine is not thread-safe */
public MethodAccessor generateMethod(Class<?> declaringClass,
String name,
Class<?>[] parameterTypes,
Class<?> returnType,
Class<?>[] checkedExceptions,
int modifiers)
{
return (MethodAccessor) generate(declaringClass,
name,
parameterTypes,
returnType,
checkedExceptions,
modifiers,
false,
false,
null);
}
generate 的实现中会出现:ClassDefiner.defineClass(xx, declaringClass.getClassLoader()).newInstance()。
在ClassDefiner.defineClass
方法实现中,每被调用一次都会生成一个DelegatingClassLoader类加载器对象 ,这里每次都生成新的类加载器,是为了性能考虑,在某些情况下可以卸载这些生成的类,因为类的卸载是只有在类加载器可以被回收的情况下才会被回收的,如果用了原来的类加载器,那可能导致这些新创建的类一直无法被卸载。
而反射生成的类,有时候可能用了就可以卸载了,所以使用其独立的类加载器,从而使得更容易控制反射类的生命周期。
反射汇总
- 反射类及反射方法的获取,都是通过从列表中搜寻查找匹配的方法,所以查找性能会随类的大小方法多少而变化;
- 每个类都会有一个与之对应的Class实例,从而每个类都可以获取method反射方法,并作用到其他实例身上;
- 反射也是考虑了线程安全;
- 反射使用软引用relectionData缓存class信息,避免每次重新从jvm获取带来的开销;
- 反射调用多次生成新代理Accessor, 而通过字节码生成的则考虑了卸载功能,所以会使用独立的类加载器;
- 当找到需要的方法,都会copy一份出来,而不是使用原来的实例,从而保证数据隔离;
- 调用反射方法,最终是由jvm执行invoke0()执行;
1.1.8 - CH08-SPI 机制
概览
Service Provider Interface(SPI) 是 JDK 内置的服务发现机制,可以用来启用框架扩展或替换组件,主要用于框架。比如 java.sql.Driver 接口,不同的数据库厂商可以针对同一接口提供不同的实现,MySQL 和 PostgreSQL 分别为用户提供了不同的实现。
Java SPI 机制的主要思想是将装配的控制权移交到程序之外,目的在于解耦。
当服务的提供者提供了一种接口的实现后,需要在 classpath 下的 META-INF/services/
目录中创建一个以服务接口命名的文件,并在该文件中添加实现类的完全限定名。其他程序可以通过 ServiceLoader 查找这个 jar 包的配置文件,根据其中的实现类名加载并实例化,然后使用实现类提供的功能。
示例
定义接口
public interface Serach { List<String> serach(String keyword); }
实现接口
public class FileSearch implements Search { @Override public List<String> search(String keyword){ return ...; } } public class DatabaseSearch implements Search { @Override public List<String> search(String keyword){ return ...; } }
在
/resource/META-INF/services/
目录下创建com.example.Search
文件,在其中添加我们要使用的某个实现类的完全限定名,如com.example.FileSearch
。加载接口实现类
ServiceLoader<Search> impls = SeraiceLoader.load(Serach.class); Iterator<Serach> itor = impls.iterator(); while(itor.hasNext()){ Search search = itor.next(); search.search("keyword"); }
实际应用
JDBC DriverManager
- JDBC 接口定义:首先 Java 中定义了接口
java.sql.Driver
。 - MySQL 实现类:在 mysql-connector-java-version.jar 中,可以找打
META-INF/services
目录查看其中的java.sql.Driver
文件,其中定义了实现类名com.mysql.cj.jdbc.Driver
。 - 加载实现类:
DriverManager.getConnection(uil,username,password)
。
DriverManager 的具体加载过程:
private static void loadInitialDrivers() {
String drivers;
try {
drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
public String run() {
return System.getProperty("jdbc.drivers");
}
});
} catch (Exception ex) {
drivers = null;
}
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//使用SPI的ServiceLoader来加载接口的实现
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
if (drivers == null || drivers.equals("")) {
return;
}
String[] driversList = drivers.split(":");
println("number of Drivers:" + driversList.length);
for (String aDriver : driversList) {
try {
println("DriverManager.Initialize: loading " + aDriver);
Class.forName(aDriver, true,
ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
}
- 从系统变量中获取有关驱动的定义。
- 使用 SPI 获取驱动实现。
- 遍历 SPI 获取到的具体实现类,实例化各个实现类。
- 根据第一步得到的驱动列表实例化具体实现类。
Common Logging
通过 LogFactory.getLog
获取日志实例:
public static getLog(Class clazz) throws LogConfigurationException {
return getFactory().getInstance(clazz);
}
LogFactory 是一个抽象类,它负责加载具体的日志实现,具体过程为:
- 从 JVM 系统属性获取相关配置
- 使用 SPI 查找得到
org.apache.commons.logging.LogFactory
实现 - 查找 classpath 根目录
commons-logging.properties
的属性是否设置特定的实现 - 使用默认实现类
Eclipse OSGI 插件体系
Eclipse使用OSGi作为插件系统的基础,动态添加新插件和停止现有插件,以动态的方式管理组件生命周期。
一般来说,插件的文件结构必须在指定目录下包含以下三个文件:
META-INF/MANIFEST.MF
: 项目基本配置信息,版本、名称、启动器等build.properties
: 项目的编译配置信息,包括,源代码路径、输出路径plugin.xml
:插件的操作配置信息,包含弹出菜单及点击菜单后对应的操作执行类等
当eclipse启动时,会遍历plugins文件夹中的目录,扫描每个插件的清单文件MANIFEST.MF
,并建立一个内部模型来记录它所找到的每个插件的信息,就实现了动态添加新的插件。
这也意味着是 eclipse 制定了一系列的规则,像是文件结构、类型、参数等。插件开发者遵循这些规则去开发自己的插件,eclipse并不需要知道插件具体是怎样开发的,只需要在启动的时候根据配置文件解析、加载到系统里就好了,是spi思想的一种体现。
Spring Factory
在 springboot 的自动装配过程中,最终会加载META-INF/spring.factories
文件,而加载的过程是由SpringFactoriesLoader
加载的。从 CLASSPATH 下的每个 Jar 包中搜寻所有META-INF/spring.factories
配置文件,然后将解析 properties 文件,找到指定名称的配置后返回。需要注意的是,其实这里不仅仅是会去 ClassPath 路径下查找,会扫描所有路径下的 Jar 包,只不过这个文件只会在 Classpath 下的 jar 包中。
实现原理
ServiceLoader 的具体实现:
//ServiceLoader实现了Iterable接口,可以遍历所有的服务实现者
public final class ServiceLoader<S>
implements Iterable<S>
{
//查找配置文件的目录
private static final String PREFIX = "META-INF/services/";
//表示要被加载的服务的类或接口
private final Class<S> service;
//这个ClassLoader用来定位,加载,实例化服务提供者
private final ClassLoader loader;
// 访问控制上下文
private final AccessControlContext acc;
// 缓存已经被实例化的服务提供者,按照实例化的顺序存储
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
// 迭代器
private LazyIterator lookupIterator;
//重新加载,就相当于重新创建ServiceLoader了,用于新的服务提供者安装到正在运行的Java虚拟机中的情况。
public void reload() {
//清空缓存中所有已实例化的服务提供者
providers.clear();
//新建一个迭代器,该迭代器会从头查找和实例化服务提供者
lookupIterator = new LazyIterator(service, loader);
}
//私有构造器
//使用指定的类加载器和服务创建服务加载器
//如果没有指定类加载器,使用系统类加载器,就是应用类加载器。
private ServiceLoader(Class<S> svc, ClassLoader cl) {
service = Objects.requireNonNull(svc, "Service interface cannot be null");
loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
reload();
}
//解析失败处理的方法
private static void fail(Class<?> service, String msg, Throwable cause)
throws ServiceConfigurationError
{
throw new ServiceConfigurationError(service.getName() + ": " + msg,
cause);
}
private static void fail(Class<?> service, String msg)
throws ServiceConfigurationError
{
throw new ServiceConfigurationError(service.getName() + ": " + msg);
}
private static void fail(Class<?> service, URL u, int line, String msg)
throws ServiceConfigurationError
{
fail(service, u + ":" + line + ": " + msg);
}
//解析服务提供者配置文件中的一行
//首先去掉注释校验,然后保存
//返回下一行行号
//重复的配置项和已经被实例化的配置项不会被保存
private int parseLine(Class<?> service, URL u, BufferedReader r, int lc,
List<String> names)
throws IOException, ServiceConfigurationError
{
//读取一行
String ln = r.readLine();
if (ln == null) {
return -1;
}
//#号代表注释行
int ci = ln.indexOf('#');
if (ci >= 0) ln = ln.substring(0, ci);
ln = ln.trim();
int n = ln.length();
if (n != 0) {
if ((ln.indexOf(' ') >= 0) || (ln.indexOf('\t') >= 0))
fail(service, u, lc, "Illegal configuration-file syntax");
int cp = ln.codePointAt(0);
if (!Character.isJavaIdentifierStart(cp))
fail(service, u, lc, "Illegal provider-class name: " + ln);
for (int i = Character.charCount(cp); i < n; i += Character.charCount(cp)) {
cp = ln.codePointAt(i);
if (!Character.isJavaIdentifierPart(cp) && (cp != '.'))
fail(service, u, lc, "Illegal provider-class name: " + ln);
}
if (!providers.containsKey(ln) && !names.contains(ln))
names.add(ln);
}
return lc + 1;
}
//解析配置文件,解析指定的url配置文件
//使用parseLine方法进行解析,未被实例化的服务提供者会被保存到缓存中去
private Iterator<String> parse(Class<?> service, URL u)
throws ServiceConfigurationError
{
InputStream in = null;
BufferedReader r = null;
ArrayList<String> names = new ArrayList<>();
try {
in = u.openStream();
r = new BufferedReader(new InputStreamReader(in, "utf-8"));
int lc = 1;
while ((lc = parseLine(service, u, r, lc, names)) >= 0);
}
return names.iterator();
}
//服务提供者查找的迭代器
private class LazyIterator
implements Iterator<S>
{
Class<S> service;//服务提供者接口
ClassLoader loader;//类加载器
Enumeration<URL> configs = null;//保存实现类的url
Iterator<String> pending = null;//保存实现类的全名
String nextName = null;//迭代器中下一个实现类的全名
private LazyIterator(Class<S> service, ClassLoader loader) {
this.service = service;
this.loader = loader;
}
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
}
if (!service.isAssignableFrom(c)) {
fail(service, "Provider " + cn + " not a subtype");
}
try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
}
}
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
//获取迭代器
//返回遍历服务提供者的迭代器
//以懒加载的方式加载可用的服务提供者
//懒加载的实现是:解析配置文件和实例化服务提供者的工作由迭代器本身完成
public Iterator<S> iterator() {
return new Iterator<S>() {
//按照实例化顺序返回已经缓存的服务提供者实例
Iterator<Map.Entry<String,S>> knownProviders
= providers.entrySet().iterator();
public boolean hasNext() {
if (knownProviders.hasNext())
return true;
return lookupIterator.hasNext();
}
public S next() {
if (knownProviders.hasNext())
return knownProviders.next().getValue();
return lookupIterator.next();
}
public void remove() {
throw new UnsupportedOperationException();
}
};
}
//为指定的服务使用指定的类加载器来创建一个ServiceLoader
public static <S> ServiceLoader<S> load(Class<S> service,
ClassLoader loader)
{
return new ServiceLoader<>(service, loader);
}
//使用线程上下文的类加载器来创建ServiceLoader
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
//使用扩展类加载器为指定的服务创建ServiceLoader
//只能找到并加载已经安装到当前Java虚拟机中的服务提供者,应用程序类路径中的服务提供者将被忽略
public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {
ClassLoader cl = ClassLoader.getSystemClassLoader();
ClassLoader prev = null;
while (cl != null) {
prev = cl;
cl = cl.getParent();
}
return ServiceLoader.load(service, prev);
}
public String toString() {
return "java.util.ServiceLoader[" + service.getName() + "]";
}
}
- SeriviceLoader 实现了 Iterable 接口,所有具有迭代器属性,即 hasNext 和 next。其中主要是调用 lookupIterator 的对应 hasNext 和 next 方法,lookupIterator 为懒加载迭代器。
- LazyIterator 中的 hasNext 方法,静态变量 PREFIX 为
META-INF/services
目录。 - 通过反射
Class.forName
加载类对象,并通过newInstance
方法创建实现类的实例,并将实例缓存,然后返回实例对象。
1.2 - Java 集合
1.2.1 - CH01-集合结构
层级结构
选择参考
1.2.2 - CH02-ArrayList
概述
- ArrayList 实现了 List 接口,是顺序型容器,允许 NULL 元素,底层结构为数组。
- 除了没有实现线程安全,其余实现与 Vector 类似。
- 拥有容量(capacity)属性,表示底层数组大小,实际元素个数不能大于容量。
- 容量不足以承载更多元素时,会执行扩容。
- size、isEmpty、get、set 均可在常数时间内完成。
- add 的时间开销与插入位置有关。
- addAll 的时间开销与所要添加元素的个数成正比。
- 其余方法大多为线性时间。
内部实现
数据结构
transient Object[] elementData;
private int size;
构造函数
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; // 10
}
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
自动扩容
每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过一个公开的方法ensureCapacity(int minCapacity)来实现。在实际添加大量元素前,我也可以使用ensureCapacity来手动增加ArrayList实例的容量,以减少递增式再分配的数量。
数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的 1.5 倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造ArrayList实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用ensureCapacity方法来手动增加ArrayList实例的容量。
/**
* Increases the capacity of this <tt>ArrayList</tt> instance, if
* necessary, to ensure that it can hold at least the number of elements
* specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
// any size if not default element table
? 0
// larger than default for default empty table. It's already
// supposed to be at default size.
: DEFAULT_CAPACITY;
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
/**
* The maximum size of array to allocate.
* Some VMs reserve some header words in an array.
* Attempts to allocate larger arrays may result in
* OutOfMemoryError: Requested array size exceeds VM limit
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
add/addAll
添加单个元素的方法 add(E e)
和 add(int index, E e)
,在执行之前都需要检查剩余容量,如果需要则自动扩容,即执行 grow 方法。
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}
add(int index, E e)
首先需要移动元素,然后完成插入操作,因此具有线性时间的复杂度。
addAll()
能够一次添加多个元素,根据添加的位置拥有两种版本的实现:
- 向末尾添加:
addAll(Collection<? extends E> c)
- 向指定位置添加:
addAll(int index, Collection<? extends E> c)
在插入之前也需要扩容检查,如果需要就执行扩容。如果插入指定位置,也需要移动元素。因此同时与插入元素的数据和插入的位置相关。
set
首先执行越界检查,然后对数组指定位置的元素赋值。
get
首先执行越界检查,然后数组指定位置的元素值,最后转换类型。
remove
remove(int index)
删除指定位置的元素remove(Object o)
删除第一满足 equals 条件的元素
remove 是 add 的逆操作,需要将删除位置之后的元素向前移动。
为了让 GC 起作用,必须显式的为最后一个位置赋值为 null,即解除引用。如果不设为 null,那么该位置将会继续引用原有的对象,除非被一个新的对象覆盖。
trimToSize
该方法可以将数组的容量调整为当前实际元素的个数。
/**
* Trims the capacity of this <tt>ArrayList</tt> instance to be the
* list's current size. An application can use this operation to minimize
* the storage of an <tt>ArrayList</tt> instance.
*/
public void trimToSize() {
modCount++;
if (size < elementData.length) {
elementData = (size == 0)
? EMPTY_ELEMENTDATA
: Arrays.copyOf(elementData, size);
}
}
indexOf, lastIndexOf
分别获取第一次和最后一次出现的元素位置:
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
public int lastIndexOf(Object o) {
if (o == null) {
for (int i = size-1; i >= 0; i--)
if (elementData[i]==null)
return i;
} else {
for (int i = size-1; i >= 0; i--)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
fail-fast 机制
通过记录 modCount 的值,在面对并发修改时,迭代器很快就会完全失败,避免在将来某个不确定时间发生任意不确定行为。
1.2.3 - CH03-LinkedList
概述
- LinkedList 同时实现了 List 接口和 Deque 接口,也就是说它既可以看作一个顺序容器,又可以看作一个队列(Queue),同时又可以看作一个栈(Stack)。
- 栈或队列,现在的首选是 ArrayDeque,它有着比 LinkedList (当作栈或队列使用时)有着更好的性能。
- 所有跟下标相关的操作都是线性时间。
- 在首段或者末尾删除元素只需要常数时间。
- 为追求效率 LinkedList 没有实现同步(synchronized)。
内部实现
数据结构
- 底层通过双向链表实现。
- 双向链表的每个节点用内部类 Node 表示。
- LinkedList 通过
first
和last
引用分别指向链表的第一个和最后一个元素。 - 当链表为空的时候
first
和last
都指向null
。
transient int size = 0;
/**
* Pointer to first node.
* Invariant: (first == null && last == null) ||
* (first.prev == null && first.item != null)
*/
transient Node<E> first;
/**
* Pointer to last node.
* Invariant: (first == null && last == null) ||
* (last.next == null && last.item != null)
*/
transient Node<E> last;
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
构造函数
/**
* Constructs an empty list.
*/
public LinkedList() {
}
/**
* Constructs a list containing the elements of the specified
* collection, in the order they are returned by the collection's
* iterator.
*
* @param c the collection whose elements are to be placed into this list
* @throws NullPointerException if the specified collection is null
*/
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
getFirst, getLast
/**
* Returns the first element in this list.
*
* @return the first element in this list
* @throws NoSuchElementException if this list is empty
*/
public E getFirst() {
final Node<E> f = first;
if (f == null)
throw new NoSuchElementException();
return f.item;
}
/**
* Returns the last element in this list.
*
* @return the last element in this list
* @throws NoSuchElementException if this list is empty
*/
public E getLast() {
final Node<E> l = last;
if (l == null)
throw new NoSuchElementException();
return l.item;
}
removeFirest(), removeLast(), remove(e), remove(index)
remove 可以删除首个 equals 指定对象的元素,或者删除指定位置的元素。
add
add(E e)
将在末尾添加元素,因为 last 指向链表的末尾元素,因此操作为常数时间,仅需修改几个相关的引用即可。
add(int index, E element)
是在指定位置插入元素,首选需要线性查找到具体位置,然后修改相关引用,完成操作。
addAll
addAll(index, c) 实现方式并不是直接调用add(index,e)来实现,主要是因为效率的问题,另一个是fail-fast中modCount只会增加1次;
/**
* Appends all of the elements in the specified collection to the end of
* this list, in the order that they are returned by the specified
* collection's iterator. The behavior of this operation is undefined if
* the specified collection is modified while the operation is in
* progress. (Note that this will occur if the specified collection is
* this list, and it's nonempty.)
*
* @param c collection containing elements to be added to this list
* @return {@code true} if this list changed as a result of the call
* @throws NullPointerException if the specified collection is null
*/
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
/**
* Inserts all of the elements in the specified collection into this
* list, starting at the specified position. Shifts the element
* currently at that position (if any) and any subsequent elements to
* the right (increases their indices). The new elements will appear
* in the list in the order that they are returned by the
* specified collection's iterator.
*
* @param index index at which to insert the first element
* from the specified collection
* @param c collection containing elements to be added to this list
* @return {@code true} if this list changed as a result of the call
* @throws IndexOutOfBoundsException {@inheritDoc}
* @throws NullPointerException if the specified collection is null
*/
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
succ = node(index);
pred = succ.prev;
}
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
clear
为了让GC更快可以回收放置的元素,需要将node之间的引用关系赋值为 null。
/**
* Removes all of the elements from this list.
* The list will be empty after this call returns.
*/
public void clear() {
// Clearing all of the links between nodes is "unnecessary", but:
// - helps a generational GC if the discarded nodes inhabit
// more than one generation
// - is sure to free memory even if there is a reachable Iterator
for (Node<E> x = first; x != null; ) {
Node<E> next = x.next;
x.item = null;
x.next = null;
x.prev = null;
x = next;
}
first = last = null;
size = 0;
modCount++;
}
Positional Access 方法
通过 index 获取元素:
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
通过 index 赋值元素:
public E set(int index, E element) {
checkElementIndex(index);
Node<E> x = node(index);
E oldVal = x.item;
x.item = element;
return oldVal;
}
通过 index 插入元素:
public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
通过 index 删除元素:
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
查找
即查找元素的下标,查找第一次出现元素值相等的 index,否则返回 -1:
public int indexOf(Object o) {
int index = 0;
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null)
return index;
index++;
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
}
return -1;
}
查找最后一次出现的元素则类似,区别是从 last 开始向前查找。
Queue 方法
- peek
- element
- poll
- remove
- offer
Deque 方法
- offerFirst
- offerLast
- peekFirst
- peekLast
- pollFirst
- pollLast
- push
- pop
1.2.4 - CH04-Stack-Queue
概述
- Java 中存在 Stack 实现类,但没有提供 Queue 实现类,仅有一个 Queue 接口。
- 但是在需要使用栈时,Java 推荐的结果是更加高效的 ArrayQueue。
- 在需要使用队列时,首选是 ArrayQueue,其次是 LinkedList。
Queue
Queue 接口继承自 Collection 接口,除了最基本的 Collection 方法之外,还额外支持 insertion、extraction、inspection 操作。这里有两组格式共 6 个方法,一组是抛出异常的实现,一组是返回值的实现(或 null)。
Throws Exception | Returns special Value | |
---|---|---|
Insert | add(e) | offer(e) |
Remove | remove() | poll() |
Examine | element() | peek() |
Deque
Deque 是 “double ended queue”,表示双向队列,英文读作 deck
。Deque 继承自 Queue 接口,除了支持 Queue 的方法外,还支持 insert、remove、examine 操作。
由于 Deque 是双向的,所以可以支持队列的头尾操作,同时支持两种格式共 12 个方法:
First Element-Head | Last Element-Tail | |||
---|---|---|---|---|
Throws Exception | Special Value | Throws Exception | Speicial Value | |
Insert | addFirst(e) | offerFirst(e) | addLast(e) | offerLast(e) |
Remove | removeFirst() | pollFirst() | removeLast() | pollLast() |
Examine | getFirst() | peekFirst() | getLast() | peekLast() |
当把 Deque 当做 FIFO 来使用时,元素是从 deque 的尾部添加,从头部进行删除。所谓 Deque 的部分方法和 Queue 是等同的。
Queue Method | Equivalent Deque Method | 说明 |
---|---|---|
add(e) | addLast(e) | 向队尾添加元素,失败时抛异常 |
offer(e) | offerLast(e) | 向队尾添加元素,失败时返回 false |
remove() | removeFirst() | 获取并删除队首元素,失败时抛异常 |
poll() | pollFirst() | 获取并删除队首元素,失败时返回 null |
element() | getFirst() | 获取但不删除队首元素,失败时抛异常 |
peek() | peekFirst() | 获取但不删除队首元素,失败时返回 null |
Deque 与 Stack 的对应方法:
Stack Method | Equivalent Deque Method | 说明 |
---|---|---|
push(e) | addFirst(e) | 向栈顶插入元素,失败则抛出异常 |
无 | offerFirst(e) | 向栈顶插入元素,失败则返回false |
pop() | removeFirst() | 获取并删除栈顶元素,失败则抛出异常 |
无 | pollFirst() | 获取并删除栈顶元素,失败则返回null |
peek() | peekFirst() | 获取但不删除栈顶元素,失败则抛出异常 |
无 | peekFirst() | 获取但不删除栈顶元素,失败则返回null |
以上操作中,除非对容量有限制,否则添加操作是不会失败的。
ArrayDeque
ArrayDeque 底层为数组结构,为了满足可以同时在数组两端添加或删除元素,该数组必须是循环数组,即数组的任何一点都可能被看做起点或终点。
ArrayDeque 非线程安全,不能添加 null 元素。
head 指向首端第一个有效元素,tail 指向尾端第一个可以插入元素的空位。
因为是循环数组,所谓 head 的位置不一定是 0,tail 的位置也不一定总是比 head 的位置大。
addFirst
在 Deque 首端添加元素,也就是在 head 前面添加元素,在空间足够且下标没有越界的情况下,只需要将 elements[--head]=e
即可。即将 head 的索引递减 1 的位置赋值为新加的元素。
//addFirst(E e)
public void addFirst(E e) {
if (e == null)//不允许放入null
throw new NullPointerException();
elements[head = (head - 1) & (elements.length - 1)] = e;//2.下标是否越界
if (head == tail)//1.空间是否够用
doubleCapacity();//扩容
}
上述代码中可以发现,空间问题是在插入之后开始解决的,因为 tail 总是指向下一个可插入的空位,也就意味着 elements 数组至少会存在一个空位,所以插入元素时不用先考虑空间问题。
下标越界的解决方法很简单,head = (head -1) & (elements.length -1)
即可,这段代码相当于取余,同时解决了 head 值为负的情况。因为 elements.length
必须是 2 的指数倍,elements -1
就是二进制低位全为 1,跟 head-1
相与之后就起到了取模的作用,如果 head-1
为负(-1),则相当于对其取 elements.length
的补码。
对于扩容函数 doubleCapacity
,其逻辑就是申请一个更大的数组(原数据的两倍空间),然后复制原来的元素。
复制分为两次,第一次复制 head 右边的元素,第二次复制 head 左边的数据。
addLast
作用是在 Deque 的尾端插入元素,也就是在tail
的位置插入元素,由于tail
总是指向下一个可以插入的空位,因此只需要elements[tail] = e;
即可。插入完成后再检查空间,如果空间已经用光,则调用doubleCapacity()
进行扩容。
poolFirst
作用是删除并返回 Deque 首端元素,也即是head
位置处的元素。如果容器不空,只需要直接返回elements[head]
即可,当然还需要处理下标的问题。由于ArrayDeque
中不允许放入null
,当elements[head] == null
时,意味着容器为空。
pollLast
作用是删除并返回Deque尾端元素,也即是tail
位置前面的那个元素。
peekFirst
作用是返回但不删除Deque首端元素,也即是head
位置处的元素,直接返回elements[head]
即可。
peekLast
作用是返回但不删除Deque尾端元素,也即是tail
位置前面的那个元素。
1.2.5 - CH05-PriorityQueue
概览
- 优先队列的作用是能保证每次取出的元素都是队列中权值最小的。
- 元素大小的评判可以通过元素本身的自然顺序(natural ordering),也可以通过构造时传入的比较器。
- Java 中 PriorityQueue 实现了 Queue 接口,不允许放入
null
元素。 - 底层结构为堆,通过完全二叉树实现的小顶堆,表示可以通过数组作为实现结构。
- PriorityQueue 的
peek()
和element()
操作是常数时间,add()
,offer()
, 无参数的remove()
以及poll()
方法的时间复杂度都是log(N)。
观察上图中每个元素的索引编号,会发现父节点与子节点的编号存在联系:
- leftNo = parentNo*2+1
- rightNo = parentNo*2+2
- parentNo = (nodeNo-1)/2
通过这三个公式,可以轻易计算出某个节点的父节点以及子节点的索引编号。即可以通过数组来实现存储堆。
方法实现
add & offer
两者语义相同,都是相对列中添加元素,只是 Queue 接口规定二者对插入失败是的处理方式不同,前者抛出异常,后者返回 false。
新加入的元素可能会破坏小顶堆的性质,因此需要进行必要的调整。
//offer(E e)
public boolean offer(E e) {
if (e == null)//不允许放入null元素
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1);//自动扩容
size = i + 1;
if (i == 0)//队列原来为空,这是插入的第一个元素
queue[0] = e;
else
siftUp(i, e);//调整
return true;
}
扩容函数 grow 类似于 ArrayList 中的 grow 函数,申请更大空间的数组并复制数据。
siftUp(int k, E x)
方法用于插入元素 x 同时维持堆的特性:
//siftUp()
private void siftUp(int k, E x) {
while (k > 0) {
int parent = (k - 1) >>> 1;//parentNo = (nodeNo-1)/2
Object e = queue[parent];
if (comparator.compare(x, (E) e) >= 0)//调用比较器的比较方法
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}
调整过程为:从 k 指定的位置开始,将 x 逐层与当前点的 parent 进行比较并交换,直到满足 x >= queue[parent]
为止。
element & peek
语义完全相同,都是获取但不删除队首元素,也就是队列中权值最小的那个元素,二者唯一的区别是当方法失败时前者抛出异常,后者返回null
。
根据小顶堆的性质,堆顶那个元素就是全局最小的那个;由于堆用数组表示,根据下标关系,0
下标处的那个元素既是堆顶元素。所以直接返回数组0
下标处的那个元素即可。
remove & poll
语义也完全相同,都是获取并删除队首元素,区别是当方法失败时前者抛出异常,后者返回null
。
由于删除操作会改变队列的结构,为维护小顶堆的性质,需要进行必要的调整。
public E poll() {
if (size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0];//0下标处的那个元素就是最小的那个
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);//调整
return result;
}
上述代码首先记录0
下标处的元素,并用最后一个元素替换0
下标位置的元素,之后调用siftDown()
方法对堆进行调整,最后返回原来0
下标处的那个元素(也就是最小的那个元素)。
重点是siftDown(int k, E x)
方法,该方法的作用是从k
指定的位置开始,将x
逐层向下与当前点的左右孩子中较小的那个交换,直到x
小于或等于左右孩子中的任何一个为止。
//siftDown()
private void siftDown(int k, E x) {
int half = size >>> 1;
while (k < half) {
//首先找到左右孩子中较小的那个,记录到c里,并用child记录其下标
int child = (k << 1) + 1;//leftNo = parentNo*2+1
Object c = queue[child];
int right = child + 1;
if (right < size &&
comparator.compare((E) c, (E) queue[right]) > 0)
c = queue[child = right];
if (comparator.compare(x, (E) c) <= 0)
break;
queue[k] = c;//然后用c取代原来的值
k = child;
}
queue[k] = x;
}
remove
用于删除队列中跟o
相等的某一个元素(如果有多个相等,只删除一个),该方法不是Queue接口内的方法,而是Collection接口的方法。由于删除操作会改变队列结构,所以要进行调整;又由于删除元素的位置可能是任意的,所以调整过程比其它函数稍加繁琐。具体来说,remove(Object o)
可以分为2种情况: 1. 删除的是最后一个元素。直接删除即可,不需要调整。2. 删除的不是最后一个元素,从删除点开始以最后一个元素为参照调用一次siftDown()
即可。此处不再赘述。
//remove(Object o)
public boolean remove(Object o) {
//通过遍历数组的方式找到第一个满足o.equals(queue[i])元素的下标
int i = indexOf(o);
if (i == -1)
return false;
int s = --size;
if (s == i) //情况1
queue[i] = null;
else {
E moved = (E) queue[s];
queue[s] = null;
siftDown(i, moved);//情况2
......
}
return true;
}
1.2.6 - CH06-HashSet-Map
概述
- HashSet 与 HashMap 在 Java 内部的实现类似,前者仅仅是对后者进行了封装。
- HashMap 实现了 Map 接口,允许放入 null key 和 null value。
- 与 HashTable 的区别在于没有实现同步。
- 与 TreeMap 的区别在于不保证元素顺序。
- 采用冲突链表(Sepratate chaining with linked lists)解决哈希冲突。
- 另一种实现是开放地址方式(Open Addressing)。
如果选择合适的哈希函数,put 与 get 方法可以在常数内完成。但是在对 HashMap 执行迭代时,需要遍历整个 table 以及后边跟的冲突链表。因此对于迭代频繁的场景,不宜将 HashMap 的初始大小设置的过大。
有两个参数可以影响 HashMap 的性能:初始容量(inital capacity)和负载系数(load factor)。
初始容量指定了初始 table 的大小,负载系数用来指定自动扩容的临界值。当 entry 的数量超过 capacity * load-factor
时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设置较大可以减少重新哈希的次数。
将对象放入到 HashSet 和 HashMap 时,有两个方法要格外留意:hashCode 和 equals。
hashCode 方法决定了对象会被放到哪个 bucket 中,当多个对象的哈希值冲突,equals 方法决定了这些对象是否是同一个对象。因此,如果要将自定义的对象放入到 HashMap 或 HashSet,需要重写 hashCode 和 equals 方法。
HashMap
get
get(Object key)
方法根据指定的key
值返回对应的value
,该方法调用了getEntry(Object key)
得到相应的entry
,然后返回entry.getValue()
。因此getEntry()
是算法的核心。 算法思想是首先通过hash()
函数得到对应bucket
的下标,然后依次遍历冲突链表,通过key.equals(k)
方法来判断是否是要找的那个entry
。
上图中 hash(k) & (table.length-1)
等价于 hash(k) % table.length
,原因是 HashMap 要求 table.length
均为 2 的指数,因此 table.length -1
就是二进制低位全是 1,跟 hash(k)
相与会将哈希值的高位全部抹掉,剩下的就是余数了。
//getEntry()方法
final Entry<K,V> getEntry(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[hash&(table.length-1)];//得到冲突链表
e != null; e = e.next) {//依次遍历冲突链表中的每个entry
Object k;
//依据equals()方法判断是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
put
put(K key, V value)
方法是将指定的key, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()
方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)
方法插入新的entry
,插入方式为头插法。
//addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//自动扩容,并重新哈希
hash = (null != key) ? hash(key) : 0;
bucketIndex = hash & (table.length-1);//hash%table.length
}
//在冲突链表头部插入新的entry
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
remove
remove(Object key)
的作用是删除key
值对应的entry
,该方法的具体逻辑是在removeEntryForKey(Object key)
里实现的。removeEntryForKey()
方法会首先找到key
值对应的entry
,然后删除该entry
(修改链表的相应引用)。查找过程跟getEntry()
过程类似。
//removeEntryForKey()
final Entry<K,V> removeEntryForKey(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);//hash&(table.length-1)
Entry<K,V> prev = table[i];//得到冲突链表
Entry<K,V> e = prev;
while (e != null) {//遍历冲突链表
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {//找到要删除的entry
modCount++; size--;
if (prev == e) table[i] = next;//删除的是冲突链表的第一个entry
else prev.next = next;
return e;
}
prev = e; e = next;
}
return e;
}
HashSet
HashSet是对HashMap的简单包装,对HashSet的函数调用都会转换成合适的HashMap方法。
//HashSet是对HashMap的简单包装
public class HashSet<E>
{
......
//HashSet里面有一个HashMap
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<>();
}
......
public boolean add(E e) {//简单的方法转换
return map.put(e, PRESENT)==null;
}
......
}
1.2.7 - CH07-LinkedHashSet-Map
概述
- LinkedHashSet 和 LinkedHashMap 在 Java 中也是类似的实现,前者只是对后者的简单封装。
- LinkedHashMap 实现了 Map 接口,允许放入 null key 和 null value。
- 同时满足 HashMap 和 linked list 的一些特性。
- 可以将 LinkedHashMap 看做是通过 linked list 增强的 HashMap。
- LinkedHashMap 是 HashMap 的直接子类,二者唯一的区别是 LinkedHashMap 在 HashMap 的基础上,采用双向链表的形式将所有 entry 连接起来,以保证元素的迭代顺序和插入顺序相同。
如上图,相比 HashMap,在 entry 部分多了个属性用于连接所有 entry。而 header 用于指向双向链表的头部。
这种结构体还有一个好处,迭代时不需要像 HashMap 那样遍历整个 table,只需要遍历 header 指向的双向链表即可。也就是说,LinkedHashMap 的迭代时间只和 entry 的数量相关,与 table 的大小无关。
有两个参数可以影响 LinkedHashMap 的性能:初始容量(inital capacity)和负载系数(load factor)。初始容量指定了 table 的大小,负载系数用来指定自动扩容的临界值。当entry
的数量超过capacity*load_factor
时,容器将自动扩容并重新哈希。对于插入元素较多的场景,将初始容量设大可以减少重新哈希的次数。
将对象放入到LinkedHashMap或LinkedHashSet中时,有两个方法需要特别关心: hashCode()
和equals()
。hashCode()
方法决定了对象会被放到哪个bucket
里,当多个对象的哈希值冲突时,equals()
方法决定了这些对象是否是“同一个对象”。所以,如果要将自定义的对象放入到LinkedHashMap
或LinkedHashSet
中,需要重写 hashCode()
和equals()
方法。
内部实现
get
get(Object key)
方法根据指定的key
值返回对应的value
。该方法跟HashMap.get()
方法的流程几乎完全一样。
put
put(K key, V value)
方法是将指定的key, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于get()
方法;如果没有找到,则会通过addEntry(int hash, K key, V value, int bucketIndex)
方法插入新的entry
。
注意这里的插入有两重含义:
- 从 table 的角度看,新的 entry 需要插入到对应的 bucket 中,当有哈希冲突时,采用头插法将新的 entry 插入到冲突链表的头部。
- 从 header 的角度看,新的 entry 需要插入到双向链表大尾部。
addEntry 的实现逻辑:
// LinkedHashMap.addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);// 自动扩容,并重新哈希
hash = (null != key) ? hash(key) : 0;
bucketIndex = hash & (table.length-1);// hash%table.length
}
// 1.在冲突链表头部插入新的entry
HashMap.Entry<K,V> old = table[bucketIndex];
Entry<K,V> e = new Entry<>(hash, key, value, old);
table[bucketIndex] = e;
// 2.在双向链表的尾部插入新的entry
e.addBefore(header);
size++;
}
上述代码中用到了 addBefore 方法将新的 entry 插入到双向链表头引用的 header 的前面,这样 e 就称为双向链表中的最后一个元素。addBefore 的实现逻辑如下:
// LinkedHashMap.Entry.addBefor(),将this插入到existingEntry的前面
private void addBefore(Entry<K,V> existingEntry) {
after = existingEntry;
before = existingEntry.before;
before.after = this;
after.before = this;
}
上述到吗只是简单的修改 entry 的引用就实现了整个逻辑。
remove
remove(Object key)
的作用是删除key
值对应的entry
,该方法的具体逻辑是在removeEntryForKey(Object key)
里实现的。removeEntryForKey()
方法会首先找到key
值对应的entry
,然后删除该entry
(修改链表的相应引用)。查找过程跟get()
方法类似。
注意这里的删除也有两重含义:
- 从 table 的角度看,需要将 entry 从对应的 bucket 中删除,如果对应的冲突链表不为空,需要修改冲突链表的引用。
- 从 header 的角度看,需要将该 entry 从双向链表中删除,同时修改链表中前置和后置元素的引用。
removeEntryForKey 的实现逻辑如下:
// LinkedHashMap.removeEntryForKey(),删除key值对应的entry
final Entry<K,V> removeEntryForKey(Object key) {
......
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);// hash&(table.length-1)
Entry<K,V> prev = table[i];// 得到冲突链表
Entry<K,V> e = prev;
while (e != null) {// 遍历冲突链表
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {// 找到要删除的entry
modCount++; size--;
// 1. 将e从对应bucket的冲突链表中删除
if (prev == e) table[i] = next;
else prev.next = next;
// 2. 将e从双向链表中删除
e.before.after = e.after;
e.after.before = e.before;
return e;
}
prev = e; e = next;
}
return e;
}
LinkedHashSet
LinkedHashSet是对LinkedHashMap的简单包装,对LinkedHashSet的函数调用都会转换成合适的LinkedHashMap方法。
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
......
// LinkedHashSet里面有一个LinkedHashMap
public LinkedHashSet(int initialCapacity, float loadFactor) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
......
public boolean add(E e) {//简单的方法转换
return map.put(e, PRESENT)==null;
}
......
}
常用场景
LinkedHashMap除了可以保证迭代顺序外,还有一个非常有用的用法: 可以轻松实现一个采用了FIFO替换策略的缓存。具体说来,LinkedHashMap有一个子类方法protected boolean removeEldestEntry(Map.Entry<K,V> eldest)
,该方法的作用是告诉Map是否要删除“最老”的Entry,所谓最老就是当前Map中最早插入的Entry,如果该方法返回true
,最老的那个元素就会被删除。在每次插入新元素的之后LinkedHashMap会自动询问removeEldestEntry()是否要删除最老的元素。这样只需要在子类中重载该方法,当元素个数超过一定数量时让removeEldestEntry()返回true,就能够实现一个固定大小的FIFO策略的缓存。示例代码如下:
class FifoCache<K,V> extends LinkedHashMap<K,v> {
private final int size;
public FifoCache(int size){
this.size = size;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K,V> eldest){
return size() > size;
}
}
1.2.8 - CH08-TreeSet-Map
概述
- TreeSet 和 TreeMap 在 Java 中具有类似的实现,前者仅仅是对后者的简单封装。
- TreeMap实现了SortedMap接口,也就是说会按照
key
的大小顺序对Map中的元素进行排序,key
大小的评判可以通过其本身的自然顺序(natural ordering),也可以通过构造时传入的比较器(Comparator)。 - *TreeMap*底层通过红黑树(Red-Black tree)实现,也就意味着
containsKey()
,get()
,put()
,remove()
都有着log(n)
的时间复杂度。 - 出于性能原因,TreeMap是非同步的(not synchronized)。
红黑树是一种近似平衡的二叉查找树,它能保证任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍。
具体来说,红黑树是满足如下条件的二叉查找树:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色。
- 红色节点不能有连续(父子节点均不能为红色)。
- 对于每个节点,从该节点至 null(树尾)的任何路径,都含有相同个数的黑色节点。
在树的结构发生改变时(插入或删除),往往会破坏上面的 3 和 4,需要执行调整以使得重新满足所有条件。
树操作
调整可以分为两类,颜色调整和结构调整。
结构调整:左旋
左旋的过程就是想 X 的右子树绕 X 向左方向(逆时针)旋转,使 X 的右子树称为 X 的父亲,同时修改相关节点的引用。旋转之后,二叉查找树的条件仍然满足。
结构调整:右旋
右旋的过程是将 X 的左子树绕 X 向右方向(顺时针)旋转,使 X 的左子树称为 X 的父亲,同时修改相关的引用。旋转之后,二叉查找树的条件仍然满足。
寻找节点后继
对二叉查找树,给定节点 T,其后继(树中大于 T 的最小元素)可以通过如下方式找到:
- T 的右子树不空,则 T 的后继是其右子树中最小的按个元素。
- T 的右子树为空,则 T 的后继是其第一个向左走的父亲。
该操作用于删除红黑树中的删除操作。
// 寻找节点后继函数successor()
static <K,V> TreeMap.Entry<K,V> successor(Entry<K,V> t) {
if (t == null)
return null;
else if (t.right != null) {// 1. t的右子树不空,则t的后继是其右子树中最小的那个元素
Entry<K,V> p = t.right;
while (p.left != null)
p = p.left;
return p;
} else {// 2. t的右孩子为空,则t的后继是其第一个向左走的祖先
Entry<K,V> p = t.parent;
Entry<K,V> ch = t;
while (p != null && ch == p.right) {
ch = p;
p = p.parent;
}
return p;
}
}
内部实现
get
get(Object key)
方法根据指定的key
值返回对应的value
,该方法调用了getEntry(Object key)
得到相应的entry
,然后返回entry.value
。因此getEntry()
是算法的核心。算法思想是根据key
的自然顺序(或者比较器顺序)对二叉查找树进行查找,直到找到满足k.compareTo(p.key) == 0
的entry
。
//getEntry()方法
final Entry<K,V> getEntry(Object key) {
......
if (key == null)//不允许key值为null
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;//使用元素的自然顺序
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)//向左找
p = p.left;
else if (cmp > 0)//向右找
p = p.right;
else
return p;
}
return null;
}
put
put(K key, V value)
方法是将指定的key
, value
对添加到map
里。该方法首先会对map
做一次查找,看是否包含该元组,如果已经包含则直接返回,查找过程类似于getEntry()
方法;如果没有找到则会在红黑树中插入新的entry
,如果插入之后破坏了红黑树的约束条件,还需要进行调整(旋转,改变某些节点的颜色)。
public V put(K key, V value) {
......
int cmp;
Entry<K,V> parent;
if (key == null)
throw new NullPointerException();
Comparable<? super K> k = (Comparable<? super K>) key;//使用元素的自然顺序
do {
parent = t;
cmp = k.compareTo(t.key);
if (cmp < 0) t = t.left;//向左找
else if (cmp > 0) t = t.right;//向右找
else return t.setValue(value);
} while (t != null);
Entry<K,V> e = new Entry<>(key, value, parent);//创建并插入新的entry
if (cmp < 0) parent.left = e;
else parent.right = e;
fixAfterInsertion(e);//调整
size++;
return null;
}
上述代码首先在红黑树上找到合适的位置,然后创建新的 entry 并插入(插入的节点一定是叶子)。难点是调整函数 fixAfterInsertion,需要执行颜色调整和结构调整。
调整函数的具体实现如下,其中用到了前面提到的 rotateLeft 和 rotateRight 函数。通过代码我们可以看到,情况 2 其实是落在情况 3 内。情况 4~6 跟前三种情况是对称的,因此图解中没有展示后 3 种情况。
//红黑树调整函数fixAfterInsertion()
private void fixAfterInsertion(Entry<K,V> x) {
x.color = RED;
while (x != null && x != root && x.parent.color == RED) {
if (parentOf(x) == leftOf(parentOf(parentOf(x)))) {
Entry<K,V> y = rightOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK); // 情况1
setColor(y, BLACK); // 情况1
setColor(parentOf(parentOf(x)), RED); // 情况1
x = parentOf(parentOf(x)); // 情况1
} else {
if (x == rightOf(parentOf(x))) {
x = parentOf(x); // 情况2
rotateLeft(x); // 情况2
}
setColor(parentOf(x), BLACK); // 情况3
setColor(parentOf(parentOf(x)), RED); // 情况3
rotateRight(parentOf(parentOf(x))); // 情况3
}
} else {
Entry<K,V> y = leftOf(parentOf(parentOf(x)));
if (colorOf(y) == RED) {
setColor(parentOf(x), BLACK); // 情况4
setColor(y, BLACK); // 情况4
setColor(parentOf(parentOf(x)), RED); // 情况4
x = parentOf(parentOf(x)); // 情况4
} else {
if (x == leftOf(parentOf(x))) {
x = parentOf(x); // 情况5
rotateRight(x); // 情况5
}
setColor(parentOf(x), BLACK); // 情况6
setColor(parentOf(parentOf(x)), RED); // 情况6
rotateLeft(parentOf(parentOf(x))); // 情况6
}
}
}
root.color = BLACK;
}
remove
remove(Object key)
的作用是删除key
值对应的entry
,该方法首先通过上文中提到的getEntry(Object key)
方法找到key
值对应的entry
,然后调用deleteEntry(Entry<K,V> entry)
删除对应的entry
。由于删除操作会改变红黑树的结构,有可能破坏红黑树的约束条件,因此有可能要进行调整。
getEntry()
函数前面已经讲解过,这里重点放deleteEntry()
上,该函数删除指定的entry
并在红黑树的约束被破坏时进行调用fixAfterDeletion(Entry<K,V> x)
进行调整。
由于红黑树是一棵增强版的二叉查找树,红黑树的删除操作跟普通二叉查找树的删除操作也就非常相似,唯一的区别是红黑树在节点删除之后可能需要进行调整。现在考虑一棵普通二叉查找树的删除过程,可以简单分为两种情况:
删除节点 P 的左右子树都为空,或者只有一个子树为空。
删除节点 P 的左右子树都非空。
对于上述情况1,处理起来比较简单,直接将p删除(左右子树都为空时),或者用非空子树替代p(只有一棵子树非空时);对于情况2,可以用p的后继s(树中大于x的最小的那个元素)代替p,然后使用情况1删除s(此时s一定满足情况1.可以画画看)。
基于以上逻辑,红黑树的节点删除函数deleteEntry()
代码如下:
// 红黑树entry删除函数deleteEntry()
private void deleteEntry(Entry<K,V> p) {
modCount++;
size--;
if (p.left != null && p.right != null) {// 2. 删除点p的左右子树都非空。
Entry<K,V> s = successor(p);// 后继
p.key = s.key;
p.value = s.value;
p = s;
}
Entry<K,V> replacement = (p.left != null ? p.left : p.right);
if (replacement != null) {// 1. 删除点p只有一棵子树非空。
replacement.parent = p.parent;
if (p.parent == null)
root = replacement;
else if (p == p.parent.left)
p.parent.left = replacement;
else
p.parent.right = replacement;
p.left = p.right = p.parent = null;
if (p.color == BLACK)
fixAfterDeletion(replacement);// 调整
} else if (p.parent == null) {
root = null;
} else { // 1. 删除点p的左右子树都为空
if (p.color == BLACK)
fixAfterDeletion(p);// 调整
if (p.parent != null) {
if (p == p.parent.left)
p.parent.left = null;
else if (p == p.parent.right)
p.parent.right = null;
p.parent = null;
}
}
}
上述代码中占据大量代码行的,是用来修改父子节点间引用关系的代码,其逻辑并不难理解。下面着重讲解删除后调整函数fixAfterDeletion()
。首先请思考一下,删除了哪些点才会导致调整?只有删除点是BLACK的时候,才会触发调整函数,因为删除RED节点不会破坏红黑树的任何约束,而删除BLACK节点会破坏规则4。
跟上文中讲过的fixAfterInsertion()
函数一样,这里也要分成若干种情况。记住,无论有多少情况,具体的调整操作只有两种: 1.改变某些节点的颜色,2.对某些节点进行旋转。
上图的整体思路为:将情况 1 首先转换为情况 2,或者转换成 3 或 4。当然,该图解并不意味着调整情况一定是从情况 1 开始的。通过后续的代码我们会发现一些规则:
- 如果是由情况 1 之后紧接着进入情况 2,那么情况 2 之后一定会退出循环(因为 X 为红色)。
- 一旦进入情况 3 和 4,一定会退出循环(因为 X 为 root)。
删除后跳转函数 fixAfterDeletion 的具体实现如下,其中用到了上文中提到的rotateLeft()
和rotateRight()
函数。通过代码我们能够看到,情况3其实是落在情况4内的。情况5~情况8跟前四种情况是对称的,因此图解中并没有画出后四种情况,读者可以参考代码自行理解。
private void fixAfterDeletion(Entry<K,V> x) {
while (x != root && colorOf(x) == BLACK) {
if (x == leftOf(parentOf(x))) {
Entry<K,V> sib = rightOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK); // 情况1
setColor(parentOf(x), RED); // 情况1
rotateLeft(parentOf(x)); // 情况1
sib = rightOf(parentOf(x)); // 情况1
}
if (colorOf(leftOf(sib)) == BLACK &&
colorOf(rightOf(sib)) == BLACK) {
setColor(sib, RED); // 情况2
x = parentOf(x); // 情况2
} else {
if (colorOf(rightOf(sib)) == BLACK) {
setColor(leftOf(sib), BLACK); // 情况3
setColor(sib, RED); // 情况3
rotateRight(sib); // 情况3
sib = rightOf(parentOf(x)); // 情况3
}
setColor(sib, colorOf(parentOf(x))); // 情况4
setColor(parentOf(x), BLACK); // 情况4
setColor(rightOf(sib), BLACK); // 情况4
rotateLeft(parentOf(x)); // 情况4
x = root; // 情况4
}
} else { // 跟前四种情况对称
Entry<K,V> sib = leftOf(parentOf(x));
if (colorOf(sib) == RED) {
setColor(sib, BLACK); // 情况5
setColor(parentOf(x), RED); // 情况5
rotateRight(parentOf(x)); // 情况5
sib = leftOf(parentOf(x)); // 情况5
}
if (colorOf(rightOf(sib)) == BLACK &&
colorOf(leftOf(sib)) == BLACK) {
setColor(sib, RED); // 情况6
x = parentOf(x); // 情况6
} else {
if (colorOf(leftOf(sib)) == BLACK) {
setColor(rightOf(sib), BLACK); // 情况7
setColor(sib, RED); // 情况7
rotateLeft(sib); // 情况7
sib = leftOf(parentOf(x)); // 情况7
}
setColor(sib, colorOf(parentOf(x))); // 情况8
setColor(parentOf(x), BLACK); // 情况8
setColor(leftOf(sib), BLACK); // 情况8
rotateRight(parentOf(x)); // 情况8
x = root; // 情况8
}
}
}
setColor(x, BLACK);
}
TreeSet
前面已经说过TreeSet
是对TreeMap
的简单包装,对TreeSet
的函数调用都会转换成合适的TreeMap
方法。
// TreeSet是对TreeMap的简单包装
public class TreeSet<E> extends AbstractSet<E>
implements NavigableSet<E>, Cloneable, java.io.Serializable
{
......
private transient NavigableMap<E,Object> m;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public TreeSet() {
this.m = new TreeMap<E,Object>();// TreeSet里面有一个TreeMap
}
......
public boolean add(E e) {
return m.put(e, PRESENT)==null;
}
......
}
1.2.9 - CH09-WeakHashMap
概述
它的特殊之处在于 WeakHashMap 里的entry
可能会被GC自动删除,即使程序员没有调用remove()
或者clear()
方法。
当使用 WeakHashMap 时,即使没有显式的添加或删除任何元素,也可能发生如下情况:
- 调用两次 size 方法所返回的结果不同。
- 调用两次 isEmpty 方法,第一次返回 false,第二次返回 true。
- 调用两次 containskey 方法,首次返回 true,第二次返回 false,尽管两次使用相同的 key。
- 调用两次 get 方法,首次返回 value,第二次返回 null,尽管两次使用相同的对象。
这些特性尤其适用于需要缓存的场景。在缓存场景中,由于内存的局限,不能缓存所有对象,对象缓存命中可以提供系统效率,但缓存 MISS 也不会引起错误,因为可以通过计算重新得到。
Java 内存是通过 GC 自动管理的,GC 会在程序运行过程中自动判断哪些对象是可以被回收的,并在合适的时机执行内存释放。GC 判断某个对象释放可以被回收的依据是,释放有有效的引用指向该对象。如果没有有效引用指向该对象(即基本意味着不存在访问该对象的方式),那么该对象就是可以被回收的。这里的有效应用并不包括弱引用。也就是说,虽然弱引用可以用来访问对象,但进行垃圾回收时弱引用并不会被考虑在内,仅有弱引用指向的对象仍然会被 GC 回收。
WeakHashMap 内部是通过弱引用来管理 entry 的,弱引用的特性应用到 WeakHashMap 上意味着什么呢?将一对 key value 放入到 WeakHashMap 中并不能避免该 key 被 GC 回收,除非在 WeakHashMap 在外还有对该 key 的强引用。
具体实现
类似于 HashMap 和 HashSet。
WeakHashSet
Set<Object> weakHashSet = Collections
.newSetFromMap(new WeakHashMap<Object, Boolean>());
该工具方法可以直接将 Map 包装为 Set,只是对 Map 的简单封装。
// Collections.newSetFromMap()用于将任何Map包装成一个Set
public static <E> Set<E> newSetFromMap(Map<E, Boolean> map) {
return new SetFromMap<>(map);
}
private static class SetFromMap<E> extends AbstractSet<E>
implements Set<E>, Serializable
{
private final Map<E, Boolean> m; // The backing map
private transient Set<E> s; // Its keySet
SetFromMap(Map<E, Boolean> map) {
if (!map.isEmpty())
throw new IllegalArgumentException("Map is non-empty");
m = map;
s = map.keySet();
}
public void clear() { m.clear(); }
public int size() { return m.size(); }
public boolean isEmpty() { return m.isEmpty(); }
public boolean contains(Object o) { return m.containsKey(o); }
public boolean remove(Object o) { return m.remove(o) != null; }
public boolean add(E e) { return m.put(e, Boolean.TRUE) == null; }
public Iterator<E> iterator() { return s.iterator(); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
public String toString() { return s.toString(); }
public int hashCode() { return s.hashCode(); }
public boolean equals(Object o) { return o == this || s.equals(o); }
public boolean containsAll(Collection<?> c) {return s.containsAll(c);}
public boolean removeAll(Collection<?> c) {return s.removeAll(c);}
public boolean retainAll(Collection<?> c) {return s.retainAll(c);}
// addAll is the only inherited implementation
......
}
1.2.10 - CH10-Stream
Stream 是 JDK1.8 中首次引入的,距今已经过去了接近8年时间(JDK1.8正式版是2013年底发布的)。Stream 的引入一方面极大地简化了某些开发场景,另一方面也可能降低了编码的可读性(确实有不少人说到Stream会降低代码的可读性,但是在笔者看来,熟练使用之后反而觉得代码的可读性提高了)。这篇文章会花巨量篇幅,详细分析 Stream 的底层实现原理,参考的源码是 JDK11 的源码,其他版本 JDK 可能不适用于本文中的源码展示和相关例子。
向前兼容
Stream
是JDK1.8
引入的,如要需要JDK1.7
或者以前的代码也能在JDK1.8
或以上运行,那么Stream
的引入必定不能在原来已经发布的接口方法进行修改,否则必定会因为兼容性问题导致老版本的接口实现无法在新版本中运行(方法签名出现异常),猜测是基于这个问题引入了接口默认方法,也就是default
关键字。查看源码可以发现,ArrayList
的超类Collection
和Iterable
分别添加了数个default
方法:
// java.util.Collection部分源码
public interface Collection<E> extends Iterable<E> {
// 省略其他代码
@Override
default Spliterator<E> spliterator() {
return Spliterators.spliterator(this, 0);
}
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
default Stream<E> parallelStream() {
return StreamSupport.stream(spliterator(), true);
}
}
// java.lang.Iterable部分源码
public interface Iterable<T> {
// 省略其他代码
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
default Spliterator<T> spliterator() {
return Spliterators.spliteratorUnknownSize(iterator(), 0);
}
}
从直觉来看,这些新增的方法应该就是Stream
实现的关键方法(后面会印证这不是直觉,而是查看源码的结果)。接口默认方法在使用上和实例方法一致,在实现上可以直接在接口方法中编写方法体,有点静态方法的意味,但是子类可以覆盖其实现(也就是接口默认方法在本接口中的实现有点像静态方法,可以被子类覆盖,使用上和实例方法一致)。这种实现方式,有可能是一种突破,也有可能是一种妥协,但是无论是妥协还是突破,都实现了向前兼容:
// JDK1.7中的java.lang.Iterable
public interface Iterable<T> {
Iterator<T> iterator();
}
// JDK1.7中的Iterable实现
public MyIterable<Long> implements Iterable<Long>{
public Iterator<Long> iterator(){
....
}
}
如上,MyIterable
在JDK1.7
中定义,如果该类在JDK1.8
中运行,那么调用其实例中的forEach()
和spliterator()
方法,相当于直接调用JDK1.8
中的Iterable
中的接口默认方法forEach()
和spliterator()
。当然受限于JDK
版本,这里只能确保编译通过,旧功能正常使用,而无法在JDK1.7
中使用Stream
相关功能或者使用default
方法关键字。总结这么多,就是想说明为什么使用JDK7
开发和编译的代码可以在JDK8
环境下运行。
可拆分迭代器 Spliterator
Stream
实现的基石是Spliterator
,Spliterator
是splitable iterator
的缩写,意为"可拆分迭代器",用于遍历指定数据源(例如数组、集合或者IO Channel
等)中的元素,在设计上充分考虑了串行和并行的场景。上一节提到了Collection
存在接口默认方法spliterator()
,此方法会生成一个Spliterator<E>
实例,意为着所有的集合子类都具备创建Spliterator
实例的能力。Stream
的实现在设计上和Netty
中的ChannelHandlerContext
十分相似,本质是一个链表,而Spliterator
就是这个链表的Head
节点(Spliterator
实例就是一个流实例的头节点,后面分析具体的源码时候再具体展开)。
Spliterator 接口方法
接着看Spliterator
接口定义的方法:
public interface Spliterator<T> {
// 暂时省略其他代码
boolean tryAdvance(Consumer<? super T> action);
default void forEachRemaining(Consumer<? super T> action) {
do { } while (tryAdvance(action));
}
Spliterator<T> trySplit();
long estimateSize();
default long getExactSizeIfKnown() {
return (characteristics() & SIZED) == 0 ? -1L : estimateSize();
}
int characteristics();
default boolean hasCharacteristics(int characteristics) {
return (characteristics() & characteristics) == characteristics;
}
default Comparator<? super T> getComparator() {
throw new IllegalStateException();
}
// 暂时省略其他代码
}
tryAdvance
- 方法签名:
boolean tryAdvance(Consumer<? super T> action)
- 功能:如果
Spliterator
中存在剩余元素,则对其中的某个元素执行传入的action
回调,并且返回true
,否则返回false
。如果Spliterator
启用了ORDERED
特性,会按照顺序(这里的顺序值可以类比为ArrayList
中容器数组元素的下标,ArrayList
中添加新元素是天然有序的,下标由零开始递增)处理下一个元素 - 例子:
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(1);
list.add(3);
Spliterator<Integer> spliterator = list.stream().spliterator();
final AtomicInteger round = new AtomicInteger(1);
final AtomicInteger loop = new AtomicInteger(1);
while (spliterator.tryAdvance(num -> System.out.printf("第%d轮回调Action,值:%d\n", round.getAndIncrement(), num))) {
System.out.printf("第%d轮循环\n", loop.getAndIncrement());
}
}
// 控制台输出
第1轮回调Action,值:2
第1轮循环
第2轮回调Action,值:1
第2轮循环
第3轮回调Action,值:3
第3轮循环
forEachRemaining
- 方法签名:
default void forEachRemaining(Consumer<? super T> action)
- 功能:如果
Spliterator
中存在剩余元素,则对其中的所有剩余元素在当前线程中执行传入的action
回调。如果Spliterator
启用了ORDERED
特性,会按照顺序处理剩余所有元素。这是一个接口默认方法,方法体比较粗暴,直接是一个死循环包裹着tryAdvance()
方法,直到false
退出循环 - 例子:
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(1);
list.add(3);
Spliterator<Integer> spliterator = list.stream().spliterator();
final AtomicInteger round = new AtomicInteger(1);
spliterator.forEachRemaining(num -> System.out.printf("第%d轮回调Action,值:%d\n", round.getAndIncrement(), num));
}
// 控制台输出
第1轮回调Action,值:2
第2轮回调Action,值:1
第3轮回调Action,值:3
trySplit
- 方法签名:
Spliterator<T> trySplit()
- 功能:如果当前的
Spliterator
是可分区(可分割)的,那么此方法将会返回一个全新的Spliterator
实例,这个全新的Spliterator
实例里面的元素不会被当前Spliterator
实例中的元素覆盖(这里是直译了API
注释,实际要表达的意思是:当前的Spliterator
实例X
是可分割的,trySplit()
方法会分割X
产生一个全新的Spliterator
实例Y
,原来的X
所包含的元素(范围)也会收缩,类似于X = [a,b,c,d] => X = [a,b], Y = [c,d]
;如果当前的Spliterator
实例X
是不可分割的,此方法会返回NULL
),具体的分割算法由实现类决定 - 例子:
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(3);
list.add(4);
list.add(1);
Spliterator<Integer> first = list.stream().spliterator();
Spliterator<Integer> second = first.trySplit();
first.forEachRemaining(num -> {
System.out.printf("first spliterator item: %d\n", num);
});
second.forEachRemaining(num -> {
System.out.printf("second spliterator item: %d\n", num);
});
}
// 控制台输出
first spliterator item: 4
first spliterator item: 1
second spliterator item: 2
second spliterator item: 3
estimateSize
- 方法签名:
long estimateSize()
- 功能:返回
forEachRemaining()
方法需要遍历的元素总量的估计值,如果样本个数是无限、计算成本过高或者未知,会直接返回Long.MAX_VALUE
- 例子:
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(3);
list.add(4);
list.add(1);
Spliterator<Integer> spliterator = list.stream().spliterator();
System.out.println(spliterator.estimateSize());
}
// 控制台输出
4
getExactSizeIfKnown
- 方法签名:
default long getExactSizeIfKnown()
- 功能:如果当前的
Spliterator
具备SIZED
特性(关于特性,下文再展开分析),那么直接调用estimateSize()
方法,否则返回-1
- 例子:
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(3);
list.add(4);
list.add(1);
Spliterator<Integer> spliterator = list.stream().spliterator();
System.out.println(spliterator.getExactSizeIfKnown());
}
// 控制台输出
4
int characteristics()
- 方法签名:
long estimateSize()
- 功能:当前的
Spliterator
具备的特性(集合),采用位运算,存储在32
位整数中(关于特性,下文再展开分析)
hasCharacteristics
- 方法签名:
default boolean hasCharacteristics(int characteristics)
- 功能:判断当前的
Spliterator
是否具备传入的特性
getComparator
- 方法签名:
default Comparator<? super T> getComparator()
- 功能:如果当前的
Spliterator
具备SORTED
特性,则需要返回一个Comparator
实例;如果Spliterator
中的元素是天然有序(例如元素实现了Comparable
接口),则返回NULL
;其他情况直接抛出IllegalStateException
异常
Spliterator自分割
Spliterator#trySplit()
可以把一个既有的Spliterator
实例分割为两个Spliterator
实例,笔者这里把这种方式称为Spliterator
自分割,示意图如下:
这里的分割在实现上可以采用两种方式:
- 物理分割:对于
ArrayList
而言,把底层数组拷贝并且进行分割,用上面的例子来说相当于X = [1,3,4,2] => X = [4,2], Y = [1,3]
,这样实现加上对于ArrayList
中本身的元素容器数组,相当于多存了一份数据,显然不是十分合理 - 逻辑分割:对于
ArrayList
而言,由于元素容器数组天然有序,可以采用数组的索引(下标)进行分割,用上面的例子来说相当于X = 索引表[0,1,2,3] => X = 索引表[2,3], Y = 索引表[0,1]
,这种方式是共享底层容器数组,只对元素索引进行分割,实现上比较简单而且相对合理
参看ArrayListSpliterator
的源码,可以分析其分割算法实现:
// ArrayList#spliterator()
public Spliterator<E> spliterator() {
return new ArrayListSpliterator(0, -1, 0);
}
// ArrayList中内部类ArrayListSpliterator
final class ArrayListSpliterator implements Spliterator<E> {
// 当前的处理的元素索引值,其实是剩余元素的下边界值(包含),在tryAdvance()或者trySplit()方法中被修改,一般初始值为0
private int index;
// 栅栏,其实是元素索引值的上边界值(不包含),一般初始化的时候为-1,使用时具体值为元素索引值上边界加1
private int fence;
// 预期的修改次数,一般初始化值等于modCount
private int expectedModCount;
ArrayListSpliterator(int origin, int fence, int expectedModCount) {
this.index = origin;
this.fence = fence;
this.expectedModCount = expectedModCount;
}
// 获取元素索引值的上边界值,如果小于0,则把hi和fence都赋值为(ArrayList中的)size,expectedModCount赋值为(ArrayList中的)modCount,返回上边界值
// 这里注意if条件中有赋值语句hi = fence,也就是此方法调用过程中临时变量hi总是重新赋值为fence,fence是ArrayListSpliterator实例中的成员属性
private int getFence() {
int hi;
if ((hi = fence) < 0) {
expectedModCount = modCount;
hi = fence = size;
}
return hi;
}
// Spliterator自分割,这里采用了二分法
public ArrayListSpliterator trySplit() {
// hi等于当前ArrayListSpliterator实例中的fence变量,相当于获取剩余元素的上边界值
// lo等于当前ArrayListSpliterator实例中的index变量,相当于获取剩余元素的下边界值
// mid = (lo + hi) >>> 1,这里的无符号右移动1位运算相当于(lo + hi)/2
int hi = getFence(), lo = index, mid = (lo + hi) >>> 1;
// 当lo >= mid的时候为不可分割,返回NULL,否则,以index = lo,fence = mid和expectedModCount = expectedModCount创建一个新的ArrayListSpliterator
// 这里有个细节之处,在新的ArrayListSpliterator构造参数中,当前的index被重新赋值为index = mid,这一点容易看漏,老程序员都喜欢做这样的赋值简化
// lo >= mid返回NULL的时候,不会创建新的ArrayListSpliterator,也不会修改当前ArrayListSpliterator中的参数
return (lo >= mid) ? null : new ArrayListSpliterator(lo, index = mid, expectedModCount);
}
// tryAdvance实现
public boolean tryAdvance(Consumer<? super E> action) {
if (action == null)
throw new NullPointerException();
// 获取迭代的上下边界
int hi = getFence(), i = index;
// 由于前面分析下边界是包含关系,上边界是非包含关系,所以这里要i < hi而不是i <= hi
if (i < hi) {
index = i + 1;
// 这里的elementData来自ArrayList中,也就是前文经常提到的元素数组容器,这里是直接通过元素索引访问容器中的数据
@SuppressWarnings("unchecked") E e = (E)elementData[i];
// 对传入的Action进行回调
action.accept(e);
// 并发修改异常判断
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
return true;
}
return false;
}
// forEachRemaining实现,这里没有采用默认实现,而是完全覆盖实现一个新方法
public void forEachRemaining(Consumer<? super E> action) {
// 这里会新建所需的中间变量,i为index的中间变量,hi为fence的中间变量,mc为expectedModCount的中间变量
int i, hi, mc;
Object[] a;
if (action == null)
throw new NullPointerException();
// 判断容器数组存在性
if ((a = elementData) != null) {
// hi、fence和mc初始化
if ((hi = fence) < 0) {
mc = modCount;
hi = size;
}
else
mc = expectedModCount;
// 这里就是先做参数合法性校验,再遍历临时数组容器a中中[i,hi)的剩余元素对传入的Action进行回调
// 这里注意有一处隐蔽的赋值(index = hi),下界被赋值为上界,意味着每个ArrayListSpliterator实例只能调用一次forEachRemaining()方法
if ((i = index) >= 0 && (index = hi) <= a.length) {
for (; i < hi; ++i) {
@SuppressWarnings("unchecked") E e = (E) a[i];
action.accept(e);
}
// 这里校验ArrayList的modCount和mc是否一致,理论上在forEachRemaining()遍历期间,不能对数组容器进行元素的新增或者移除,一旦发生modCount更变会抛出异常
if (modCount == mc)
return;
}
}
throw new ConcurrentModificationException();
}
// 获取剩余元素估计值,就是用剩余元素索引上边界直接减去下边界
public long estimateSize() {
return getFence() - index;
}
// 具备ORDERED、SIZED和SUBSIZED特性
public int characteristics() {
return Spliterator.ORDERED | Spliterator.SIZED | Spliterator.SUBSIZED;
}
}
在阅读源码的时候务必注意,老一辈的程序员有时候会采用比较隐蔽的赋值方式,笔者认为需要展开一下:
第一处红圈位置在构建新的ArrayListSpliterator
的时候,当前ArrayListSpliterator
的index
属性也被修改了,过程如下图:
第二处红圈位置,在forEachRemaining()
方法调用时候做参数校验,并且if
分支里面把index
(下边界值)赋值为hi
(上边界值),那么一个ArrayListSpliterator
实例中的forEachRemaining()
方法的遍历操作必定只会执行一次。可以这样验证一下:
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(2);
list.add(1);
list.add(3);
Spliterator<Integer> spliterator = list.stream().spliterator();
final AtomicInteger round = new AtomicInteger(1);
spliterator.forEachRemaining(num -> System.out.printf("[第一次遍历forEachRemaining]第%d轮回调Action,值:%d\n", round.getAndIncrement(), num));
round.set(1);
spliterator.forEachRemaining(num -> System.out.printf("[第二次遍历forEachRemaining]第%d轮回调Action,值:%d\n", round.getAndIncrement(), num));
}
// 控制台输出
[第一次遍历forEachRemaining]第1轮回调Action,值:2
[第一次遍历forEachRemaining]第2轮回调Action,值:1
[第一次遍历forEachRemaining]第3轮回调Action,值:3
对于ArrayListSpliterator
的实现可以确认下面几点:
- 一个新的
ArrayListSpliterator
实例中的forEachRemaining()
方法只能调用一次 ArrayListSpliterator
实例中的forEachRemaining()
方法遍历元素的边界是[index, fence)
ArrayListSpliterator
自分割的时候,分割出来的新ArrayListSpliterator
负责处理元素下标小的分段(类比fork
的左分支),而原ArrayListSpliterator
负责处理元素下标大的分段(类比fork
的右分支)ArrayListSpliterator
提供的estimateSize()
方法得到的分段元素剩余数量是一个准确值
如果把上面的例子继续分割,可以得到下面的过程:
Spliterator
自分割是并行流实现的基础,并行流计算过程其实就是fork-join
的处理过程,trySplit()
方法的实现决定了fork
任务的粒度,每个fork
任务进行计算的时候是并发安全的,这一点由线程封闭(线程栈封闭)保证,每一个fork
任务计算完成最后的结果再由单个线程进行join
操作,才能得到正确的结果。下面的例子是求整数1 ~ 100
的和:
public class ConcurrentSplitCalculateSum {
private static class ForkTask extends Thread {
private int result = 0;
private final Spliterator<Integer> spliterator;
private final CountDownLatch latch;
public ForkTask(Spliterator<Integer> spliterator,
CountDownLatch latch) {
this.spliterator = spliterator;
this.latch = latch;
}
@Override
public void run() {
long start = System.currentTimeMillis();
spliterator.forEachRemaining(num -> result = result + num);
long end = System.currentTimeMillis();
System.out.printf("线程[%s]完成计算任务,当前段计算结果:%d,耗时:%d ms\n",
Thread.currentThread().getName(), result, end - start);
latch.countDown();
}
public int result() {
return result;
}
}
private static int join(List<ForkTask> tasks) {
int result = 0;
for (ForkTask task : tasks) {
result = result + task.result();
}
return result;
}
private static final int THREAD_NUM = 4;
public static void main(String[] args) throws Exception {
List<Integer> source = new ArrayList<>();
for (int i = 1; i < 101; i++) {
source.add(i);
}
Spliterator<Integer> root = source.stream().spliterator();
List<Spliterator<Integer>> spliteratorList = new ArrayList<>();
Spliterator<Integer> x = root.trySplit();
Spliterator<Integer> y = x.trySplit();
Spliterator<Integer> z = root.trySplit();
spliteratorList.add(root);
spliteratorList.add(x);
spliteratorList.add(y);
spliteratorList.add(z);
List<ForkTask> tasks = new ArrayList<>();
CountDownLatch latch = new CountDownLatch(THREAD_NUM);
for (int i = 0; i < THREAD_NUM; i++) {
ForkTask task = new ForkTask(spliteratorList.get(i), latch);
task.setName("fork-task-" + (i + 1));
tasks.add(task);
}
tasks.forEach(Thread::start);
latch.await();
int result = join(tasks);
System.out.println("最终计算结果为:" + result);
}
}
// 控制台输出结果
线程[fork-task-4]完成计算任务,当前段计算结果:1575,耗时:0 ms
线程[fork-task-2]完成计算任务,当前段计算结果:950,耗时:1 ms
线程[fork-task-3]完成计算任务,当前段计算结果:325,耗时:1 ms
线程[fork-task-1]完成计算任务,当前段计算结果:2200,耗时:1 ms
最终计算结果为:5050
当然,最终并行流的计算用到了ForkJoinPool
,并不像这个例子中这么粗暴地进行异步执行。关于并行流的实现下文会详细分析。
Spliterator 支持特性
某一个Spliterator
实例支持的特性由方法characteristics()
决定,这个方法返回的是一个32
位数值,实际使用中会展开为bit
数组,所有的特性分配在不同的位上,而hasCharacteristics(int characteristics)
就是通过输入的具体特性值通过位运算判断该特性是否存在于characteristics()
中。下面简化characteristics
为byte
分析一下这个技巧:
假设:byte characteristics() => 也就是最多8个位用于表示特性集合,如果每个位只表示一种特性,那么可以总共表示8种特性
特性X:0000 0001
特性Y:0000 0010
以此类推
假设:characteristics = X | Y = 0000 0001 | 0000 0010 = 0000 0011
那么:characteristics & X = 0000 0011 & 0000 0001 = 0000 0001
判断characteristics是否包含X:(characteristics & X) == X
上面推断的过程就是Spliterator
中特性判断方法的处理逻辑:
// 返回特性集合
int characteristics();
// 基于位运算判断特性集合中是否存在输入的特性
default boolean hasCharacteristics(int characteristics) {
return (characteristics() & characteristics) == characteristics;
}
这里可以验证一下:
public class CharacteristicsCheck {
public static void main(String[] args) {
System.out.printf("是否存在ORDERED特性:%s\n", hasCharacteristics(Spliterator.ORDERED));
System.out.printf("是否存在SIZED特性:%s\n", hasCharacteristics(Spliterator.SIZED));
System.out.printf("是否存在DISTINCT特性:%s\n", hasCharacteristics(Spliterator.DISTINCT));
}
private static int characteristics() {
return Spliterator.ORDERED | Spliterator.SIZED | Spliterator.SORTED;
}
private static boolean hasCharacteristics(int characteristics) {
return (characteristics() & characteristics) == characteristics;
}
}
// 控制台输出
是否存在ORDERED特性:true
是否存在SIZED特性:true
是否存在DISTINCT特性:false
目前Spliterator
支持的特性一共有8
个,如下:
特性 | 十六进制值 | 二进制值 | 功能 |
---|---|---|---|
DISTINCT | 0x00000001 | 0000 0000 0000 0001 | 去重,例如对于每对要处理的元素(x,y) ,使用!x.equals(y) 比较,Spliterator 中去重实际上基于Set 处理 |
ORDERED | 0x00000010 | 0000 0000 0001 0000 | (元素)顺序处理,可以理解为trySplit() 、tryAdvance() 和forEachRemaining() 方法对所有元素处理都保证一个严格的前缀顺序 |
SORTED | 0x00000004 | 0000 0000 0000 0100 | 排序,元素使用getComparator() 方法提供的Comparator 进行排序,如果定义了SORTED 特性,则必须定义ORDERED 特性 |
SIZED | 0x00000040 | 0000 0000 0100 0000 | (元素)预估数量,启用此特性,那么Spliterator 拆分或者迭代之前,estimateSize() 返回的是元素的准确数量 |
NONNULL | 0x00000040 | 0000 0001 0000 0000 | (元素)非NULL ,数据源保证Spliterator 需要处理的元素不能为NULL ,最常用于并发容器中的集合、队列和Map |
IMMUTABLE | 0x00000400 | 0000 0100 0000 0000 | (元素)不可变,数据源不可被修改,也就是处理过程中元素不能被添加、替换和移除(更新属性是允许的) |
CONCURRENT | 0x00001000 | 0001 0000 0000 0000 | (元素源)的修改是并发安全的,意味着多线程在数据源中添加、替换或者移除元素在不需要额外的同步条件下是并发安全的 |
SUBSIZED | 0x00004000 | 0100 0000 0000 0000 | (子Spliterator 元素)预估数量,启用此特性,意味着通过trySplit() 方法分割出来的所有子Spliterator (当前Spliterator 分割后也属于子Spliterator )都启用SIZED 特性 |
细心点观察可以发现:所有特性采用32位的整数存储,使用了隔1位存储的策略,位下标和特性的映射是:(0 => DISTINCT)、(3 => SORTED)、(5 => ORDERED)、(7=> SIZED)、(9 => NONNULL)、(11 => IMMUTABLE)、(13 => CONCURRENT)、(15 => SUBSIZED)
所有特性的功能这里只概括了核心的定义,还有一些小字或者特例描述限于篇幅没有完全加上,这一点可以参考具体的源码中的API
注释。这些特性最终会转化为StreamOpFlag
再提供给Stream
中的操作判断使用,由于StreamOpFlag
会更加复杂,下文再进行详细分析。
流的实现原理以及源码分析
由于流的实现是高度抽象的工程代码,所以在源码阅读上会有点困难。整个体系涉及到大量的接口、类和枚举,如下图:
图中的顶层类结构图描述的就是流的流水线相关类继承关系,其中IntStream
、LongStream
和DoubleStream
都是特化类型,分别针对于Integer
、Long
和Double
三种类型,其他引用类型构建的Pipeline
都是ReferencePipeline
实例,因此笔者认为,ReferencePipeline
(引用类型流水线)是流的核心数据结构,下面会基于ReferencePipeline
的实现做深入分析。
StreamOpFlag源码分析
StreamOpFlag
是一个枚举,功能是存储Stream
和操作的标志(Flags corresponding to characteristics of streams and operations
,下称Stream
标志),这些标志提供给Stream
框架用于控制、定制化和优化计算。Stream
标志可以用于描述与流相关联的若干不同实体的特征,这些实体包括:Stream
的源、Stream
的中间操作(Op
)和Stream
的终端操作(Terminal Op
)。但是并非所有的Stream
标志对所有的Stream
实体都具备意义,目前这些实体和标志映射关系如下:
Type(Stream Entity Type) | DISTINCT | SORTED | ORDERED | SIZED | SHORT_CIRCUIT |
---|---|---|---|---|---|
SPLITERATOR | 01 | 01 | 01 | 01 | 00 |
STREAM | 01 | 01 | 01 | 01 | 00 |
OP | 11 | 11 | 11 | 10 | 01 |
TERMINAL_OP | 00 | 00 | 10 | 00 | 01 |
UPSTREAM_TERMINAL_OP | 00 | 00 | 10 | 00 | 00 |
其中:
- 01:表示设置/注入
- 10:表示清除
- 11:表示保留
- 00:表示初始化值(默认填充值),这是一个关键点,
0
值表示绝对不会是某个类型的标志
StreamOpFlag
的顶部注释中还有一个表格如下:
- | DISTINCT | SORTED | ORDERED | SIZED | SHORT_CIRCUIT |
---|---|---|---|---|---|
Stream source(Stream 的源) | Y | Y | Y | Y | N |
Intermediate operation(中间操作) | PCI | PCI | PCI | PC | PI |
Terminal operation(终结操作) | N | N | PC | N | PI |
标记 ->
含义:
Y
:允许N
:非法P
:保留C
:清除I
:注入- 组合
PCI
:可以保留、清除或者注入 - 组合
PC
:可以保留或者清除 - 组合
PI
:可以保留或者注入
两个表格其实是在描述同一个结论,可以相互对照和理解,但是最终实现参照于第一个表的定义。注意一点:这里的preserved
(P
)表示保留的意思,如果Stream
实体某个标志被赋值为preserved
,意味着该实体可以使用此标志代表的特性。例如此小节第一个表格中的OP
的DISTINCT
、SORTED
和ORDERED
都赋值为11
(preserved
),意味着OP
类型的实体允许使用去重、自然排序和顺序处理特性。回到源码部分,先看StreamOpFlag
的核心属性和构造器:
enum StreamOpFlag {
// 暂时忽略其他代码
// 类型枚举,Stream相关实体类型
enum Type {
// SPLITERATOR类型,关联所有和Spliterator相关的特性
SPLITERATOR,
// STREAM类型,关联所有和Stream相关的标志
STREAM,
// STREAM类型,关联所有和Stream中间操作相关的标志
OP,
// TERMINAL_OP类型,关联所有和Stream终结操作相关的标志
TERMINAL_OP,
// UPSTREAM_TERMINAL_OP类型,关联所有在最后一个有状态操作边界上游传播的终止操作标志
// 这个类型的意义直译有点拗口,不过实际上在JDK11源码中,这个类型没有被流相关功能引用,暂时可以忽略
UPSTREAM_TERMINAL_OP
}
// 设置/注入标志的bit模式,二进制数0001,十进制数1
private static final int SET_BITS = 0b01;
// 清除标志的bit模式,二进制数0010,十进制数2
private static final int CLEAR_BITS = 0b10;
// 保留标志的bit模式,二进制数0011,十进制数3
private static final int PRESERVE_BITS = 0b11;
// 掩码建造器工厂方法,注意这个方法用于实例化MaskBuilder
private static MaskBuilder set(Type t) {
return new MaskBuilder(new EnumMap<>(Type.class)).set(t);
}
// 私有静态内部类,掩码建造器,里面的map由上面的set(Type t)方法得知是EnumMap实例
private static class MaskBuilder {
// Type -> SET_BITS|CLEAR_BITS|PRESERVE_BITS|0
final Map<Type, Integer> map;
MaskBuilder(Map<Type, Integer> map) {
this.map = map;
}
// 设置类型和对应的掩码
MaskBuilder mask(Type t, Integer i) {
map.put(t, i);
return this;
}
// 对类型添加/inject
MaskBuilder set(Type t) {
return mask(t, SET_BITS);
}
MaskBuilder clear(Type t) {
return mask(t, CLEAR_BITS);
}
MaskBuilder setAndClear(Type t) {
return mask(t, PRESERVE_BITS);
}
// 这里的build方法对于类型中的NULL掩码填充为0,然后把map返回
Map<Type, Integer> build() {
for (Type t : Type.values()) {
map.putIfAbsent(t, 0b00);
}
return map;
}
}
// 类型->掩码映射
private final Map<Type, Integer> maskTable;
// bit的起始偏移量,控制下面set、clear和preserve的起始偏移量
private final int bitPosition;
// set/inject的bit set(map),其实准确来说应该是一个表示set/inject的bit map
private final int set;
// clear的bit set(map),其实准确来说应该是一个表示clear的bit map
private final int clear;
// preserve的bit set(map),其实准确来说应该是一个表示preserve的bit map
private final int preserve;
private StreamOpFlag(int position, MaskBuilder maskBuilder) {
// 这里会基于MaskBuilder初始化内部的EnumMap
this.maskTable = maskBuilder.build();
// Two bits per flag <= 这里会把入参position放大一倍
position *= 2;
this.bitPosition = position;
this.set = SET_BITS << position; // 设置/注入标志的bit模式左移2倍position
this.clear = CLEAR_BITS << position; // 清除标志的bit模式左移2倍position
this.preserve = PRESERVE_BITS << position; // 保留标志的bit模式左移2倍position
}
// 省略中间一些方法
// 下面这些静态变量就是直接返回标志对应的set/injec、清除和保留的bit map
/**
* The bit value to set or inject {@link #DISTINCT}.
*/
static final int IS_DISTINCT = DISTINCT.set;
/**
* The bit value to clear {@link #DISTINCT}.
*/
static final int NOT_DISTINCT = DISTINCT.clear;
/**
* The bit value to set or inject {@link #SORTED}.
*/
static final int IS_SORTED = SORTED.set;
/**
* The bit value to clear {@link #SORTED}.
*/
static final int NOT_SORTED = SORTED.clear;
/**
* The bit value to set or inject {@link #ORDERED}.
*/
static final int IS_ORDERED = ORDERED.set;
/**
* The bit value to clear {@link #ORDERED}.
*/
static final int NOT_ORDERED = ORDERED.clear;
/**
* The bit value to set {@link #SIZED}.
*/
static final int IS_SIZED = SIZED.set;
/**
* The bit value to clear {@link #SIZED}.
*/
static final int NOT_SIZED = SIZED.clear;
/**
* The bit value to inject {@link #SHORT_CIRCUIT}.
*/
static final int IS_SHORT_CIRCUIT = SHORT_CIRCUIT.set;
}
又因为StreamOpFlag
是一个枚举,一个枚举成员是一个独立的标志,而一个标志会对多个Stream
实体类型产生作用,所以它的一个成员描述的是上面实体和标志映射关系的一个列(竖着看):
// 纵向看
DISTINCT Flag:
maskTable: {
SPLITERATOR: 0000 0001,
STREAM: 0000 0001,
OP: 0000 0011,
TERMINAL_OP: 0000 0000,
UPSTREAM_TERMINAL_OP: 0000 0000
}
position(input): 0
bitPosition: 0
set: 1 => 0000 0000 0000 0000 0000 0000 0000 0001
clear: 2 => 0000 0000 0000 0000 0000 0000 0000 0010
preserve: 3 => 0000 0000 0000 0000 0000 0000 0000 0011
SORTED Flag:
maskTable: {
SPLITERATOR: 0000 0001,
STREAM: 0000 0001,
OP: 0000 0011,
TERMINAL_OP: 0000 0000,
UPSTREAM_TERMINAL_OP: 0000 0000
}
position(input): 1
bitPosition: 2
set: 4 => 0000 0000 0000 0000 0000 0000 0000 0100
clear: 8 => 0000 0000 0000 0000 0000 0000 0000 1000
preserve: 12 => 0000 0000 0000 0000 0000 0000 0000 1100
ORDERED Flag:
maskTable: {
SPLITERATOR: 0000 0001,
STREAM: 0000 0001,
OP: 0000 0011,
TERMINAL_OP: 0000 0010,
UPSTREAM_TERMINAL_OP: 0000 0010
}
position(input): 2
bitPosition: 4
set: 16 => 0000 0000 0000 0000 0000 0000 0001 0000
clear: 32 => 0000 0000 0000 0000 0000 0000 0010 0000
preserve: 48 => 0000 0000 0000 0000 0000 0000 0011 0000
SIZED Flag:
maskTable: {
SPLITERATOR: 0000 0001,
STREAM: 0000 0001,
OP: 0000 0010,
TERMINAL_OP: 0000 0000,
UPSTREAM_TERMINAL_OP: 0000 0000
}
position(input): 3
bitPosition: 6
set: 64 => 0000 0000 0000 0000 0000 0000 0100 0000
clear: 128 => 0000 0000 0000 0000 0000 0000 1000 0000
preserve: 192 => 0000 0000 0000 0000 0000 0000 1100 0000
SHORT_CIRCUIT Flag:
maskTable: {
SPLITERATOR: 0000 0000,
STREAM: 0000 0000,
OP: 0000 0001,
TERMINAL_OP: 0000 0001,
UPSTREAM_TERMINAL_OP: 0000 0000
}
position(input): 12
bitPosition: 24
set: 16777216 => 0000 0001 0000 0000 0000 0000 0000 0000
clear: 33554432 => 0000 0010 0000 0000 0000 0000 0000 0000
preserve: 50331648 => 0000 0011 0000 0000 0000 0000 0000 0000
接着就用到按位与(&
)和按位或(|
)的操作,假设A = 0001
、B = 0010
、C = 1000
,那么:
A|B = A | B = 0001 | 0010 = 0011
(按位或,1|0=1, 0|1=1,0|0 =0,1|1=1
)A&B = A & B = 0001 | 0010 = 0000
(按位与,1|0=0, 0|1=0,0|0 =0,1|1=1
)MASK = A | B | C = 0001 | 0010 | 1000 = 1011
- 那么判断
A|B
是否包含A
的条件为:A == (A|B & A)
- 那么判断
MASK
是否包含A
的条件为:A == MASK & A
这里把StreamOpFlag
中的枚举套用进去分析:
static int DISTINCT_SET = 0b0001;
static int SORTED_CLEAR = 0b1000;
public static void main(String[] args) throws Exception {
// 支持DISTINCT标志和不支持SORTED标志
int flags = DISTINCT_SET | SORTED_CLEAR;
System.out.println(Integer.toBinaryString(flags));
System.out.printf("支持DISTINCT标志:%s\n", DISTINCT_SET == (DISTINCT_SET & flags));
System.out.printf("不支持SORTED标志:%s\n", SORTED_CLEAR == (SORTED_CLEAR & flags));
}
// 控制台输出
1001
支持DISTINCT标志:true
不支持SORTED标志:true
由于StreamOpFlag
的修饰符是默认,不能直接使用,可以把它的代码拷贝出来修改包名验证里面的功能:
public static void main(String[] args) {
int flags = StreamOpFlag.DISTINCT.set | StreamOpFlag.SORTED.clear;
System.out.println(StreamOpFlag.DISTINCT.set == (StreamOpFlag.DISTINCT.set & flags));
System.out.println(StreamOpFlag.SORTED.clear == (StreamOpFlag.SORTED.clear & flags));
}
// 输出
true
true
下面这些方法就是基于这些运算特性而定义的:
enum StreamOpFlag {
// 暂时忽略其他代码
// 返回当前StreamOpFlag的set/inject的bit map
int set() {
return set;
}
// 返回当前StreamOpFlag的清除的bit map
int clear() {
return clear;
}
// 这里判断当前StreamOpFlag类型->标记映射中Stream类型的标记,如果大于0说明不是初始化状态,那么当前StreamOpFlag就是Stream相关的标志
boolean isStreamFlag() {
return maskTable.get(Type.STREAM) > 0;
}
// 这里就用到按位与判断输入的flags中是否设置当前StreamOpFlag(StreamOpFlag.set)
boolean isKnown(int flags) {
return (flags & preserve) == set;
}
// 这里就用到按位与判断输入的flags中是否清除当前StreamOpFlag(StreamOpFlag.clear)
boolean isCleared(int flags) {
return (flags & preserve) == clear;
}
// 这里就用到按位与判断输入的flags中是否保留当前StreamOpFlag(StreamOpFlag.clear)
boolean isPreserved(int flags) {
return (flags & preserve) == preserve;
}
// 判断当前的Stream实体类型是否可以设置本标志,要求Stream实体类型的标志位为set或者preserve,按位与要大于0
boolean canSet(Type t) {
return (maskTable.get(t) & SET_BITS) > 0;
}
// 暂时忽略其他代码
}
这里有个特殊操作,位运算的时候采用了(flags & preserve)
,理由是:同一个标志中的同一个Stream
实体类型只可能存在set/inject
、clear
和preserve
的其中一种,也就是同一个flags
中不可能同时存在StreamOpFlag.SORTED.set
和StreamOpFlag.SORTED.clear
,从语义上已经矛盾,而set/inject
、clear
和preserve
在bit map
中的大小(为2
位)和位置已经是固定的,preserve
在设计的时候为0b11
刚好2
位取反,因此可以特化为(这个特化也让判断更加严谨):
(flags & set) == set => (flags & preserve) == set
(flags & clear) == clear => (flags & preserve) == clear
(flags & preserve) == preserve => (flags & preserve) == preserve
分析这么多,总的来说,就是想通过一个32
位整数,每2
位分别表示3
种状态,那么一个完整的Flags
(标志集合)一共可以表示16
种标志(position=[0,15]
,可以查看API
注释,[4,11]
和[13,15]
的位置是未需实现或者预留的,属于gap
)。接着分析掩码Mask
的计算过程例子:
// 横向看(位移动运算符优先级高于与或,例如<<的优先级比|高)
SPLITERATOR_CHARACTERISTICS_MASK:
mask(init) = 0
mask(DISTINCT,SPLITERATOR[DISTINCT]=01,bitPosition=0) = 0000 0000 | 0000 0001 << 0 = 0000 0000 | 0000 0001 = 0000 0001
mask(SORTED,SPLITERATOR[SORTED]=01,bitPosition=2) = 0000 0001 | 0000 0001 << 2 = 0000 0001 | 0000 0100 = 0000 0101
mask(ORDERED,SPLITERATOR[ORDERED]=01,bitPosition=4) = 0000 0101 | 0000 0001 << 4 = 0000 0101 | 0001 0000 = 0001 0101
mask(SIZED,SPLITERATOR[SIZED]=01,bitPosition=6) = 0001 0101 | 0000 0001 << 6 = 0001 0101 | 0100 0000 = 0101 0101
mask(SHORT_CIRCUIT,SPLITERATOR[SHORT_CIRCUIT]=00,bitPosition=24) = 0101 0101 | 0000 0000 << 24 = 0101 0101 | 0000 0000 = 0101 0101
mask(final) = 0000 0000 0000 0000 0000 0000 0101 0101(二进制)、85(十进制)
STREAM_MASK:
mask(init) = 0
mask(DISTINCT,SPLITERATOR[DISTINCT]=01,bitPosition=0) = 0000 0000 | 0000 0001 << 0 = 0000 0000 | 0000 0001 = 0000 0001
mask(SORTED,SPLITERATOR[SORTED]=01,bitPosition=2) = 0000 0001 | 0000 0001 << 2 = 0000 0001 | 0000 0100 = 0000 0101
mask(ORDERED,SPLITERATOR[ORDERED]=01,bitPosition=4) = 0000 0101 | 0000 0001 << 4 = 0000 0101 | 0001 0000 = 0001 0101
mask(SIZED,SPLITERATOR[SIZED]=01,bitPosition=6) = 0001 0101 | 0000 0001 << 6 = 0001 0101 | 0100 0000 = 0101 0101
mask(SHORT_CIRCUIT,SPLITERATOR[SHORT_CIRCUIT]=00,bitPosition=24) = 0101 0101 | 0000 0000 << 24 = 0101 0101 | 0000 0000 = 0101 0101
mask(final) = 0000 0000 0000 0000 0000 0000 0101 0101(二进制)、85(十进制)
OP_MASK:
mask(init) = 0
mask(DISTINCT,SPLITERATOR[DISTINCT]=11,bitPosition=0) = 0000 0000 | 0000 0011 << 0 = 0000 0000 | 0000 0011 = 0000 0011
mask(SORTED,SPLITERATOR[SORTED]=11,bitPosition=2) = 0000 0011 | 0000 0011 << 2 = 0000 0011 | 0000 1100 = 0000 1111
mask(ORDERED,SPLITERATOR[ORDERED]=11,bitPosition=4) = 0000 1111 | 0000 0011 << 4 = 0000 1111 | 0011 0000 = 0011 1111
mask(SIZED,SPLITERATOR[SIZED]=10,bitPosition=6) = 0011 1111 | 0000 0010 << 6 = 0011 1111 | 1000 0000 = 1011 1111
mask(SHORT_CIRCUIT,SPLITERATOR[SHORT_CIRCUIT]=01,bitPosition=24) = 1011 1111 | 0000 0001 << 24 = 1011 1111 | 0100 0000 0000 0000 0000 0000 0000 = 0100 0000 0000 0000 0000 1011 1111
mask(final) = 0000 0000 1000 0000 0000 0000 1011 1111(二进制)、16777407(十进制)
TERMINAL_OP_MASK:
mask(init) = 0
mask(DISTINCT,SPLITERATOR[DISTINCT]=00,bitPosition=0) = 0000 0000 | 0000 0000 << 0 = 0000 0000 | 0000 0000 = 0000 0000
mask(SORTED,SPLITERATOR[SORTED]=00,bitPosition=2) = 0000 0000 | 0000 0000 << 2 = 0000 0000 | 0000 0000 = 0000 0000
mask(ORDERED,SPLITERATOR[ORDERED]=10,bitPosition=4) = 0000 0000 | 0000 0010 << 4 = 0000 0000 | 0010 0000 = 0010 0000
mask(SIZED,SPLITERATOR[SIZED]=00,bitPosition=6) = 0010 0000 | 0000 0000 << 6 = 0010 0000 | 0000 0000 = 0010 0000
mask(SHORT_CIRCUIT,SPLITERATOR[SHORT_CIRCUIT]=01,bitPosition=24) = 0010 0000 | 0000 0001 << 24 = 0010 0000 | 0001 0000 0000 0000 0000 0000 0000 = 0001 0000 0000 0000 0000 0010 0000
mask(final) = 0000 0001 0000 0000 0000 0000 0010 0000(二进制)、16777248(十进制)
UPSTREAM_TERMINAL_OP_MASK:
mask(init) = 0
mask(DISTINCT,SPLITERATOR[DISTINCT]=00,bitPosition=0) = 0000 0000 | 0000 0000 << 0 = 0000 0000 | 0000 0000 = 0000 0000
mask(SORTED,SPLITERATOR[SORTED]=00,bitPosition=2) = 0000 0000 | 0000 0000 << 2 = 0000 0000 | 0000 0000 = 0000 0000
mask(ORDERED,SPLITERATOR[ORDERED]=10,bitPosition=4) = 0000 0000 | 0000 0010 << 4 = 0000 0000 | 0010 0000 = 0010 0000
mask(SIZED,SPLITERATOR[SIZED]=00,bitPosition=6) = 0010 0000 | 0000 0000 << 6 = 0010 0000 | 0000 0000 = 0010 0000
mask(SHORT_CIRCUIT,SPLITERATOR[SHORT_CIRCUIT]=00,bitPosition=24) = 0010 0000 | 0000 0000 << 24 = 0010 0000 | 0000 0000 = 0010 0000
mask(final) = 0000 0000 0000 0000 0000 0000 0010 0000(二进制)、32(十进制)
相关的方法和属性如下:
enum StreamOpFlag {
// SPLITERATOR类型的标志bit map
static final int SPLITERATOR_CHARACTERISTICS_MASK = createMask(Type.SPLITERATOR);
// STREAM类型的标志bit map
static final int STREAM_MASK = createMask(Type.STREAM);
// OP类型的标志bit map
static final int OP_MASK = createMask(Type.OP);
// TERMINAL_OP类型的标志bit map
static final int TERMINAL_OP_MASK = createMask(Type.TERMINAL_OP);
// UPSTREAM_TERMINAL_OP类型的标志bit map
static final int UPSTREAM_TERMINAL_OP_MASK = createMask(Type.UPSTREAM_TERMINAL_OP);
// 基于Stream类型,创建对应类型填充所有标志的bit map
private static int createMask(Type t) {
int mask = 0;
for (StreamOpFlag flag : StreamOpFlag.values()) {
mask |= flag.maskTable.get(t) << flag.bitPosition;
}
return mask;
}
// 构造一个标志本身的掩码,就是所有标志都采用保留位表示,目前作为flags == 0时候的初始值
private static final int FLAG_MASK = createFlagMask();
// 构造一个包含全部标志中的preserve位的bit map,按照目前来看是暂时是一个固定值,二进制表示为0011 0000 0000 0000 0000 1111 1111
private static int createFlagMask() {
int mask = 0;
for (StreamOpFlag flag : StreamOpFlag.values()) {
mask |= flag.preserve;
}
return mask;
}
// 构造一个Stream类型包含全部标志中的set位的bit map,这里直接使用了STREAM_MASK,按照目前来看是暂时是一个固定值,二进制表示为0000 0000 0000 0000 0000 0000 0101 0101
private static final int FLAG_MASK_IS = STREAM_MASK;
// 构造一个Stream类型包含全部标志中的clear位的bit map,按照目前来看是暂时是一个固定值,二进制表示为0000 0000 0000 0000 0000 0000 1010 1010
private static final int FLAG_MASK_NOT = STREAM_MASK << 1;
// 初始化操作的标志bit map,目前来看就是Stream的头节点初始化时候需要合并在flags里面的初始化值,照目前来看是暂时是一个固定值,二进制表示为0000 0000 0000 0000 0000 0000 1111 1111
static final int INITIAL_OPS_VALUE = FLAG_MASK_IS | FLAG_MASK_NOT;
}
SPLITERATOR_CHARACTERISTICS_MASK
等5
个成员(见上面的Mask
计算例子)其实就是预先计算好对应的Stream
实体类型的所有StreamOpFlag
标志的bit map
,也就是之前那个展示Stream
的类型和标志的映射图的"横向"展示:
前面的分析已经相对详细,过程非常复杂,但是更复杂的Mask
应用还在后面的方法。Mask
的初始化就是提供给标志的合并(combine
)和转化(从Spliterator
中的characteristics
转化为flags
)操作的,见下面的方法:
enum StreamOpFlag {
// 这个方法完全没有注释,只使用在下面的combineOpFlags()方法中
// 从源码来看
// 入参flags == 0的时候,那么直接返回0011 0000 0000 0000 0000 1111 1111
// 入参flags != 0的时候,那么会把当前flags的所有set/inject、clear和preserve所在位在bit map中全部置为0,然后其他位全部置为1
private static int getMask(int flags) {
return (flags == 0)
? FLAG_MASK
: ~(flags | ((FLAG_MASK_IS & flags) << 1) | ((FLAG_MASK_NOT & flags) >> 1));
}
// 合并新的flags和前一个flags,这里还是用到老套路先和Mask按位与,再进行一次按位或
// 作为Stream的头节点的时候,prevCombOpFlags必须为INITIAL_OPS_VALUE
static int combineOpFlags(int newStreamOrOpFlags, int prevCombOpFlags) {
// 0x01 or 0x10 nibbles are transformed to 0x11
// 0x00 nibbles remain unchanged
// Then all the bits are flipped
// Then the result is logically or'ed with the operation flags.
return (prevCombOpFlags & StreamOpFlag.getMask(newStreamOrOpFlags)) | newStreamOrOpFlags;
}
// 通过合并后的flags,转换出Stream类型的flags
static int toStreamFlags(int combOpFlags) {
// By flipping the nibbles 0x11 become 0x00 and 0x01 become 0x10
// Shift left 1 to restore set flags and mask off anything other than the set flags
return ((~combOpFlags) >> 1) & FLAG_MASK_IS & combOpFlags;
}
// Stream的标志转换为Spliterator的characteristics
static int toCharacteristics(int streamFlags) {
return streamFlags & SPLITERATOR_CHARACTERISTICS_MASK;
}
// Spliterator的characteristics转换为Stream的标志,入参是Spliterator实例
static int fromCharacteristics(Spliterator<?> spliterator) {
int characteristics = spliterator.characteristics();
if ((characteristics & Spliterator.SORTED) != 0 && spliterator.getComparator() != null) {
// Do not propagate the SORTED characteristic if it does not correspond
// to a natural sort order
return characteristics & SPLITERATOR_CHARACTERISTICS_MASK & ~Spliterator.SORTED;
}
else {
return characteristics & SPLITERATOR_CHARACTERISTICS_MASK;
}
}
// Spliterator的characteristics转换为Stream的标志,入参是Spliterator的characteristics
static int fromCharacteristics(int characteristics) {
return characteristics & SPLITERATOR_CHARACTERISTICS_MASK;
}
}
这里的位运算很复杂,只展示简单的计算结果和相关功能:
combineOpFlags()
:用于合并新的flags
和上一个flags
,因为Stream
的数据结构是一个Pipeline
,后继节点需要合并前驱节点的flags
,例如前驱节点flags
是ORDERED.set
,当前新加入Pipeline
的节点(后继节点)的新flags
为SIZED.set
,那么在后继节点中应该合并前驱节点的标志,简单想象为SIZED.set | ORDERED.set
,如果是头节点,那么初始化头节点时候的flags
要合并INITIAL_OPS_VALUE
,这里举个例子:
int left = ORDERED.set | DISTINCT.set;
int right = SIZED.clear | SORTED.clear;
System.out.println("left:" + Integer.toBinaryString(left));
System.out.println("right:" + Integer.toBinaryString(right));
System.out.println("right mask:" + Integer.toBinaryString(getMask(right)));
System.out.println("combine:" + Integer.toBinaryString(combineOpFlags(right, left)));
// 输出结果
left:1010001
right:10001000
right mask:11111111111111111111111100110011
combine:10011001
characteristics
的转化问题:Spliterator
中的characteristics
可以通过简单的按位与转换为flags
的原因是Spliterator
中的characteristics
在设计时候本身就是和StreamOpFlag
匹配的,准确来说就是bit map
的位分布是匹配的,所以直接与SPLITERATOR_CHARACTERISTICS_MASK
做按位与即可,见下面的例子:
// 这里简单点只展示8 bit
SPLITERATOR_CHARACTERISTICS_MASK: 0101 0101
Spliterator.ORDERED: 0001 0000
StreamOpFlag.ORDERED.set: 0001 0000
至此,已经分析完StreamOpFlag
的完整实现,Mask
相关的方法限于篇幅就不打算详细展开,下面会开始分析Stream
中的"流水线"结构实现,因为习惯问题,下文的"标志"和"特性"两个词语会混用。
ReferencePipeline源码分析
既然Stream
具备流的特性,那么就需要一个链式数据结构,让元素能够从Source
一直往下"流动"和传递到每一个链节点,实现这种场景的常用数据结构就是双向链表(考虑需要回溯,单向链表不太合适),目前比较著名的实现有AQS
和Netty
中的ChannelHandlerContext
。例如Netty
中的流水线ChannelPipeline
设计如下:
对于这个双向链表的数据结构,Stream
中对应的类就是AbstractPipeline
,核心实现类在ReferencePipeline
和ReferencePipeline
的内部类。
主要接口
先简单展示AbstractPipeline
的核心父类方法定义,主要接父类是Stream
、BaseStream
和PipelineHelper
:
Stream
代表一个支持串行和并行聚合操作集合的元素序列,此顶层接口提供了流中间操作、终结操作和一些静态工厂方法的定义(由于方法太多,这里不全部列举),这个接口本质是一个建造器类型接口(对接中间操作来说),可以构成一个多中间操作,单终结操作的链,例如:
public interface Stream<T> extends BaseStream<T, Stream<T>> {
// 忽略其他代码
// 过滤Op
Stream<T> filter(Predicate<? super T> predicate);
// 映射Op
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
// 终结操作 - 遍历
void forEach(Consumer<? super T> action);
// 忽略其他代码
}
// init
Stream x = buildStream();
// chain: head -> filter(Op) -> map(Op) -> forEach(Terminal Op)
x.filter().map().forEach()
BaseStream
:Stream
的基础接口,定义流的迭代器、流的等效变体(并发处理变体、同步处理变体和不支持顺序处理元素变体)、并发和同步判断以及关闭相关方法
// T是元素类型,S是BaseStream<T, S>类型
// 流的基础接口,这里的流指定的支持同步执行和异步执行的聚合操作的元素序列
public interface BaseStream<T, S extends BaseStream<T, S>> extends AutoCloseable {
// 返回一个当前Stream实例中所有元素的迭代器
// 这是一个终结操作
Iterator<T> iterator();
// 返回一个当前Stream实例中所有元素的可拆分迭代器
Spliterator<T> spliterator();
// 当前的Stream实例是否支持并发
boolean isParallel();
// 返回一个等效的同步处理的Stream实例
S sequential();
// 返回一个等效的并发处理的Stream实例
S parallel();
// 返回一个等效的不支持StreamOpFlag.ORDERED特性的Stream实例
// 或者说支持StreamOpFlag.NOT_ORDERED的特性,也就返回的变体Stream在处理元素的时候不需要顺序处理
S unordered();
// 返回一个添加了close处理器的Stream实例,close处理器会在下面的close方法中回调
S onClose(Runnable closeHandler);
// 关闭当前Stream实例,回调关联本Stream的所有close处理器
@Override
void close();
}
PipelineHelper
:
abstract class PipelineHelper<P_OUT> {
// 获取流的流水线的数据源的"形状",其实就是数据源元素的类型
// 主要有四种类型:REFERENCE(除了int、long和double之外的引用类型)、INT_VALUE、LONG_VALUE和DOUBLE_VALUE
abstract StreamShape getSourceShape();
// 获取合并流和流操作的标志,合并的标志包括流的数据源标志、中间操作标志和终结操作标志
// 从实现上看是当前流管道节点合并前面所有节点和自身节点标志的所有标志
abstract int getStreamAndOpFlags();
// 如果当前的流管道节点的合并标志集合支持SIZED,则调用Spliterator.getExactSizeIfKnown()返回数据源中的准确元素数量,否则返回-1
abstract<P_IN> long exactOutputSizeIfKnown(Spliterator<P_IN> spliterator);
// 相当于调用下面的方法组合:copyInto(wrapSink(sink), spliterator)
abstract<P_IN, S extends Sink<P_OUT>> S wrapAndCopyInto(S sink, Spliterator<P_IN> spliterator);
// 发送所有来自Spliterator中的元素到Sink中,如果支持SHORT_CIRCUIT标志,则会调用copyIntoWithCancel
abstract<P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator);
// 发送所有来自Spliterator中的元素到Sink中,Sink处理完每个元素后会检查Sink#cancellationRequested()方法的状态去判断是否中断推送元素的操作
abstract <P_IN> boolean copyIntoWithCancel(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator);
// 创建接收元素类型为P_IN的Sink实例,实现PipelineHelper中描述的所有中间操作,用这个Sink去包装传入的Sink实例(传入的Sink实例的元素类型为PipelineHelper的输出类型P_OUT)
abstract<P_IN> Sink<P_IN> wrapSink(Sink<P_OUT> sink);
// 包装传入的spliterator,从源码来看,在Stream链的头节点调用会直接返回传入的实例,如果在非头节点调用会委托到StreamSpliterators.WrappingSpliterator()方法进行包装
// 这个方法在源码中没有API注释
abstract<P_IN> Spliterator<P_OUT> wrapSpliterator(Spliterator<P_IN> spliterator);
// 构造一个兼容当前Stream元素"形状"的Node.Builder实例
// 从源码来看直接委托到Nodes.builder()方法
abstract Node.Builder<P_OUT> makeNodeBuilder(long exactSizeIfKnown,
IntFunction<P_OUT[]> generator);
// Stream流水线所有阶段(节点)应用于数据源Spliterator,输出的元素作为结果收集起来转化为Node实例
// 此方法应用于toArray()方法的计算,本质上是一个终结操作
abstract<P_IN> Node<P_OUT> evaluate(Spliterator<P_IN> spliterator,
boolean flatten,
IntFunction<P_OUT[]> generator);
}
注意一点(重复3
次):
- 这里把同步流称为同步处理|执行的流,“并行流"称为并发处理|执行的流,因为并行流有歧义,实际上只是并发执行,不是并行执行
- 这里把同步流称为同步处理|执行的流,“并行流"称为并发处理|执行的流,因为并行流有歧义,实际上只是并发执行,不是并行执行
- 这里把同步流称为同步处理|执行的流,“并行流"称为并发处理|执行的流,因为并行流有歧义,实际上只是并发执行,不是并行执行
Sink 和引用类型链
PipelineHelper
的几个方法中存在Sink
这个接口,上一节没有分析,这一小节会详细展开。Stream
在构建的时候虽然是一个双向链表的结构,但是在最终应用终结操作的时候,会把所有操作转化为引用类型链(ChainedReference
),记得之前也提到过这种类似于多层包装器的编程模式,简化一下模型如下:
public class WrapperApp {
interface Wrapper {
void doAction();
}
public static void main(String[] args) {
AtomicInteger counter = new AtomicInteger(0);
Wrapper first = () -> System.out.printf("wrapper [depth => %d] invoke\n", counter.incrementAndGet());
Wrapper second = () -> {
first.doAction();
System.out.printf("wrapper [depth => %d] invoke\n", counter.incrementAndGet());
};
second.doAction();
}
}
// 控制台输出
wrapper [depth => 1] invoke
wrapper [depth => 2] invoke
上面的例子有点突兀,两个不同Sink
的实现可以做到无感知融合,举另一个例子如下:
public interface Sink<T> extends Consumer<T> {
default void begin(long size) {
}
default void end() {
}
abstract class ChainedReference<T, OUT> implements Sink<T> {
protected final Sink<OUT> downstream;
public ChainedReference(Sink<OUT> downstream) {
this.downstream = downstream;
}
}
}
@SuppressWarnings({"unchecked", "rawtypes"})
public class ReferenceChain<OUT, R> {
/**
* sink chain
*/
private final List<Supplier<Sink<?>>> sinkBuilders = new ArrayList<>();
/**
* current sink
*/
private final AtomicReference<Sink> sinkReference = new AtomicReference<>();
public ReferenceChain<OUT, R> filter(Predicate<OUT> predicate) {
//filter
sinkBuilders.add(() -> {
Sink<OUT> prevSink = (Sink<OUT>) sinkReference.get();
Sink.ChainedReference<OUT, OUT> currentSink = new Sink.ChainedReference<>(prevSink) {
@Override
public void accept(OUT out) {
if (predicate.test(out)) {
downstream.accept(out);
}
}
};
sinkReference.set(currentSink);
return currentSink;
});
return this;
}
public ReferenceChain<OUT, R> map(Function<OUT, R> function) {
// map
sinkBuilders.add(() -> {
Sink<R> prevSink = (Sink<R>) sinkReference.get();
Sink.ChainedReference<OUT, R> currentSink = new Sink.ChainedReference<>(prevSink) {
@Override
public void accept(OUT in) {
downstream.accept(function.apply(in));
}
};
sinkReference.set(currentSink);
return currentSink;
});
return this;
}
public void forEachPrint(Collection<OUT> collection) {
forEachPrint(collection, false);
}
public void forEachPrint(Collection<OUT> collection, boolean reverse) {
Spliterator<OUT> spliterator = collection.spliterator();
// 这个是类似于terminal op
Sink<OUT> sink = System.out::println;
sinkReference.set(sink);
Sink<OUT> stage = sink;
// 反向包装 -> 正向遍历
if (reverse) {
for (int i = 0; i <= sinkBuilders.size() - 1; i++) {
Supplier<Sink<?>> supplier = sinkBuilders.get(i);
stage = (Sink<OUT>) supplier.get();
}
} else {
// 正向包装 -> 反向遍历
for (int i = sinkBuilders.size() - 1; i >= 0; i--) {
Supplier<Sink<?>> supplier = sinkBuilders.get(i);
stage = (Sink<OUT>) supplier.get();
}
}
Sink<OUT> finalStage = stage;
spliterator.forEachRemaining(finalStage);
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(12);
ReferenceChain<Integer, Integer> chain = new ReferenceChain<>();
// filter -> map -> for each
chain.filter(item -> item > 10)
.map(item -> item * 2)
.forEachPrint(list);
}
}
// 输出结果
24
执行的流程如下:
多层包装器的编程模式的核心要领就是:
- 绝大部分操作可以转换为
java.util.function.Consumer
的实现,也就是实现accept(T t)
方法完成对传入的元素进行处理 - 先处理的
Sink
总是以后处理的Sink
为入参,在自身处理方法中判断和回调传入的Sink
的处理方法回调,也就是构建引用链的时候,需要从后往前构建,这种方式的实现逻辑可以参考AbstractPipeline#wrapSink()
,例如:
// 目标顺序:filter -> map
Sink mapSink = new Sink(inputSink){
private Function mapper;
public void accept(E ele) {
inputSink.accept(mapper.apply(ele))
}
}
Sink filterSink = new Sink(mapSink){
private Predicate predicate;
public void accept(E ele) {
if(predicate.test(ele)){
mapSink.accept(ele);
}
}
}
- 由上一点得知,一般来说,最后的终结操作会应用在引用链的第一个
Sink
上
上面的代码并非笔者虚构出来,可见java.util.stream.Sink
的源码:
// 继承自Consumer,主要是继承函数式接口方法void accept(T t)
interface Sink<T> extends Consumer<T> {
// 重置当前Sink的状态(为了接收一个新的数据集),传入的size是推送到downstream的准确数据量,无法评估数据量则传入-1
default void begin(long size) {}
//
default void end() {}
// 返回true的时候表示当前的Sink不会接收数据
default boolean cancellationRequested() {
return false;
}
// 特化方法,接受一个int类型的值
default void accept(int value) {
throw new IllegalStateException("called wrong accept method");
}
// 特化方法,接受一个long类型的值
default void accept(long value) {
throw new IllegalStateException("called wrong accept method");
}
// 特化方法,接受一个double类型的值
default void accept(double value) {
throw new IllegalStateException("called wrong accept method");
}
// 引用类型链,准确来说是Sink链
abstract static class ChainedReference<T, E_OUT> implements Sink<T> {
// 下一个Sink
protected final Sink<? super E_OUT> downstream;
public ChainedReference(Sink<? super E_OUT> downstream) {
this.downstream = Objects.requireNonNull(downstream);
}
@Override
public void begin(long size) {
downstream.begin(size);
}
@Override
public void end() {
downstream.end();
}
@Override
public boolean cancellationRequested() {
return downstream.cancellationRequested();
}
}
// 暂时忽略Int、Long、Double的特化类型场景
}
如果用过RxJava
或者Project-Reactor
,Sink
更像是Subscriber
,多个Subscriber
组成了ChainedReference
(Sink Chain
,可以理解为一个复合的Subscriber
),而Terminal Op
则类似于Publisher
,只有在Subscriber
订阅Publisher
的时候才会进行数据的处理,这里是应用了Reactive
编程模式。
AbstractPipeline和ReferencePipeline的实现
AbstractPipeline
和ReferencePipeline
都是抽象类,AbstractPipeline
用于构建Pipeline
的数据结构,提供一些Shape
相关的抽象方法给ReferencePipeline
实现,而ReferencePipeline
就是Stream
中Pipeline
的基础类型,从源码上看,Stream
链式(管道式)结构的头节点和操作节点都是ReferencePipeline
的子类。先看AbstractPipeline
的成员变量和构造函数:
abstract class AbstractPipeline<E_IN, E_OUT, S extends BaseStream<E_OUT, S>>
extends PipelineHelper<E_OUT> implements BaseStream<E_OUT, S> {
// 流管道链式结构的头节点(只有当前的AbstractPipeline引用是头节点,此变量才会被赋值,非头节点为NULL)
@SuppressWarnings("rawtypes")
private final AbstractPipeline sourceStage;
// 流管道链式结构的upstream,也就是上一个节点,如果是头节点此引用为NULL
@SuppressWarnings("rawtypes")
private final AbstractPipeline previousStage;
// 合并数据源的标志和操作标志的掩码
protected final int sourceOrOpFlags;
// 流管道链式结构的下一个节点,如果是头节点此引用为NULL
@SuppressWarnings("rawtypes")
private AbstractPipeline nextStage;
// 流的深度
// 串行执行的流中,表示当前流管道实例中中间操作节点的个数(除去头节点和终结操作)
// 并发执行的流中,表示当前流管道实例中中间操作节点和前一个有状态操作节点之间的节点个数
private int depth;
// 合并了所有数据源的标志、操作标志和当前的节点(AbstractPipeline)实例的标志,也就是当前的节点可以基于此属性得知所有支持的标志
private int combinedFlags;
// 数据源的Spliterator实例
private Spliterator<?> sourceSpliterator;
// 数据源的Spliterator实例封装的Supplier实例
private Supplier<? extends Spliterator<?>> sourceSupplier;
// 标记当前的流节点是否被连接或者消费掉,不能重复连接或者重复消费
private boolean linkedOrConsumed;
// 标记当前的流管道链式结构中是否存在有状态的操作节点,这个属性只会在头节点中有效
private boolean sourceAnyStateful;
// 数据源关闭动作,这个属性只会在头节点中有效,由sourceStage持有
private Runnable sourceCloseAction;
// 标记当前流是否并发执行
private boolean parallel;
// 流管道结构头节点的父构造方法,使用数据源的Spliterator实例封装的Supplier实例
AbstractPipeline(Supplier<? extends Spliterator<?>> source,
int sourceFlags, boolean parallel) {
// 头节点的前驱节点置为NULL
this.previousStage = null;
this.sourceSupplier = source;
this.sourceStage = this;
// 合并传入的源标志和流标志的掩码
this.sourceOrOpFlags = sourceFlags & StreamOpFlag.STREAM_MASK;
// The following is an optimization of:
// StreamOpFlag.combineOpFlags(sourceOrOpFlags, StreamOpFlag.INITIAL_OPS_VALUE);
// 初始化合并标志集合为sourceOrOpFlags和所有流操作标志的初始化值
this.combinedFlags = (~(sourceOrOpFlags << 1)) & StreamOpFlag.INITIAL_OPS_VALUE;
// 深度设置为0
this.depth = 0;
this.parallel = parallel;
}
// 流管道结构头节点的父构造方法,使用数据源的Spliterator实例
AbstractPipeline(Spliterator<?> source,
int sourceFlags, boolean parallel) {
// 头节点的前驱节点置为NULL
this.previousStage = null;
this.sourceSpliterator = source;
this.sourceStage = this;
// 合并传入的源标志和流标志的掩码
this.sourceOrOpFlags = sourceFlags & StreamOpFlag.STREAM_MASK;
// The following is an optimization of:
// StreamOpFlag.combineOpFlags(sourceOrOpFlags, StreamOpFlag.INITIAL_OPS_VALUE);
// 初始化合并标志集合为sourceOrOpFlags和所有流操作标志的初始化值
this.combinedFlags = (~(sourceOrOpFlags << 1)) & StreamOpFlag.INITIAL_OPS_VALUE;
this.depth = 0;
this.parallel = parallel;
}
// 流管道结构中间操作节点的父构造方法
AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
if (previousStage.linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
previousStage.linkedOrConsumed = true;
// 设置前驱节点的后继节点引用为当前的AbstractPipeline实例
previousStage.nextStage = this;
// 设置前驱节点引用为传入的前驱节点实例
this.previousStage = previousStage;
// 合并传入的中间操作标志和流操作标志的掩码
this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
// 合并标志集合为传入的标志和前驱节点的标志集合
this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
// 赋值sourceStage为前驱节点的sourceStage
this.sourceStage = previousStage.sourceStage;
if (opIsStateful())
// 标记当前的流存在有状态操作
sourceStage.sourceAnyStateful = true;
// 深度设置为前驱节点深度加1
this.depth = previousStage.depth + 1;
}
// 省略其他方法
}
至此,可以看出流管道的数据结构:
Terminal Op
不参与管道链式结构的构建。接着看AbstractPipeline
中的终结求值方法(Terminal evaluation methods
):
abstract class AbstractPipeline<E_IN, E_OUT, S extends BaseStream<E_OUT, S>>
extends PipelineHelper<E_OUT> implements BaseStream<E_OUT, S> {
// 省略其他方法
// 基于终结操作进行求值,这个是Stream执行的常用核心方法,常用于collect()这类终结操作
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
// 判断linkedOrConsumed,以防多次终结求值,也就是每个终结操作只能执行一次
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
// 如果当前流支持并发执行,则委托到TerminalOp.evaluateParallel(),如果当前流只支持同步执行,则委托到TerminalOp.evaluateSequential()
// 这里注意传入到TerminalOp中的方法参数分别是this(PipelineHelper类型)和数据源Spliterator
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
// 基于当前的流实例转换为最终的Node实例,传入的IntFunction用于创建数组实例
// 此终结方法一般用于toArray()这类终结操作
@SuppressWarnings("unchecked")
final Node<E_OUT> evaluateToArrayNode(IntFunction<E_OUT[]> generator) {
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
// If the last intermediate operation is stateful then
// evaluate directly to avoid an extra collection step
// 当前流支持并发执行,并且最后一个中间操作是有状态,则委托到opEvaluateParallel(),否则委托到evaluate(),这两个都是AbstractPipeline中的方法
if (isParallel() && previousStage != null && opIsStateful()) {
// Set the depth of this, last, pipeline stage to zero to slice the
// pipeline such that this operation will not be included in the
// upstream slice and upstream operations will not be included
// in this slice
depth = 0;
return opEvaluateParallel(previousStage, previousStage.sourceSpliterator(0), generator);
}
else {
return evaluate(sourceSpliterator(0), true, generator);
}
}
// 这个方法比较简单,就是获取当前流的数据源所在的Spliterator,并且确保流已经消费,一般用于forEach()这类终结操作
final Spliterator<E_OUT> sourceStageSpliterator() {
if (this != sourceStage)
throw new IllegalStateException();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
if (sourceStage.sourceSpliterator != null) {
@SuppressWarnings("unchecked")
Spliterator<E_OUT> s = sourceStage.sourceSpliterator;
sourceStage.sourceSpliterator = null;
return s;
}
else if (sourceStage.sourceSupplier != null) {
@SuppressWarnings("unchecked")
Spliterator<E_OUT> s = (Spliterator<E_OUT>) sourceStage.sourceSupplier.get();
sourceStage.sourceSupplier = null;
return s;
}
else {
throw new IllegalStateException(MSG_CONSUMED);
}
}
// 省略其他方法
}
AbstractPipeline
中实现了BaseStream
的方法:
abstract class AbstractPipeline<E_IN, E_OUT, S extends BaseStream<E_OUT, S>>
extends PipelineHelper<E_OUT> implements BaseStream<E_OUT, S> {
// 省略其他方法
// 设置头节点的parallel属性为false,返回自身实例,表示当前的流是同步执行的
@Override
@SuppressWarnings("unchecked")
public final S sequential() {
sourceStage.parallel = false;
return (S) this;
}
// 设置头节点的parallel属性为true,返回自身实例,表示当前的流是并发执行的
@Override
@SuppressWarnings("unchecked")
public final S parallel() {
sourceStage.parallel = true;
return (S) this;
}
// 流关闭操作,设置linkedOrConsumed为true,数据源的Spliterator相关引用置为NULL,置空并且回调sourceCloseAction钩子实例
@Override
public void close() {
linkedOrConsumed = true;
sourceSupplier = null;
sourceSpliterator = null;
if (sourceStage.sourceCloseAction != null) {
Runnable closeAction = sourceStage.sourceCloseAction;
sourceStage.sourceCloseAction = null;
closeAction.run();
}
}
// 返回一个添加了close处理器的Stream实例,close处理器会在下面的close方法中回调
// 如果本来持有的引用sourceStage.sourceCloseAction非空,会使用传入的closeHandler与sourceStage.sourceCloseAction进行合并
@Override
@SuppressWarnings("unchecked")
public S onClose(Runnable closeHandler) {
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
Objects.requireNonNull(closeHandler);
Runnable existingHandler = sourceStage.sourceCloseAction;
sourceStage.sourceCloseAction =
(existingHandler == null)
? closeHandler
: Streams.composeWithExceptions(existingHandler, closeHandler);
return (S) this;
}
// Primitive specialization use co-variant overrides, hence is not final
// 返回当前流实例中所有元素的Spliterator实例
@Override
@SuppressWarnings("unchecked")
public Spliterator<E_OUT> spliterator() {
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
// 标记当前节点被链接或者消费
linkedOrConsumed = true;
// 如果当前节点为头节点,那么返回sourceStage.sourceSpliterator或者延时加载的sourceStage.sourceSupplier(延时加载封装由lazySpliterator实现)
if (this == sourceStage) {
if (sourceStage.sourceSpliterator != null) {
@SuppressWarnings("unchecked")
Spliterator<E_OUT> s = (Spliterator<E_OUT>) sourceStage.sourceSpliterator;
sourceStage.sourceSpliterator = null;
return s;
}
else if (sourceStage.sourceSupplier != null) {
@SuppressWarnings("unchecked")
Supplier<Spliterator<E_OUT>> s = (Supplier<Spliterator<E_OUT>>) sourceStage.sourceSupplier;
sourceStage.sourceSupplier = null;
return lazySpliterator(s);
}
else {
throw new IllegalStateException(MSG_CONSUMED);
}
}
else {
// 如果当前节点不是头节点,重新对sourceSpliterator进行包装,包装后的实例为WrappingSpliterator
return wrap(this, () -> sourceSpliterator(0), isParallel());
}
}
// 当前流实例是否并发执行,从头节点的parallel属性进行判断
@Override
public final boolean isParallel() {
return sourceStage.parallel;
}
// 从当前combinedFlags中获取数据源标志和所有流中间操作标志的集合
final int getStreamFlags() {
return StreamOpFlag.toStreamFlags(combinedFlags);
}
/**
* Get the source spliterator for this pipeline stage. For a sequential or
* stateless parallel pipeline, this is the source spliterator. For a
* stateful parallel pipeline, this is a spliterator describing the results
* of all computations up to and including the most recent stateful
* operation.
*/
@SuppressWarnings("unchecked")
private Spliterator<?> sourceSpliterator(int terminalFlags) {
// 从sourceStage.sourceSpliterator或者sourceStage.sourceSupplier中获取当前流实例中的Spliterator实例,确保必定存在,否则抛出IllegalStateException
Spliterator<?> spliterator = null;
if (sourceStage.sourceSpliterator != null) {
spliterator = sourceStage.sourceSpliterator;
sourceStage.sourceSpliterator = null;
}
else if (sourceStage.sourceSupplier != null) {
spliterator = (Spliterator<?>) sourceStage.sourceSupplier.get();
sourceStage.sourceSupplier = null;
}
else {
throw new IllegalStateException(MSG_CONSUMED);
}
// 下面这段逻辑是对于并发执行并且存在有状态操作的节点,那么需要重新计算节点的深度和节点的合并标志集合
// 这里只提一下计算过程,从头节点的后继节点开始遍历到当前节点,如果被遍历的节点时有状态的,那么对depth、combinedFlags和spliterator会进行重新计算
// depth一旦出现有状态节点就会重置为0,然后从1重新开始增加
// combinedFlags会重新合并sourceOrOpFlags、SHORT_CIRCUIT(如果sourceOrOpFlags支持)和Spliterator.SIZED
// spliterator简单来看就是从并发执行的toArray()=>Array数组=>Spliterator实例
if (isParallel() && sourceStage.sourceAnyStateful) {
// Adapt the source spliterator, evaluating each stateful op
// in the pipeline up to and including this pipeline stage.
// The depth and flags of each pipeline stage are adjusted accordingly.
int depth = 1;
for (@SuppressWarnings("rawtypes") AbstractPipeline u = sourceStage, p = sourceStage.nextStage, e = this;
u != e;
u = p, p = p.nextStage) {
int thisOpFlags = p.sourceOrOpFlags;
if (p.opIsStateful()) {
depth = 0;
if (StreamOpFlag.SHORT_CIRCUIT.isKnown(thisOpFlags)) {
// Clear the short circuit flag for next pipeline stage
// This stage encapsulates short-circuiting, the next
// stage may not have any short-circuit operations, and
// if so spliterator.forEachRemaining should be used
// for traversal
thisOpFlags = thisOpFlags & ~StreamOpFlag.IS_SHORT_CIRCUIT;
}
spliterator = p.opEvaluateParallelLazy(u, spliterator);
// Inject or clear SIZED on the source pipeline stage
// based on the stage's spliterator
thisOpFlags = spliterator.hasCharacteristics(Spliterator.SIZED)
? (thisOpFlags & ~StreamOpFlag.NOT_SIZED) | StreamOpFlag.IS_SIZED
: (thisOpFlags & ~StreamOpFlag.IS_SIZED) | StreamOpFlag.NOT_SIZED;
}
p.depth = depth++;
p.combinedFlags = StreamOpFlag.combineOpFlags(thisOpFlags, u.combinedFlags);
}
}
// 如果传入的terminalFlags标志不为0,则当前节点的combinedFlags会合并terminalFlags
if (terminalFlags != 0) {
// Apply flags from the terminal operation to last pipeline stage
combinedFlags = StreamOpFlag.combineOpFlags(terminalFlags, combinedFlags);
}
return spliterator;
}
// 省略其他方法
}
AbstractPipeline
中实现了PipelineHelper
的方法:
abstract class AbstractPipeline<E_IN, E_OUT, S extends BaseStream<E_OUT, S>>
extends PipelineHelper<E_OUT> implements BaseStream<E_OUT, S> {
// 省略其他方法
// 获取数据源元素的类型,这里的类型包括引用、int、double和float
// 其实实现上就是获取depth<=0的第一个节点的输出类型
@Override
final StreamShape getSourceShape() {
@SuppressWarnings("rawtypes")
AbstractPipeline p = AbstractPipeline.this;
while (p.depth > 0) {
p = p.previousStage;
}
return p.getOutputShape();
}
// 基于当前节点的标志集合判断和返回流中待处理的元素数量,无法获取则返回-1
@Override
final <P_IN> long exactOutputSizeIfKnown(Spliterator<P_IN> spliterator) {
return StreamOpFlag.SIZED.isKnown(getStreamAndOpFlags()) ? spliterator.getExactSizeIfKnown() : -1;
}
// 通过流管道链式结构构建元素引用链,再遍历元素引用链
@Override
final <P_IN, S extends Sink<E_OUT>> S wrapAndCopyInto(S sink, Spliterator<P_IN> spliterator) {
copyInto(wrapSink(Objects.requireNonNull(sink)), spliterator);
return sink;
}
// 遍历元素引用链
@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
Objects.requireNonNull(wrappedSink);
// 当前节点不支持SHORT_CIRCUIT(短路)特性,则直接遍历元素引用链,不支持短路跳出
if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
wrappedSink.begin(spliterator.getExactSizeIfKnown());
spliterator.forEachRemaining(wrappedSink);
wrappedSink.end();
}
else {
// 支持短路(中途取消)遍历元素引用链
copyIntoWithCancel(wrappedSink, spliterator);
}
}
// 支持短路(中途取消)遍历元素引用链
@Override
@SuppressWarnings("unchecked")
final <P_IN> boolean copyIntoWithCancel(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
@SuppressWarnings({"rawtypes","unchecked"})
AbstractPipeline p = AbstractPipeline.this;
// 基于当前节点,获取流管道链式结构中第最后一个depth=0的前驱节点
while (p.depth > 0) {
p = p.previousStage;
}
wrappedSink.begin(spliterator.getExactSizeIfKnown());
// 委托到forEachWithCancel()进行遍历
boolean cancelled = p.forEachWithCancel(spliterator, wrappedSink);
wrappedSink.end();
return cancelled;
}
// 返回当前节点的标志集合
@Override
final int getStreamAndOpFlags() {
return combinedFlags;
}
// 当前节点标志集合中是否支持ORDERED
final boolean isOrdered() {
return StreamOpFlag.ORDERED.isKnown(combinedFlags);
}
// 构建元素引用链,生成一个多重包装的Sink(WrapSink),这里的逻辑可以看前面的分析章节
@Override
@SuppressWarnings("unchecked")
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
Objects.requireNonNull(sink);
// 这里遍历的时候,总是从当前节点向前驱节点遍历,也就是传入的sink实例总是包裹在最里面一层执行
for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink<P_IN>) sink;
}
// 包装数据源的Spliterator,如果depth=0,则直接返回sourceSpliterator,否则返回的是延迟加载的WrappingSpliterator
@Override
@SuppressWarnings("unchecked")
final <P_IN> Spliterator<E_OUT> wrapSpliterator(Spliterator<P_IN> sourceSpliterator) {
if (depth == 0) {
return (Spliterator<E_OUT>) sourceSpliterator;
}
else {
return wrap(this, () -> sourceSpliterator, isParallel());
}
}
// 计算Node实例,这个方法用于toArray()方法系列,是一个终结操作,下面会另开章节详细分析
@Override
@SuppressWarnings("unchecked")
final <P_IN> Node<E_OUT> evaluate(Spliterator<P_IN> spliterator,
boolean flatten,
IntFunction<E_OUT[]> generator) {
if (isParallel()) {
// @@@ Optimize if op of this pipeline stage is a stateful op
return evaluateToNode(this, spliterator, flatten, generator);
}
else {
Node.Builder<E_OUT> nb = makeNodeBuilder(
exactOutputSizeIfKnown(spliterator), generator);
return wrapAndCopyInto(nb, spliterator).build();
}
}
// 省略其他方法
}
AbstractPipeline
中剩余的待如XXYYZZPipeline
等子类实现的抽象方法:
abstract class AbstractPipeline<E_IN, E_OUT, S extends BaseStream<E_OUT, S>>
extends PipelineHelper<E_OUT> implements BaseStream<E_OUT, S> {
// 省略其他方法
// 获取当前流的输出"形状",REFERENCE、INT_VALUE、LONG_VALUE或者DOUBLE_VALUE
abstract StreamShape getOutputShape();
// 收集当前流的所有输出元素,转化为一个适配当前流输出"形状"的Node实例
abstract <P_IN> Node<E_OUT> evaluateToNode(PipelineHelper<E_OUT> helper,
Spliterator<P_IN> spliterator,
boolean flattenTree,
IntFunction<E_OUT[]> generator);
// 包装Spliterator为WrappingSpliterator实例
abstract <P_IN> Spliterator<E_OUT> wrap(PipelineHelper<E_OUT> ph,
Supplier<Spliterator<P_IN>> supplier,
boolean isParallel);
// 包装Spliterator为DelegatingSpliterator实例
abstract <P_IN> Spliterator<E_OUT> wrap(PipelineHelper<E_OUT> ph,
Supplier<Spliterator<P_IN>> supplier,
boolean isParallel);
// 基于Sink遍历Spliterator中的元素,支持取消操作,简单理解就是支持cancel的tryAdvance方法
abstract boolean forEachWithCancel(Spliterator<E_OUT> spliterator, Sink<E_OUT> sink);
// 返回Node的建造器实例,用于toArray方法系列
abstract Node.Builder<E_OUT> makeNodeBuilder(long exactSizeIfKnown,
IntFunction<E_OUT[]> generator);
// 判断当前的操作(节点)是否有状态,如果是有状态的操作,必须覆盖opEvaluateParallel方法
abstract boolean opIsStateful();
// 当前操作生成的结果会作为传入的Sink实例的入参,这是一个包装Sink的过程,通俗理解就是之前提到的元素引用链添加一个新的链节点,这个方法算是流执行的一个核心方法
abstract Sink<E_IN> opWrapSink(int flags, Sink<E_OUT> sink);
// 并发执行的操作节点求值
<P_IN> Node<E_OUT> opEvaluateParallel(PipelineHelper<E_OUT> helper,
Spliterator<P_IN> spliterator,
IntFunction<E_OUT[]> generator) {
throw new UnsupportedOperationException("Parallel evaluation is not supported");
}
// 并发执行的操作节点惰性求值
@SuppressWarnings("unchecked")
<P_IN> Spliterator<E_OUT> opEvaluateParallelLazy(PipelineHelper<E_OUT> helper,
Spliterator<P_IN> spliterator) {
return opEvaluateParallel(helper, spliterator, i -> (E_OUT[]) new Object[i]).spliterator();
}
// 省略其他方法
}
这里提到的抽象方法opWrapSink()
其实就是元素引用链的添加链节点的方法,它的实现逻辑见子类,这里只考虑非特化子类ReferencePipeline
的部分源码:
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 构造函数,用于头节点,传入基于Supplier封装的Spliterator实例作为数据源,数据源的标志集合和是否支持并发执行的判断标记
ReferencePipeline(Supplier<? extends Spliterator<?>> source,
int sourceFlags, boolean parallel) {
super(source, sourceFlags, parallel);
}
// 构造函数,用于头节点,传入Spliterator实例作为数据源,数据源的标志集合和是否支持并发执行的判断标记
ReferencePipeline(Spliterator<?> source,
int sourceFlags, boolean parallel) {
super(source, sourceFlags, parallel);
}
// 构造函数,用于中间节点,传入上一个流管道节点的实例(前驱节点)和当前操作节点支持的标志集合
ReferencePipeline(AbstractPipeline<?, P_IN, ?> upstream, int opFlags) {
super(upstream, opFlags);
}
// 这里流的输出"形状"固定为REFERENCE
@Override
final StreamShape getOutputShape() {
return StreamShape.REFERENCE;
}
// 转换当前流实例为Node实例,应用于toArray方法,后面详细分析终结操作的时候再展开
@Override
final <P_IN> Node<P_OUT> evaluateToNode(PipelineHelper<P_OUT> helper,
Spliterator<P_IN> spliterator,
boolean flattenTree,
IntFunction<P_OUT[]> generator) {
return Nodes.collect(helper, spliterator, flattenTree, generator);
}
// 包装Spliterator=>WrappingSpliterator
@Override
final <P_IN> Spliterator<P_OUT> wrap(PipelineHelper<P_OUT> ph,
Supplier<Spliterator<P_IN>> supplier,
boolean isParallel) {
return new StreamSpliterators.WrappingSpliterator<>(ph, supplier, isParallel);
}
// 包装Spliterator=>DelegatingSpliterator,实现惰性加载
@Override
final Spliterator<P_OUT> lazySpliterator(Supplier<? extends Spliterator<P_OUT>> supplier) {
return new StreamSpliterators.DelegatingSpliterator<>(supplier);
}
// 遍历Spliterator中的元素,基于传入的Sink实例进行处理,支持Cancel操作
@Override
final boolean forEachWithCancel(Spliterator<P_OUT> spliterator, Sink<P_OUT> sink) {
boolean cancelled;
do { } while (!(cancelled = sink.cancellationRequested()) && spliterator.tryAdvance(sink));
return cancelled;
}
// 构造Node建造器实例
@Override
final Node.Builder<P_OUT> makeNodeBuilder(long exactSizeIfKnown, IntFunction<P_OUT[]> generator) {
return Nodes.builder(exactSizeIfKnown, generator);
}
// 基于当前流的Spliterator生成迭代器实例
@Override
public final Iterator<P_OUT> iterator() {
return Spliterators.iterator(spliterator());
}
// 省略其他OP的代码
// 流管道结构的头节点
static class Head<E_IN, E_OUT> extends ReferencePipeline<E_IN, E_OUT> {
// 构造函数,用于头节点,传入基于Supplier封装的Spliterator实例作为数据源,数据源的标志集合和是否支持并发执行的判断标记
Head(Supplier<? extends Spliterator<?>> source,
int sourceFlags, boolean parallel) {
super(source, sourceFlags, parallel);
}
// 构造函数,用于头节点,传入Spliterator实例作为数据源,数据源的标志集合和是否支持并发执行的判断标记
Head(Spliterator<?> source,
int sourceFlags, boolean parallel) {
super(source, sourceFlags, parallel);
}
// 不支持判断是否状态操作
@Override
final boolean opIsStateful() {
throw new UnsupportedOperationException();
}
// 不支持包装Sink实例
@Override
final Sink<E_IN> opWrapSink(int flags, Sink<E_OUT> sink) {
throw new UnsupportedOperationException();
}
// 区分同步异步执行forEach,同步则简单理解为调用Spliterator.forEachRemaining,异步则调用终结操作forEach
@Override
public void forEach(Consumer<? super E_OUT> action) {
if (!isParallel()) {
sourceStageSpliterator().forEachRemaining(action);
}
else {
super.forEach(action);
}
}
// 区分同步异步执行forEachOrdered,同步则简单理解为调用Spliterator.forEachRemaining,异步则调用终结操作forEachOrdered
@Override
public void forEachOrdered(Consumer<? super E_OUT> action) {
if (!isParallel()) {
sourceStageSpliterator().forEachRemaining(action);
}
else {
super.forEachOrdered(action);
}
}
}
// 无状态操作节点的父类
abstract static class StatelessOp<E_IN, E_OUT>
extends ReferencePipeline<E_IN, E_OUT> {
// 基于上一个节点引用、输入元素"形状"和当前节点支持的标志集合创建StatelessOp实例
StatelessOp(AbstractPipeline<?, E_IN, ?> upstream,
StreamShape inputShape,
int opFlags) {
super(upstream, opFlags);
assert upstream.getOutputShape() == inputShape;
}
// 操作状态标记设置为无状态
@Override
final boolean opIsStateful() {
return false;
}
}
// 有状态操作节点的父类
abstract static class StatefulOp<E_IN, E_OUT>
extends ReferencePipeline<E_IN, E_OUT> {
// 基于上一个节点引用、输入元素"形状"和当前节点支持的标志集合创建StatefulOp实例
StatefulOp(AbstractPipeline<?, E_IN, ?> upstream,
StreamShape inputShape,
int opFlags) {
super(upstream, opFlags);
assert upstream.getOutputShape() == inputShape;
}
// 操作状态标记设置为有状态
@Override
final boolean opIsStateful() {
return true;
}
// 前面也提到,节点操作异步求值的方法在无状态节点下必须覆盖,这里重新把这个方法抽象,子类必须实现
@Override
abstract <P_IN> Node<E_OUT> opEvaluateParallel(PipelineHelper<E_OUT> helper,
Spliterator<P_IN> spliterator,
IntFunction<E_OUT[]> generator);
}
}
这里重重重点分析一下ReferencePipeline
中的wrapSink
方法实现:
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
Objects.requireNonNull(sink);
for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink<P_IN>) sink;
}
入参是一个Sink
实例,返回值也是一个Sink
实例,里面的for
循环是基于当前的AbstractPipeline
节点向前遍历,直到depth
为0
的节点跳出循环,而depth
为0
意味着该节点必定为头节点,也就是该循环是遍历当前节点到头节点的后继节点,Sink
是"向前包装的”,也就是处于链后面的节点Sink
总是会作为其前驱节点的opWrapSink()
方法的入参,在同步执行流求值计算的时候,前驱节点的Sink
处理完元素后就会通过downstream
引用(其实就是后驱节点的Sink
)调用其accept()
把元素或者处理完的元素结果传递进去,激活下一个Sink
,以此类推。另外,ReferencePipeline
的三个内部类Head
、StatelessOp
和StatefulOp
就是流的节点类,其中只有Head
是非抽象类,代表流管道结构(或者说双向链表结构)的头节点,StatelessOp
(无状态操作)和StatefulOp
(有状态操作)的子类构成了流管道结构的操作节点或者是终结操作。在忽略是否有状态操作的前提下看ReferencePipeline
,它只是流数据结构的承载体,表面上看到的双向链表结构在流的求值计算过程中并不会进行直接遍历每个节点进行求值,而是先转化成一个多层包装的Sink
,也就是前文笔者提到的元素引用链后者前一句分析的Sink
元素处理以及传递,正确来说应该是一个Sink
栈或者Sink
包装器,它的实现可以类比为现实生活中的洋葱,或者编程模式中的AOP
编程模式。形象一点的描述如下:
Head(Spliterator) -> Op(filter) -> Op(map) -> Op(sorted) -> Terminal Op(forEach)
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
forEach ele in Spliterator:
Sink[filter](ele){
if filter process == true:
Sink[map](ele){
ele = mapper(ele)
Sink[sorted](ele){
var array
begin:
accept(ele):
add ele to array
end:
sort ele in array
}
}
}
终结操作forEach
是目前分析源码中最简单的实现,下面会详细分析每种终结操作的实现细节。
流中间操作的源码实现
限于篇幅,这里只能挑选一部分的中间Op
进行分析。流的中间操作基本都是由BaseStream
接口定义,在ReferencePipeline
中进行实现,这里挑选比较常用的filter
、map
和sorted
进行分析。先看filter
:
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// filter操作,泛型参数Predicate类型接受一个任意类型(这里考虑到泛型擦除)的元素,输出布尔值,它是一个无状态操作
@Override
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
Objects.requireNonNull(predicate);
// 这里注意到,StatelessOp的第一个参数是指upstream,也就是理解为上一个节点,这里使用了this,意味着upstream为当前的ReferencePipeline实例,元素"形状"为引用类型,操作标志位不支持SIZED
// 在AbstractPipeline,previousStage指向了this,当前的节点就是StatelessOp[filter]实例,那么前驱节点this的后继节点引用nextStage就指向了StatelessOp[filter]实例
// 也就是StatelessOp[filter].previousStage = this; this.nextStage = StatelessOp[filter]; ===> 也就是这个看起来简单的new StatelessOp()其实已经把自身加入到管道中
return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SIZED) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
@Override
public void begin(long size) {
// 这里通知下一个节点的Sink.begin(),由于filter方法不感知元素数量,所以传值-1
downstream.begin(-1);
}
@Override
public void accept(P_OUT u) {
// 基于输入的Predicate实例判断当前处理元素是否符合判断,只有判断结果为true才会把元素原封不动直接传递到下一个Sink
if (predicate.test(u))
downstream.accept(u);
}
};
}
};
}
// 暂时省略其他代码
}
接着是map
:
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// map操作,基于传入的Function实例做映射转换(P_OUT->R),它是一个无状态操作
@Override
@SuppressWarnings("unchecked")
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
// upstream为当前的ReferencePipeline实例,元素"形状"为引用类型,操作标志位不支持SORTED和DISTINCT
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
// 基于传入的Function实例转换元素后把转换结果传递到下一个Sink
downstream.accept(mapper.apply(u));
}
};
}
};
}
// 暂时省略其他代码
}
然后是sorted
,sorted
操作会相对复杂一点:
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// sorted操作,基于传入的Comparator实例对处理的元素进行排序,从源码中看,它是一个有状态操作
@Override
public final Stream<P_OUT> sorted(Comparator<? super P_OUT> comparator) {
return SortedOps.makeRef(this, comparator);
}
// 暂时省略其他代码
}
// SortedOps工具类
final class SortedOps {
// 暂时省略其他代码
// 构建排序操作的链节点
static <T> Stream<T> makeRef(AbstractPipeline<?, T, ?> upstream,
Comparator<? super T> comparator) {
return new OfRef<>(upstream, comparator);
}
// 有状态的排序操作节点
private static final class OfRef<T> extends ReferencePipeline.StatefulOp<T, T> {
// 是否自然排序,不定义Comparator实例的时候为true,否则为false
private final boolean isNaturalSort;
// 用于排序的Comparator实例
private final Comparator<? super T> comparator;
// 自然排序情况下的构造方法,元素"形状"为引用类型,操作标志位不支持ORDERED和SORTED
OfRef(AbstractPipeline<?, T, ?> upstream) {
super(upstream, StreamShape.REFERENCE,
StreamOpFlag.IS_ORDERED | StreamOpFlag.IS_SORTED);
this.isNaturalSort = true;
// Comparator实例赋值为Comparator.naturalOrder(),本质是基于Object中的equals或者子类覆盖Object中的equals方法进行元素排序
@SuppressWarnings("unchecked")
Comparator<? super T> comp = (Comparator<? super T>) Comparator.naturalOrder();
this.comparator = comp;
}
// 非自然排序情况下的构造方法,需要传入Comparator实例,元素"形状"为引用类型,操作标志位不支持ORDERED和SORTED
OfRef(AbstractPipeline<?, T, ?> upstream, Comparator<? super T> comparator) {
super(upstream, StreamShape.REFERENCE,
StreamOpFlag.IS_ORDERED | StreamOpFlag.NOT_SORTED);
this.isNaturalSort = false;
this.comparator = Objects.requireNonNull(comparator);
}
@Override
public Sink<T> opWrapSink(int flags, Sink<T> sink) {
Objects.requireNonNull(sink);
// If the input is already naturally sorted and this operation
// also naturally sorted then this is a no-op
// 流中的所有元素本身已经按照自然顺序排序,并且没有定义Comparator实例,则不需要进行排序,所以no op就行
if (StreamOpFlag.SORTED.isKnown(flags) && isNaturalSort)
return sink;
else if (StreamOpFlag.SIZED.isKnown(flags))
// 知道要处理的元素的确切数量,使用数组进行排序
return new SizedRefSortingSink<>(sink, comparator);
else
// 不知道要处理的元素的确切数量,使用ArrayList进行排序
return new RefSortingSink<>(sink, comparator);
}
// 这里是并行执行流中toArray方法的实现,暂不分析
@Override
public <P_IN> Node<T> opEvaluateParallel(PipelineHelper<T> helper,
Spliterator<P_IN> spliterator,
IntFunction<T[]> generator) {
// If the input is already naturally sorted and this operation
// naturally sorts then collect the output
if (StreamOpFlag.SORTED.isKnown(helper.getStreamAndOpFlags()) && isNaturalSort) {
return helper.evaluate(spliterator, false, generator);
}
else {
// @@@ Weak two-pass parallel implementation; parallel collect, parallel sort
T[] flattenedData = helper.evaluate(spliterator, true, generator).asArray(generator);
Arrays.parallelSort(flattenedData, comparator);
return Nodes.node(flattenedData);
}
}
}
// 这里考虑到篇幅太长,SizedRefSortingSink和RefSortingSink的源码不复杂,只展开RefSortingSink进行分析
// 无法确认待处理元素确切数量时候用于元素排序的Sink实现
private static final class RefSortingSink<T> extends AbstractRefSortingSink<T> {
// 临时ArrayList实例
private ArrayList<T> list;
// 构造函数,需要的参数为下一个Sink引用和Comparator实例
RefSortingSink(Sink<? super T> sink, Comparator<? super T> comparator) {
super(sink, comparator);
}
@Override
public void begin(long size) {
if (size >= Nodes.MAX_ARRAY_SIZE)
throw new IllegalArgumentException(Nodes.BAD_SIZE);
// 基于传入的size是否大于0,大于等于0用于作为initialCapacity构建ArrayList,小于0则构建默认initialCapacity的ArrayList,赋值到临时变量list
list = (size >= 0) ? new ArrayList<>((int) size) : new ArrayList<>();
}
@Override
public void end() {
// 临时的ArrayList实例基于Comparator实例进行潘旭
list.sort(comparator);
// 下一个Sink节点的激活,区分是否支持取消操作
downstream.begin(list.size());
if (!cancellationRequestedCalled) {
list.forEach(downstream::accept);
}
else {
for (T t : list) {
if (downstream.cancellationRequested()) break;
downstream.accept(t);
}
}
downstream.end();
// 激活下一个Sink完成后,临时的ArrayList实例置为NULL,便于GC回收
list = null;
}
@Override
public void accept(T t) {
// 当前Sink处理元素直接添加到临时的ArrayList实例
list.add(t);
}
}
// 暂时省略其他代码
}
sorted
操作有个比较显著的特点,一般的Sink
处理完自身的逻辑,会在accept()
方法激活下一个Sink
引用,但是它在accept()
方法中只做元素的累积(元素富集),在end()
方法进行最终的排序操作和模仿Spliterator
的两个元素遍历方法向downstream
推送待处理的元素。示意图如下:
其他中间操作的实现逻辑是大致相同的。
同步执行流终结操作的源码实现
限于篇幅,这里只能挑选一部分的Terminal Op
进行分析,简单起见只分析同步执行的场景,这里挑选最典型和最复杂的froEach()
和collect()
,还有比较独特的toArray()
方法。先看froEach()
方法的实现过程:
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// 遍历元素
@Override
public void forEach(Consumer<? super P_OUT> action) {
evaluate(ForEachOps.makeRef(action, false));
}
// 暂时省略其他代码
// 基于终结操作的求值方法
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
// 确保只会执行一次,linkedOrConsumed是流管道结构最后一个节点的属性
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
// 这里暂且只分析同步执行的流的终结操作,终结操作节点的标志会合并到流最后一个节点的combinedFlags中,执行的关键就是evaluateSequential方法
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
// 暂时省略其他代码
}
// ForEachOps类,TerminalOp接口的定义比较简单,这里不展开
final class ForEachOps {
// 暂时省略其他代码
// 构造变量元素的终结操作实例,传入的元素是T类型,结果是Void类型(返回NULL,或者说是没有返回值,毕竟是一个元素遍历过程)
// 参数为一个Consumer接口实例和一个标记是否顺序处理元素的布尔值
public static <T> TerminalOp<T, Void> makeRef(Consumer<? super T> action,
boolean ordered) {
Objects.requireNonNull(action);
return new ForEachOp.OfRef<>(action, ordered);
}
// 遍历元素操作的终结操作实现,同时它是一个适配器,适配TerminalSink(Sink)接口
abstract static class ForEachOp<T>
implements TerminalOp<T, Void>, TerminalSink<T, Void> {
// 标记是否顺序处理元素
private final boolean ordered;
protected ForEachOp(boolean ordered) {
this.ordered = ordered;
}
// TerminalOp
// 终结操作节点的标志集合,如果ordered为true则返回0,否则返回StreamOpFlag.NOT_ORDERED,表示不支持顺序处理元素
@Override
public int getOpFlags() {
return ordered ? 0 : StreamOpFlag.NOT_ORDERED;
}
// 同步遍历和处理元素
@Override
public <S> Void evaluateSequential(PipelineHelper<T> helper,
Spliterator<S> spliterator) {
// 以当前的ForEachOp实例作为最后一个Sink添加到Sink链(也就是前面经常说的元素引用链),然后对Sink链进行遍历
return helper.wrapAndCopyInto(this, spliterator).get();
}
// 并发遍历和处理元素,这里暂不分析
@Override
public <S> Void evaluateParallel(PipelineHelper<T> helper,
Spliterator<S> spliterator) {
if (ordered)
new ForEachOrderedTask<>(helper, spliterator, this).invoke();
else
new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
return null;
}
// TerminalSink
// 实现TerminalSink的方法,实际上TerminalSink继承接口Supplier,这里是实现了Supplier接口的get()方法,由于PipelineHelper.wrapAndCopyInto()方法会返回最后一个Sink的引用,这里其实就是evaluateSequential()中的返回值
@Override
public Void get() {
return null;
}
// ForEachOp的静态内部类,引用类型的ForEachOp的最终实现,依赖入参遍历元素处理的最后一步回调Consumer实例
static final class OfRef<T> extends ForEachOp<T> {
// 最后的遍历回调的Consumer句柄
final Consumer<? super T> consumer;
OfRef(Consumer<? super T> consumer, boolean ordered) {
super(ordered);
this.consumer = consumer;
}
@Override
public void accept(T t) {
// 遍历元素回调操作
consumer.accept(t);
}
}
}
}
forEach
终结操作实现上,自身这个操作并不会构成流的链式结构的一部分,也就是它不是一个AbstractPipeline
的子类实例,而是构建一个回调Consumer
实例操作的一个Sink
实例(准确来说是TerminalSink
)实例,这里暂且叫forEach terminal sink
,通过流最后一个操作节点的wrapSink()
方法,把forEach terminal sink
添加到Sink
链的尾部,通过流最后一个操作节点的copyInto()
方法进行元素遍历,按照copyInto()
方法的套路,只要多层包装的Sink
方法在回调其实现方法的时候总是激活downstream
的前提下,执行的顺序就是流链式结构定义的操作节点顺序,而forEach
最后添加的Consumer
实例一定就是最后回调的。
接着分析collect()
方法的实现,先看Collector
接口的定义:
// T:需要进行reduce操作的输入元素类型
// A:reduce操作中可变累加对象的类型,可以简单理解为累加操作中,累加到Container<A>中的可变对象类型
// R:reduce操作结果类型
public interface Collector<T, A, R> {
// 注释中称为Container,用于承载最终结果的可变容器,而此方法的Supplier实例持有的是创建Container实例的get()方法实现,后面称为Supplier
// 也就是一般使用如:Supplier<Container> supplier = () -> new Container();
Supplier<A> supplier();
// Accumulator,翻译为累加器,用于处理值并且把处理结果传递(累加)到Container中,后面称为Accumulator
BiConsumer<A, T> accumulator();
// Combiner,翻译为合并器,真实泛型类型为BinaryOperator<A,A,A>,BiFunction的子类,接收两个部分的结果并且合并为一个结果,后面称为Combiner
// 这个方法可以把一个参数的状态转移到另一个参数,然后返回更新状态后的参数,例如:(arg1, arg2) -> {arg2.state = arg1.state; return arg2;}
// 可以把一个参数的状态转移到另一个参数,然后返回一个新的容器,例如:(arg1, arg2) -> {arg2.state = arg1.state; return new Container(arg2);}
BinaryOperator<A> combiner();
// Finisher,直接翻译感觉意义不合理,实际上就是做最后一步转换工作的处理器,后面称为Finisher
Function<A, R> finisher();
// Collector支持的特性集合,见枚举Characteristics
Set<Characteristics> characteristics();
// 这里忽略两个Collector的静态工厂方法,因为并不常用
enum Characteristics {
// 标记Collector支持并发执行,一般和并发容器相关
CONCURRENT,
// 标记Collector处理元素时候无序
UNORDERED,
// 标记Collector的输入和输出元素是同类型,也就是Finisher在实现上R -> A可以等效于A -> R,unchecked cast会成功(也就是类型强转可以成功)
// 在这种场景下,对于Container来说其实类型强制转换也是等效的,也就是Supplier<A>和Supplier<R>得出的Container是同一种类型的Container
IDENTITY_FINISH
}
}
// Collector的实现Collectors.CollectorImpl
public final class Collectors {
// 这一大堆常量就是预设的多种特性组合,CH_NOID比较特殊,是空集合,也就是Collector三种特性都不支持
static final Set<Collector.Characteristics> CH_CONCURRENT_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_CONCURRENT_NOID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.CONCURRENT,
Collector.Characteristics.UNORDERED));
static final Set<Collector.Characteristics> CH_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_UNORDERED_ID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.UNORDERED,
Collector.Characteristics.IDENTITY_FINISH));
static final Set<Collector.Characteristics> CH_NOID = Collections.emptySet();
static final Set<Collector.Characteristics> CH_UNORDERED_NOID
= Collections.unmodifiableSet(EnumSet.of(Collector.Characteristics.UNORDERED));
private Collectors() { }
// 省略大量代码
// 静态类,Collector的实现,实现其实就是Supplier、Accumulator、Combiner、Finisher和Characteristics集合的成员属性承载
static class CollectorImpl<T, A, R> implements Collector<T, A, R> {
private final Supplier<A> supplier;
private final BiConsumer<A, T> accumulator;
private final BinaryOperator<A> combiner;
private final Function<A, R> finisher;
private final Set<Characteristics> characteristics;
CollectorImpl(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Function<A,R> finisher,
Set<Characteristics> characteristics) {
this.supplier = supplier;
this.accumulator = accumulator;
this.combiner = combiner;
this.finisher = finisher;
this.characteristics = characteristics;
}
CollectorImpl(Supplier<A> supplier,
BiConsumer<A, T> accumulator,
BinaryOperator<A> combiner,
Set<Characteristics> characteristics) {
this(supplier, accumulator, combiner, castingIdentity(), characteristics);
}
@Override
public BiConsumer<A, T> accumulator() {
return accumulator;
}
@Override
public Supplier<A> supplier() {
return supplier;
}
@Override
public BinaryOperator<A> combiner() {
return combiner;
}
@Override
public Function<A, R> finisher() {
return finisher;
}
@Override
public Set<Characteristics> characteristics() {
return characteristics;
}
}
// 省略大量代码
// IDENTITY_FINISH特性下,Finisher的实现,也就是之前提到的A->R和R->A等效,可以强转
private static <I, R> Function<I, R> castingIdentity() {
return i -> (R) i;
}
// 省略大量代码
}
collect()
方法的求值执行入口在ReferencePipeline
中:
// ReferencePipeline
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// 基于Collector实例进行求值
public final <R, A> R collect(Collector<? super P_OUT, A, R> collector) {
A container;
// 并发求值场景暂不考虑
if (isParallel()
&& (collector.characteristics().contains(Collector.Characteristics.CONCURRENT))
&& (!isOrdered() || collector.characteristics().contains(Collector.Characteristics.UNORDERED))) {
container = collector.supplier().get();
BiConsumer<A, ? super P_OUT> accumulator = collector.accumulator();
forEach(u -> accumulator.accept(container, u));
}
else {
// 这里就是同步执行场景下的求值过程,这里可以看出其实所有Collector的求值都是Reduce操作
container = evaluate(ReduceOps.makeRef(collector));
}
// 如果Collector的Finisher输入类型和输出类型相同,所以Supplier<A>和Supplier<R>得出的Container是同一种类型的Container,可以直接类型转换,否则就要调用Collector中的Finisher进行最后一步处理
return collector.characteristics().contains(Collector.Characteristics.IDENTITY_FINISH)
? (R) container
: collector.finisher().apply(container);
}
// 暂时省略其他代码
}
// ReduceOps
final class ReduceOps {
private ReduceOps() { }
// 暂时省略其他代码
// 引用类型Reduce操作创建TerminalOp实例
public static <T, I> TerminalOp<T, I>
makeRef(Collector<? super T, I, ?> collector) {
// Supplier
Supplier<I> supplier = Objects.requireNonNull(collector).supplier();
// Accumulator
BiConsumer<I, ? super T> accumulator = collector.accumulator();
// Combiner
BinaryOperator<I> combiner = collector.combiner();
// 这里注意一点,ReducingSink是方法makeRef中的内部类,作用域只在方法内,它是封装为TerminalOp最终转化为Sink链中最后一个Sink实例的类型
class ReducingSink extends Box<I>
implements AccumulatingSink<T, I, ReducingSink> {
@Override
public void begin(long size) {
// 这里把从Supplier创建的新Container实例存放在父类Box的状态属性中
state = supplier.get();
}
@Override
public void accept(T t) {
// 处理元素,Accumulator处理状态(容器实例)和元素,这里可以想象,如果state为一个ArrayList实例,这里的accept()实现可能为add(ele)操作
accumulator.accept(state, t);
}
@Override
public void combine(ReducingSink other) {
// Combiner合并两个状态(容器实例)
state = combiner.apply(state, other.state);
}
}
return new ReduceOp<T, I, ReducingSink>(StreamShape.REFERENCE) {
@Override
public ReducingSink makeSink() {
return new ReducingSink();
}
@Override
public int getOpFlags() {
return collector.characteristics().contains(Collector.Characteristics.UNORDERED)
? StreamOpFlag.NOT_ORDERED
: 0;
}
};
}
// 暂时省略其他代码
// 继承自接口TerminalSink,主要添加了combine()抽象方法,用于合并元素
private interface AccumulatingSink<T, R, K extends AccumulatingSink<T, R, K>>
extends TerminalSink<T, R> {
void combine(K other);
}
// 状态盒,用于持有和获取状态,状态属性的修饰符为default,包内的类实例都能修改
private abstract static class Box<U> {
U state;
Box() {} // Avoid creation of special accessor
public U get() {
return state;
}
}
// ReduceOp的最终实现,这个就是Reduce操作终结操作的实现
private abstract static class ReduceOp<T, R, S extends AccumulatingSink<T, R, S>>
implements TerminalOp<T, R> {
// 流输入元素"形状"
private final StreamShape inputShape;
ReduceOp(StreamShape shape) {
inputShape = shape;
}
// 抽象方法,让子类生成终结操作的Sink
public abstract S makeSink();
// 获取流输入元素"形状"
@Override
public StreamShape inputShape() {
return inputShape;
}
// 同步执行求值,还是相似的思路,使用wrapAndCopyInto()进行Sink链构建和元素遍历
@Override
public <P_IN> R evaluateSequential(PipelineHelper<T> helper,
Spliterator<P_IN> spliterator) {
// 以当前的ReduceOp实例的makeSink()返回的Sink实例作为最后一个Sink添加到Sink链(也就是前面经常说的元素引用链),然后对Sink链进行遍历
// 这里向上一步一步推演思考,最终get()方法执行完毕拿到的结果就是ReducingSink父类Box中的state变量,也就是容器实例
return helper.wrapAndCopyInto(makeSink(), spliterator).get();
}
// 异步执行求值,暂时忽略
@Override
public <P_IN> R evaluateParallel(PipelineHelper<T> helper,
Spliterator<P_IN> spliterator) {
return new ReduceTask<>(this, helper, spliterator).invoke().get();
}
}
// 暂时省略其他代码
}
接着就看Collector
的静态工厂方法,看一些常用的Collector
实例是如何构建的,例如看Collectors.toList()
:
// Supplier => () -> new ArrayList<T>(); // 初始化ArrayList
// Accumulator => (list,number) -> list.add(number); // 往ArrayList中添加元素
// Combiner => (left, right) -> { left.addAll(right); return left;} // 合并ArrayList
// Finisher => X -> X; // 输入什么就返回什么,这里实际返回的是ArrayList
public static <T>
Collector<T, ?, List<T>> toList() {
return new CollectorImpl<>((Supplier<List<T>>) ArrayList::new, List::add,
(left, right) -> { left.addAll(right); return left; },
CH_ID);
}
把过程画成流程图如下:
甚至可以更通俗地用伪代码表示Collector
这类Terminal Op
的执行过程(还是以Collectors.toList()
为例):
[begin]
Supplier supplier = () -> new ArrayList<T>();
Container container = supplier.get();
Box.state = container;
[accept]
Box.state.add(element);
[end]
return supplier.get(); (=> return Box.state);
↓↓↓↓↓↓↓↓↓甚至更加通俗的过程如下↓↓↓↓↓↓↓↓↓↓↓↓↓↓
ArrayList<T> container = new ArrayList<T>();
loop:
container.add(element)
return container;
也就是虽然工程化的代码看起来很复杂,最终的实现就是简单的:初始化ArrayList
实例由state
属性持有,遍历处理元素的时候把元素添加到state
中,最终返回state
。最后看toArray()
的方法实现(下面的方法代码没有按照实际的位置贴出,笔者把零散的代码块放在一起方便分析):
abstract class ReferencePipeline<P_IN, P_OUT>
extends AbstractPipeline<P_IN, P_OUT, Stream<P_OUT>>
implements Stream<P_OUT> {
// 暂时省略其他代码
// 流的所有元素转换为数组,这里的IntFunction有一种比较特殊的用法,就是用于创建数组实例
// 例如IntFunction<String[]> f = String::new; String[] arry = f.apply(2); 相当于String[] arry = new String[2];
@Override
@SuppressWarnings("unchecked")
public final <A> A[] toArray(IntFunction<A[]> generator) {
// 这里主动擦除了IntFunction的类型,只要保证求值的过程是正确,最终可以做类型强转
@SuppressWarnings("rawtypes")
IntFunction rawGenerator = (IntFunction) generator;
// 委托到evaluateToArrayNode()方法进行计算
return (A[]) Nodes.flatten(evaluateToArrayNode(rawGenerator), rawGenerator)
.asArray(rawGenerator);
}
// 流的所有元素转换为Object数组
@Override
public final Object[] toArray() {
return toArray(Object[]::new);
}
// 流元素求值转换为ArrayNode
final Node<E_OUT> evaluateToArrayNode(IntFunction<E_OUT[]> generator) {
// 确保不会处理多次
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
// 并发执行暂时跳过
if (isParallel() && previousStage != null && opIsStateful()) {
depth = 0;
return opEvaluateParallel(previousStage, previousStage.sourceSpliterator(0), generator);
}
else {
return evaluate(sourceSpliterator(0), true, generator);
}
}
// 最终的转换Node的方法
final <P_IN> Node<E_OUT> evaluate(Spliterator<P_IN> spliterator,
boolean flatten,
IntFunction<E_OUT[]> generator) {
// 并发执行暂时跳过
if (isParallel()) {
// @@@ Optimize if op of this pipeline stage is a stateful op
return evaluateToNode(this, spliterator, flatten, generator);
}
else {
// 兜兜转换还是回到了wrapAndCopyInto()方法,遍历Sink链,所以基本可以得知Node.Builder是Sink的一个实现
Node.Builder<E_OUT> nb = makeNodeBuilder(
exactOutputSizeIfKnown(spliterator), generator);
return wrapAndCopyInto(nb, spliterator).build();
}
}
// 获取Node的建造器实例
final Node.Builder<P_OUT> makeNodeBuilder(long exactSizeIfKnown, IntFunction<P_OUT[]> generator) {
return Nodes.builder(exactSizeIfKnown, generator);
}
// 暂时省略其他代码
}
// Node接口定义
interface Node<T> {
// 获取待处理的元素封装成的Spliterator实例
Spliterator<T> spliterator();
// 遍历当前Node实例中所有待处理的元素,回调到Consumer实例中
void forEach(Consumer<? super T> consumer);
// 获取当前Node实例的所有子Node的个数
default int getChildCount() {
return 0;
}
// 获取当前Node实例的子Node实例,入参i是子Node的索引
default Node<T> getChild(int i) {
throw new IndexOutOfBoundsException();
}
// 分割当前Node实例的一个部分,生成一个新的sub Node,类似于ArrayList中的subList方法
default Node<T> truncate(long from, long to, IntFunction<T[]> generator) {
if (from == 0 && to == count())
return this;
Spliterator<T> spliterator = spliterator();
long size = to - from;
Node.Builder<T> nodeBuilder = Nodes.builder(size, generator);
nodeBuilder.begin(size);
for (int i = 0; i < from && spliterator.tryAdvance(e -> { }); i++) { }
if (to == count()) {
spliterator.forEachRemaining(nodeBuilder);
} else {
for (int i = 0; i < size && spliterator.tryAdvance(nodeBuilder); i++) { }
}
nodeBuilder.end();
return nodeBuilder.build();
}
// 创建一个包含当前Node实例所有元素的元素数组视图
T[] asArray(IntFunction<T[]> generator);
//
void copyInto(T[] array, int offset);
// 返回Node实例基于Stream的元素"形状"
default StreamShape getShape() {
return StreamShape.REFERENCE;
}
// 获取当前Node实例包含的元素个数
long count();
// Node建造器,注意这个Node.Builder接口是继承自Sink,那么其子类实现都可以添加到Sink链中作为一个节点(终结节点)
interface Builder<T> extends Sink<T> {
// 创建Node实例
Node<T> build();
// 基于Integer元素类型的特化类型Node.Builder
interface OfInt extends Node.Builder<Integer>, Sink.OfInt {
@Override
Node.OfInt build();
}
// 基于Long元素类型的特化类型Node.Builder
interface OfLong extends Node.Builder<Long>, Sink.OfLong {
@Override
Node.OfLong build();
}
// 基于Double元素类型的特化类型Node.Builder
interface OfDouble extends Node.Builder<Double>, Sink.OfDouble {
@Override
Node.OfDouble build();
}
}
// 暂时省略其他代码
}
// 这里下面的方法来源于Nodes类
final class Nodes {
// 暂时省略其他代码
// Node扁平化处理,如果传入的Node实例存在子Node实例,则使用fork-join对Node进行分割和并发计算,结果添加到IntFunction生成的数组中,如果不存在子Node,直接返回传入的Node实例
// 关于并发计算部分暂时不分析
public static <T> Node<T> flatten(Node<T> node, IntFunction<T[]> generator) {
if (node.getChildCount() > 0) {
long size = node.count();
if (size >= MAX_ARRAY_SIZE)
throw new IllegalArgumentException(BAD_SIZE);
T[] array = generator.apply((int) size);
new ToArrayTask.OfRef<>(node, array, 0).invoke();
return node(array);
} else {
return node;
}
}
// 创建Node的建造器实例
static <T> Node.Builder<T> builder(long exactSizeIfKnown, IntFunction<T[]> generator) {
// 当知道待处理元素的准确数量并且小于允许创建的数组的最大长度MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8),使用FixedNodeBuilder(固定长度数组Node建造器),否则使用SpinedNodeBuilder实例
return (exactSizeIfKnown >= 0 && exactSizeIfKnown < MAX_ARRAY_SIZE)
? new FixedNodeBuilder<>(exactSizeIfKnown, generator)
: builder();
}
// 创建Node的建造器实例,使用SpinedNodeBuilder的实例,此SpinedNode支持元素添加,但是不支持元素移除
static <T> Node.Builder<T> builder() {
return new SpinedNodeBuilder<>();
}
// 固定长度固定长度数组Node实现(也就是最终的Node实现是一个ArrayNode,最终的容器为一个T类型元素的数组T[])
private static final class FixedNodeBuilder<T>
extends ArrayNode<T>
implements Node.Builder<T> {
// 基于size(元素个数,或者说创建数组的长度)和数组创建方法IntFunction构建FixedNodeBuilder实例
FixedNodeBuilder(long size, IntFunction<T[]> generator) {
super(size, generator);
assert size < MAX_ARRAY_SIZE;
}
// 返回当前FixedNodeBuilder实例,判断数组元素计数值curSize必须大于等于实际数组容器中元素的个数
@Override
public Node<T> build() {
if (curSize < array.length)
throw new IllegalStateException(String.format("Current size %d is less than fixed size %d",
curSize, array.length));
return this;
}
// Sink的begin方法回调,传入的size必须和数组长度相等,因为后面的accept()方法会执行size此
@Override
public void begin(long size) {
if (size != array.length)
throw new IllegalStateException(String.format("Begin size %d is not equal to fixed size %d",
size, array.length));
// 重置数组元素计数值为0
curSize = 0;
}
// Sink的accept方法回调,当数组元素计数值小于数组长度,直接向数组下标curSize++添加传入的元素
@Override
public void accept(T t) {
if (curSize < array.length) {
array[curSize++] = t;
} else {
throw new IllegalStateException(String.format("Accept exceeded fixed size of %d",
array.length));
}
}
// Sink的end方法回调,再次判断数组元素计数值curSize必须大于等于实际数组容器中元素的个数
@Override
public void end() {
if (curSize < array.length)
throw new IllegalStateException(String.format("End size %d is less than fixed size %d",
curSize, array.length));
}
// 返回FixedNodeBuilder当前信息,当前处理的下标和当前数组中所有的元素
@Override
public String toString() {
return String.format("FixedNodeBuilder[%d][%s]",
array.length - curSize, Arrays.toString(array));
}
}
// Node实现,容器为一个固定长度的数组
private static class ArrayNode<T> implements Node<T> {
// 数组容器
final T[] array;
// 数组容器中当前元素的个数,这个值是一个固定值,或者在FixedNodeBuilder的accept()方法回调中递增
int curSize;
// 基于size和数组创建的工厂IntFunction构建ArrayNode实例
@SuppressWarnings("unchecked")
ArrayNode(long size, IntFunction<T[]> generator) {
if (size >= MAX_ARRAY_SIZE)
throw new IllegalArgumentException(BAD_SIZE);
// 创建szie长度的数组容器
this.array = generator.apply((int) size);
this.curSize = 0;
}
// 这个方法是基于一个现成的数组创建ArrayNode实例,直接改变数组的引用为array,元素个数curSize置为输入参数长度
ArrayNode(T[] array) {
this.array = array;
this.curSize = array.length;
}
// Node - 接下来是Node接口的实现
// 基于数组实例,起始索引0和结束索引curSize构造一个全新的Spliterator实例
@Override
public Spliterator<T> spliterator() {
return Arrays.spliterator(array, 0, curSize);
}
// 拷贝array中的元素到外部传入的dest数组中
@Override
public void copyInto(T[] dest, int destOffset) {
System.arraycopy(array, 0, dest, destOffset, curSize);
}
// 返回元素数组视图,这里直接返回array引用
@Override
public T[] asArray(IntFunction<T[]> generator) {
if (array.length == curSize) {
return array;
} else {
throw new IllegalStateException();
}
}
// 获取array中的元素个数
@Override
public long count() {
return curSize;
}
// 遍历array,每个元素回调Consumer实例
@Override
public void forEach(Consumer<? super T> consumer) {
for (int i = 0; i < curSize; i++) {
consumer.accept(array[i]);
}
}
// 返回ArrayNode当前信息,当前处理的下标和当前数组中所有的元素
@Override
public String toString() {
return String.format("ArrayNode[%d][%s]",
array.length - curSize, Arrays.toString(array));
}
}
// 暂时省略其他代码
}
很多集合容器的Spliterator
其实并不支持SIZED
特性,其实Node
的最终实现很多情况下都是Nodes.SpinedNodeBuilder
,因为SpinedNodeBuilder
重实现实现了数组扩容和Spliterator
基于数组进行分割的方法,源码相对复杂(特别是spliterator()
方法),这里挑部分进行分析,由于SpinedNodeBuilder
绝大部分方法都是使用父类SpinedBuffer
中的实现,这里可以直接分析SpinedBuffer
:
// SpinedBuffer的当前数组在超过了元素数量阈值之后,会拆分为多个数组块,存储到spine中,而curChunk引用指向的是当前处理的数组块
class SpinedBuffer<E>
extends AbstractSpinedBuffer
implements Consumer<E>, Iterable<E> {
// 暂时省略其他代码
// 当前的数组块
protected E[] curChunk;
// 所有数组块
protected E[][] spine;
// 构造函数,指定初始化容量
SpinedBuffer(int initialCapacity) {
super(initialCapacity);
curChunk = (E[]) new Object[1 << initialChunkPower];
}
// 构造函数,指定默认初始化容量
@SuppressWarnings("unchecked")
SpinedBuffer() {
super();
curChunk = (E[]) new Object[1 << initialChunkPower];
}
// 拷贝当前SpinedBuffer中的数组元素到传入的数组实例
public void copyInto(E[] array, int offset) {
// 计算最终的offset,区分单个chunk和多个chunk的情况
long finalOffset = offset + count();
if (finalOffset > array.length || finalOffset < offset) {
throw new IndexOutOfBoundsException("does not fit");
}
// 单个chunk的情况,由curChunk最接拷贝
if (spineIndex == 0)
System.arraycopy(curChunk, 0, array, offset, elementIndex);
else {
// 多个chunk的情况,由遍历spine并且对每个chunk进行拷贝
// full chunks
for (int i=0; i < spineIndex; i++) {
System.arraycopy(spine[i], 0, array, offset, spine[i].length);
offset += spine[i].length;
}
if (elementIndex > 0)
System.arraycopy(curChunk, 0, array, offset, elementIndex);
}
}
// 返回数组元素视图,基于IntFunction构建数组实例,使用copyInto()方法进行元素拷贝
public E[] asArray(IntFunction<E[]> arrayFactory) {
long size = count();
if (size >= Nodes.MAX_ARRAY_SIZE)
throw new IllegalArgumentException(Nodes.BAD_SIZE);
E[] result = arrayFactory.apply((int) size);
copyInto(result, 0);
return result;
}
// 清空SpinedBuffer,清空分块元素和所有引用
@Override
public void clear() {
if (spine != null) {
curChunk = spine[0];
for (int i=0; i<curChunk.length; i++)
curChunk[i] = null;
spine = null;
priorElementCount = null;
}
else {
for (int i=0; i<elementIndex; i++)
curChunk[i] = null;
}
elementIndex = 0;
spineIndex = 0;
}
// 遍历元素回调Consumer,分别遍历spine和curChunk
@Override
public void forEach(Consumer<? super E> consumer) {
// completed chunks, if any
for (int j = 0; j < spineIndex; j++)
for (E t : spine[j])
consumer.accept(t);
// current chunk
for (int i=0; i<elementIndex; i++)
consumer.accept(curChunk[i]);
}
// Consumer的accept实现,最终会作为Sink接口的accept方法调用
@Override
public void accept(E e) {
// 如果当前分块(第一个)的元素已经满了,就初始化spine,然后元素添加到spine[0]中
if (elementIndex == curChunk.length) {
inflateSpine();
// 然后元素添加到spine[0]中的元素已经满了,就新增spine[n],把元素放进spine[n]中
if (spineIndex+1 >= spine.length || spine[spineIndex+1] == null)
increaseCapacity();
elementIndex = 0;
++spineIndex;
// 当前的chunk更新为最新的chunk,就是spine中的最新一个chunk
curChunk = spine[spineIndex];
}
// 当前的curChunk添加元素
curChunk[elementIndex++] = e;
}
// 暂时省略其他代码
}
源码已经基本分析完毕,下面还是用一个例子转化为流程图:
流并发执行的源码实现
如果流实例调用了parallel()
,注释中提到会返回一个异步执行流的变体,实际上并没有构造变体,只是把sourceStage.parallel
标记为true
,异步求值的基本过程是:构建流管道结构的时候和同步求值的过程一致,构建完Sink
链之后,Spliterator
会使用特定算法基于trySplit()
进行自分割,自分割算法由具体的子类决定,例如ArrayList
采用的就是二分法,分割完成后每个Spliterator
持有所有元素中的一小部分,然后把每个Spliterator
作为sourceSpliterator
在fork-join
线程池中执行Sink
链,得到多个部分的结果在当前调用线程中聚合,得到最终结果。这里用到的技巧就是:线程封闭和fork-join
。因为不同Terminal Op
的并发求值过程大同小异,这里只分析forEach
并发执行的实现。首先展示一个使用fork-join
线程池的简单例子:
public class MapReduceApp {
public static void main(String[] args) {
// 数组中每个元素*2,再求和
Integer result = new MapReducer<>(new Integer[]{1, 2, 3, 4}, x -> x * 2, Integer::sum).invoke();
System.out.println(result);
}
interface Mapper<S, T> {
T apply(S source);
}
interface Reducer<S, T> {
T apply(S first, S second);
}
public static class MapReducer<T> extends CountedCompleter<T> {
final T[] array;
final Mapper<T, T> mapper;
final Reducer<T, T> reducer;
final int lo, hi;
MapReducer<T> sibling;
T result;
public MapReducer(T[] array,
Mapper<T, T> mapper,
Reducer<T, T> reducer) {
this.array = array;
this.mapper = mapper;
this.reducer = reducer;
this.lo = 0;
this.hi = array.length;
}
public MapReducer(CountedCompleter<?> p,
T[] array,
Mapper<T, T> mapper,
Reducer<T, T> reducer,
int lo,
int hi) {
super(p);
this.array = array;
this.mapper = mapper;
this.reducer = reducer;
this.lo = lo;
this.hi = hi;
}
@Override
public void compute() {
if (hi - lo >= 2) {
int mid = (lo + hi) >> 1;
MapReducer<T> left = new MapReducer<>(this, array, mapper, reducer, lo, mid);
MapReducer<T> right = new MapReducer<>(this, array, mapper, reducer, mid, hi);
left.sibling = right;
right.sibling = left;
// 创建子任务父任务的pending计数器加1
setPendingCount(1);
// 提交右子任务
right.fork();
// 在当前线程计算左子任务
left.compute();
} else {
if (hi > lo) {
result = mapper.apply(array[lo]);
}
// 叶子节点完成,尝试合并其他兄弟节点的结果,会调用onCompletion方法
tryComplete();
}
}
@Override
public T getRawResult() {
return result;
}
@SuppressWarnings("unchecked")
@Override
public void onCompletion(CountedCompleter<?> caller) {
if (caller != this) {
MapReducer<T> child = (MapReducer<T>) caller;
MapReducer<T> sib = child.sibling;
// 合并子任务结果,只有两个子任务
if (Objects.isNull(sib) || Objects.isNull(sib.result)) {
result = child.result;
} else {
result = reducer.apply(child.result, sib.result);
}
}
}
}
}
这里简单使用了fork-join
编写了一个简易的MapReduce
应用,main
方法中运行的是数组[1,2,3,4]
中的所有元素先映射为i -> i * 2
,再进行reduce
(求和)的过程,代码中也是简单使用二分法对原始的array
进行分割,当最终的任务只包含一个元素,也就是lo < hi
且hi - lo == 1
的时候,会基于单个元素调用Mapper
的方法进行完成通知tryComplete()
,任务完成会最终通知onCompletion()
方法,Reducer
就是在此方法中进行结果的聚合操作。对于流的并发求值来说,过程是类似的,ForEachOp
中最终调用ForEachOrderedTask
或者ForEachTask
,这里挑选ForEachTask
进行分析:
abstract static class ForEachOp<T>
implements TerminalOp<T, Void>, TerminalSink<T, Void> {
// 暂时省略其他代码
@Override
public <S> Void evaluateParallel(PipelineHelper<T> helper,
Spliterator<S> spliterator) {
if (ordered)
new ForEachOrderedTask<>(helper, spliterator, this).invoke();
else
// 最终是调用ForEachTask的invoke方法,invoke会阻塞到所有fork任务执行完,获取最终的结果
new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
return null;
}
// 暂时省略其他代码
}
// ForEachOps类
final class ForEachOps {
private ForEachOps() { }
// forEach的fork-join任务实现,没有覆盖getRawResult()方法,最终只会返回NULL
static final class ForEachTask<S, T> extends CountedCompleter<Void> {
// Spliterator实例,如果是父任务则代表所有待处理的元素,如果是子任务则是一个分割后的新Spliterator实例
private Spliterator<S> spliterator;
// Sink链实例
private final Sink<S> sink;
// 流管道引用
private final PipelineHelper<T> helper;
// 目标数量,其实是每个任务处理元素数量的建议值
private long targetSize;
// 这个构造器是提供给父(根)任务
ForEachTask(PipelineHelper<T> helper,
Spliterator<S> spliterator,
Sink<S> sink) {
super(null);
this.sink = sink;
this.helper = helper;
this.spliterator = spliterator;
this.targetSize = 0L;
}
// 这个构造器是提供给子任务,所以需要父任务的引用和一个分割后的新Spliterator实例作为参数
ForEachTask(ForEachTask<S, T> parent, Spliterator<S> spliterator) {
super(parent);
this.spliterator = spliterator;
this.sink = parent.sink;
this.targetSize = parent.targetSize;
this.helper = parent.helper;
}
// Similar to AbstractTask but doesn't need to track child tasks
// 实现compute方法,用于分割Spliterator成多个子任务,这里不需要跟踪所有子任务
public void compute() {
// 神奇的赋值,相当于Spliterator<S> rightSplit = spliterator; Spliterator<S> leftSplit;
// rightSplit总是指向当前的spliterator实例
Spliterator<S> rightSplit = spliterator, leftSplit;
// 这里也是神奇的赋值,相当于long sizeEstimate = rightSplit.estimateSize(); long sizeThreshold;
long sizeEstimate = rightSplit.estimateSize(), sizeThreshold;
// sizeThreshold赋值为targetSize
if ((sizeThreshold = targetSize) == 0L)
// 基于Spliterator分割后的右分支实例的元素数量重新赋值sizeThreshold和targetSize
// 计算方式是待处理元素数量/(fork-join线程池并行度<<2)或者1(当前一个计算方式结果为0的时候)
targetSize = sizeThreshold = AbstractTask.suggestTargetSize(sizeEstimate);
// 当前的流是否支持SHORT_CIRCUIT,也就是短路特性
boolean isShortCircuit = StreamOpFlag.SHORT_CIRCUIT.isKnown(helper.getStreamAndOpFlags());
// 当前的任务是否fork右分支
boolean forkRight = false;
// taskSink作为Sink的临时变量
Sink<S> taskSink = sink;
// 当前任务的临时变量
ForEachTask<S, T> task = this;
// Spliterator分割和创建新的fork任务ForEachTask,前提是不支持短路或者Sink不支持取消
while (!isShortCircuit || !taskSink.cancellationRequested()) {
// 当前的任务中的Spliterator(rightSplit)中的待处理元素小于等于每个任务应该处理的元素阈值或者再分割后得到NULL,则不需要再分割,直接基于rightSplit和Sink链执行循环处理元素
if (sizeEstimate <= sizeThreshold || (leftSplit = rightSplit.trySplit()) == null) {
// 这里就是遍历rightSplit元素回调Sink链的操作
task.helper.copyInto(taskSink, rightSplit);
break;
}
// rightSplit还能分割,则基于分割后的leftSplit和以当前任务作为父任务创建一个新的fork任务
ForEachTask<S, T> leftTask = new ForEachTask<>(task, leftSplit);
// 待处理子任务加1
task.addToPendingCount(1);
// 需要fork的任务实例临时变量
ForEachTask<S, T> taskToFork;
// 因为rightSplit总是分割Spliterator后对应原来的Spliterator引用,而leftSplit总是trySplit()后生成的新的Spliterator
// 所以这里leftSplit也需要作为rightSplit进行分割,通俗来说就是周星驰007那把梅花间足发射的枪
if (forkRight) {
// 这里交换leftSplit为rightSplit,所以forkRight设置为false,下一轮循环相当于fork left
forkRight = false;
rightSplit = leftSplit;
taskToFork = task;
// 赋值下一轮的父Task为当前的fork task
task = leftTask;
}
else {
forkRight = true;
taskToFork = leftTask;
}
// 添加fork任务到任务队列中
taskToFork.fork();
// 其实这里是更新剩余待分割的Spliterator中的所有元素数量到sizeEstimate
sizeEstimate = rightSplit.estimateSize();
}
// 置空spliterator实例并且传播任务完成状态,等待所有任务执行完成
task.spliterator = null;
task.propagateCompletion();
}
}
}
上面的源码分析看起来可能比较难理解,这里举个简单的例子:
public static void main(String[] args) throws Exception {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.stream().parallel().forEach(System.out::println);
}
这段代码中最终转换成ForEachTask
中评估后得到的targetSize = sizeThreshold == 1
,当前调用线程会参与计算,会执行3
次fork
,也就是一共有4
个处理流程实例(也就是原始的Spliterator
实例最终会分割出3
个全新的Spliterator
实例,加上自身一个4
个Spliterator
实例),每个处理流程实例只处理1
个元素,对应的流程图如下:
最终的计算结果是调用CountedCompleter.invoke()
方法获取的,此方法会阻塞直到所有子任务处理完成,当然forEach
终结操作不需要返回值,所以没有实现getRawResult()
方法,这里只是为了阻塞到所有任务执行完毕才解除调用线程的阻塞状态。
状态操作与短路操作
Stream
中按照中间操作是否有状态可以把这些操作分为无状态操作和有状态操作。Stream
中按照终结操作是否支持短路特性可以把这些操作分为非短路操作和短路操作。理解如下:
- 无状态操作:当前操作节点处理元素完成后,在满足前提条件下直接把结果传递到下一个操作节点,也就是操作内部不存在状态也不需要保存状态,例如
filter
、map
等操作 - 有状态操作:处理元素的时候,依赖于节点的内部状态对元素进行累积,当处理一个新的元素的时候,其实可以感知到所有处理过的元素的历史状态,这个"状态"其实更像是缓冲区的概念,例如
sort
、limit
等操作,以sort
操作为例,一般是把所有待处理的元素全部添加到一个容器如ArrayList
,再进行所有元素的排序,然后再重新模拟Spliterator
把元素推送到后一个节点 - 非短路(终结)操作:终结操作在处理元素时候不能基于短路条件提前中断处理并且返回,也就是必须处理所有的元素,如
forEach
- 短路(终结)操作:终结操作在处理元素时候允许基于短路条件提前中断处理并且返回,但是最终实现中是有可能遍历完所有的元素中,只是在处理方法中基于前置的短路条件跳过了实际的处理过程,如
anyMatch
(实际上anyMatch
会遍历完所有的元素,不过在命中了短路条件下,元素回调Sink.accept()
方法时候会基于stop
短路标记跳过具体的处理流程)
这里不展开源码进行分析,仅仅展示一个经常见到的Stream
操作汇总表如下:
这里还有两点要注意:
- 从源码上看部分中间操作也是支持短路的,例如
slice
和while
相关操作 - 从源码上看
find
相关终结操作中findFirst
、findAny
均支持和判断StreamOpFlag.SHORT_CIRCUIT
,而match
相关终结操作是通过内部的临时状态stop
和value
进行短路控制
总结
前前后后写了十多万字,其实也仅仅相对浅层次介绍了Stream
的基本实现,笔者认为很多没分析到的中间操作实现和终结操作实现,特别是并发执行的终结操作实现是十分复杂的,多线程环境下需要进行一些想象和多处DEBUG
定位执行位置和推演执行的过程。简单总结一下:
JDK
中Stream
的实现是精炼的高度工程化代码Stream
的载体虽然是AbstractPipeline
,管道结构,但是只用其形,实际求值操作之前会转化为一个多层包裹的Sink
结构,也就是前文一直说的Sink
链,从编程模式来看,应用的是Reactor
编程模式Stream
目前支持的固有求值执行结构一定是Head(Source Spliterator) -> Op -> Op ... -> Terminal Op
的形式,这算是一个局限性,没有办法做到像LINQ
那样可以灵活实现类似内存视图的功能Stream
目前支持并发求值方案是针对Source Spliterator
进行分割,封装Terminal Op
和固定Sink
链构造的ForkJoinTask
进行并发计算,调用线程和fork-join
线程池中的工作线程都可以参与求值过程,笔者认为这部分是Stream
中除了那些标志集合位运算外最复杂的实现Stream
实现的功能是一个突破,也有人说过此功能是一个"早产儿”,在此希望JDK
能够在矛盾螺旋中前进和发展
Reference
1.3 - Java 并发
1.3.1 - CH01-并发体系
理论基础
- 为什么需要多线程
- 什么是线程不安全
- 并发问题的根源
- 可见性
- 原子性
- 有序性
- Java 提供的方案
- 关键字
- volatile
- synchronized
- final
- 内存模型
- Happens Before 规则
- 锁优化
- 关键字
- 线程安全的范围
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立
- 实现方法
- 互斥同步
- synchronized
- ReentrantLock
- 非阻塞同步
- CAS
- Atomic Class
- 无同步方案
- 栈封闭
- ThreadLocal
- 可重入代码 Reentrant Code
- 互斥同步
线程基础
- 线程状态转换
- 新建 New
- 可运行 Runnable
- 阻塞 Blocking
- 无限期等待 Waiting
- 限期等待 Timed Waiting
- 终止 Terminated
- 线程使用方式
- 实现 Runnable 接口
- 继承 Thread 类
- 实现 Callable 接口
- 线程基础机制
- Executor
- Daemon
- sleep
- yield
- 线程中断
- InterruptedException
- interrupted()
- Executor 的中断操作
- 线程互斥同步
- synchronized
- ReentrantLock
- 线程协作
- join()
- wait() notify() notifyAll()
- await() signal() signalAll()
并发工具
- Locks
- Lock 接口
- AQS
- Condition
- LockSupport
- 重入锁 ReentrantLock
- 读写锁 ReadWriteLock
- Collections
- ConcurrentHashMap
- ConcurrentLinkedQueue
- BlockingQueue
- CopyOnWriteArrayList
- Executors
- Executor
- ForkJoin
- ThreadPoolExecutor
- FutureTask
- Atomic
- 基本类型
- AtomicBoolean
- AtomicInteger
- AtomicLong
- Array
- AtomicBooleanArray
- AtomicIntegerArray
- AtomicLongArray
- Reference
- AtomicReference
- AtomicMarkedReference
- AtomicStampedReference
- FieldUpdater
- AtomicIntegerFiledUpdater
- AtomicLongFiledUpdater
- AtomicReferenceFiledUpdater
- 基本类型
- Tools
- CountDownLatch
- CyclicBarrier
- Semaphore
- Excahnger
并发本质
- 协作
- 管理
- Lock & Condition
- synchronized
- 信号量 Semaphone
- CountDownLatch
- CyclicBarrier
- Pharser
- Exchanger
- 管理
- 分工
- Executor 与 ThreadPool
- ForkJoin
- Future
- 模式
- Guarded Suspension
- ThreadPerMessage
- Balking
- Worker Thread
- 两阶段终止
- 生产消费
- 互斥
- 无锁
- CAS
- Atomic
- 模式
- Imutablity
- CopyOnWrite
- ThreadLocal
- 互斥锁
- synchronized
- Lock
- ReadWriteLock
- 无锁
并发模式
框架案例
- Guava RateLimitor
- Netty
- Disrutor
- HikariCP
1.3.2 - CH02-理论基础
多线程的优势
CPU、内存、IO 设备的速度存在巨大差异,为了合理利用 CPU 的高性能,平衡三者之间的速度差异,计算机体系结构、操作系统、编译程序实现了相关优化:
- CPU 增加了缓存,以平衡与内存的速度差异——导致了可见性问题
- 操作系统提供了进程、线程,以分时复用 CPU,进而均衡 CPU 与 IO 设备之间的速度差异——导致原子性问题
- 编译程序优化了指令执行顺序,使缓存能够得到更合理的利用——导致了有序性问题
线程不安全
如果多个线程对同一份数据执行读写而不采取同步措施的话,可能导致混乱(非预期)的操作结果。
class ThreadUnsafeCounter {
private int count =0;
public void add() {
count++;
}
public int get() {
return count;
}
}
class Bootstrap {
public static void main(String[] args) {
int threadSize=1000;
ThreadUnsafeCounter counter = new ThreadUnsafeCounter();
CountDownLatch latch = new CountDownLatch(threadSize);
ExecutorService executor = Executors.newCachedThreadPool();
for(int i=0;i<threadSize;i++){
executor.execute(() -> {
counter.add();
latch.countDown();
})
}
latch.await();
executor.shutdown();
System.out.println(counter.get()); // will always < 1000
}
}
并发三要素
可见性:CPU 缓存
可见性:一个线程对共享变量的修改,其他线程能够立即看到。
// thread 1
int i=0;
i=10;
// thread 2
j = i;
如果 CPU1 执行 Thread1、CPU2 执行 Thread2。当 Thread1 执行 i=10
时,会首先将 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为 10,那么在 CPU1 的高速缓存中 i 的值变为了 10,却被没有被立即写回主存。
此时 Thread2 执行 j=i
,首先去主存读取 i 的值加载到 CPU2 的高速缓存,(这时主存中 i 的值仍未 0),这就导致 j 的值为 0,而非 10。
原子性:分时复用
原子性:一个操作或多个操作那么全都执行,要么全不执行,不会被任何因素打断。
有序性:指令重排序
有序性:程序执行的顺序完全按照代码的先后顺序执行。
程序执行时为了提高性能,编译器和处理器通常会对执行进行重排序,分为三种类型:
编译器优化:编译器再不改变单线程程序语义的前提下,重新安排语句的执行顺序。
指令级并行:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。
内存系统重排序:由于处理器通过高速缓存读写缓冲区,是的加载和存储操作看上去实在乱序执行。
从 Java 代码到最终要执行的指令序列,会经历以上三种重排序。
- 第一种属于编译器重排序,2、3 属于处理器重排序。
- 这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 中的编译器重排序规则会禁止特定类型的重排序操作。
- 对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过这些内存屏障指令来禁止特定类型的处理器重排序操作。
Java 如何解决并发问题:JMM
核心知识点
Java 内存模型规范了 JVM 如何提供按需禁用编译和缓存优化的方法。
- volatile、synchronized、final 关键字
- Happens Before 规则
可见性、有序性、原子性
- 原子性:Java 中通过 synchronized 和 Lock 实现原子性保证。
- 可见性:Java 中通过 volatie 提供可见性保证。
- synchronized 和 Lock 保证同一时刻只有一个线程获取锁然后执行代码,释放锁前或将数据刷新到主存。
- 有序性:Java 中通过 volatile 保证一定的有序性。
- synchronized 和 Lock 保证同一时刻只有一个线程执行,相当于多个线程顺序执行代码,即有序执行。
volatile、synchronized、final
Happens Before
除了 volatile、synchronized、Lock 能够保证有序性,JVM 还规定了先行发生规则,使一个操作无需显式控制即可保证先于另一个操作发生。
单一线程:Single Thread Rule
在一个线程内,程序中前面的操作先于后面的操作。
管程锁定:Monitor Lock Rule
- 一个 unlock 操作先于后面对一个锁的 lock 操作。
Volatile 变量:Volatile Variable Rule
对一个 volatile 变量的写操作先于对该变量的读操作。
线程启动:Thread Start Rule
Thread 对象的 start 方法先于该线程的每一个动作。
线程加入:Thread Join Rule
Thread 对象的结束先于 join 方法返回。
线程中断:Thread Interruption Rule
- 对线程 interrupt 方法的调用先于检测到中断的代码执行。
对象终结:Finalizer Rule
- 对象构造函数执行完成先于 finalize 方法开始。
传递性:Transitivity
- 如果操作 A 先于 B,B 先于 C,那么 A 先于 C。
线程安全:安全程度
一个类可以被多个线程安全调用时,该类就是线程安全的。
将共享数据按照安全程度的强弱来划分安全强度的等级:
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立
不可变
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。
final 关键字修饰的基本数据类型
String
枚举类型
Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施。
相对线程安全
相对线程安全需要保证对这个对象单独的操作是线程安全的,在调用的时候不需要做额外的保障措施。但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用,我们平常说一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API 中大部分的类都是属于线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap 等。
线程对立
线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具备多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。
线程安全:实现
互斥同步—阻塞同步
- synchronized
- ReentrantLock
非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
- CAS
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个参数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
- AtomicInteger
J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
- ABA
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
无同步方案
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
- 栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
- ThreadLocal
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象,Thread 类中就定义了 ThreadLocal.ThreadLocalMap 成员。
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。
- 可重入代码
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
1.3.3 - CH03-线程基础-1
线程状态
- New:新建,创建后尚未启动。
- Runnable:可运行,可能正在运行,也可能在等待 CPU 时间片。
- 包含操作系统线程状态的 Running 和 Ready。
- Blocking:等待获取一个排它锁,如果其他线程释放了锁就会结束该状态。
- Waiting:无限期等待,需要其他线程唤醒,否则不会分配 CPU 时间片。
- 未设置 Timeout 参数的 Object.wait 方法,需要 Object.notify 或 Object.notifyAll 唤醒
- 未设置 Timeout 参数的 Thread.join 方法,被调用的线程执行完毕
- LockSupport.park 调用
- Timed Waiting:限时等待,在一定时间后自动唤醒。
- 调用 Thread.sleep 方法,线程睡眠
- 设置了 Timeout 参数调用 Object.wait 进入限期等待,挂起线程
- 睡眠和挂起用于表述行为,阻塞和等待用于描述状态
- 阻塞和等待的区别在于,阻塞是被动的,等待的是一个排它锁,锁的释放由其他线程决定。
- 等待是祖东的,等待的是一个时间点,是线程自身通过 Thread.sleep 或 Object.wait 主动触发的等待。
- 设置了 Timeout 参数调用 Thread.join 方法
- LockSupport.parkNanos
- LockSupport.parkUntil
- Terminated:死亡,线程结束任务之后自然死亡,或异常导致任务终止而死亡
应用方式
- 实现 Runnable 接口:无返回值
- 实现 Callable 接口:有返回值
- 继承 Thread 类
实现 Runnable 后 Callable 接口的类只能被当做是一个可以在线程中执行的任务,并不是真正意义上的线程实例,因此最后还是需要通过 Thread 类来调用。即任务是通过线程来执行的。
线程机制
Executor
Executor 管理多个异步任务的执行,而无需开发者显式管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要执行同步操作。
- CachedThreadPool:每个任务创建一个线程
- FixedThreadPool:所有任务共用固定数量的线程
- SingleThreadExecutor:仅有一个线程的 FixedThreadPool
Daemon
守护线程是程序运行时在后台提供服务的线程,不属于程序中必要的部分,非必须。
- 当所有非守护线程结束时,程序即终止,同时会杀死所有守护线程。
- main 属于非守护线程。
- 通过 setDaemon 方法将一个线程设置为守护线程。
sleep
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程任务可能出现的其它异常也同样需要在本地进行处理。
yield
对静态方法 Thread.yield() 的调用表示当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
线程中断
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
InterruptedException
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。
public class InterruptExample {
private static class MyThread1 extends Thread {
@Override
public void run() {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new MyThread1();
thread1.start();
thread1.interrupt();
System.out.println("Main run");
}
// 在线程 sleep 期间中断,“Thread run” 将不会被打印
Main run
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at InterruptExample.lambda$main$0(InterruptExample.java:5)
at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)
}
interrupted
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
public class InterruptExample {
private static class MyThread2 extends Thread {
@Override
public void run() {
while (!interrupted()) {
// ..
}
System.out.println("Thread end");
}
}
public static void main(String[] args) throws InterruptedException {
Thread thread2 = new MyThread2();
thread2.start();
thread2.interrupt();
}
// Thread end
}
Executor 中断操作
调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> {
try {
Thread.sleep(2000);
System.out.println("Thread run");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
executorService.shutdownNow();
System.out.println("Main run");
// 在线程 sleep 期间被中断,不会打印 "Thread run"
Main run
java.lang.InterruptedException: sleep interrupted
如果想要中断 Executor 中的一个线程,可以通过 submit 方法提交一个任务,然后返回一个 Future 对象,调用该 Future 对象的 cancel 方法即可中断对应线程:
Future<?> future = executorService.submit(() -> {
// ..
});
future.cancel(true);
线程同步:互斥
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
synchronized
- 同步代码块
- 只作用于同一个对象实例,比如 new Object(),如果调用两个对象上的同步代码块,就不会进行同步。
- 同步方法
- 它和同步代码块一样,作用于同一个对象。只是作用在了该方法所属的实例。
- 同步类
- 作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
- 同步静态方法
- 作用于整个类。
ReentrantLock
ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。
private Lock lock = new ReentrantLock();
public void func() {
lock.lock();
try {
for (int i = 0; i < 10; i++) {
System.out.print(i + " ");
}
} finally {
lock.unlock(); // 确保释放锁,从而避免发生死锁。
}
}
基本对比
- 实现层次:
- synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
- 性能:
- 新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
- 等待可中断:
- 当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
- ReentrantLock 可中断,而 synchronized 不行。
- 公平锁
- 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
- synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
- 锁绑定多个条件
- 一个 ReentrantLock 可以同时绑定多个 Condition 对象。
应用选择
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
线程协作
当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。
join
在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
wait、notify、notifyAll
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
它们都属于 Object 的一部分,而不属于 Thread。
只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。
使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
public class WaitNotifyExample {
public synchronized void before() {
System.out.println("before");
notifyAll();
}
public synchronized void after() {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("after");
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
WaitNotifyExample example = new WaitNotifyExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
// before
// after
}
wait() 和 sleep() 的区别
- wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
- wait() 会释放锁,sleep() 不会。
await() signal() signalAll()
JUC 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
使用 Lock 来获取一个 Condition 对象。
public class AwaitSignalExample {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
public void before() {
lock.lock();
try {
System.out.println("before");
condition.signalAll();
} finally {
lock.unlock();
}
}
public void after() {
lock.lock();
try {
condition.await();
System.out.println("after");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
AwaitSignalExample example = new AwaitSignalExample();
executorService.execute(() -> example.after());
executorService.execute(() -> example.before());
}
// before
// after
}
1.3.4 - CH04-线程基础-2
Create Thread
- What happens when creating a Thread instance
Thread thread = new Thread(){
@Override
public void run() {
// code
}
};
// at this point the thread is in NEW state, all you have a simple java object,
// no actual thread is created
thread.start();
// when start() is invoked, at some unspecified point in the near future
// the thread will go into RUNNABLE state, this means an actual thread will be created.
// That can happen before start() returns.
- 通过
new
创建线程时,你只是创建了一个 Thread 类的实例,该 Thread 实例的状态为 NEW。 - 通过
thread.start()
调用线程时,该 Thread 实例的状态将会在未来某个时刻变为 RUNNABLE,这表示 OS 级别的线程将被创建,这部分工作由 JVM 完成。
用户空间 & 内核空间
在操作系统中,内存通常会被分成用户空间(User space)与内核空间(Kernel space)这两个部分。当进程/线程运行在用户空间时就处于用户态,运行在内核空间时就处于内核态:
- 运行在内核态的程序可以访问用户空间和内核空间,或者说它可以访问计算机的任何资源,不受限制,为所欲为,例如协调 CPU 资源,分配内存资源,提供稳定的环境供应用程序运行等
- 而应用程序基本都是运行在用户态的,或者说用户态就是提供应用程序运行的空间。运行在用户态的程序只能访问用户空间
那为什么要区分用户态和内核态呢?
早期操作系统是不区分用户态和内核态的,也就是说应用程序可以访问任意内存空间,如果程序不稳定常常会让系统崩溃,比如清除了操作系统的内存数据。为此大佬们设计出了一套规则:对于那些比较危险的操作需要切到内核态才能运行,比如 CPU、内存、设备等资源管理器程序就应该在内核态运行,否则安全性没有保证。
用户态的程序不能随意操作内核地址空间,这样有效地防止了操作系统程序受到应用程序的侵害。
那如果处于用户态的程序想要访问内核空间的话怎么办呢?就需要进行系统调用从用户态切换到内核态。
操作系统线程
在用户空间实现线程
在早期的操作系统中,所有的线程都是在用户空间下实现的,操作系统只能看到线程所属的进程,而不能看到线程。
从我们开发者的角度来理解用户级线程就是说:在这种模型下,我们需要自己定义线程的数据结构、创建、销毁、调度和维护等,这些线程运行在操作系统的某个进程内,然后操作系统直接对进程进行调度。
这种方式的好处一目了然,首先第一点,就是即使操作系统原生不支持线程,我们也可以通过库函数来支持线程;第二点,线程的调度只发生在用户态,避免了操作系统从内核态到用户态的转换开销。
当然缺点也很明显:由于操作系统看不见线程,不知道线程的存在,而 CPU 的时间片切换是以进程为维度的,所以如果进程中某个线程进行了耗时比较长的操作,那么由于用户空间中没有时钟中断机制,就会导致此进程中的其它线程因为得不到 CPU 资源而长时间的持续等待;另外,如果某个线程进行系统调用时比如缺页中断而导致了线程阻塞,此时操作系统也会阻塞住整个进程,即使这个进程中其它线程还在工作。
在内核空间中实现线程
所谓内核级线程就是运行在内核空间的线程, 直接由内核负责,只能由内核来完成线程的调度。
每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。
从我们开发者的角度来理解内核级线程就是说:我们可以直接使用操作系统中已经内置好的线程,线程的创建、销毁、调度和维护等,都是直接由操作系统的内核来实现,我们只需要使用系统调用就好了,不需要像用户级线程那样自己设计线程调度等。
上图画的是 1:1 的线程模型,所谓线程模型,也就是用户线程和内核线程之间的关联方式,线程模型当然不止 1:1 这一种,下面我们来详细解释以下这三种多线程模型:
1. 多对一线程模型:
- 在多对一模型中,多个用户级线程映射到某一个内核线程上
- 线程管理由用户空间中的线程库处理,这非常有效
- 但是,如果进行了阻塞系统调用,那么即使其他用户线程能够继续,整个进程也会阻塞
- 由于单个内核线程只能在单个 CPU 上运行,因此多对一模型不允许在多个 CPU 之间拆分单个进程
从并发性角度来总结下,虽然多对一模型允许开发人员创建任意多的用户线程,但是由于内核只能一次调度一个线程,所以并未增加并发性。现在已经几乎没有操作系统来使用这个模型了,因为它无法利用多个处理核。
2. 一对一线程模型:
- 一对一模型克服了多对一模型的问题
- 一对一模型创建一个单独的内核线程来处理每个用户线程
- 但是,管理一对一模型的开销更大,涉及更多开销和减慢系统速度
- 此模型的大多数实现都限制了可以创建的线程数
从并发性角度来总结下,虽然一对一模型提供了更大的并发性,但是开发人员应注意不要在应用程序内创建太多线程(有时系统可能会限制创建线程的数量),因为管理一对一模型的开销更大。
3. 多对多线程模型:
- 多对多模型将任意数量的用户线程复用到相同或更少数量的内核线程上,结合了一对一和多对一模型的最佳特性
- 用户对创建的线程数没有限制
- 阻止内核系统调用不会阻止整个进程
- 进程可以分布在多个处理器上
- 可以为各个进程分配可变数量的内核线程,具体取决于存在的 CPU 数量和其他因素
Java Thread
在上面的模型介绍中,我们提到了通过线程库来创建、管理线程,那么什么是线程库呢?
线程库就是为开发人员提供创建和管理线程的一套 API。
当然,线程库不仅可以在用户空间中实现,还可以在内核空间中实现。前者涉及仅在用户空间内实现的 API 函数,没有内核支持。后者涉及系统调用,也就是说调用库中的一个 API 函数将会导致对内核的系统调用,并且需要具有线程库支持的内核。
下面简单介绍下三个主要的线程库:
POSIX Pthreads:可以作为用户或内核库提供,作为 POSIX 标准的扩展
Win32 线程:用于 Window 操作系统的内核级线程库
Java 线程:Java 线程 API 通常采用宿主系统的线程库来实现,也就是说在 Win 系统上,Java 线程 API 通常采用 Win API 来实现,在 UNIX 类系统上,采用 Pthread 来实现。
事实上,在 JDK 1.2 之前,Java 线程是基于称为 “绿色线程”(Green Threads)的用户级线程实现的,也就是说程序员大佬们为 JVM 开发了自己的一套线程库或者说线程管理机制。
而在 JDK 1.2 及以后,JVM 选择了更加稳定且方便使用的操作系统原生的内核级线程,通过系统调用,将线程的调度交给了操作系统内核。而对于不同的操作系统来说,它们本身的设计思路基本上是完全不一样的,因此它们各自对于线程的设计也存在种种差异,所以 JVM 中明确声明了:虚拟机中的线程状态,不反应任何操作系统中的线程状态。
也就是说,在 JDK 1.2 及之后的版本中,Java 的线程很大程度上依赖于操作系统采用什么样的线程模型,这点在不同的平台上没有办法达成一致,JVM 规范中也并未限定 Java 线程需要使用哪种线程模型来实现,可能是一对一,也可能是多对多或多对一。
总结来说,现今 Java 中线程的本质,其实就是操作系统中的线程,其线程库和线程模型很大程度上依赖于操作系统(宿主系统)的具体实现,比如在 Windows 中 Java 就是基于 Wind32 线程库来管理线程,且 Windows 采用的是一对一的线程模型。
Java线程调度
线程调度是指系统为线程分配处理使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
- 协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。 优点:实现简单,切换操作对线程自己是可知的,所以一般没有什么线程同步问题。 缺点:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
- 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
优点:可以主动让出执行时间(例如Java的
Thread::yield()
方法),并且线程的执行时间是系统可控的,也不会有一个线程导致整个系统阻塞的问题。 缺点:无法主动获取执行时间。
Java使用的就是抢占式线程调度,虽然这种方式的线程调度是系统自己的完成的,但是我们可以给操作系统一些建议,就是通过设置线程优先级来实现。Java语言一共设置了10个级别的线程优先级。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
不过由于各个系统的提供的优先级数量不一致,所以导致Java提供的10个级别的线程优先级并不见得能与各系统的优先级都一一对应。
Java 线程状态转换
Java语言定义了6种线程状态,在任意一个时间点钟,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间切换。
- 新建(New):创建后尚未启动的线程处于这种状态。
- 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处理此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显示唤醒。
以下方法会让线程陷入无限期等待状态:
1、没有设置Timeout参数的
Object::wait()
方法; 2、没有设置Timeout参数的Thread::join()
方法; 3、LockSupport::park()
方法。 - 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。
以下方法会让线程进入限期等待状态:
1、
Thread::sleep()
方法; 2、设置了Timeout参数的Object::wait()
方法; 3、设置了Timeout参数的Thread::join()
方法; 4、LockSupport::parkNanos()
方法; 5、LockSupport::parkUntil()
方法; - 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间 ,或者唤醒动作发生。在程序进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):已终止线程的线程状态,线程已经结束执行。
Thread.sleep
如果执行了 Thread.sleep, 底层的执行流程:
- JVM 调用底层 OS 的线程 API
- 因为 JVM 采用关于内核线性一对一的线程模型, JVM 会要求操作系统在执行的时间内将线程的使用权归还给 CPU
- 一旦休眠时间到期, OS 调度器将会通过中断来唤醒线程, 并为线性分配 CPU 时间片以恢复该线程的执行
这里的关键点是, JVM 层面的这个线程在休眠期间是完全无法被复用的。
但是一个 JVM 内部能够创建的线程数量是有限的的,创建过多则会引起 OOM。
- java.lang.OutOfMemoryError : unable to create new native Thread
JVM 中的每个线程都会带来昂贵的内存开销,它会附带一个线程栈。
太多的 JVM 线程将产生开销,因为上下文切换非常昂贵,而且它们共享有限的硬件资源。
How to Thread.sleep without blocking on the JVM | by Daniel Sebban | Medium
1.3.5 - CH05-Synchronized
应用实践
- 一把锁同时只能被一个线程获取,没有获得锁的线程只能等待。
- 每个对象实例都有自己的锁(this),该锁不同实例之间互不影响。
- synchronized 修饰的方法,物理方法成功还是抛出异常,都会释放锁。
对象锁
包含实例方法锁(this)和同步代码块锁(自定义)。
代码块形式:手动设置锁定对象,也可以是 this,也可以是自定义的(对象实例)锁。
syhchronized(this)
synchronized(object)
,比如new Object()
作为一个实例锁。
方法锁形式:修饰实例的方法,锁对象是 this。
class Example { public synchronized void show() { System.out.println("example..."); } }
类锁
指 synchronized 修饰静态的方法或指定锁对象为 Class 对象。
静态方法:
class Example { public synchronized static void show() { System.out.println("example..."); } }
Class 对象:
class Example { public void show() { synchronized(Example.class) { System.out.println("example..."); } } }
原理分析
加锁-解锁
创建如下代码:
public class SynchronizedDemo2 {
Object object = new Object();
public void method1() {
synchronized (object) {
}
}
}
使用 javac 命令编译生成 class 文件:
javac SynchronizedDemo2.java
使用 javap 命令反编译查看 class 文件的信息:
javap -verbose SynchronizedDemo2.class
得到如下信息:
mointorenter 和 moniterexit 指令,会在程序执行时,使其锁计数器加一或减一。每个对象在同一时间只有一个 mointor(锁) 与其相关联,而一个 mointor 在同一时间只能被一个线程获得,一个对象在尝试获得与该对象关联的 monitor 锁的所有权时,monitorenter 指令会发生如下三种情况之一:
- mointor 计数器为 0,意味着目前尚未被某个线程获得,该线程会立即获得锁并将计数器加一,一旦执行加一,别的线程要想再获取就需要等待。
- 如果该线程已经拿到了该 mointor 锁的所有权,又重入了这把锁,锁计数器会继续累加一,值变为 2,随着重入次数的增加,计数值会一直累加。
- 如果该 monitor 锁已经被其他线程获得,当前线程等待锁被释放。
monitorexit 指令将释放对应 monitor 锁的所有权,释放过程很简单,即将 monitor 的计数器减一,如果结果不为 0,则表示当前是重入获得的锁,当前线程还继续持有该锁的所有权,如果计数器为 0,则表示当前线程不再拥有该 monitor 的所有权,即释放了锁。
下图描绘了真个过程:
上图可以看出,任意线程对 Object 的访问,首先要获得 Object 的监视锁,如果获取失败,该线程就会进入同步状态,线程状态变为 Blocked,当 Object 的监视器占有者释放后,在同步队列中的线程就有就会获取到该监视器。
可冲入:加锁次数计数器
在同一个线程中,线程不需要再次获取通一把锁。synchronized 先天具有重入性。每个对象拥有一个计数器,当线程获取对象 monitor 锁后,计数器就会加一,释放锁后就会减一。
可见性保证:内存模型与 happens-before
synchronized 的 happens-before 规则,即监视器锁规则:(一个线程)对同一个监视器解锁,happens-before 于(另一个线程)对该监视器加锁。
public class MonitorDemo {
private int a = 0;
public synchronized void writer() { // 1
a++; // 2
} // 3
public synchronized void reader() { // 4
int i = a; // 5
} // 6
}
图中每个箭头的两个节点之间都是 happens-before 关系。黑色箭头由程序顺序规则推导得出,红色为监视器锁规则推导而出:线程 A 释放锁先于线程 B 获得锁。蓝色则是通过程序顺序规则和监视器锁规则推测出来的 happens-before 关系,通过传递性规则进一步推导出 happens-before 规则。
根据 happens-before 的定义:如果 A 先于 B,则 A 的执行顺序先于 B,并且 A 的执行结果对 B 可见。
线程 A 先对共享变量 +1,由 2 先于 5 得知线程 A 的执行结果对 B 可见,即 B 读取到 a 的值为 1。
JVM 锁优化
JVM 在执行 monitorenter 和 monitorexit 这些指令时,依赖于底层操作系统的 Mutex Lock(互斥锁),但是由于 Mutex Lock 需要挂起当前线程,并从用户态切换到内核态来执行,这种切换的代价昂贵。然而在大部分的实际情况中,同步方法是运行在单线程环境(无锁竞争环境),如果每次都调用 Mutex Lock 则会严重影响性能。
JDK 1.6 引入了大量优化来提升性能:
- 锁粗化:减少不必要的紧连在一起的加锁、解锁操作,将多个连续的小锁扩展为一个更大的锁。
- 锁消除:通过运行时 JIT 编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本地 Stack 上进行空间对象的分配(通知还可以减少 Heap 上垃圾收集的开销)。
- 轻量级锁:实现的原理是基于这样的假设,即在真是情况下程序中的大部分同步代码一般都属于无锁竞争状态(单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层次的重量级互斥锁,取而代之的是在 monitorenter 和 monitorexit 之间依靠一条 CAS 原子指令就可以完成加锁解锁操作。但存在锁竞争时,执行 CAS 指令失败的线程将再去调用操作系统互斥锁进入阻塞状态,当锁被释放时再被唤醒。
- 偏向锁:为了在无锁竞争的情况下,避免在加锁过程中执行不必要的 CAS 原子指令,因为 CAS 指令虽然轻于OS 互斥锁,但还是存在(相对)可观的本地延迟。
- 适应性自旋:当线程在获取轻量级锁的过程中,如果 CAS 执行失败,在进入与 monitor 相关联的 OS 互斥锁之前,首先进入忙等待(自旋-Spinning),然后再次尝试 CAS,当尝试一定次数知乎仍然失败,再去调用与该 mointor 相关的 OS 互斥锁,进入阻塞状态。
锁的类型
Java 1.6 中 synchronized 同步锁,共有 4 种状态:无锁、偏向锁、轻量锁、重量锁。
会随着竞争状况逐渐升级。锁可以升级但不能降级,目的是为了提高获取锁和释放锁的效率。
自旋锁、自适应自旋
自旋锁
在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时HotSpot团队注意到在很多情况下,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得。在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。
自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin
来更改。
可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。
自适应自旋
在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100次循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。
锁消除
锁消除是指虚拟机即时编译器在运行过冲中,对一些在代码上要求同步、但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM 会判断在一段程序中的同步数据明显不会逃逸出去从而被其他线程访问到,那 JVM 就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。
当然在实际开发中,我们很清楚的知道那些地方是线程独有的,不需要加同步锁,但是在 Java API 中有很多方法都是加了同步的,那么此时 JVM 会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。比如如下操作:在操作 String 类型数据时,由于 String 是一个不可变类,对字符串的连接操作总是通过生成的新的 String 对象。因此 Javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前会使用 StringBuffer 对象的连续 append() 操作,在 JDK 1.5 及以后的版本中,会转化为 StringBuidler 对象的连续 append() 操作。
锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁。
大部分上述情况是正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
轻量锁
在 JDK 1.6 之后引入的轻量级锁,需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块并不会有竞争出现时提供的一种优化。它可以减少重量级锁对线程的阻塞带来地线程开销。从而提高并发性能。
如果要理解轻量级锁,那么必须先要了解 HotSpot 虚拟机中对象头地内存布局。在对象头中(Object Header
)存在两部分。第一部分用于存储对象自身的运行时数据,HashCode
、GC Age
、锁标记位
、是否为偏向锁
等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word
,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针(Klass Point
),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。
如上图所示,如果当前对象没有被锁定,那么锁标志位为 01 状态,JVM 在指向当前线程时,首先会在当前线程帧栈中创建锁记录 Lock Record 的空间,用于存储锁对象目前的 Mark Word 的拷贝。
然后,虚拟机使用 CAS 操作将标记字段 Mark Word 拷贝到锁记录中,并将 Mark Word 更新为指向 Lock Record 的指针。如果更新成功了,那么这个线程就有了使用该对象的锁,并且对象 Mark Word 的所标志位更新为(Mark Word 中最后为 2 bit) 00,即表示该对象处于轻量级锁定状态,如图:
如果更新操作失败,JVM 会检查当前 Mark Word 中是否存在指向当前线程帧栈的指针,如果有,则表示锁已经被获取,可以直接使用。如果没有,则说明该锁已经被其他线程抢占,如果有两条以上的线程同时经常一个锁,那么轻量级锁就不再有效,直接升级为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为 10,Mark Word 中存储的是指向重量级锁的指针。
轻量级锁解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换会对象头中,如果成功,则表示没有发生竞争,如果失败,则表示当前锁存在竞争关系。锁就会升级为重量级锁。
两个线程同时抢占锁,导致锁升级的流程如下:
偏向锁
在大多数实际环境中,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复加锁解锁的过程中,其中并没有对锁的竞争,这样一来,多次加锁解锁带来了不必要的性能开销。
为了解决这一问题,HotSpot 的作者在 Java SE 1.6 中对 Synchronized 进行了优化,引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和帧栈中的锁记录里存储偏向锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁。只需要简单的测试一下对象头的 Mark Word 中是否保存了指向当前线程的偏向锁。如果成功,表示线程已经获得了锁。
偏向锁使用了一种等待竞争出现时才会释放锁的机制。当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(即当前线程没有正在执行的字节码)。
它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果活着,JVM 会遍历帧栈中的锁记录,帧栈中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。
锁对比
锁 | 优点 | 缺点 | 场景 |
---|---|---|---|
偏向锁 | 加锁解锁不需要 CAS,没有额外性能开销 | 如果线程间存在竞争,撤销锁会带来额外开销 | 仅一个线程访问同步块 |
轻量锁 | 竞争的线程不会阻塞,提供响应速度 | 如果线程始终得到到锁,自旋会消耗性能 | 同步块执行速度非常快 |
重量锁 | 线程竞争不适用自旋,不会消耗 CPU | 线程阻塞、响应慢、频繁加解锁开销大 | 追求吞吐量,同步块执行速度慢 |
Synchronized 与 Lock
Synchronized 的缺陷
- 效率低:锁的释放情况少,只有代码指向完或抛出异常时才会解锁;试图获取锁时不能设置超时,不能中断正在使用锁的线程,而 Lock 可以中断或设置超时。
- 不灵活:加锁和解锁的时机单一,每个锁仅有一个单一的条件(对象实例),Lock 更加灵活。
- 无法感知是否获得锁:Lock 可以显式获取状态,然后基于状态执行判断。
相比 Lock
Lock 的方法:
- lock:加锁
- unlock:解锁
- tryLock:尝试加锁,返回布尔值
- tryLock(long,TimeUnit):尝试加锁,设定超时
多线程竞争锁时,其余未获得锁的线程只能不停的尝试加锁,而不能中断,高并发情况下会导致性能下降。
ReentrantLock 的 lockInterruptibly() 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。
注意事项
Synchronized 由 JVM 实现,无需显式控制加解锁逻辑。
- 锁对象不能为空,因为锁的信息都保存在对象头里
- 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
- 避免死锁
- 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 JUC 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,避免手动操作引起错误
- synchronized 是公平锁吗?
- 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待。
- 但这种抢占的方式可以预防饥饿。
1.3.6 - CH06-Volatile
基本作用
防止重排序
比如一个对象构造过程的场景,实例化一个对象可以分为 3 个步骤:
- 分配内存空间
- 初始化对象
- 将内存空间的地址赋值给对应的引用
但是由于操作系统可以“对指令进行重排序”,所以上面的过程可能会被转换为:
- 分配内存空间
- 将内存空间的地址赋值给对应的引用
- 初始化对象
这样一来,多线程环境下可能将一个尚未初始化的对象引用暴露到外部,从而导致非预期的行为。
因此为了防止该过程的重排序,我们可以将变量设置为 volatile 类型的变量。
实现可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile 能有效的解决这个问题。
保证原子性:单次读/写
基于 volatile 保证单次的读/写操作具有原子性的理解,你将能够理解如下两个问题:
i++ 为什么不能保证原子性
对 volatile 变量的单次读/写操作可以保证原子性的,如 long 和 double 类型变量,但是并不能保证 i++ 这种操作的原子性,因为本质上 i++ 是读、写两次操作,包括三步骤:
- 读取 i 的值。
- 对 i 加 1。
- 将 i 的值写回内存。 volatile 是无法保证这三个操作是具有原子性的,我们可以通过 AtomicInteger 或者 Synchronized 来保证 +1 操作的原子性。 注:上面几段代码中多处执行了 Thread.sleep() 方法,目的是为了增加并发问题的产生几率,无其他作用。
共享的 long 和 double 变量的为什么要用 volatile?
因为 long 和 double 两种数据类型的操作可分为高 32 位和低 32 位两部分,因此普通的 long 或 double 类型读/写可能不是原子的。因此,鼓励大家将共享的 long 和 double 变量设置为 volatile 类型,这样能保证任何情况下对 long 和 double 的单次读/写操作都具有原子性。
目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把 long 和 double 变量专门声明为 volatile 多数情况下也是不会错的。
实现原理
实现可见性
volatile 变量的内存可见性是基于内存屏障(Memory Barrier)实现。
- 内存屏障,又称内存栅栏,是一个 CPU 指令。
- 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序。
- 插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令执行重排序。
比如代码:
public class Test {
private volatile int a;
public void update() {
a = 1;
}
public static void main(String[] args) {
Test test = new Test();
test.update();
}
}
通过 hsdis 和 jitwatch 工具可以得到编译后的汇编代码:
......
0x0000000002951563: and $0xffffffffffffff87,%rdi
0x0000000002951567: je 0x00000000029515f8
0x000000000295156d: test $0x7,%rdi
0x0000000002951574: jne 0x00000000029515bd
0x0000000002951576: test $0x300,%rdi
0x000000000295157d: jne 0x000000000295159c
0x000000000295157f: and $0x37f,%rax
0x0000000002951586: mov %rax,%rdi
0x0000000002951589: or %r15,%rdi
0x000000000295158c: lock cmpxchg %rdi,(%rdx) //在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令
0x0000000002951591: jne 0x0000000002951a15
0x0000000002951597: jmpq 0x00000000029515f8
0x000000000295159c: mov 0x8(%rdx),%edi
0x000000000295159f: shl $0x3,%rdi
0x00000000029515a3: mov 0xa8(%rdi),%rdi
0x00000000029515aa: or %r15,%rdi
......
lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
lock 指令
在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。
缓存一致性
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。 LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。
缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
实现有序性
volatile 与 happens-before 的关系
happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,先于任意后续对这个 volatile 域的读。
//假设线程A执行writer方法,线程B执行reader方法
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 1 线程A修改共享变量
flag = true; // 2 线程A写volatile变量
}
public void reader() {
if (flag) { // 3 线程B读同一个volatile变量
int i = a; // 4 线程B读共享变量
……
}
}
}
根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。
- 根据程序次序规则:1 先于 2,且 3 先于 4。
- 根据 volatile 规则:2 先于 3。
- 根据 happens-before 传递性:1 先于 4。
因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。
volatile 禁止重排序
为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。
Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM 会针对编译器制定 volatile 重排序规则表。
上图中 NO 表示禁止重排序。
为了实现 volatile 内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的重排序。
对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,为此,JVM 采取了保守策略:
- 在每个 volatile 写操作前插入 StoreStore 屏障。
- 在每个 volatile 写操作后插入 StoreLoad 屏障。
- 在每个 volatile 读操作后插入 LoadLoad 屏障。
- 在每个 volatile 读操作后插入 LoadStore 屏障。
volatile 写是在前后分别插入屏障,而读是在后面插入两个内存屏障。
- StoreStore:禁止上面的普通写和下面的 volatile 写重排序。
- StoreLoad:防止上面的 volatile 写与下面可能出现的 volatile 读/写重排序。
- LoadLoad:禁止下面所有的普通读操作和上面的 volatile 读重排序。
- LoadStore:禁止下面所有的普通下和上面的 volatile 读重排序。
应用场景
使用 volatile 时必须具备的条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在其他变量的不变式中。
- 只有在状态真正独立于成语其他内容时才能使用 volatile。
模式-1:状态标志
或许实现 volatile 变量的规范应用仅仅是通过一个布尔状态标志,用于指示发生了一个重要的一次性事件,比如初始化完成或已经停机,即对变量的简单读写:
volatile boolean shutdownRequested;
...
public void shutdown(){}
public void execute() {
while(!shutdownRequested){
// execute something
}
}
模式-2:一次性安全发布
缺乏同步会导致无法实现可见性,这会使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。
这就是著名的双检锁问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是可能看到一个更新的引用,但是也可能看到尚未构造完成的对象。
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;
public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}
public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}
模式-3:独立观察
安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
public volatile String lastUser;
public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}
模式-4:volatile bean
在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
@ThreadSafe
public class Person {
private volatile String firstName;
private volatile String lastName;
private volatile int age;
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public void setAge(int age) {
this.age = age;
}
}
模式-5:开销较低的读写锁策略
volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
模式-6:双重检查
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
syschronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
1.3.7 - CH07-Final
基本用法
修饰类
当某个类的整体定义为 final 时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的。
final 类中的所有方法都隐式为 final,因为无法覆写他们,所以在 final 类中给任何方法添加 final 关键字是没有任何意义的。
修饰方法
- private 方法是隐式的 final,即不能被子类重写
- final 方法是可以被重载的
private final
类中所有 private 方法都隐式地指定为 final 的,由于无法取用 private 方法,所以也就不能覆盖它。可以对 private 方法增添 final 关键字,但这样做并没有什么好处。
final 方法可以被重载
修饰参数
Java 允许在参数列表中以声明的方式将参数指明为 final,这意味这你无法在方法中更改参数引用所指向的对象。这个特性主要用来向匿名内部类传递数据。
修饰字段
并非所有的 fianl 字段都是编译期常量
比如:
class Example {
Random random = new Random();
final int value = random.nextInt();
}
这里的字段 value 并不能在编译期推导出实际的值,而是在运行时由 random 决定。
static final
static final 字段只是占用一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译期无法同步。
blank final
Java 允许生成空白 final,也就是说被声明为 final 但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:
- 在定义处进行赋值(这不是空白 final)
- 在构造器中进行赋值,保证了该值在被使用之前赋值。
重排序规则
final 域为基本类型
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo; //静态域
public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
假设线程 A 执行 writer 方法,线程 B 执行 reader 方法。
写操作
写 final 域的重排序规则禁止对 final 域的写操作重排序到构造函数之外,该规则的实现主要包含两个方面:
- JMM 禁止编译器把 final 域的写重排序到构造函数之外。
- 编译器会在 final 域写之后,构造函数 return 之前,插入一个 storestore 屏障。
- 该屏障可以禁止处理器将 final 域的写重排序到构造函数之外。
writer 方法分析:
- 构造了一个 FinalDemo 对象。
- 把这个对象复制给成员变量 finalDemo。
由于 a,b 之间没有依赖,普通域 a 可能会被重排序到构造函数之外,线程 B 肯呢个读到普通变量 a 初始化之前的值(零值),即引起错误。
而 final 域变量 b,根据重排序规则,会禁止 final 修饰的变量 b 被重排序到构造函数之外,因此 b 会在构造函数内完成赋值,线程 B 可以读到正确赋值后的 b 变量。
因此,写 final 域的重排序规则可以确保:在对象引用被任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域就不具有这个保障。
读操作
读 final 域的重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的 final 域,JMM 会禁止这两个操作的重排序。(仅针对处理器),处理器会在读 final 域操作之前插入一个 LoadLoad 屏障。
实际上,度对象的引用和读对象的 final 域存在间接依赖性,一般处理器不会对这两个操作执行重排序。但是不能排除有些处理器会执行重排序,因此,该规则就是针对这些处理器设定的。
reader 方法分析:
- 初次读引用变量 finalDemo;
- 初次读引用变量 finalDemo 的普通域;
- 初次读引用变量 finalDemo 的 fianl 域 b;
假设线程A写过程没有重排序,那么线程A和线程B有一种的可能执行时序为下图:
读对象的普通域被排序到读对象引用之前,就会出现线程 B 还未多读到对象引用就在读取该对象的普票域变量,这显然是错误操作。
而 final 域的读操作就限定了在读 final 域变量前就已经读到了该对象的引用,从而避免这种错误。
读 final 域的重排序规则可以保证:在读取一个对象的 fianl 域之前,一定会先读取该 final 域所属的对象引用。
final 域为引用类型
对 final 修饰对象的成员域执行写操作
针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看。
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}
public void writerTwo() {
arrays[0] = 2; //4
}
public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}
针对上面的实例程序,线程线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。下图就以这种执行时序出现的一种情况来讨论(耐心看完才有收获)。
由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
对final 修饰的对象的成员域执行读操作
JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下 arrays[0] = 1
,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。
final 重排序总结
- 基本数据类型
- 禁止 final 域写与构造函数重排序,即禁止 final 域重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的 final 域全部已经初始化过。
- 禁止初次读取该对象的引用与读取该对象 fianl 域的重排序。
- 引用数据类型
- 相比基本数据类型增加额外规则
- 禁止在构造函数对一个 final 修饰的对象的成员域的写入与随后将这个被构造的对象的引用复制给引用变量重排序。
- 即:现在构造函数中完成对 final 修饰的引用类型的字段赋值,再将该引用对象整体复制给 final 修饰的变量。
深入理解
实现原理
- 写 final 域会要求编译器在 final 域写之后,构造函数返回前插入一个 StoreStore 屏障。
- 读 final 域的重排序规则会要求编译器在读 final 域的操作前插入一个 LoadLoad 屏障。
为什么 final 引用不能从构造函数中逸出
上面对final域写重排序规则可以确保我们在使用一个对象引用的时候该对象的final域已经在构造函数被初始化过了。
但是这里其实是有一个前提条件的,也就是:在构造函数,不能让这个被构造的对象被其他线程可见,也就是说该对象引用不能在构造函数中“逸出”。
public class FinalReferenceEscapeDemo {
private final int a;
private FinalReferenceEscapeDemo referenceDemo;
public FinalReferenceEscapeDemo() {
a = 1; //1
referenceDemo = this; //2
}
public void writer() {
new FinalReferenceEscapeDemo();
}
public void reader() {
if (referenceDemo != null) { //3
int temp = referenceDemo.a; //4
}
}
}
假设一个线程A执行writer方法另一个线程执行reader方法。因为构造函数中操作1和2之间没有数据依赖性,1和2可以重排序,先执行了2,这个时候引用对象referenceDemo是个没有完全初始化的对象,而当线程B去读取该对象时就会出错。尽管依然满足了final域写重排序规则:在引用对象对所有线程可见时,其final域已经完全初始化成功。但是,引用对象“this”逸出,该代码依然存在线程安全的问题。
使用 final 的限制条件和局限性
- 当声明一个 final 成员时,必须在构造函数退出前设置它的值。
- 或者,将指向对象的成员声明为 final 只能将该引用设为不可变的,而非所指的对象。
- 如果一个对象将会在多个线程中访问并且你并没有将其成员声明为 final,则必须提供其他方式保证线程安全。
- 比如声明成员为 volatile,使用 synchronized 或者显式 Lock 控制所有该成员的访问。
1.3.8 - CH08-并发概览
Locks & Tools
层级结构
接口:Condition
Condition 为接口类型,它将 Object 监视器方法(wait、notify 和 notifyAll)分解成截然不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待集 (wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。可以通过 await(),signal() 来休眠/唤醒线程。
接口:Lock
Lock 为接口类型,Lock 提供了比使用 synchronized 方法和语句可获得的更广泛的锁定操作。允许更灵活的结构,可以具有差别很大的属性,可以支持多个相关的 Condition 对象。
接口:ReadWriteLock
维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
抽象类:AbstractOwnableSynchonizer
可以由线程以独占方式拥有的同步器。此类为创建锁和相关同步器(伴随着所有权的概念)提供了基础。AbstractOwnableSynchronizer 类本身不管理或使用此信息。但是,子类和工具可以使用适当维护的值帮助控制和监视访问以及提供诊断。
抽象类(long):AbstractQueuedLongSynchronizer
以 long 形式维护同步状态的一个 AbstractQueuedSynchronizer 版本。此类具有的结构、属性和方法与 AbstractQueuedSynchronizer 完全相同,但所有与状态相关的参数和结果都定义为 long 而不是 int。当创建需要 64 位状态的多级别锁和屏障等同步器时,此类很有用。
抽象类(int):AbstractQueuedSynchonizer
其为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
锁工具类:LockSupport
LockSupport为常用类,用来创建锁和其他同步类的基本线程阻塞原语。LockSupport的功能和"Thread中的 Thread.suspend()和Thread.resume()有点类似",LockSupport中的park() 和 unpark() 的作用分别是阻塞线程和解除阻塞线程。但是park()和unpark()不会遇到“Thread.suspend 和 Thread.resume所可能引发的死锁”问题。
锁常用类:ReentrantLock
它是一个可重入的互斥锁 Lock,它具有与使用 synchronized 方法和语句所访问的隐式监视器锁相同的一些基本行为和语义,但功能更强大。
锁常用类: ReentrantReadWriteLock
ReentrantReadWriteLock是读写锁接口ReadWriteLock的实现类,它包括Lock子类ReadLock和WriteLock。ReadLock是共享锁,WriteLock是独占锁。
锁常用类: StampedLock
它是 java8 在 java.util.concurrent.locks 新增的一个 API。StampedLock 控制锁有三种模式(写,读,乐观读),一个 StampedLock 状态是由版本和模式两个部分组成,锁获取方法返回一个数字作为票据 stamp,它用相应的锁状态表示并控制访问,数字 0 表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。
工具常用类: CountDownLatch
它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待。
工具常用类: CyclicBarrier
CyclicBarrier 为常用类,其是一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环的 barrier。
工具常用类: Phaser
Phaser 是 JDK 7 新增的一个同步辅助类,它可以实现 CyclicBarrier 和 CountDownLatch 类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
工具常用类: Semaphore
Semaphore 为常用类,其是一个计数信号量,从概念上讲,信号量维护了一个许可集。如有必要,在许可可用前会阻塞每一个 acquire(),然后再获取该许可。每个 release() 添加一个许可,从而可能释放一个正在阻塞的获取者。但是,不使用实际的许可对象,Semaphore 只对可用许可的号码进行计数,并采取相应的行动。通常用于限制可以访问某些资源(物理或逻辑的)的线程数目。
工具常用类: Exchanger
Exchanger 是用于线程协作的工具类, 主要用于两个线程之间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过 exchange() 方法交换数据,当一个线程先执行 exchange() 方法后,它会一直等待第二个线程也执行 exchange() 方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
Collections: 并发集合
层级结构
Queue: ArrayBlockingQueue
一个由数组支持的有界阻塞队列。此队列按 FIFO(先进先出)原则对元素进行排序。队列的头部是在队列中存在时间最长的元素。队列的尾部是在队列中存在时间最短的元素。新元素插入到队列的尾部,队列获取操作则是从队列头部开始获得元素。
Queue: LinkedBlockingQueue
一个基于已链接节点的、范围任意的 blocking queue。此队列按 FIFO(先进先出)排序元素。队列的头部 是在队列中时间最长的元素。队列的尾部 是在队列中时间最短的元素。新元素插入到队列的尾部,并且队列获取操作会获得位于队列头部的元素。链接队列的吞吐量通常要高于基于数组的队列,但是在大多数并发应用程序中,其可预知的性能要低。
Queue: LinkedBlockingDeque
一个基于已链接节点的、任选范围的阻塞双端队列。
Queue: ConcurrentLinkedQueue
一个基于链接节点的无界线程安全队列。此队列按照 FIFO(先进先出)原则对元素进行排序。队列的头部 是队列中时间最长的元素。队列的尾部 是队列中时间最短的元素。新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。此队列不允许使用 null 元素。
Queue: ConcurrentLinkedDeque
是双向链表实现的无界队列,该队列同时支持FIFO和FILO两种操作方式。
Queue: DelayQueue
延时无界阻塞队列,使用 Lock 机制实现并发访问。队列里只允许放可以“延期”的元素,队列中的 head 是最先“到期”的元素。如果队里中没有元素到“到期”,那么就算队列中有元素也不能获取到。
Queue: PriorityBlockingQueue
无界优先级阻塞队列,使用 Lock 机制实现并发访问。priorityQueue 的线程安全版,不允许存放 null 值,依赖于 comparable 的排序,不允许存放不可比较的对象类型。
Queue: SynchronousQueue
没有容量的同步队列,通过CAS实现并发访问,支持FIFO和FILO
Queue: LinkedTransferQueue
JDK 7新增,单向链表实现的无界阻塞队列,通过CAS实现并发访问,队列元素使用 FIFO(先进先出)方式。LinkedTransferQueue可以说是ConcurrentLinkedQueue、SynchronousQueue(公平模式)和LinkedBlockingQueue的超集, 它不仅仅综合了这几个类的功能,同时也提供了更高效的实现。
List: CopyOnWriteArrayList
ArrayList 的一个线程安全的变体,其中所有可变操作(add、set 等等)都是通过对底层数组进行一次新的复制来实现的。这一般需要很大的开销,但是当遍历操作的数量大大超过可变操作的数量时,这种方法可能比其他替代方法更 有效。在不能或不想进行同步遍历,但又需要从并发线程中排除冲突时,它也很有用。
Set: CopyOnWriteArraySet
对其所有操作使用内部CopyOnWriteArrayList的Set。即将所有操作转发至CopyOnWriteArayList来进行操作,能够保证线程安全。在add时,会调用addIfAbsent,由于每次add时都要进行数组遍历,因此性能会略低于CopyOnWriteArrayList。
Set: ConcurrentSkipListSet
一个基于ConcurrentSkipListMap 的可缩放并发 NavigableSet 实现。set 的元素可以根据它们的自然顺序进行排序,也可以根据创建 set 时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
Map: ConcurrentHashMap
是线程安全HashMap的。ConcurrentHashMap在JDK 7之前是通过Lock和segment(分段锁)实现,JDK 8 之后改为CAS+synchronized来保证并发安全。
Map: ConcurrentSkipListMap
线程安全的有序的哈希表(相当于线程安全的TreeMap);映射可以根据键的自然顺序进行排序,也可以根据创建映射时所提供的 Comparator 进行排序,具体取决于使用的构造方法。
Atomic: 原子类
其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。实际上是借助硬件的相关指令来实现的,不会阻塞线程(或者说只是在硬件级别上阻塞了)。
基础类型:AtomicBoolean、AtomicInteger、AtomicLong
数组:AtomicIntegerArray,AtomicLongArray,BooleanArray
引用:AtomicReference,AtomicMarkedReference,AtomicStampedReference
FieldUpdater:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
Executors:线程池
层级结构
接口:Executor
Executor 接口提供一种将任务提交与每个任务将如何运行的机制(包括线程使用的细节、调度等)分离开来的方法。通常使用 Executor 而不是显式地创建线程。
ExecutorService
ExecutorService 继承自 Executor 接口,ExecutorService 提供了管理终止的方法,以及可为跟踪一个或多个异步任务执行状况而生成 Future 的方法。 可以关闭 ExecutorService,这将导致其停止接受新任务。关闭后,执行程序将最后终止,这时没有任务在执行,也没有任务在等待执行,并且无法提交新任务。
ScheduledExecutorService
ScheduledExecutorService继承自ExecutorService接口,可安排在给定的延迟后运行或定期执行的命令。
AbstractExecutorService
AbstractExecutorService 继承自 ExecutorService 接口,其提供 ExecutorService 执行方法的默认实现。此类使用 newTaskFor 返回的 RunnableFuture 实现 submit、invokeAny 和 invokeAll 方法,默认情况下,RunnableFuture 是此包中提供的 FutureTask 类。
FutureTask
FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务(cancel)等。如果任务尚未完成,获取任务执行结果时将会阻塞。一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。FutureTask 的线程安全由CAS来保证。
核心: ThreadPoolExecutor
ThreadPoolExecutor 实现了 AbstractExecutorService 接口,也是一个 ExecutorService,它使用可能的几个池线程之一执行每个提交的任务,通常使用 Executors 工厂方法配置。 线程池可以解决两个不同问题: 由于减少了每个任务调用的开销,它们通常可以在执行大量异步任务时提供增强的性能,并且还可以提供绑定和管理资源(包括执行任务集时使用的线程)的方法。每个 ThreadPoolExecutor 还维护着一些基本的统计数据,如完成的任务数。
核心: ScheduledThreadExecutor
ScheduledThreadPoolExecutor 实现 ScheduledExecutorService 接口,可安排在给定的延迟后运行命令,或者定期执行命令。需要多个辅助线程时,或者要求 ThreadPoolExecutor 具有额外的灵活性或功能时,此类要优于 Timer。
核心: Fork/Join框架
ForkJoinPool 是JDK 7 加入的一个线程池类。Fork/Join 技术是分治算法(Divide-and-Conquer)的并行实现,它是一项可以获得良好的并行性能的简单且高效的设计技术。目的是为了帮助我们更好地利用多处理器带来的好处,使用所有可用的运算能力来提升应用的性能。
工具类: Executors
Executors 是一个工具类,用其可以创建 ExecutorService、ScheduledExecutorService、ThreadFactory、Callable 等对象。它的使用融入到了 ThreadPoolExecutor, ScheduledThreadExecutor 和 ForkJoinPool 中。
1.3.9 - CH09-底层支撑
CAS
现在安全的实现方法:
- 互斥同步:synchronized、ReentrantLock
- 非阻塞同步:CAS、Atomic-
- 无同步方案:栈封闭、TreadLocal、可重入代码
什么是 CAS
CAS 的全称为 Compare-And-Swap,直译就是对比交换。是一条 CPU 的原子指令,其作用是让 CPU 先进行比较两个值是否相等,然后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的,JVM 只是封装了汇编调用,那些 AtomicInteger 类便是使用了这些封装后的接口。
简单解释:CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下在旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。
CAS 操作是原子性的,所以多线程并发使用 CAS 更新数据时,可以不使用锁。JDK 中大量使用了 CAS 来更新数据而防止加锁(synchronized 重量级锁)来保持原子更新。
应用示例
如果不使用CAS,在高并发下,多线程同时修改一个变量的值我们需要synchronized加锁(可能有人说可以用Lock加锁,Lock底层的AQS也是基于CAS进行获取锁的)。
public class Test {
private int i=0;
public synchronized int add(){
return i++;
}
}
java中为我们提供了AtomicInteger 原子类(底层基于CAS进行更新数据的),不需要加锁就在多线程并发场景下实现数据的一致性。
public class Test {
private AtomicInteger i = new AtomicInteger(0);
public int add(){
return i.addAndGet(1);
}
}
CAS 问题
CAS 方式为乐观锁,synchronized 为悲观锁。因此使用 CAS 解决并发问题通常情况下性能更优。
但使用 CAS 方式也会有几个问题:
ABA 问题
因为 CAS 需要在操作值的时候,检查值有没有发生变化,比如没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时则会发现它的值没有发生变化,但是实际上却变化了。
ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么 A->B->A 就会变成 1A->2B->3A。
从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
循环时间长开销大
自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升。
pause 指令有两个作用:
- 第一,它可以延迟流水线执行命令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;
- 第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起 CPU 流水线被清空(CPU Pipeline Flush),从而提高 CPU 的执行效率。
仅作用于单个变量
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i = 2,j = a,合并一下 ij = 2a,然后用 CAS 来操作 ij。
从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。
UnSafe 类
Java 原子类是通过 UnSafe 类实现的。
Unsafe 是位于 sun.misc 包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升 Java 运行效率、增强 Java 语言底层资源操作能力方面起到了很大的作用。
但由于 Unsafe 类使 Java 语言拥有了类似 C 语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用 Unsafe 类会使得程序出错的概率变大,使得 Java 这种安全的语言变得不再“安全”,因此对 Unsafe 的使用一定要慎重。
这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。
功能概览:
UnSafe 与 CAS
内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)。
public final int getAndAddInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, i + paramInt));
return i;
}
public final long getAndAddLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, l + paramLong2));
return l;
}
public final int getAndSetInt(Object paramObject, long paramLong, int paramInt)
{
int i;
do
i = getIntVolatile(paramObject, paramLong);
while (!compareAndSwapInt(paramObject, paramLong, i, paramInt));
return i;
}
public final long getAndSetLong(Object paramObject, long paramLong1, long paramLong2)
{
long l;
do
l = getLongVolatile(paramObject, paramLong1);
while (!compareAndSwapLong(paramObject, paramLong1, l, paramLong2));
return l;
}
public final Object getAndSetObject(Object paramObject1, long paramLong, Object paramObject2)
{
Object localObject;
do
localObject = getObjectVolatile(paramObject1, paramLong);
while (!compareAndSwapObject(paramObject1, paramLong, localObject, paramObject2));
return localObject;
}
从 UnSafe 类中发现,原子操作仅提供了三个方法:
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);
public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);
UnSafe 底层
查看 Unsafe的compareAndSwap- 方法来实现 CAS 操作,它是一个本地方法,实现位于 unsafe.cpp 中。
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
可以看到它通过 Atomic::cmpxchg
来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。
如果是Linux的x86,Atomic::cmpxchg
方法的实现如下:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
而 windows 的 x86 的实现如下:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::isMP(); //判断是否是多处理器
_asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
如果是多处理器,为 cmpxchg 指令添加 lock 前缀。反之,就省略 lock 前缀(单处理器会不需要 lock 前缀提供的内存屏障效果)。这里的 lock 前缀就是使用了处理器的总线锁(最新的处理器都使用缓存锁代替总线锁来提高性能)。
cmpxchg(void* ptr, int old, int new)
,如果 ptr 和 old 的值一样,则把 new 写到 ptr 内存,否则返回 ptr 的值,整个操作是原子的。在 Intel 平台下,会用 lock cmpxchg 来实现,使用 lock 触发缓存锁,这样另一个线程想访问 ptr 的内存,就会被 block 住。
UnSafe 其他功能
Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。
举两个例子,比方说:
public native long staticFieldOffset(Field paramField);
这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。
public native int arrayBaseOffset(Class paramClass);
public native int arrayIndexScale(Class paramClass);
前一个方法是用来获取数组第一个元素的偏移地址,后一个方法是用来获取数组的转换因子即数组中元素的增量地址的。
public native long allocateMemory(long paramLong);
public native long reallocateMemory(long paramLong1, long paramLong2);
public native void freeMemory(long paramLong);
分别用来分配内存,扩充内存和释放内存的。
AtomicInteger
public final int get():获取当前的值
public final int getAndSet(int newValue):获取当前的值,并设置新的值
public final int getAndIncrement():获取当前的值,并自增
public final int getAndDecrement():获取当前的值,并自减
public final int getAndAdd(int delta):获取当前的值,并加上预期的值
void lazySet(int newValue): 最终会设置成newValue,使用lazySet设置值后,可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
源码解析
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
static {
try {
//用于获取value字段相对当前对象的“起始地址”的偏移量
valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
//返回当前值
public final int get() {
return value;
}
//递增加detla
public final int getAndAdd(int delta) {
//三个参数,1、当前的实例 2、value实例变量的偏移量 3、当前value要加上的数(value+delta)。
return unsafe.getAndAddInt(this, valueOffset, delta);
}
//递增加1
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
...
}
AtomicInteger 底层用的是volatile的变量和CAS来进行更改数据的:
- volatile 保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
- CAS 保证数据更新的原子性
所有原子类
原子基本类型
使用原子的方式更新基本类型,Atomic 包共有 3 个类:
- AtomicBoolean
- AtomicInteger
- AtomicLong
原子数组
通过原子的方式更新数组里的某个元素,Atomic 包提供了以下的 4 个类:
- AtomicIntegerArray
- AtomicLongArray
- AtomicReferenceArray
常用方法:
get(int index)
compareAndSet(int i, E expect, E update)
原子引用
- AtomicReference: 原子更新引用类型。
- AtomicStampedReference: 原子更新引用类型, 内部使用Pair来存储元素值及其版本号。
- AtomicMarkableReferce: 原子更新带有标记位的引用类型。
都是基于 UnSafe 实现,但 AtomicReferenceFieldUpdater 所更新的字段必须使用 volatile 修饰。
原子字段更新
AtomicIntegerFieldUpdater: 原子更新整型的字段的更新器。
AtomicLongFieldUpdater: 原子更新长整型字段的更新器。
AtomicStampedFieldUpdater: 原子更新带有版本号的引用类型。
AtomicReferenceFieldUpdater: 上面已经说过此处不在赘述。
以上均为基于反射的原子更新字段的值,要想原子地更新字段类需要两步:
- 第一步,因为原子更新字段类都是抽象类,每次使用的时候必须使用静态方法newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。
- 第二步,更新类的字段必须使用public volatile修饰。
public class TestAtomicIntegerFieldUpdater {
public static void main(String[] args){
TestAtomicIntegerFieldUpdater tIA = new TestAtomicIntegerFieldUpdater();
tIA.doIt();
}
public AtomicIntegerFieldUpdater<DataDemo> updater(String name){
return AtomicIntegerFieldUpdater.newUpdater(DataDemo.class,name);
}
public void doIt(){
DataDemo data = new DataDemo();
System.out.println("publicVar = "+updater("publicVar").getAndAdd(data, 2));
}
}
class DataDemo{
public volatile int publicVar=3;
protected volatile int protectedVar=4;
private volatile int privateVar=5;
public volatile static int staticVar = 10;
//public final int finalVar = 11;
public volatile Integer integerVar = 19;
public volatile Long longVar = 18L;
}
AtomicIntegerFieldUpdater 应用约束:
- 字段必须是 volatile 类型的,在线程之间共享变量时保证立即可见。
- 字段的描述类型(修饰符public/protected/default/private)是与调用者与操作对象字段的关系一致。
- 也就是说调用者能够直接操作对象字段,那么就可以反射进行原子操作。但是对于父类的字段,子类是不能直接操作的,尽管子类可以访问父类的字段。
- 只能是实例变量,不能是类变量,也就是说不能加 static 关键字。
- 只能是可修改变量,不能使 final 变量,因为 final 的语义就是不可修改。
- 实际上 final 的语义和 volatile 是有冲突的,这两个关键字不能同时存在。
- 对于 AtomicIntegerFieldUpdater 和 AtomicLongFieldUpdater 只能修改 int/long 类型的字段,不能修改其包装类型(Integer/Long)。
- 如果要修改包装类型就需要使用 AtomicReferenceFieldUpdater。
AtomicStampedReference 与 ABA
AtomicStampedReference 主要维护包含一个对象引用以及一个可以自动更新的整数 “stamp” 的 pair 对象来解决 ABA 问题。
public class AtomicStampedReference<V> {
private static class Pair<T> {
final T reference; //维护对象引用
final int stamp; //用于标志版本
private Pair(T reference, int stamp) {
this.reference = reference;
this.stamp = stamp;
}
static <T> Pair<T> of(T reference, int stamp) {
return new Pair<T>(reference, stamp);
}
}
private volatile Pair<V> pair;
....
/**
* expectedReference :更新之前的原始值
* newReference : 将要更新的新值
* expectedStamp : 期待更新的标志版本
* newStamp : 将要更新的标志版本
*/
public boolean compareAndSet(V expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
// 获取当前的(元素值,版本号)对
Pair<V> current = pair;
return
// 引用没变
expectedReference == current.reference &&
// 版本号没变
expectedStamp == current.stamp &&
// 新引用等于旧引用
((newReference == current.reference &&
// 新版本号等于旧版本号
newStamp == current.stamp) ||
// 构造新的Pair对象并CAS更新
casPair(current, Pair.of(newReference, newStamp)));
}
private boolean casPair(Pair<V> cmp, Pair<V> val) {
// 调用Unsafe的compareAndSwapObject()方法CAS更新pair的引用为新引用
return UNSAFE.compareAndSwapObject(this, pairOffset, cmp, val);
}
- 如果元素值和版本号都没有变化,并且和新的也相同,返回true;
- 如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。
可以看到,java中的实现跟我们上面讲的ABA的解决方法是一致的。
- 首先,使用版本号控制;
- 其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;
- 最后,外部传入元素值及版本号,而不是节点(Pair)的引用。
AtomicMarkableReference
AtomicMarkableReference,它不是维护一个版本号,而是维护一个boolean类型的标记,标记值有修改。
1.3.10 - CH10-LockSupport
功能介绍
LockSupport 是用来创建锁和其他同步类的基本线程阻塞原语。
- 当调用 LockSupport.park 时,当前线程会等待直至获取许可;
- 当调用 LockSupport.unpack 时,必须把扥带获取许可的线程作为参数传递,以使其恢复运行。
源码分析
基本属性
public class LockSupport {
// Hotspot implementation via intrinsics API
private static final sun.misc.Unsafe UNSAFE;
// 表示内存偏移地址
private static final long parkBlockerOffset;
// 表示内存偏移地址
private static final long SEED;
// 表示内存偏移地址
private static final long PROBE;
// 表示内存偏移地址
private static final long SECONDARY;
static {
try {
// 获取Unsafe实例
UNSAFE = sun.misc.Unsafe.getUnsafe();
// 线程类类型
Class<?> tk = Thread.class;
// 获取Thread的parkBlocker字段的内存偏移地址
parkBlockerOffset = UNSAFE.objectFieldOffset
(tk.getDeclaredField("parkBlocker"));
// 获取Thread的threadLocalRandomSeed字段的内存偏移地址
SEED = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSeed"));
// 获取Thread的threadLocalRandomProbe字段的内存偏移地址
PROBE = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomProbe"));
// 获取Thread的threadLocalRandomSecondarySeed字段的内存偏移地址
SECONDARY = UNSAFE.objectFieldOffset
(tk.getDeclaredField("threadLocalRandomSecondarySeed"));
} catch (Exception ex) { throw new Error(ex); }
}
}
构造函数
仅有一个私有构造函数,无法被实例化。
核心函数
LockSupport的核心函数都是基于Unsafe类中定义的park和unpark函数,下面给出两个函数的定义:
public native void park(boolean isAbsolute, long time);
public native void unpark(Thread thread);
- park 函数:阻塞线程,该线程在下列情况发生之前都会被阻塞:
- 调用 unpark 函数,释放该线程的许可。
- 该线程被中断。
- 设置的时间到期,如果 time 为 0 则表示无限等待。
- unpark 函数:释放线程的许可,使调用 park 的线程恢复执行。调用时要确保线性仍然活着。
park
public static void park();
public static void park(Object blocker);
// 第二个函数的实现
public static void park(Object blocker) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可
UNSAFE.park(false, 0L);
// 重新可运行后再此设置Blocker
setBlocker(t, null);
}
调用 park 函数时,首先获取当前线程,然后设置当前线程的 parkBlocker 字段,即调用 setBlocker 方法,之后调用 UnSafe.park,之后再调用 setBlocker 方法。
调用 park 函数式,当前线程首先设置好 parkBlocker 字段,然后调用 UnSafe.park,此后,当前线程就阻塞了,开始等待该线程的 unpark 函数被调用,所以后面的一个 setBlocker 函数无法执行;unpack 函数被调用后,该线程获得许可,就可以接着执行第二个 setBlocker,把该线程的 parkBlocker 设为 null,即完成了整个 park 函数的逻辑。
如果没有第二个 setBlocker,那么之后没有调用 park(blocker),而直接调用 getBlocker 函数时,会得到原来设置的 blocker,显然不符合逻辑。总之,必须要保证 park 执行完成之后,blocker 被设为 null。
说明: 调用了park函数后,会禁用当前线程,除非许可可用。在以下三种情况之一发生之前,当前线程都将处于休眠状态,即下列情况发生时,当前线程会获取许可,可以继续运行。
- 其他某个线程将当前线程作为目标调用 unpark。
- 其他某个线程中断当前线程。
- 该调用不合逻辑地(即毫无理由地)返回。
parkNanos
此函数表示在许可可用前禁用当前线程,并最多等待指定的等待时间。
public static void parkNanos(Object blocker, long nanos) {
if (nanos > 0) { // 时间大于0
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
// 获取许可,并设置了时间
UNSAFE.park(false, nanos);
// 设置许可
setBlocker(t, null);
}
}
parkUntil
此函数表示在指定的时限前禁用当前线程,除非许可可用, 具体函数如下:
public static void parkUntil(Object blocker, long deadline) {
// 获取当前线程
Thread t = Thread.currentThread();
// 设置Blocker
setBlocker(t, blocker);
UNSAFE.park(true, deadline);
// 设置Blocker为null
setBlocker(t, null);
}
unpark
此函数表示如果给定线程的许可尚不可用,则使其可用。如果线程在 park 上受阻塞,则它将解除其阻塞状态。否则,保证下一次调用 park 不会受阻塞。如果给定线程尚未启动,则无法保证此操作有任何效果。
public static void unpark(Thread thread) {
if (thread != null) // 线程为不空
UNSAFE.unpark(thread); // 释放该线程许可
}
应用示例
使用wait/notify实现线程同步
class MyThread extends Thread {
public void run() {
synchronized (this) {
System.out.println("before notify");
notify();
System.out.println("after notify");
}
}
}
public class WaitAndNotifyDemo {
public static void main(String[] args) throws InterruptedException {
MyThread myThread = new MyThread();
synchronized (myThread) {
try {
myThread.start();
// 主线程睡眠3s
Thread.sleep(3000);
System.out.println("before wait");
// 阻塞主线程
myThread.wait();
System.out.println("after wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
before wait
before notify
after notify
after wait
使用wait/notify实现同步时,必须先调用wait,后调用notify,如果先调用notify,再调用wait,将起不了作用。
使用park/unpark实现线程同步
import java.util.concurrent.locks.LockSupport;
class MyThread extends Thread {
private Object object;
public MyThread(Object object) {
this.object = object;
}
public void run() {
System.out.println("before unpark");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));
// 释放许可
LockSupport.unpark((Thread) object);
// 休眠500ms,保证先执行park中的setBlocker(t, null);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 再次获取blocker
System.out.println("Blocker info " + LockSupport.getBlocker((Thread) object));
System.out.println("after unpark");
}
}
public class test {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}
before park
before unpark
Blocker info ParkAndUnparkDemo
after park
Blocker info null
after unpark
本程序先执行park,然后在执行unpark,进行同步,并且在unpark的前后都调用了getBlocker,可以看到两次的结果不一样,并且第二次调用的结果为null,这是因为在调用unpark之后,执行了Lock.park(Object blocker)函数中的setBlocker(t, null)函数,所以第二次调用getBlocker时为null。
中断响应
import java.util.concurrent.locks.LockSupport;
class MyThread extends Thread {
private Object object;
public MyThread(Object object) {
this.object = object;
}
public void run() {
System.out.println("before interrupt");
try {
// 休眠3s
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread thread = (Thread) object;
// 中断线程
thread.interrupt();
System.out.println("after interrupt");
}
}
public class InterruptDemo {
public static void main(String[] args) {
MyThread myThread = new MyThread(Thread.currentThread());
myThread.start();
System.out.println("before park");
// 获取许可
LockSupport.park("ParkAndUnparkDemo");
System.out.println("after park");
}
}
before park
before interrupt
after interrupt
after park
可以看到,在主线程调用park阻塞后,在myThread线程中发出了中断信号,此时主线程会继续运行,也就是说明此时interrupt起到的作用与unpark一样。
深入理解
Thread.sleep() 和 Object.wait() 的区别
- Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
- Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
- Thread.sleep()到时间了会自动唤醒,然后继续执行;
- Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
- Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况:
- 一是立即获取到了锁,线程自然会继续执行;
- 二是没有立即获取锁,线程进入同步队列等待获取锁;
他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。
Object.wait() 和 Condition.await() 的区别
Object.wait()和Condition.await()的原理是基本一致的,不同的是Condition.await()底层是调用LockSupport.park()来实现阻塞当前线程的。
实际上,它在阻塞当前线程之前还干了两件事,一是把当前线程添加到条件队列中,二是“完全”释放锁,也就是让state状态变量变为0,然后才是调用LockSupport.park()阻塞当前线程。
Thread.sleep()和LockSupport.park()的区别
LockSupport.park()还有几个兄弟方法——parkNanos()、parkUtil()等,我们这里说的park()方法统称这一类方法。
- 从功能上来说,Thread.sleep()和LockSupport.park()方法类似,都是阻塞当前线程的执行,且都不会释放当前线程占有的锁资源;
- Thread.sleep()没法从外部唤醒,只能自己醒过来;
- LockSupport.park()方法可以被另一个线程调用LockSupport.unpark()方法唤醒;
- Thread.sleep()方法声明上抛出了InterruptedException中断异常,所以调用者需要捕获这个异常或者再抛出;
- LockSupport.park()方法不需要捕获中断异常;
- Thread.sleep()本身就是一个native方法;
- LockSupport.park()底层是调用的Unsafe的native方法;
Object.wait()和LockSupport.park()的区别
二者都会阻塞当前线程的运行:
- Object.wait()方法需要在synchronized块中执行;
- LockSupport.park()可以在任意地方执行;
- Object.wait()方法声明抛出了中断异常,调用者需要捕获或者再抛出;
- LockSupport.park()不需要捕获中断异常;
- Object.wait()不带超时的,需要另一个线程执行notify()来唤醒,但不一定继续执行后续内容;
- LockSupport.park()不带超时的,需要另一个线程执行unpark()来唤醒,一定会继续执行后续内容;
- 如果在wait()之前执行了notify()会怎样? 抛出IllegalMonitorStateException异常;
- 如果在park()之前执行了unpark()会怎样? 线程不会被阻塞,直接跳过park(),继续执行后续内容;
park()/unpark()底层的原理是“二元信号量”,你可以把它相像成只有一个许可证的Semaphore,只不过这个信号量在重复执行unpark()的时候也不会再增加许可证,最多只有一个许可证。
LockSupport.park()会释放锁资源吗?
不会,它只负责阻塞当前线程,释放锁资源实际上是在Condition的await()方法中实现的。
1.3.11 - CH11-AQS-1
AbstractQueuedSynchronizer
AQS 是一个用来构建锁和同步器的框架,使用 AQS 能够简单高效的构造出应用广泛的同步器,比如 ReentrantLock、Semaphore,其他诸如 ReentrantReadWriteLock、SynchronousQueue、FutureTask 等也是基于 AQS 实现的。我们自己也可以基于 AQS 构造满足自己需要的同步器。
核心思想
- 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。
- 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。
- AQS 使用 CLH 队列锁实现了该机制,将暂时获取不到锁的线程加入到队列中。
- AQS 使用一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。
- AQS 使用 CAS 对该同步状态执行原子操作以实现值的修改,并使用 volatile 保证该状态的可见性。
CLH(Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(即不存在队列实例、仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁的分配。
状态信息通过 protected 范围的方法执行操作:
private volatile int state;
//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS操作)将同步状态值设置为给定值update如果当前同步状态的值等于expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
资源共享方式
- 独占(Exclusive):只有一个线程能够执行,如 ReentrantLock。又可以分为公平锁和非公平锁:
- 公平锁:按照线程在队列中的排队顺序,先到者先获得锁。
- 非公平锁:当线程要获得锁时,无视队列顺序直接抢锁,谁抢到随获取。
- 共享(Share):多个线程可以同时执行,如 Semaphore、CountDownLatch。
而 ReentrantReadWriteLock 可以看做是对以上两种方式的组合,因为它允许多个线程同时对一个资源执行读,但仅能有一个线程执行写。
不同的自定义同步器争用共享资源的方式不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败后入队/唤醒出队等),AQS 已经在上层实现了。
AQS 底层使用的模板方法模式
同步器的设计基于模板方法模式,自定义同步器时继承 AQS 并重写指定的方法即可:
- isHeldExclusively:判断线程是否正在独占资源,只有用到 condition 才需要实现。
- tryAcquire(int):独占获取资源,成功失败返回 ture、false。
- tryRelease(int):独占释放资源,成功失败返回 true、false。
- tryAcquireShared(int):共享获取资源,失败为负,为 0 表示成功但没有剩余可用资源,为正表示成且有可用资源。
- tryReleaseShared(int):共享释放资源,成功失败返回 true、false。
相关细节:
- 默认情况下,每个方法都能抛出 UnsupportedException。
- 所有方法的实现必须是内部线程安全的,并且应该简短且不阻塞。
- 其他方法均为 final,所以仅有以上方法可以被其他类使用。
以 ReentrantLock 为例:
- 初始状态 state 为 0,表示未锁定状态。
- A 线程 lock 时,会调用 tryAcquire 独占锁定并将 state+1。
- 此后其他线程再 tryAcquire 时会失败,直到 A 线程 unlock 并将 state=0(释放锁)为止。
- 在 A 线程释放锁之前,A 线程可以重复获取此锁(state 累加),即可重入。
- 获取多次就要释放多次,直至 state 为 0 才表示释放锁。
数据结构
- AQS 底层使用 CLH,将每条请求共享资源的线程封装为 CLH 队列的一个节点。
- 其中同步队列 Sync Queue 为双向链表,包括 head 和 tail 节点,head 节点主要用作后续的调度。
- 其中 Condition Queue 不是必须,是一个单向链表,只有使用 Condition 时,才会使用该队列。
- 并且可能会有多个 Condition Queue。
源码分析
层级结构
AQS 继承抽象类 AbstractOwnableSynchronizer,实现了 Serializable 接口,支持序列化。
public abstract class AbstractOwnableSynchronizer implements java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 3737899427754241961L;
// 构造方法
protected AbstractOwnableSynchronizer() { }
// 独占模式下的线程
private transient Thread exclusiveOwnerThread;
// 设置独占线程
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
// 获取独占线程
protected final Thread getExclusiveOwnerThread() {
return exclusiveOwnerThread;
}
}
- 其中可以“设置独占资源线程”和“获取独占资源线程”,分别为 setExclusiveOwnerThread 与 getExclusiveOwnerThread 方法,这两个方法会被子类调用。
- 其中有两个内部类,Node、ConditionObject。
内部类:Node
static final class Node {
// 模式,分为共享与独占
// 共享模式
static final Node SHARED = new Node();
// 独占模式
static final Node EXCLUSIVE = null;
// 结点状态
// CANCELLED,值为1,表示当前的线程被取消
// SIGNAL,值为-1,表示当前节点的后继节点包含的线程需要运行,也就是unpark
// CONDITION,值为-2,表示当前节点在等待condition,也就是在condition队列中
// PROPAGATE,值为-3,表示当前场景下后续的acquireShared能够得以执行
// 值为0,表示当前节点在sync队列中,等待着获取锁
static final int CANCELLED = 1;
static final int SIGNAL = -1;
static final int CONDITION = -2;
static final int PROPAGATE = -3;
// 结点状态
volatile int waitStatus;
// 前驱结点
volatile Node prev;
// 后继结点
volatile Node next;
// 结点所对应的线程
volatile Thread thread;
// 下一个等待者
Node nextWaiter;
// 结点是否在共享模式下等待
final boolean isShared() {
return nextWaiter == SHARED;
}
// 获取前驱结点,若前驱结点为空,抛出异常
final Node predecessor() throws NullPointerException {
// 保存前驱结点
Node p = prev;
if (p == null) // 前驱结点为空,抛出异常
throw new NullPointerException();
else // 前驱结点不为空,返回
return p;
}
// 无参构造方法
Node() { // Used to establish initial head or SHARED marker
}
// 构造方法
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
// 构造方法
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
- 每个被阻塞的线程都会被封装为一个 Node 节点并放入队列。
- 每个节点包含了一个 Thread 类型的引用,并且每个节点都有一个状态,状态如下:
- CANCELLED:1,当前线程被取消。
- SIGNAL:-1,当前节点的后继节点中的线程需要运行,需要进行 unpark 操作。
- CONDITION:-2,当前节点在等待 condition,即 condition queue 中。
- PROPAGATE:-3,当前场景下后续的 acquireSHared 能够得以执行。
- 0:当前节点在 sync queue 中,等待获取锁。
内部类:ConditionObject
// 内部类
public class ConditionObject implements Condition, java.io.Serializable {
// 版本号
private static final long serialVersionUID = 1173984872572414699L;
/** First node of condition queue. */
// condition队列的头结点
private transient Node firstWaiter;
/** Last node of condition queue. */
// condition队列的尾结点
private transient Node lastWaiter;
/**
* Creates a new {@code ConditionObject} instance.
*/
// 构造方法
public ConditionObject() { }
// Internal methods
/**
* Adds a new waiter to wait queue.
* @return its new wait node
*/
// 添加新的waiter到wait队列
private Node addConditionWaiter() {
// 保存尾结点
Node t = lastWaiter;
// If lastWaiter is cancelled, clean out.
if (t != null && t.waitStatus != Node.CONDITION) { // 尾结点不为空,并且尾结点的状态不为CONDITION
// 清除状态为CONDITION的结点
unlinkCancelledWaiters();
// 将最后一个结点重新赋值给t
t = lastWaiter;
}
// 新建一个结点
Node node = new Node(Thread.currentThread(), Node.CONDITION);
if (t == null) // 尾结点为空
// 设置condition队列的头结点
firstWaiter = node;
else // 尾结点不为空
// 设置为节点的nextWaiter域为node结点
t.nextWaiter = node;
// 更新condition队列的尾结点
lastWaiter = node;
return node;
}
/**
* Removes and transfers nodes until hit non-cancelled one or
* null. Split out from signal in part to encourage compilers
* to inline the case of no waiters.
* @param first (non-null) the first node on condition queue
*/
private void doSignal(Node first) {
// 循环
do {
if ( (firstWaiter = first.nextWaiter) == null) // 该节点的nextWaiter为空
// 设置尾结点为空
lastWaiter = null;
// 设置first结点的nextWaiter域
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null); // 将结点从condition队列转移到sync队列失败并且condition队列中的头结点不为空,一直循环
}
/**
* Removes and transfers all nodes.
* @param first (non-null) the first node on condition queue
*/
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}
/**
* Unlinks cancelled waiter nodes from condition queue.
* Called only while holding lock. This is called when
* cancellation occurred during condition wait, and upon
* insertion of a new waiter when lastWaiter is seen to have
* been cancelled. This method is needed to avoid garbage
* retention in the absence of signals. So even though it may
* require a full traversal, it comes into play only when
* timeouts or cancellations occur in the absence of
* signals. It traverses all nodes rather than stopping at a
* particular target to unlink all pointers to garbage nodes
* without requiring many re-traversals during cancellation
* storms.
*/
// 从condition队列中清除状态为CANCEL的结点
private void unlinkCancelledWaiters() {
// 保存condition队列头结点
Node t = firstWaiter;
Node trail = null;
while (t != null) { // t不为空
// 下一个结点
Node next = t.nextWaiter;
if (t.waitStatus != Node.CONDITION) { // t结点的状态不为CONDTION状态
// 设置t节点的额nextWaiter域为空
t.nextWaiter = null;
if (trail == null) // trail为空
// 重新设置condition队列的头结点
firstWaiter = next;
else // trail不为空
// 设置trail结点的nextWaiter域为next结点
trail.nextWaiter = next;
if (next == null) // next结点为空
// 设置condition队列的尾结点
lastWaiter = trail;
}
else // t结点的状态为CONDTION状态
// 设置trail结点
trail = t;
// 设置t结点
t = next;
}
}
// public methods
/**
* Moves the longest-waiting thread, if one exists, from the
* wait queue for this condition to the wait queue for the
* owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
public final void signal() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒一个等待线程
doSignal(first);
}
/**
* Moves all threads from the wait queue for this condition to
* the wait queue for the owning lock.
*
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}
/**
* Implements uninterruptible condition wait.
* <ol>
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* </ol>
*/
// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
public final void awaitUninterruptibly() {
// 添加一个结点到等待队列
Node node = addConditionWaiter();
// 获取释放的状态
int savedState = fullyRelease(node);
boolean interrupted = false;
while (!isOnSyncQueue(node)) { //
// 阻塞当前线程
LockSupport.park(this);
if (Thread.interrupted()) // 当前线程被中断
// 设置interrupted状态
interrupted = true;
}
if (acquireQueued(node, savedState) || interrupted) //
selfInterrupt();
}
/*
* For interruptible waits, we need to track whether to throw
* InterruptedException, if interrupted while blocked on
* condition, versus reinterrupt current thread, if
* interrupted while blocked waiting to re-acquire.
*/
/** Mode meaning to reinterrupt on exit from wait */
private static final int REINTERRUPT = 1;
/** Mode meaning to throw InterruptedException on exit from wait */
private static final int THROW_IE = -1;
/**
* Checks for interrupt, returning THROW_IE if interrupted
* before signalled, REINTERRUPT if after signalled, or
* 0 if not interrupted.
*/
private int checkInterruptWhileWaiting(Node node) {
return Thread.interrupted() ?
(transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
0;
}
/**
* Throws InterruptedException, reinterrupts current thread, or
* does nothing, depending on mode.
*/
private void reportInterruptAfterWait(int interruptMode)
throws InterruptedException {
if (interruptMode == THROW_IE)
throw new InterruptedException();
else if (interruptMode == REINTERRUPT)
selfInterrupt();
}
/**
* Implements interruptible condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled or interrupted.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// // 等待,当前线程在接到信号或被中断之前一直处于等待状态
public final void await() throws InterruptedException {
if (Thread.interrupted()) // 当前线程被中断,抛出异常
throw new InterruptedException();
// 在wait队列上添加一个结点
Node node = addConditionWaiter();
//
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
// 阻塞当前线程
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0) // 检查结点等待时的中断类型
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
public final long awaitNanos(long nanosTimeout)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
final long deadline = System.nanoTime() + nanosTimeout;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return deadline - System.nanoTime();
}
/**
* Implements absolute timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
public final boolean awaitUntil(Date deadline)
throws InterruptedException {
long abstime = deadline.getTime();
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (System.currentTimeMillis() > abstime) {
timedout = transferAfterCancelledWait(node);
break;
}
LockSupport.parkUntil(this, abstime);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}
/**
* Implements timed condition wait.
* <ol>
* <li> If current thread is interrupted, throw InterruptedException.
* <li> Save lock state returned by {@link #getState}.
* <li> Invoke {@link #release} with saved state as argument,
* throwing IllegalMonitorStateException if it fails.
* <li> Block until signalled, interrupted, or timed out.
* <li> Reacquire by invoking specialized version of
* {@link #acquire} with saved state as argument.
* <li> If interrupted while blocked in step 4, throw InterruptedException.
* <li> If timed out while blocked in step 4, return false, else true.
* </ol>
*/
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
public final boolean await(long time, TimeUnit unit)
throws InterruptedException {
long nanosTimeout = unit.toNanos(time);
if (Thread.interrupted())
throw new InterruptedException();
Node node = addConditionWaiter();
int savedState = fullyRelease(node);
final long deadline = System.nanoTime() + nanosTimeout;
boolean timedout = false;
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
if (nanosTimeout <= 0L) {
timedout = transferAfterCancelledWait(node);
break;
}
if (nanosTimeout >= spinForTimeoutThreshold)
LockSupport.parkNanos(this, nanosTimeout);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
nanosTimeout = deadline - System.nanoTime();
}
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null)
unlinkCancelledWaiters();
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
return !timedout;
}
// support for instrumentation
/**
* Returns true if this condition was created by the given
* synchronization object.
*
* @return {@code true} if owned
*/
final boolean isOwnedBy(AbstractQueuedSynchronizer sync) {
return sync == AbstractQueuedSynchronizer.this;
}
/**
* Queries whether any threads are waiting on this condition.
* Implements {@link AbstractQueuedSynchronizer#hasWaiters(ConditionObject)}.
*
* @return {@code true} if there are any waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 查询是否有正在等待此条件的任何线程
protected final boolean hasWaiters() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
return true;
}
return false;
}
/**
* Returns an estimate of the number of threads waiting on
* this condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitQueueLength(ConditionObject)}.
*
* @return the estimated number of waiting threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回正在等待此条件的线程数估计值
protected final int getWaitQueueLength() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int n = 0;
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION)
++n;
}
return n;
}
/**
* Returns a collection containing those threads that may be
* waiting on this Condition.
* Implements {@link AbstractQueuedSynchronizer#getWaitingThreads(ConditionObject)}.
*
* @return the collection of threads
* @throws IllegalMonitorStateException if {@link #isHeldExclusively}
* returns {@code false}
*/
// 返回包含那些可能正在等待此条件的线程集合
protected final Collection<Thread> getWaitingThreads() {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
ArrayList<Thread> list = new ArrayList<Thread>();
for (Node w = firstWaiter; w != null; w = w.nextWaiter) {
if (w.waitStatus == Node.CONDITION) {
Thread t = w.thread;
if (t != null)
list.add(t);
}
}
return list;
}
}
该类实现了 Condition 接口,Condition 接口定义了条件操作的规范:
public interface Condition {
// 等待,当前线程在接到信号或被中断之前一直处于等待状态
void await() throws InterruptedException;
// 等待,当前线程在接到信号之前一直处于等待状态,不响应中断
void awaitUninterruptibly();
//等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态
long awaitNanos(long nanosTimeout) throws InterruptedException;
// 等待,当前线程在接到信号、被中断或到达指定等待时间之前一直处于等待状态。此方法在行为上等效于: awaitNanos(unit.toNanos(time)) > 0
boolean await(long time, TimeUnit unit) throws InterruptedException;
// 等待,当前线程在接到信号、被中断或到达指定最后期限之前一直处于等待状态
boolean awaitUntil(Date deadline) throws InterruptedException;
// 唤醒一个等待线程。如果所有的线程都在等待此条件,则选择其中的一个唤醒。在从 await 返回之前,该线程必须重新获取锁。
void signal();
// 唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。在从 await 返回之前,每个线程都必须重新获取锁。
void signalAll();
}
类的属性
属性中包含了头结点 head,为节点 tail,状态 state,自旋时间 spinForTimeoutThreshold,以及 AQS 抽象的属性在内存中的便宜地址,通过该便宜地址,可以获取和设置属性的值,同时该包括一个静态初始化块,用于加载内存偏移地址:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer
implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = 7373984972572414691L;
// 头结点
private transient volatile Node head;
// 尾结点
private transient volatile Node tail;
// 状态
private volatile int state;
// 自旋时间
static final long spinForTimeoutThreshold = 1000L;
// Unsafe类实例
private static final Unsafe unsafe = Unsafe.getUnsafe();
// state内存偏移地址
private static final long stateOffset;
// head内存偏移地址
private static final long headOffset;
// state内存偏移地址
private static final long tailOffset;
// tail内存偏移地址
private static final long waitStatusOffset;
// next内存偏移地址
private static final long nextOffset;
// 静态初始化块
static {
try {
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
headOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("head"));
tailOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("tail"));
waitStatusOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("waitStatus"));
nextOffset = unsafe.objectFieldOffset
(Node.class.getDeclaredField("next"));
} catch (Exception ex) { throw new Error(ex); }
}
}
类的构造方法
该类构造方法为抽象构造方法,仅供子类调用。
类的核心方法:acquire
该方法以独占模式获取(资源),忽略中断,即线程在aquire过程中,中断此线程是无效的。源码如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
线程在调用 tryAcquire 时的流程如下:
- 首先调用 tryAcquire 方法,线程会尝试在独占模式下获取对象状态。
- 此方法会查询是否允许它在独占模式下获取对象状态,如果允许则获取。
- 在 AQS 源码中会默认抛出一个异常,即需要子类重写该方法以实现需要的逻辑。
- 若 tryAcquire 失败,则调用 addWaiter 方法,addWaiter 方法完成的功能是将调用此方法的线程封装成为一个节点并放入 Sync Queue。
- 调用 acquireQueued 方法,此方法完成的功能是 Sync Queue 中的节点不断尝试获取资源,成功失败返回 true、false。
- 调用 tryAcquire 默认实现是抛出异常,因此需要继承者实现。
首先是 addWaiter 方法:
// 添加等待者
private Node addWaiter(Node mode) {
// 新生成一个结点,默认为独占模式
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 保存尾结点
Node pred = tail;
if (pred != null) { // 尾结点不为空,即已经被初始化
// 将node结点的prev域连接到尾结点
node.prev = pred;
if (compareAndSetTail(pred, node)) { // 比较pred是否为尾结点,是则将尾结点设置为node
// 设置尾结点的next域为node
pred.next = node;
return node; // 返回新生成的结点
}
}
enq(node); // 尾结点为空(即还没有被初始化过),或者是compareAndSetTail操作失败,则入队列
return node;
}
addWaiter 方法使用快速添加的方式往 sync queue 尾部添加结点,如果 sync queue 队列还没有初始化,则会使用 enq 插入队列中,enq 方法源码如下:
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}
enq 方法会使用无限循环来确保节点的成功插入。
acquireQueue方法:
// sync队列中的结点在独占且忽略中断的模式下获取(资源)
final boolean acquireQueued(final Node node, int arg) {
// 标志
boolean failed = true;
try {
// 中断标志
boolean interrupted = false;
for (;;) { // 无限循环
// 获取node节点的前驱结点
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 前驱为头结点并且成功获得锁
setHead(node); // 设置头结点
p.next = null; // help GC
failed = false; // 设置标志
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
首先获取当前节点的前驱节点,如果前驱节点是头结点并且能够获取(资源),代表该当前节点能够占有锁,设置头结点为当前节点,返回。否则,调用shouldParkAfterFailedAcquire和parkAndCheckInterrupt方法,首先,我们看shouldParkAfterFailedAcquire方法,代码如下:
// 当获取(资源)失败后,检查并且更新结点状态
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱结点的状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // 状态为SIGNAL,为-1
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
// 可以进行park操作
return true;
if (ws > 0) { // 表示状态为CANCELLED,为1
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0); // 找到pred结点前面最近的一个状态不为CANCELLED的结点
// 赋值pred结点的next域
pred.next = node;
} else { // 为PROPAGATE -3 或者是0 表示无状态,(为CONDITION -2时,表示此节点在condition queue中)
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
// 比较并设置前驱结点的状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
// 不能进行park操作
return false;
}
只有当该节点的前驱结点的状态为SIGNAL时,才可以对该结点所封装的线程进行park操作。否则,将不能进行park操作。再看parkAndCheckInterrupt方法,源码如下:
// 进行park操作并且返回该线程是否被中断
private final boolean parkAndCheckInterrupt() {
// 在许可可用之前禁用当前线程,并且设置了blocker
LockSupport.park(this);
return Thread.interrupted(); // 当前线程是否已被中断,并清除中断标记位
}
parkAndCheckInterrupt方法里的逻辑是首先执行park操作,即禁用当前线程,然后返回该线程是否已经被中断。再看final块中的cancelAcquire方法,其源码如下:
// 取消继续获取(资源)
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
// node为空,返回
if (node == null)
return;
// 设置node结点的thread为空
node.thread = null;
// Skip cancelled predecessors
// 保存node的前驱结点
Node pred = node.prev;
while (pred.waitStatus > 0) // 找到node前驱结点中第一个状态小于0的结点,即不为CANCELLED状态的结点
node.prev = pred = pred.prev;
// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
// 获取pred结点的下一个结点
Node predNext = pred.next;
// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
// 设置node结点的状态为CANCELLED
node.waitStatus = Node.CANCELLED;
// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) { // node结点为尾结点,则设置尾结点为pred结点
// 比较并设置pred结点的next节点为null
compareAndSetNext(pred, predNext, null);
} else { // node结点不为尾结点,或者比较设置不成功
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) { // (pred结点不为头结点,并且pred结点的状态为SIGNAL)或者
// pred结点状态小于等于0,并且比较并设置等待状态为SIGNAL成功,并且pred结点所封装的线程不为空
// 保存结点的后继
Node next = node.next;
if (next != null && next.waitStatus <= 0) // 后继不为空并且后继的状态小于等于0
compareAndSetNext(pred, predNext, next); // 比较并设置pred.next = next;
} else {
unparkSuccessor(node); // 释放node的前一个结点
}
node.next = node; // help GC
}
}
该方法完成的功能就是取消当前线程对资源的获取,即设置该结点的状态为CANCELLED,接着我们再看unparkSuccessor方法,源码如下:
// 释放后继结点
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
// 获取node结点的等待状态
int ws = node.waitStatus;
if (ws < 0) // 状态值小于0,为SIGNAL -1 或 CONDITION -2 或 PROPAGATE -3
// 比较并且设置结点等待状态,设置为0
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 获取node节点的下一个结点
Node s = node.next;
if (s == null || s.waitStatus > 0) { // 下一个结点为空或者下一个节点的等待状态大于0,即为CANCELLED
// s赋值为空
s = null;
// 从尾结点开始从后往前开始遍历
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0) // 找到等待状态小于等于0的结点,找到最前的状态小于等于0的结点
// 保存结点
s = t;
}
if (s != null) // 该结点不为为空,释放许可
LockSupport.unpark(s.thread);
}
该方法的作用就是为了释放node节点的后继结点。
对于cancelAcquire与unparkSuccessor方法,如下示意图可以清晰的表示:
其中node为参数,在执行完cancelAcquire方法后的效果就是unpark了s结点所包含的t4线程。
现在,再来看acquireQueued方法的整个的逻辑。逻辑如下:
判断结点的前驱是否为head并且是否成功获取(资源)。
若步骤1均满足,则设置结点为head,之后会判断是否finally模块,然后返回。
若步骤2不满足,则判断是否需要park当前线程,是否需要park当前线程的逻辑是判断结点的前驱结点的状态是否为SIGNAL,若是,则park当前结点,否则,不进行park操作。
若park了当前线程,之后某个线程对本线程unpark后,并且本线程也获得机会运行。那么,将会继续进行步骤 1 的判断。
类的核心方法:release
以独占模式释放资源:
public final boolean release(int arg) {
if (tryRelease(arg)) { // 释放成功
// 保存头结点
Node h = head;
if (h != null && h.waitStatus != 0) // 头结点不为空并且头结点状态不为0
unparkSuccessor(h); //释放头结点的后继结点
return true;
}
return false;
}
其中,tryRelease的默认实现是抛出异常,需要具体的子类实现,如果tryRelease成功,那么如果头结点不为空并且头结点的状态不为0,则释放头结点的后继结点,unparkSuccessor方法已经分析过,不再累赘。
对于其他方法我们也可以分析,与前面分析的方法大同小异,所以,不再累赘。
参考资料
1.3.12 - CH12-AQS-2
应用示例
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}
public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
} finally {
lock.unlock();
}
}
}
public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
t1.start();
t2.start();
}
}
// 前后随机
Thread[t1,5,main] running
Thread[t2,5,main] running
从示例可知,线程t1与t2共用了一把锁,即同一个lock。可能会存在如下一种时序:
首先 t1 线程调用 lock.lock 操作,然后 t2 再执行 lock.lock 操作,然后 t1 执行 lock.unlock,最后 t2 执行 lock.unlock。基于这样的时序尝试分析 AQS 内部的机制。
- t1 线程调用 lock.lock 操作:
- t2 再执行 lock.lock 操作:
进过一系列方法调用,最后达到的状态是 t2 被禁用,因此调用了 LockSupport.lock。
- t1线程调用lock.unlock:
t1线程中调用lock.unlock后,经过一系列的调用,最终的状态是释放了许可,因为调用了LockSupport.unpark。这时,t2线程就可以继续运行了。此时,会继续恢复t2线程运行环境,继续执行LockSupport.park后面的语句,即进一步调用如下。
在上一步调用了LockSupport.unpark后,t2线程恢复运行,则运行parkAndCheckInterrupt,之后,继续运行acquireQueued方法,最后达到的状态是头结点head与尾结点tail均指向了t2线程所在的结点,并且之前的头结点已经从sync队列中断开了。
- t2线程调用lock.unlock,其方法调用顺序如下,只给出了主要的方法调用。
t2线程执行lock.unlock后,最终达到的状态还是与之前的状态一样。
1.3.13 - CH13-AQS-3
应用实例
下面我们结合Condition实现生产者与消费者,来进一步分析AbstractQueuedSynchronizer的内部工作机制。
Depot(仓库)类:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Depot {
private int size;
private int capacity;
private Lock lock;
private Condition fullCondition;
private Condition emptyCondition;
public Depot(int capacity) {
this.capacity = capacity;
lock = new ReentrantLock();
fullCondition = lock.newCondition();
emptyCondition = lock.newCondition();
}
public void produce(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size >= capacity) {
System.out.println(Thread.currentThread() + " before await");
fullCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int inc = (left + size) > capacity ? (capacity - size) : left;
left -= inc;
size += inc;
System.out.println("produce = " + inc + ", size = " + size);
emptyCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void consume(int no) {
lock.lock();
int left = no;
try {
while (left > 0) {
while (size <= 0) {
System.out.println(Thread.currentThread() + " before await");
emptyCondition.await();
System.out.println(Thread.currentThread() + " after await");
}
int dec = (size - left) > 0 ? left : size;
left -= dec;
size -= dec;
System.out.println("consume = " + dec + ", size = " + size);
fullCondition.signal();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
测试类:
class Consumer {
private Depot depot;
public Consumer(Depot depot) {
this.depot = depot;
}
public void consume(int no) {
new Thread(new Runnable() {
@Override
public void run() {
depot.consume(no);
}
}, no + " consume thread").start();
}
}
class Producer {
private Depot depot;
public Producer(Depot depot) {
this.depot = depot;
}
public void produce(int no) {
new Thread(new Runnable() {
@Override
public void run() {
depot.produce(no);
}
}, no + " produce thread").start();
}
}
public class ReentrantLockDemo {
public static void main(String[] args) throws InterruptedException {
Depot depot = new Depot(500);
new Producer(depot).produce(500);
new Producer(depot).produce(200);
new Consumer(depot).consume(500);
new Consumer(depot).consume(200);
}
}
运行结果(随机):
produce = 500, size = 500
Thread[200 produce thread,5,main] before await
consume = 500, size = 0
Thread[200 consume thread,5,main] before await
Thread[200 produce thread,5,main] after await
produce = 200, size = 200
Thread[200 consume thread,5,main] after await
consume = 200, size = 0
根据结果,我们猜测一种可能的时序如下:
p1代表produce 500的那个线程,p2代表produce 200的那个线程,c1代表consume 500的那个线程,c2代表consume 200的那个线程。
- p1线程调用lock.lock,获得锁,继续运行,方法调用顺序在前面已经给出。
- p2线程调用lock.lock,由前面的分析可得到如下的最终状态。
p2线程调用lock.lock后,会禁止p2线程的继续运行,因为执行了LockSupport.park操作。
- c1线程调用lock.lock,由前面的分析得到如下的最终状态。
最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含p2的结点)的waitStatus变为了SIGNAL。
- c2线程调用lock.lock,由前面的分析得到如下的最终状态。
最终c1线程会在sync queue队列的尾部,并且其结点的前驱结点(包含c1的结点)的waitStatus变为了SIGNAL。
- p1线程执行emptyCondition.signal,其方法调用顺序如下,只给出了主要的方法调用。
AQS.CO表示AbstractQueuedSynchronizer.ConditionObject类。此时调用signal方法不会产生任何其他效果。
- p1线程执行lock.unlock,根据前面的分析可知,最终的状态如下。
此时,p2线程所在的结点为头结点,并且其他两个线程(c1、c2)依旧被禁止,所以,此时p2线程继续运行,执行用户逻辑。
- p2线程执行fullCondition.await,其方法调用顺序如下,只给出了主要的方法调用。
最终到达的状态是新生成了一个结点,包含了p2线程,此结点在condition queue中;并且sync queue中p2线程被禁止了,因为在执行了LockSupport.park操作。从方法一些调用可知,在await操作中线程会释放锁资源,供其他线程获取。同时,head结点后继结点的包含的线程的许可被释放了,故其可以继续运行。由于此时,只有c1线程可以运行,故运行c1。
- 继续运行c1线程,c1线程由于之前被park了,所以此时恢复,继续之前的步骤,即还是执行前面提到的acquireQueued方法,之后,c1判断自己的前驱结点为head,并且可以获取锁资源,最终到达的状态如下。
其中,head设置为包含c1线程的结点,c1继续运行。
- c1线程执行fullCondtion.signal,其方法调用顺序如下,只给出了主要的方法调用。
signal方法达到的最终结果是将包含p2线程的结点从condition queue中转移到sync queue中,之后condition queue为null,之前的尾结点的状态变为SIGNAL。
- c1线程执行lock.unlock操作,根据之前的分析,经历的状态变化如下。
最终c2线程会获取锁资源,继续运行用户逻辑。
- c2线程执行emptyCondition.await,由前面的第七步分析,可知最终的状态如下。
await操作将会生成一个结点放入condition queue中与之前的一个condition queue是不相同的,并且unpark头结点后面的结点,即包含线程p2的结点。
- p2线程被unpark,故可以继续运行,经过CPU调度后,p2继续运行,之后p2线程在AQS:await方法中被park,继续AQS.CO:await方法的运行,其方法调用顺序如下,只给出了主要的方法调用。
- p2继续运行,执行emptyCondition.signal,根据第九步分析可知,最终到达的状态如下。
最终,将condition queue中的结点转移到sync queue中,并添加至尾部,condition queue会为空,并且将head的状态设置为SIGNAL。
- p2线程执行lock.unlock操作,根据前面的分析可知,最后的到达的状态如下。
unlock操作会释放c2线程的许可,并且将头结点设置为c2线程所在的结点。
- c2线程继续运行,执行fullCondition. signal,由于此时fullCondition的condition queue已经不存在任何结点了,故其不会产生作用。
- c2执行lock.unlock,由于c2是sync队列中最后一个结点,故其不会再调用unparkSuccessor了,直接返回true。即整个流程就完成了。
1.3.14 - CH14-AQS-4
AQS 总结
最核心的就是sync queue的分析。
- 每个节点都是由前驱节点唤醒。
- 如果节点发现前驱节点是 head 并且尝试获取成功,则会轮到该线程执行。
- condition queue 中的节点想 sync queue 中转移是通过 signal 操作完成的。
- 当节点状态为 SIGNAL 时,表示后面的节点需要运行。
1.3.15 - CH15-ReentrantLock
源码分析
层级结构
ReentrantLock实现了Lock接口,Lock接口中定义了lock与unlock相关操作,并且还存在newCondition方法,表示生成一个条件。
内部类
ReentrantLock总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。
ReentrantLock类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。下面逐个进行分析。
内部类:Sync
abstract static class Sync extends AbstractQueuedSynchronizer {
// 序列号
private static final long serialVersionUID = -5179523762034025860L;
// 获取锁
abstract void lock();
// 非公平方式获取
final boolean nonfairTryAcquire(int acquires) {
// 当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 表示没有线程正在竞争该锁
if (compareAndSetState(0, acquires)) { // 比较并设置状态成功,状态0表示锁没有被占用
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true; // 成功
}
}
else if (current == getExclusiveOwnerThread()) { // 当前线程拥有该锁
int nextc = c + acquires; // 增加重入次数
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
// 成功
return true;
}
// 失败
return false;
}
// 试图在共享模式下获取对象状态,此方法应该查询是否允许它在共享模式下获取对象状态,如果允许,则获取它
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread()) // 当前线程不为独占线程
throw new IllegalMonitorStateException(); // 抛出异常
// 释放标识
boolean free = false;
if (c == 0) {
free = true;
// 已经释放,清空独占
setExclusiveOwnerThread(null);
}
// 设置标识
setState(c);
return free;
}
// 判断资源是否被当前线程占有
protected final boolean isHeldExclusively() {
// While we must in general read state before owner,
// we don't need to do so to check if current thread is owner
return getExclusiveOwnerThread() == Thread.currentThread();
}
// 新生一个条件
final ConditionObject newCondition() {
return new ConditionObject();
}
// Methods relayed from outer class
// 返回资源的占用线程
final Thread getOwner() {
return getState() == 0 ? null : getExclusiveOwnerThread();
}
// 返回状态
final int getHoldCount() {
return isHeldExclusively() ? getState() : 0;
}
// 资源是否被占用
final boolean isLocked() {
return getState() != 0;
}
/**
* Reconstitutes the instance from a stream (that is, deserializes it).
*/
// 自定义反序列化逻辑
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
s.defaultReadObject();
setState(0); // reset to unlocked state
}
}
其中的方法及作用如下:
内部类:NonfairSync
NonfairSync类继承了Sync类,表示采用非公平策略获取锁,其实现了Sync类中抽象的lock方法,源码如下:
// 非公平锁
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = 7316153563782823691L;
// 获得锁
final void lock() {
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
从lock方法的源码可知,每一次都尝试获取锁,而并不会按照公平等待的原则进行等待,让等待时间最久的线程获得锁。
内部类:FairSync
FairSync类也继承了Sync类,表示采用公平策略获取锁,其实现了Sync类中的抽象lock方法,源码如下:
// 公平锁
static final class FairSync extends Sync {
// 版本序列化
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
// 以独占模式获取对象,忽略中断
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试公平获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 状态为0
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
// 下一个状态
int nextc = c + acquires;
if (nextc < 0) // 超过了int的表示范围
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
return true;
}
return false;
}
}
当资源空闲时,它总是会先判断sync队列(AbstractQueuedSynchronizer中的数据结构)是否有等待时间更长的线程,如果存在,则将该线程加入到等待队列的尾部,实现了公平获取原则。
其中,FairSync类的lock的方法调用如下,只给出了主要的方法。
可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和Nonfair最大的区别,Nonfair每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。
类的属性
ReentrantLock类的sync非常重要,对ReentrantLock类的操作大部分都直接转化为对Sync和AQS类的操作。
public class ReentrantLock implements Lock, java.io.Serializable {
// 序列号
private static final long serialVersionUID = 7373984872572414699L;
// 同步队列
private final Sync sync;
}
构造函数
- ReentrantLock()型构造函数:默认采用非公平策略获取锁
- ReentrantLock(boolean)型构造函数:true 表示采用公平策略获取锁,否则采用非公平策略
核心函数
通过分析ReentrantLock的源码,可知对其操作都转化为对Sync对象的操作,由于Sync继承了AQS,所以基本上都可以转化为对AQS的操作。如将ReentrantLock的lock函数转化为对Sync的lock函数的调用,而具体会根据采用的策略(如公平策略或者非公平策略)的不同而调用到Sync的不同子类。
应用示例
公平锁
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class MyThread extends Thread {
private Lock lock;
public MyThread(String name, Lock lock) {
super(name);
this.lock = lock;
}
public void run () {
lock.lock();
try {
System.out.println(Thread.currentThread() + " running");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
lock.unlock();
}
}
}
public class AbstractQueuedSynchonizerDemo {
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock(true);
MyThread t1 = new MyThread("t1", lock);
MyThread t2 = new MyThread("t2", lock);
MyThread t3 = new MyThread("t3", lock);
t1.start();
t2.start();
t3.start();
}
}
// 随机结果
Thread[t1,5,main] running
Thread[t2,5,main] running
Thread[t3,5,main] running
该示例使用的是公平策略,由结果可知,可能会存在如下一种时序。
首先,t1线程的lock操作 -> t2线程的lock操作 -> t3线程的lock操作 -> t1线程的unlock操作 -> t2线程的unlock操作 -> t3线程的unlock操作。根据这个时序图来进一步分析源码的工作流程。
- t1线程执行lock.lock,下图给出了方法调用中的主要方法。
由调用流程可知,t1线程成功获取了资源,可以继续执行。
- t2线程执行lock.lock,下图给出了方法调用中的主要方法。
由上图可知,最后的结果是t2线程会被禁止,因为调用了LockSupport.park。
- t3线程执行lock.lock,下图给出了方法调用中的主要方法。
由上图可知,最后的结果是t3线程会被禁止,因为调用了LockSupport.park。
- t1线程调用了lock.unlock,下图给出了方法调用中的主要方法。
如上图所示,最后,head的状态会变为0,t2线程会被unpark,即t2线程可以继续运行。此时t3线程还是被禁止。
- t2获得cpu资源,继续运行,由于t2之前被park了,现在需要恢复之前的状态,下图给出了方法调用中的主要方法。
在setHead函数中会将head设置为之前head的下一个结点,并且将pre域与thread域都设置为null,在acquireQueued返回之前,sync queue就只有两个结点了。
- t2执行lock.unlock,下图给出了方法调用中的主要方法。
由上图可知,最终unpark t3线程,让t3线程可以继续运行。
- t3线程获取cpu资源,恢复之前的状态,继续运行。
最终达到的状态是sync queue中只剩下了一个结点,并且该节点除了状态为0外,其余均为null。
- t3执行lock.unlock,下图给出了方法调用中的主要方法。
最后的状态和之前的状态是一样的,队列中有一个空节点,头结点为尾节点均指向它。
使用公平策略和Condition的情况可以参考上一篇关于AQS的源码示例分析部分,不再累赘。
1.3.16 - CH16-ReentrantReadWriteLock
数据结构
其实现是基于 ReentrantLock 和 AQS,因此底层基于 AQS 的数据结构。
源码分析
层级结构
- ReentrantReadWriteLock 实现了 ReadWriteLock 接口,ReadWriteLock 接口定义了获取读锁和写锁的规范,需要实现类来提供具体实现;
- 同时实现了 Serializable 接口,表示可以进行序列化。
内部类
内部有 5 个类,5 个内部类之间互相关联,关系如下:
内部类:Sync
Sync 直接继承了 AQS,它的内部又有两个内部类,分别为 HoldCounter 和 ThreadLocalHoldCounter,其中 HoldCounter 主要与读锁配套使用。
HoldCounter 源码如下:
// 计数器
static final class HoldCounter {
// 计数
int count = 0;
// Use id, not reference, to avoid garbage retention
// 获取当前线程的TID属性的值
final long tid = getThreadId(Thread.currentThread());
}
- count 表示某个读线程重入的次数
- tid 表示该线程的 tid 字段值,线程的唯一标识
ThreadLocalHoldCounter 源码如下:
// 本地线程计数器
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
// 重写初始化方法,在没有进行set的情况下,获取的都是该HoldCounter值
public HoldCounter initialValue() {
return new HoldCounter();
}
}
ThreadLocalHoldCounter 重写了 ThreadLocal 的 initialValue 方法,ThreadLocal 类可以将线程与对象进行关联。在没有执行 set 的情况下,get 到的均为 initialValue 方法中生成的 HolderCounter 对象。
Sync 类的属性:
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本序列号
private static final long serialVersionUID = 6317671515068378041L;
// 高16位为读锁,低16位为写锁
static final int SHARED_SHIFT = 16;
// 读锁单位
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
// 读锁最大数量
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
// 写锁最大数量
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
// 本地线程计数器
private transient ThreadLocalHoldCounter readHolds;
// 缓存的计数器
private transient HoldCounter cachedHoldCounter;
// 第一个读线程
private transient Thread firstReader = null;
// 第一个读线程的计数
private transient int firstReaderHoldCount;
}
- 属性中包括了读锁和写锁线程的最大数量、本地线程计数器等。
Sync 类的构造函数:
// 构造函数
Sync() {
// 本地线程计数器
readHolds = new ThreadLocalHoldCounter();
// 设置AQS的状态
setState(getState()); // ensures visibility of readHolds
}
- 在 Sync 类的构造函数中设置了本地线程计数器和 AQS 的状态 state。
内部类:Sync 核心函数
ReentrantReadWriteLock 的大部分操作都会交由 Sync 对象执行。以下是 Sync 的主要函数:
sharedCount:
- 表示占有读锁的线程数量
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
- 直接将 state 右移 16 位,就可以得到读锁的线程数量,因为 state 的高 16 位表示读锁,低 16 位表示写锁的线程数量。
exclusiveCount:
- 表示占有写锁的线程数量
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
- 直接将 state 和
(2^16 - 1)
做与运算,等效于将 state 模上 2^16,写锁线程数量由低 16 位表示。
tryRelease:
/*
* Note that tryRelease and tryAcquire can be called by
* Conditions. So it is possible that their arguments contain
* both read and write holds that are all released during a
* condition wait and re-established in tryAcquire.
*/
protected final boolean tryRelease(int releases) {
// 判断是否伪独占线程
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 计算释放资源后的写锁的数量
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0; // 是否释放成功
if (free)
setExclusiveOwnerThread(null); // 设置独占线程为空
setState(nextc); // 设置状态
return free;
}
- 用于释放写锁资源,首先会判断该线程是否为独占线程,如果不是独占线程,则抛出异常;否则,计算释放资源后的写锁数量,如果为 0 则表示释放成功,资源不再被占用,否则表示资源仍被占用。
tryAcquire:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
// 获取当前线程
Thread current = Thread.currentThread();
// 获取状态
int c = getState();
// 写线程数量
int w = exclusiveCount(c);
if (c != 0) { // 状态不为0
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread()) // 写线程数量为0或者当前线程没有占有独占资源
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT) // 判断是否超过最高写线程数量
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 设置AQS状态
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires)) // 写线程是否应该被阻塞
return false;
// 设置独占线程
setExclusiveOwnerThread(current);
return true;
}
用于获取写锁。首先获取 state,判断如果为 0 表示此时没有读锁线程,再判断写线程是否应该被阻塞,而在非公平策略下总是不会被阻塞,在公平策略下会进行判断(判断同步队列中是否已有等待时间更长的线程,存在则被阻塞,否则无需组织),之后设置状态 state 并返回 true。
如果 state 不为 0,表示此时存在读锁或写锁线程,弱写锁线程数量为 0 或当前线程为独占锁线程则返回 false,表示不成功。否则,判断写线程的重入次数是否大于了最大值,若是则抛出异常,否则设置状态 state 并返回 true,表示成功。
tryReleaseShared:
protected final boolean tryReleaseShared(int unused) {
// 获取当前线程
Thread current = Thread.currentThread();
if (firstReader == current) { // 当前线程为第一个读线程
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1) // 读线程占用的资源数为1
firstReader = null;
else // 减少占用的资源
firstReaderHoldCount--;
} else { // 当前线程不为第一个读线程
// 获取缓存的计数器
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
// 获取当前线程对应的计数器
rh = readHolds.get();
// 获取计数
int count = rh.count;
if (count <= 1) { // 计数小于等于1
// 移除
readHolds.remove();
if (count <= 0) // 计数小于等于0,抛出异常
throw unmatchedUnlockException();
}
// 减少计数
--rh.count;
}
for (;;) { // 无限循环
// 获取状态
int c = getState();
// 获取状态
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc)) // 比较并进行设置
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}
此函数表示读锁线程释放锁。首先判断当前线程是否为第一个读线程 firstReader,若是,则判断第一个读线程占有的资源数 firstReaderHoldCount 是否为 1,若是,则设置第一个读线程 firstReader 为空,否则,将第一个读线程占有的资源数 firstReaderHoldCount 减1;若当前线程不是第一个读线程,那么首先会获取缓存计数器(上一个读锁线程对应的计数器 ),若计数器为空或者 tid 不等于当前线程的 tid 值,则获取当前线程的计数器,如果计数器的计数 count 小于等于1,则移除当前线程对应的计数器,如果计数器的计数 count 小于等于 0,则抛出异常,之后再减少计数即可。无论何种情况,都会进入无限循环,该循环可以确保成功设置状态 state。
tryAcquireShared
private IllegalMonitorStateException unmatchedUnlockException() {
return new IllegalMonitorStateException(
"attempt to unlock read lock, not locked by current thread");
}
// 共享模式下获取资源
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
// 获取当前线程
Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current) // 写线程数不为0并且占有资源的不是当前线程
return -1;
// 读锁数量
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) { // 读线程是否应该被阻塞、并且小于最大值、并且比较设置成功
if (r == 0) { // 读锁数量为0
// 设置第一个读线程
firstReader = current;
// 读线程占用的资源数为1
firstReaderHoldCount = 1;
} else if (firstReader == current) { // 当前线程为第一个读线程
// 占用资源数加1
firstReaderHoldCount++;
} else { // 读锁数量不为0并且不为当前线程
// 获取计数器
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
// 获取当前线程对应的计数器
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0) // 计数为0
// 设置
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}
此函数表示读锁线程获取读锁。首先判断写锁是否为 0 并且当前线程不占有独占锁,直接返回;否则,判断读线程是否需要被阻塞并且读锁数量是否小于最大值并且比较设置状态成功,若当前没有读锁,则设置第一个读线程 firstReade r和 firstReaderHoldCount;若当前线程线程为第一个读线程,则增加 firstReaderHoldCount;否则,将设置当前线程对应的 HoldCounter 对象的值。
fullTryAcquireShared
final int fullTryAcquireShared(Thread current) {
/*
* This code is in part redundant with that in
* tryAcquireShared but is simpler overall by not
* complicating tryAcquireShared with interactions between
* retries and lazily reading hold counts.
*/
HoldCounter rh = null;
for (;;) { // 无限循环
// 获取状态
int c = getState();
if (exclusiveCount(c) != 0) { // 写线程数量不为0
if (getExclusiveOwnerThread() != current) // 不为当前线程
return -1;
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) { // 写线程数量为0并且读线程被阻塞
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) { // 当前线程为第一个读线程
// assert firstReaderHoldCount > 0;
} else { // 当前线程不为第一个读线程
if (rh == null) { // 计数器不为空
//
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) { // 计数器为空或者计数器的tid不为当前正在运行的线程的tid
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT) // 读锁数量为最大值,抛出异常
throw new Error("Maximum lock count exceeded");
if (compareAndSetState(c, c + SHARED_UNIT)) { // 比较并且设置成功
if (sharedCount(c) == 0) { // 读线程数量为0
// 设置第一个读线程
firstReader = current;
//
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
在 tryAcquireShared 函数中,如果下列三个条件不满足(读线程是否应该被阻塞、小于最大值、比较设置成功)则会进行 fullTryAcquireShared 函数中,它用来保证相关操作可以成功。其逻辑与 tryAcquireShared 逻辑类似,不再累赘。
而其他内部类的操作基本上都是转化到了对Sync对象的操作,在此不再累赘。
类属性
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = -6992448646407690164L;
// 读锁
private final ReentrantReadWriteLock.ReadLock readerLock;
// 写锁
private final ReentrantReadWriteLock.WriteLock writerLock;
// 同步队列
final Sync sync;
private static final sun.misc.Unsafe UNSAFE;
// 线程ID的偏移地址
private static final long TID_OFFSET;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> tk = Thread.class;
// 获取线程的tid字段的内存地址
TID_OFFSET = UNSAFE.objectFieldOffset
(tk.getDeclaredField("tid"));
} catch (Exception e) {
throw new Error(e);
}
}
}
包括了一个 ReentrantReadWriteLock.ReadLock 对象,表示读锁;
一个ReentrantReadWriteLock.WriteLock对象,表示写锁;
一个Sync对象,表示同步队列。
类的构造函数
- ReentrantReadWriteLock()
- 默认非公平策略
- ReentrantReadWriteLock(boolean)
- true:公平策略
- false:非公平策略
类的核心函数
对ReentrantReadWriteLock的操作基本上都转化为了对Sync对象的操作。
应用示例
import java.util.concurrent.locks.ReentrantReadWriteLock;
class ReadThread extends Thread {
private ReentrantReadWriteLock rrwLock;
public ReadThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.readLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rrwLock.readLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}
class WriteThread extends Thread {
private ReentrantReadWriteLock rrwLock;
public WriteThread(String name, ReentrantReadWriteLock rrwLock) {
super(name);
this.rrwLock = rrwLock;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " trying to lock");
try {
rrwLock.writeLock().lock();
System.out.println(Thread.currentThread().getName() + " lock successfully");
} finally {
rrwLock.writeLock().unlock();
System.out.println(Thread.currentThread().getName() + " unlock successfully");
}
}
}
public class ReentrantReadWriteLockDemo {
public static void main(String[] args) {
ReentrantReadWriteLock rrwLock = new ReentrantReadWriteLock();
ReadThread rt1 = new ReadThread("rt1", rrwLock);
ReadThread rt2 = new ReadThread("rt2", rrwLock);
WriteThread wt1 = new WriteThread("wt1", rrwLock);
rt1.start();
rt2.start();
wt1.start();
}
}
rt1 trying to lock
rt2 trying to lock
wt1 trying to lock
rt1 lock successfully
rt2 lock successfully
rt1 unlock successfully
rt2 unlock successfully
wt1 lock successfully
wt1 unlock successfully
程序中生成了一个ReentrantReadWriteLock对象,并且设置了两个读线程,一个写线程。根据结果,可能存在如下的时序图。
rt1 线程执行 rrwLock.readLock().lock 操作的调用链路:
此时 AQS 的状态 state 为 2^16,表示当前读线程数量为 1。
rt2 线程执行 rrwLock.readLock().lock 操作,主要的函数调用如下:
此时,在同步队列 Sync queue 中存在两个结点,并且 wt1 线程会被禁止运行。
rt1 线程执行 rrwLock.readLock().unlock 操作,主要的函数调用如下:
此时,AQS的state为2^16次方,表示还有一个读线程。
rt2 线程执行 rrwLock.readLock().unlock 操作,主要的函数调用如下:
当 rt2 线程执行 unlock 操作后,AQS 的 state 为 0,并且 wt1 线程将会被 unpark,其获得 CPU 资源就可以运行。
wt1线程获得CPU资源,继续运行,需要恢复。由于之前acquireQueued函数中的parkAndCheckInterrupt函数中被禁止的,所以,恢复到parkAndCheckInterrupt函数中,主要的函数调用如下:
最后,sync queue 队列中只有一个结点,并且头结点尾节点均指向它,AQS 的 state 值为 1,表示此时有一个写线程。
wt1 执行 rrwLock.writeLock().unlock 操作,主要的函数调用如下:
此时,AQS 的 state 为 0,表示没有任何读线程或者写线程了。并且 Sync queue 结构与上一个状态的结构相同,没有变化。
深入理解
什么是锁升级、降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。
锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
因为数据不常变化,所以多个线程可以并发地进行数据处理,当数据变更后,如果当前线程感知到数据变化,则进行数据的准备工作,同时其他处理线程被阻塞,直到当前线程完成数据的准备工作,如代码如下所示:
public void processData() {
readLock.lock();
if (!update) {
// 必须先释放读锁
readLock.unlock();
// 锁降级从写锁获取到开始
writeLock.lock();
try {
if (!update) {
// 准备数据的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 锁降级完成,写锁降级为读锁
}
try {
// 使用数据的流程(略)
} finally {
readLock.unlock();
}
}
上述示例中,当数据发生变更后,update 变量(布尔类型且volatile修饰)被设置为 false,此时所有访问 processData() 方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的 lock() 方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。
锁降级中读锁的获取是否必要呢? 答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
RentrantReadWriteLock不支持锁升级(把持读锁、获取写锁,最后释放读锁的过程)。目的也是保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。
1.3.17 - CH17-ConcurrentHashMap
HashTable 为什么慢
Hashtable 之所以效率低下主要是因为其实现使用了 synchronized 关键字对 put 等操作进行加锁,而 synchronized 关键字加锁是对整个对象示例进行加锁,也就是说在进行 put 等修改 Hash 表的操作时,锁住了整个 Hash 表,从而使得其表现的效率低下。
JDK 1.7-ConcurrentHashMap
在 JDK 1.5~1.7 版本,Java 使用了分段锁机制实现 ConcurrentHashMap。
简而言之,ConcurrentHashMap 在对象中保存了一个 Segment 数组,即将整个 Hash 表划分为多个分段;
而每个 Segment 元素,即每个分段则类似于一个 Hashtable;
这样,在执行 put 操作时首先根据 hash 算法定位到元素属于哪个 Segment,然后对该 Segment 加锁即可。
因此,ConcurrentHashMap 在多线程并发编程中可是实现多线程 put 操作。
数据结构
整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个 segment。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
- concurrencyLevel:并行级别、Segment 数量。
- 默认为 16,即拥有 16 个 segments,理论上同时支持 16 个线程并发写,只要它们的操作分布在不同的 Segment 上。
- 该值可以在初始化时设定为其他值,但是一点设置不可修改。
- 每个 Segment 内部类似于 HashMap,但通过继承 ReentrantLock 来保证线程安全。
初始化
- initialCapacity: 初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。
- loadFactor: 负载因子,之前我们说了,Segment 数量不可变,所以这个负载因子是给每个 Segment 内部使用的。
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int sshift = 0;
int ssize = 1;
// 计算并行级别 ssize,因为要保持并行级别是 2 的 n 次方
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
// 我们这里先不要那么烧脑,用默认值,concurrencyLevel 为 16,sshift 为 4
// 那么计算出 segmentShift 为 28,segmentMask 为 15,后面会用到这两个值
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// initialCapacity 是设置整个 map 初始的大小,
// 这里根据 initialCapacity 计算 Segment 数组中每个位置可以分到的大小
// 如 initialCapacity 为 64,那么每个 Segment 或称之为"槽"可以分到 4 个
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity)
++c;
// 默认 MIN_SEGMENT_TABLE_CAPACITY 是 2,这个值也是有讲究的,因为这样的话,对于具体的槽上,
// 插入一个元素不至于扩容,插入第二个的时候才会扩容
int cap = MIN_SEGMENT_TABLE_CAPACITY;
while (cap < c)
cap <<= 1;
// 创建 Segment 数组,
// 并创建数组的第一个元素 segment[0]
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];
// 往数组写入 segment[0]
UNSAFE.putOrderedObject(ss, SBASE, s0); // ordered write of segments[0]
this.segments = ss;
}
当使用无参构造器创建 ConcurrentHashMap 实例时,初始化完成后的状态如下:
- Segment 数组长度为 16,不可以扩容
Segment[i]
的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容- 这里初始化了
segment[0]
,其他位置还是 null,至于为什么要初始化segment[0]
,后面的代码会介绍 - 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数和掩码,这两个值马上就会用到
put 过程分析
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
return s.put(key, hash, value, false);
}
第一层操作较简单,基于 hash 值找到对应的 segment,之后执行 segment 内部的 put 操作。
Segment 内部由 数组+链表 构成:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 segment 写入前,需要先获取该 segment 的独占锁
// 先看主流程,后面还会具体介绍这部分内容
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
// 这个是 segment 内部的数组
HashEntry<K,V>[] tab = table;
// 再利用 hash 值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
// first 是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);
// 下面这串 for 循环虽然很长,不过也很好理解,想想该位置没有任何元素和已经存在一个链表这两种情况
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
if ((k = e.key) == key ||
(e.hash == hash && key.equals(k))) {
oldValue = e.value;
if (!onlyIfAbsent) {
// 覆盖旧值
e.value = value;
++modCount;
}
break;
}
// 继续顺着链表走
e = e.next;
}
else {
// node 到底是不是 null,这个要看获取锁的过程,不过和这里都没有关系。
// 如果不为 null,那就直接将它设置为链表表头;如果是null,初始化并设置为链表表头。
if (node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
// 如果超过了该 segment 的阈值,这个 segment 需要扩容
if (c > threshold && tab.length < MAXIMUM_CAPACITY)
rehash(node); // 扩容后面也会具体分析
else
// 没有达到阈值,将 node 放到数组 tab 的 index 位置,
// 其实就是将新的节点设置成原链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
} finally {
// 解锁
unlock();
}
return oldValue;
}
由于有独占锁的保护,所以 segment 内部的操作并不复杂。
下面为其中的关键函数:
初始化槽:ensureSegment
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0]
,对于其他槽来说,在插入第一个值的时候才进行初始化。
这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k]
,不过只要有一个成功了就可以。
private Segment<K,V> ensureSegment(int k) {
final Segment<K,V>[] ss = this.segments;
long u = (k << SSHIFT) + SBASE; // raw offset
Segment<K,V> seg;
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u)) == null) {
// 这里看到为什么之前要初始化 segment[0] 了,
// 使用当前 segment[0] 处的数组长度和负载因子来初始化 segment[k]
// 为什么要用“当前”,因为 segment[0] 可能早就扩容过了
Segment<K,V> proto = ss[0];
int cap = proto.table.length;
float lf = proto.loadFactor;
int threshold = (int)(cap * lf);
// 初始化 segment[k] 内部的数组
HashEntry<K,V>[] tab = (HashEntry<K,V>[])new HashEntry[cap];
if ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) { // 再次检查一遍该槽是否被其他线程初始化了。
Segment<K,V> s = new Segment<K,V>(lf, threshold, tab);
// 使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出
while ((seg = (Segment<K,V>)UNSAFE.getObjectVolatile(ss, u))
== null) {
if (UNSAFE.compareAndSwapObject(ss, u, null, seg = s))
break;
}
}
}
return seg;
}
对于并发操作使用 CAS 进行控制。
获取写入锁: scanAndLockForPut
在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value)
,也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。
这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node。
扩容:rehash
segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组
HashEntry<K,V>[]
进行扩容,扩容后,容量为原来的 2 倍。执行 put 时,如果判断该值的插入会导致 segment 的元素个数超过阈值,需要先扩容再插入。
因为这时已经持有了 segment 的独占锁,因此无需再考虑并发:
// 方法参数上的 node 是这次扩容后,需要添加到新的数组中的数据。
private void rehash(HashEntry<K,V> node) {
HashEntry<K,V>[] oldTable = table;
int oldCapacity = oldTable.length;
// 2 倍
int newCapacity = oldCapacity << 1;
threshold = (int)(newCapacity * loadFactor);
// 创建新数组
HashEntry<K,V>[] newTable =
(HashEntry<K,V>[]) new HashEntry[newCapacity];
// 新的掩码,如从 16 扩容到 32,那么 sizeMask 为 31,对应二进制 ‘000...00011111’
int sizeMask = newCapacity - 1;
// 遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置
for (int i = 0; i < oldCapacity ; i++) {
// e 是链表的第一个元素
HashEntry<K,V> e = oldTable[i];
if (e != null) {
HashEntry<K,V> next = e.next;
// 计算应该放置在新数组中的位置,
// 假设原数组长度为 16,e 在 oldTable[3] 处,那么 idx 只可能是 3 或者是 3 + 16 = 19
int idx = e.hash & sizeMask;
if (next == null) // 该位置处只有一个元素,那比较好办
newTable[idx] = e;
else { // Reuse consecutive sequence at same slot
// e 是链表表头
HashEntry<K,V> lastRun = e;
// idx 是当前链表的头结点 e 的新位置
int lastIdx = idx;
// 下面这个 for 循环会找到一个 lastRun 节点,这个节点之后的所有元素是将要放到一起的
for (HashEntry<K,V> last = next;
last != null;
last = last.next) {
int k = last.hash & sizeMask;
if (k != lastIdx) {
lastIdx = k;
lastRun = last;
}
}
// 将 lastRun 及其之后的所有节点组成的这个链表放到 lastIdx 这个位置
newTable[lastIdx] = lastRun;
// 下面的操作是处理 lastRun 之前的节点,
// 这些节点可能分配在另一个链表中,也可能分配到上面的那个链表中
for (HashEntry<K,V> p = e; p != lastRun; p = p.next) {
V v = p.value;
int h = p.hash;
int k = h & sizeMask;
HashEntry<K,V> n = newTable[k];
newTable[k] = new HashEntry<K,V>(h, p.key, v, n);
}
}
}
}
// 将新来的 node 放到新数组中刚刚的 两个链表之一 的 头部
int nodeIndex = node.hash & sizeMask; // add the new node
node.setNext(newTable[nodeIndex]);
newTable[nodeIndex] = node;
table = newTable;
}
扩容过程中有两个 for 循环。如果没有第一个 for 循环也是可以工作的,但是在首个 for 循环中,如果 lastRun 的后面还有比较多的节点,那么首次循环就值得。因为我们要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 走就可以了,无需其他操作。
比较坏的情况是每次 lastRun 都是链表的最后一个元素或者很靠后的元素,那么就会比较浪费。基于 Doug Lea 的说法,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。
get 过程分析
- 计算 hash 值,找到 segment 的数组下标,得到 segment
- segment 中也是一个数组,根据 hash 找到数据中的位置
- 得到链表,顺着链表查找即可
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
// 1. hash 值
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 2. 根据 hash 找到对应的 segment
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 3. 找到segment 内部数组相应位置的链表,遍历
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
并发问题分析
注意 get 操作并未加锁。
添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题。
我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。
put 操作的安全性:
- 初始化槽,这个我们之前就说过了,使用了 CAS 来初始化 Segment 中的数组。
- 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
- 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
remove 操作的线程安全性:
- get 操作需要遍历链表,但是 remove 操作会"破坏"链表。
- 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
- 如果 remove 先破坏了一个节点,分两种情况考虑。
- 1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。
- 2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。
JDK 1.8-ConcurrentHashMap
在 JDK 1.7 之前,ConcurrentHashMap 是通过分段锁机制来实现的,所以其最大并发度受 Segment 的个数限制。因此,在 JDK1.8 中,ConcurrentHashMap 的实现原理摒弃了这种设计,而是选择了与 HashMap 类似的数组+链表+红黑树的方式实现,而加锁则采用 CAS 和 synchronized 实现。
数据结构
初始化
// 这构造函数里,什么都不干
public ConcurrentHashMap() {
}
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
通过提供的初始容量,计算了 sizeCtl,sizeCtl = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】
,如果 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,sizeCtl 为 32。
put 过程分析
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 得到 hash 值
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 如果数组"空",进行数组初始化
if (tab == null || (n = tab.length) == 0)
// 初始化数组,后面会详细介绍
tab = initTable();
// 找该 hash 值对应的数组下标,得到第一个节点 f
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了,可以拉到最后面了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hash 居然可以等于 MOVED,这个需要到后面才能看明白,不过从名字上也能猜到,肯定是因为在扩容
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,这个等到看完数据迁移部分的介绍后,再理解这个就很简单了
tab = helpTransfer(tab, f);
else { // 到这里就是说,f 是该位置的头结点,而且不为空
V oldVal = null;
// 获取数组该位置的头结点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 到了链表的最末端,将这个新值放到链表的最后面
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) { // 红黑树
Node<K,V> p;
binCount = 2;
// 调用红黑树的插值方法插入新节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
// 具体源码我们就不看了,扩容部分后面说
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//
addCount(1L, binCount);
return null;
}
初始化数组:initTable
初始化一个合适大小的数组,然后会设置 sizeCtl。初始化方法中的并发问题通过对 sizeCtl 执行一个 CAS 操作来控制的。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的"功劳"被其他线程"抢去"了
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
链表转红黑树:treeifyBin
treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// MIN_TREEIFY_CAPACITY 为 64
// 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 后面我们再详细分析这个方法
tryPresize(n << 1);
// b 是头结点
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {
if (tabAt(tab, index) == b) {
// 下面就是遍历链表,建立一颗红黑树
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将红黑树设置到数组相应位置中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
扩容:tryPresize
扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。
// 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了
private final void tryPresize(int size) {
// c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 我没看懂 rs 的真正含义是什么,不过也关系不大
int rs = resizeStamp(n);
if (sc < 0) {
Node<K,V>[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 2. 用 CAS 将 sizeCtl 加 1,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 我是没看懂这个值真正的意义是什么? 不过可以计算出来的是,结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。
所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。
数据迁移:transfer
将原来的 tab 数组的元素迁移到新的 nextTab 数组中。
虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。
此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。
阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,有 n 个位置是需要进行迁移的,
// 将这 n 个任务分为多个任务包,每个任务包有 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果 nextTab 为 null,先进行一次初始化
// 前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
// 之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) {
try {
// 容量翻倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
// nextTable 是 ConcurrentHashMap 中的属性
nextTable = nextTab;
// transferIndex 也是 ConcurrentHashMap 的属性,用于控制迁移的位置
transferIndex = n;
}
int nextn = nextTab.length;
// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
// 就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
*
*/
// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 下面这个 while 真的是不好理解
// advance 为 true 表示可以进行下一个位置的迁移了
// 简单理解结局: i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
// 将 transferIndex 值赋给 nextIndex
// 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
// 所有的迁移操作已经完成
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl: n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 之前我们说过,sizeCtl 在迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
// 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 任务结束,方法退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 也就是说,所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 其中的一个链表放在新数组的位置 i
setTabAt(nextTab, i, ln);
// 另一个链表放在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
}
}
}
transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制。
get 过程分析
- 计算 hash 值
- 根据 hash 值找到数组对应位置:
(n - 1) & h
- 根据该位置处结点性质进行相应查找
- 如果该位置为 null,那么直接返回 null 就可以了
- 如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
- 如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
- 如果以上 3 条都不满足,那就是链表,进行遍历比对即可
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判断头结点是否就是我们需要的节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
else if (eh < 0)
// 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
return (p = e.find(h, key)) != null ? p.val : null;
// 遍历链表
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
当遇到扩容时的情况最为复杂,ForwardingNode.find(int h, Object k)
。
对比总结
HashTable
: 使用了synchronized关键字对put等操作进行加锁;ConcurrentHashMap JDK1.7
: 使用分段锁机制实现;ConcurrentHashMap JDK1.8
: 则使用数组+链表+红黑树数据结构和 CAS 原子操作实现;
1.3.18 - CH18-ConcurrentLinkedQueue
- 基于链接节点的无界线程安全队列。
- 此队列按照 FIFO(先进先出)原则对元素进行排序。
- 队列的头部是队列中时间最长的元素。
- 队列的尾部是队列中时间最短的元素。
- 新的元素插入到队列的尾部,队列获取操作从队列头部获得元素。
- 当多个线程共享访问一个公共 collection 时,ConcurrentLinkedQueue 是一个恰当的选择。
- 此队列不允许使用 null 元素。
数据结构
与 LinkedBlockingQueue 的数据结构相同,都是使用的链表结构。ConcurrentLinkedQueue 的数据结构如下:
ConcurrentLinkedQueue 采用的链表结构,并且包含有一个头结点和一个尾结点。
源码分析
层级结构
继承了抽象类 AbstractQueue,AbstractQueue 定义了对队列的基本操作;
同时实现了 Queue 接口,Queue 定义了对队列的基本操作,
同时,还实现了 Serializable 接口,表示可以被序列化。
内部类
Node 类表示链表结点,用于存放元素,包含 item 域和 next 域,item 域表示元素,next 域表示下一个结点,其利用反射机制和 CAS 机制来更新 item 域和 next 域,保证原子性。
private static class Node<E> {
// 元素
volatile E item;
// next域
volatile Node<E> next;
/**
* Constructs a new node. Uses relaxed write because item can
* only be seen after publication via casNext.
*/
// 构造函数
Node(E item) {
// 设置item的值
UNSAFE.putObject(this, itemOffset, item);
}
// 比较并替换item值
boolean casItem(E cmp, E val) {
return UNSAFE.compareAndSwapObject(this, itemOffset, cmp, val);
}
void lazySetNext(Node<E> val) {
// 设置next域的值,并不会保证修改对其他线程立即可见
UNSAFE.putOrderedObject(this, nextOffset, val);
}
// 比较并替换next域的值
boolean casNext(Node<E> cmp, Node<E> val) {
return UNSAFE.compareAndSwapObject(this, nextOffset, cmp, val);
}
// Unsafe mechanics
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// item域的偏移量
private static final long itemOffset;
// next域的偏移量
private static final long nextOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = Node.class;
itemOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("item"));
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("next"));
} catch (Exception e) {
throw new Error(e);
}
}
}
类的属性
属性中包含了 head 域和 tail 域,表示链表的头结点和尾结点,同时,ConcurrentLinkedQueue 也使用了反射机制和 CAS 机制来更新头结点和尾结点,保证原子性。
public class ConcurrentLinkedQueue<E> extends AbstractQueue<E>
implements Queue<E>, java.io.Serializable {
// 版本序列号
private static final long serialVersionUID = 196745693267521676L;
// 反射机制
private static final sun.misc.Unsafe UNSAFE;
// head域的偏移量
private static final long headOffset;
// tail域的偏移量
private static final long tailOffset;
static {
try {
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class<?> k = ConcurrentLinkedQueue.class;
headOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("head"));
tailOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField("tail"));
} catch (Exception e) {
throw new Error(e);
}
}
// 头结点
private transient volatile Node<E> head;
// 尾结点
private transient volatile Node<E> tail;
}
类的构造函数
ConcurrentLinkedQueue()
型构造函数- 该构造函数用于创建一个最初为空的 ConcurrentLinkedQueue,头结点与尾结点指向同一个结点,该结点的item域为null,next域也为null。
ConcurrentLinkedQueue(Collection<? extends E>)
型构造函数- 该构造函数用于创建一个最初包含给定 collection 元素的 ConcurrentLinkedQueue,按照此 collection 迭代器的遍历顺序来添加元素。
核心函数
offer
public boolean offer(E e) {
// 元素不为null
checkNotNull(e);
// 新生一个结点
final Node<E> newNode = new Node<E>(e);
for (Node<E> t = tail, p = t;;) { // 无限循环
// q为p结点的下一个结点
Node<E> q = p.next;
if (q == null) { // q结点为null
// p is last node
if (p.casNext(null, newNode)) { // 比较并进行替换p结点的next域
// Successful CAS is the linearization point
// for e to become an element of this queue,
// and for newNode to become "live".
if (p != t) // p不等于t结点,不一致 // hop two nodes at a time
// 比较并替换尾结点
casTail(t, newNode); // Failure is OK.
// 返回
return true;
}
// Lost CAS race to another thread; re-read next
}
else if (p == q) // p结点等于q结点
// We have fallen off list. If tail is unchanged, it
// will also be off-list, in which case we need to
// jump to head, from which all live nodes are always
// reachable. Else the new tail is a better bet.
// 原来的尾结点与现在的尾结点是否相等,若相等,则p赋值为head,否则,赋值为现在的尾结点
p = (t != (t = tail)) ? t : head;
else
// Check for tail updates after two hops.
// 重新赋值p结点
p = (p != t && t != (t = tail)) ? t : q;
}
}
offer 函数用于将指定元素插入此队列的尾部。下面模拟 offer 函数的操作,队列状态的变化(假设单线程添加元素,连续添加10、20两个元素)。
- 若ConcurrentLinkedQueue的初始状态如上图所示,即队列为空。单线程添加元素,此时,添加元素10,则状态如下所示
- 如上图所示,添加元素10后,tail没有变化,还是指向之前的结点,继续添加元素20,则状态如下所示
- 如上图所示,添加元素20后,tail指向了最新添加的结点。
poll
public E poll() {
restartFromHead:
for (;;) { // 无限循环
for (Node<E> h = head, p = h, q;;) { // 保存头结点
// item项
E item = p.item;
if (item != null && p.casItem(item, null)) { // item不为null并且比较并替换item成功
// Successful CAS is the linearization point
// for item to be removed from this queue.
if (p != h) // p不等于h // hop two nodes at a time
// 更新头结点
updateHead(h, ((q = p.next) != null) ? q : p);
// 返回item
return item;
}
else if ((q = p.next) == null) { // q结点为null
// 更新头结点
updateHead(h, p);
return null;
}
else if (p == q) // p等于q
// 继续循环
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}
此函数用于获取并移除此队列的头,如果此队列为空,则返回 null。
下面模拟 poll 函数的操作,队列状态的变化(假设单线程操作,状态为之前 offer10、20 后的状态,poll 两次)。
- 队列初始状态如上图所示,在poll操作后,队列的状态如下图所示
- 如上图可知,poll操作后,head改变了,并且head所指向的结点的item变为了null。再进行一次poll操作,队列的状态如下图所示。
- 如上图可知,poll操作后,head结点没有变化,只是指示的结点的item域变成了null。
remove
public boolean remove(Object o) {
// 元素为null,返回
if (o == null) return false;
Node<E> pred = null;
for (Node<E> p = first(); p != null; p = succ(p)) { // 获取第一个存活的结点
// 第一个存活结点的item值
E item = p.item;
if (item != null &&
o.equals(item) &&
p.casItem(item, null)) { // 找到item相等的结点,并且将该结点的item设置为null
// p的后继结点
Node<E> next = succ(p);
if (pred != null && next != null) // pred不为null并且next不为null
// 比较并替换next域
pred.casNext(p, next);
return true;
}
// pred赋值为p
pred = p;
}
return false;
}
此函数用于从队列中移除指定元素的单个实例(如果存在)。其中,会调用到first函数和succ函数,first函数的源码如下:
Node<E> first() {
restartFromHead:
for (;;) { // 无限循环,确保成功
for (Node<E> h = head, p = h, q;;) {
// p结点的item域是否为null
boolean hasItem = (p.item != null);
if (hasItem || (q = p.next) == null) { // item不为null或者next域为null
// 更新头结点
updateHead(h, p);
// 返回结点
return hasItem ? p : null;
}
else if (p == q) // p等于q
// 继续从头结点开始
continue restartFromHead;
else
// p赋值为q
p = q;
}
}
}
first函数用于找到链表中第一个存活的结点。
succ函数源码如下:
final Node<E> succ(Node<E> p) {
// p结点的next域
Node<E> next = p.next;
// 如果next域为自身,则返回头结点,否则,返回next
return (p == next) ? head : next;
}
succ用于获取结点的下一个结点。如果结点的next域指向自身,则返回head头结点,否则,返回next结点。
下面模拟remove函数的操作,队列状态的变化(假设单线程操作,状态为之前offer10、20后的状态,执行remove(10)、remove(20)操作)。
- 如上图所示,为ConcurrentLinkedQueue的初始状态,remove(10)后的状态如下图所示
- 如上图所示,当执行remove(10)后,head指向了head结点之前指向的结点的下一个结点,并且head结点的item域置为null。继续执行remove(20),状态如下图所示
- 如上图所示,执行remove(20)后,head与tail指向同一个结点,item域为null。
size
public int size() {
// 计数
int count = 0;
for (Node<E> p = first(); p != null; p = succ(p)) // 从第一个存活的结点开始往后遍历
if (p.item != null) // 结点的item域不为null
// Collection.size() spec says to max out
if (++count == Integer.MAX_VALUE) // 增加计数,若达到最大值,则跳出循环
break;
// 返回大小
return count;
}
此函数用于返回ConcurrenLinkedQueue的大小,从第一个存活的结点(first)开始,往后遍历链表,当结点的item域不为null时,增加计数,之后返回大小。
应用示例
import java.util.concurrent.ConcurrentLinkedQueue;
class PutThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public PutThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("add " + i);
clq.add(i);
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
class GetThread extends Thread {
private ConcurrentLinkedQueue<Integer> clq;
public GetThread(ConcurrentLinkedQueue<Integer> clq) {
this.clq = clq;
}
public void run() {
for (int i = 0; i < 10; i++) {
try {
System.out.println("poll " + clq.poll());
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<Integer> clq = new ConcurrentLinkedQueue<Integer>();
PutThread p1 = new PutThread(clq);
GetThread g1 = new GetThread(clq);
p1.start();
g1.start();
}
}
GetThread 线程不会因为 ConcurrentLinkedQueue 队列为空而等待,而是直接返回 null,所以当实现队列不空时,等待时,则需要用户自己实现等待逻辑。
深入理解
HOPS:延迟更新策略
通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:
tail更新触发时机
:当tail指向的节点的下一个节点不为null的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。head更新触发时机
:当head指向的节点的item域为null的时候,会执行定位队列真正的队头节点的操作,找到队头节点后完成删除之后才会通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。
并且在更新操作时,源码中会有注释为:hop two nodes at a time
。所以这种延迟更新的策略就被叫做HOPS的大概原因是这个,从上面更新时的状态图可以看出,head和tail的更新是“跳着的”即中间总是间隔了一个。那么这样设计的意图是什么呢?
如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以doug lea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。
适用场景
通过无锁来做到了更高的并发量,是个高性能的队列,但是使用场景相对不如阻塞队列常见,毕竟取数据也要不停的去循环,不如阻塞的逻辑好设计,但是在并发量特别大的情况下,是个不错的选择,性能上好很多,而且这个队列的设计也是特别费力,尤其的使用的改良算法和对哨兵的处理。整体的思路都是比较严谨的,这个也是使用了无锁造成的,我们自己使用无锁的条件的话,这个队列是个不错的参考。
1.3.19 - CH19-BlockingQueue
BlockingQueue
通常用于一个线程生产对象,而另外一个线程消费这些对象的场景。下图是对这个原理的阐述:
一个线程将会持续生产新对象并将其插入到队列之中,直到队列达到它所能容纳的临界点。也就是说,它是有限的。如果该阻塞队列到达了其临界点,负责生产的线程将会在往里边插入新对象时发生阻塞。它会一直处于阻塞之中,直到负责消费的线程从队列中拿走一个对象。 负责消费的线程将会一直从该阻塞队列中拿出对象。如果消费线程尝试去从一个空的队列中提取对象的话,这个消费线程将会处于阻塞之中,直到一个生产线程把一个对象丢进队列。
操作方法
具有 4 组不同的方法用于插入、移除以及对队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:
抛异常 | 布尔值 | 阻塞 | 超时 | |
---|---|---|---|---|
插入 | add(o) | offer(o) | put(o) | offer(o,timeout,timeunit) |
移除 | remove(o) | poll(o) | take(o) | poll(timeout,timeunit) |
检查 | element(o) | peek(o) |
- 抛异常:如果试图的操作无法立即执行,抛一个异常。
- 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
- 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
- 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
无法向一个 BlockingQueue 中插入 null。如果你试图插入 null,BlockingQueue 将会抛出一个 NullPointerException。
可以访问到 BlockingQueue 中的所有元素,而不仅仅是开始和结束的元素。比如说,你将一个对象放入队列之中以等待处理,但你的应用想要将其取消掉。那么你可以调用诸如 remove(o) 方法来将队列之中的特定对象进行移除。但是这么干效率并不高(译者注: 基于队列的数据结构,获取除开始或结束位置的其他对象的效率不会太高),因此你尽量不要用这一类的方法,除非你确实不得不那么做。
BlockingDeque
BlockingDeque 接口表示一个线程安放入和提取实例的双端队列。
BlockingDeque 类是一个双端队列,在不能够插入元素时,它将阻塞住试图插入元素的线程;在不能够抽取元素时,它将阻塞住试图抽取的线程。 deque(双端队列) 是 “Double Ended Queue” 的缩写。因此,双端队列是一个你可以从任意一端插入或者抽取元素的队列。
在线程既是一个队列的生产者又是这个队列的消费者的时候可以使用到 BlockingDeque。如果生产者线程需要在队列的两端都可以插入数据,消费者线程需要在队列的两端都可以移除数据,这个时候也可以使用 BlockingDeque。BlockingDeque 图解:
操作方法
一个 BlockingDeque - 线程在双端队列的两端都可以插入和提取元素。 一个线程生产元素,并把它们插入到队列的任意一端。如果双端队列已满,插入线程将被阻塞,直到一个移除线程从该队列中移出了一个元素。如果双端队列为空,移除线程将被阻塞,直到一个插入线程向该队列插入了一个新元素。
BlockingDeque 具有 4 组不同的方法用于插入、移除以及对双端队列中的元素进行检查。如果请求的操作不能得到立即执行的话,每个方法的表现也不同。这些方法如下:
抛异常 | 布尔值 | 阻塞 | 超时 | |
---|---|---|---|---|
队首-插入 | addFirst(o) | offerFirst(o) | putFirst(o) | offerFirst(o, timeout, timeunit) |
队首-移除 | removeFirst(o) | pollFirst(o) | takeFirst(o) | pollFirst(timeout, timeunit) |
队首-检查 | getFirst(o) | peekFirst(o) | ||
队尾-插入 | addLast(o) | offerLast(o) | putLast(o) | offerLast(o, timeout, timeunit) |
队尾-移除 | removeLast(o) | pollLast(o) | takeLast(o) | pollLast(timeout, timeunit) |
队尾-检查 | getLast(o) | peekLast(o) |
- 抛异常:如果试图的操作无法立即执行,抛一个异常。
- 特定值:如果试图的操作无法立即执行,返回一个特定的值(常常是 true / false)。
- 阻塞:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行。
- 超时:如果试图的操作无法立即执行,该方法调用将会发生阻塞,直到能够执行,但等待时间不会超过给定值。返回一个特定值以告知该操作是否成功(典型的是 true / false)。
BlockingQueue & BlockingDeque
BlockingDeque 接口继承自 BlockingQueue 接口。这就意味着你可以像使用一个 BlockingQueue 那样使用 BlockingDeque。如果你这么干的话,各种插入方法将会把新元素添加到双端队列的尾端,而移除方法将会把双端队列的首端的元素移除。正如 BlockingQueue 接口的插入和移除方法一样。
应用实例
这里是一个 Java 中使用 BlockingQueue 的示例。本示例使用的是 BlockingQueue 接口的 ArrayBlockingQueue 实现。 首先,BlockingQueueExample 类分别在两个独立的线程中启动了一个 Producer 和 一个 Consumer。Producer 向一个共享的 BlockingQueue 中注入字符串,而 Consumer 则会从中把它们拿出来。
public class BlockingQueueExample {
public static void main(String[] args) throws Exception {
BlockingQueue queue = new ArrayBlockingQueue(1024);
Producer producer = new Producer(queue);
Consumer consumer = new Consumer(queue);
new Thread(producer).start();
new Thread(consumer).start();
Thread.sleep(4000);
}
}
以下是 Producer 类。注意它在每次 put() 调用时是如何休眠一秒钟的。这将导致 Consumer 在等待队列中对象的时候发生阻塞。
public class Producer implements Runnable{
protected BlockingQueue queue = null;
public Producer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
queue.put("1");
Thread.sleep(1000);
queue.put("2");
Thread.sleep(1000);
queue.put("3");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
以下是 Consumer 类。它只是把对象从队列中抽取出来,然后将它们打印到 System.out。
public class Consumer implements Runnable{
protected BlockingQueue queue = null;
public Consumer(BlockingQueue queue) {
this.queue = queue;
}
public void run() {
try {
System.out.println(queue.take());
System.out.println(queue.take());
System.out.println(queue.take());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
ArrayBlockingQueue
ArrayBlockingQueue 类实现了 BlockingQueue 接口。
ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了。
ArrayBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
DelayQueue
DelayQueue 实现了 BlockingQueue 接口。
DelayQueue是一个无界的 BlockingQueue,用于放置实现了 Delayed 接口的对象,其中的对象只能在其到期时才能从队列中取走。这种队列是有序的,即队头对象的延迟到期时间最长。
元素进入队列后,先进行排序,然后,只有 getDelay 也就是剩余时间为0的时候,该元素才有资格被消费者从队列中取出来,所以构造函数一般都有一个时间传入。
public interface Delayed extends Comparable<Delayed< {
public long getDelay(TimeUnit timeUnit);
}
传递给 getDelay 方法的 getDelay 实例是一个枚举类型,它表明了将要延迟的时间段。
Delayed 接口也继承了 java.lang.Comparable 接口,这也就意味着 Delayed 对象之间可以进行对比。这个可能在对 DelayQueue 队列中的元素进行排序时有用,因此它们可以根据过期时间进行有序释放。 以下是使用 DelayQueue 的例子:
public class DelayQueueExample {
public static void main(String[] args) {
DelayQueue queue = new DelayQueue();
Delayed element1 = new DelayedElement();
queue.put(element1);
Delayed element2 = queue.take();
}
}
LinkedBlocingQueue
LinkedBlockingQueue 类实现了 BlockingQueue 接口。
LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。
LinkedBlockingQueue 内部以 FIFO(先进先出)的顺序对元素进行存储。队列中的头元素在所有元素之中是放入时间最久的那个,而尾元素则是最短的那个。
PriorityBlockingQueue
PriorityBlockingQueue 类实现了 BlockingQueue 接口。
PriorityBlockingQueue 是一个无界的并发队列。它使用了和类 java.util.PriorityQueue 一样的排序规则。你无法向这个队列中插入 null 值。 所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。 注意 PriorityBlockingQueue 对于具有相等优先级(compare() == 0)的元素并不强制任何特定行为。
同时注意,如果你从一个 PriorityBlockingQueue 获得一个 Iterator 的话,该 Iterator 并不能保证它对元素的遍历是以优先级为序的。
SynchronousQueue
SynchronousQueue 类实现了 BlockingQueue 接口。
SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。 据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。
1.3.20 - CH20-FutureTask
概览
- FutureTask 为 Future 提供了基础实现,如获取任务执行结果(get)和取消任务等。
- 如果任务尚未完成,获取任务执行结果时将会阻塞。
- 一旦执行结束,任务就不能被重启或取消(除非使用runAndReset执行计算)。
- FutureTask 常用来封装 Callable 和 Runnable,也可以作为一个任务提交到线程池中执行。
- 除了作为一个独立的类之外,此类也提供了一些功能性函数供我们创建自定义 task 类使用。
- FutureTask 的线程安全由 CAS 来保证。
层级结构
FutureTask 实现了 RunnableFuture 接口,则 RunnableFuture 接口继承了 Runnable 接口和 Future 接口,所以 FutureTask 既能当做一个 Runnable 直接被 Thread 执行,也能作为 Future 用来得到 Callable 的计算结果。
源码分析
Callable 接口
Callable 是个泛型接口,泛型V就是要 call() 方法返回的类型。对比 Runnable 接口,Runnable 不会返回数据也不能抛出异常。
public interface Callable<V> {
/**
* Computes a result, or throws an exception if unable to do so.
*
* @return computed result
* @throws Exception if unable to compute a result
*/
V call() throws Exception;
}
Future 接口
Future 接口代表异步计算的结果,通过 Future 接口提供的方法可以查看异步计算是否执行完成,或者等待执行结果并获取执行结果,同时还可以取消执行。Future 接口的定义如下:
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}
- cancel:取消异步任务的执行。
- 如果异步任务已经完成或者已经被取消,或者由于某些原因不能取消,则会返回 false。
- 如果任务还没有被执行,则会返回 true 并且异步任务不会被执行。
- 如果任务已经开始执行了但是还没有执行完成:
- 若 mayInterruptIfRunning 为 true,则会立即中断执行任务的线程并返回 true;
- 若 mayInterruptIfRunning 为 false,则会返回 true 且不会中断任务执行线程。
- isCanceled:判断任务是否被取消。
- 如果任务在结束(正常执行结束或者执行异常结束)前被取消则返回 true,
- 否则返回 false。
- isDone:判断任务是否已经完成。
- 如果完成则返回 true,否则返回 false。
- 任务执行过程中发生异常、任务被取消也属于任务已完成,也会返回true。
- get:获取任务执行结果。
- 如果任务还没完成则会阻塞等待直到任务执行完成。
- 如果任务被取消则会抛出 CancellationException 异常。
- 如果任务执行过程发生异常则会抛出 ExecutionException 异常。
- 如果阻塞等待过程中被中断则会抛出 InterruptedException 异常。
- get(timeout,timeunit):带超时时间的 get() 版本。
- 如果阻塞等待过程中超时则会抛出 TimeoutException 异常。
核心属性
//内部持有的callable任务,运行完毕后置空
private Callable<V> callable;
//从get()中返回的结果或抛出的异常
private Object outcome; // non-volatile, protected by state reads/writes
//运行callable的线程
private volatile Thread runner;
//使用Treiber栈保存等待线程
private volatile WaitNode waiters;
//任务状态
private volatile int state;
private static final int NEW = 0;
private static final int COMPLETING = 1;
private static final int NORMAL = 2;
private static final int EXCEPTIONAL = 3;
private static final int CANCELLED = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED = 6;
其中的状态值 state 使用 volatile 修饰,以确保任何一个线程对状态的修改立即会对其他线程可见。
7 种具体状态表示:
- NEW:初始状态,表示这个是新任务或者尚未被执行完的任务。
- COMPLETING:任务已经执行完成或者执行任务的时候发生异常。
- 但是任务执行结果或者异常原因还没有保存到 outcome 字段时,状态由 NEW 变为 COMPLETING。
- outcome字段用来保存任务执行结果,如果发生异常,则用来保存异常原因。
- 该状态持续时间较短,属于中间状态。
- NORMAL:任务已经执行完成并且任务执行结果已经保存到 outcome 字段,状态会从 COMPLETING 转换到 NORMAL。
- 这是一个最终态。
- EXCEPTIONAL:任务执行发生异常并且异常原因已经保存到 outcome 字段中后,状态会从 COMPLETING 转换到 EXCEPTIONAL。
- 这是一个最终态。
- CANCELED:任务还没开始执行或者已经开始执行但是还没有执行完成的时候,用户调用了
cancel(false)
方法取消任务且不中断任务执行线程,这个时候状态会从 NEW 转化为 CANCELLED 状态。- 这是一个最终态。
- INTERRUPTING:任务还没开始执行或者已经执行但是还没有执行完成的时候,用户调用了
cancel(true)
方法取消任务并且要中断任务执行线程但是还没有中断任务执行线程之前,状态会从 NEW 转化为 INTERRUPTING。- 这是一个中间状态。
- INTERRUPTED:调用 interrupt() 中断任务执行线程之后状态会从 INTERRUPTING 转换到 INTERRUPTED。
- 这是一个最终态。
- 所有值大于 COMPLETING 的状态都表示任务已经执行完成(任务正常执行完成,任务执行异常或者任务被取消)。
构造函数
FutureTask(Callable<V> callable)
public FutureTask(Callable<V> callable) {
if (callable == null)
throw new NullPointerException();
this.callable = callable;
this.state = NEW; // ensure visibility of callable
}
- 该构造函数会把传入的 Callable 变量保存在t his.callable 字段中。
- 该字段定义为
private Callable<V> callable
; 用来保存底层的调用,在被执行完成以后会指向 null。 - 接着会初始化 state 字段为 NEW。
FutureTask(Runnable runnable, V result)
public FutureTask(Runnable runnable, V result) {
this.callable = Executors.callable(runnable, result);
this.state = NEW; // ensure visibility of callable
}
- 这个构造函数会把传入的 Runnable 封装成一个 Callable 对象保存在 callable 字段中。
- 同时如果任务执行成功的话就会返回传入的 result。
- 如果不需要返回值的话可以传入一个 null 作为 result。
- Executors.callable() 的功能是把 Runnable 转换成 Callable。
public static <T> Callable<T> callable(Runnable task, T result) {
if (task == null)
throw new NullPointerException();
return new RunnableAdapter<T>(task, result); // 适配器
}
这里采用了适配器模式:
static final class RunnableAdapter<T> implements Callable<T> {
final Runnable task;
final T result;
RunnableAdapter(Runnable task, T result) {
this.task = task;
this.result = result;
}
public T call() {
task.run();
return result;
}
}
这里的适配器只是简单实现了 Callable 接口,在 call 中调用 Runnable.run 方法,然后把传入的 result 作为返回值返回调用。
在 new 了一个 FutureTask 之后,接下来就是在另一个线程中执行该 Task,无论是通过直接 new 一个 Thread 还是通过线程池,执行的都是 run 方法。
核心方法:run
public void run() {
//新建任务,CAS替换runner为当前线程
if (state != NEW ||
!UNSAFE.compareAndSwapObject(this, runnerOffset,
null, Thread.currentThread()))
return;
try {
Callable<V> c = callable;
if (c != null && state == NEW) {
V result;
boolean ran;
try {
result = c.call();
ran = true;
} catch (Throwable ex) {
result = null;
ran = false;
setException(ex);
}
if (ran)
set(result);//设置执行结果
}
} finally {
// runner must be non-null until state is settled to
// prevent concurrent calls to run()
runner = null;
// state must be re-read after nulling runner to prevent
// leaked interrupts
int s = state;
if (s >= INTERRUPTING)
handlePossibleCancellationInterrupt(s);//处理中断逻辑
}
}
- 运行任务:如果任务状态为NEW状态,则利用CAS修改为当前线程。执行完毕调用set(result)方法设置执行结果。set(result)源码如下:
protected void set(V v) {
if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
outcome = v;
UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
finishCompletion();//执行完毕,唤醒等待线程
}
}
- 首先利用cas修改state状态为COMPLETING,设置返回结果,然后使用 lazySet(UNSAFE.putOrderedInt)的方式设置state状态为NORMAL。结果设置完毕后,调用finishCompletion()方法唤醒等待线程,源码如下:
private void finishCompletion() {
// assert state > COMPLETING;
for (WaitNode q; (q = waiters) != null;) {
if (UNSAFE.compareAndSwapObject(this, waitersOffset, q, null)) {//移除等待线程
for (;;) {//自旋遍历等待线程
Thread t = q.thread;
if (t != null) {
q.thread = null;
LockSupport.unpark(t);//唤醒等待线程
}
WaitNode next = q.next;
if (next == null)
break;
q.next = null; // unlink to help gc
q = next;
}
break;
}
}
//任务完成后调用函数,自定义扩展
done();
callable = null; // to reduce footprint
}
- 回到run方法,如果在 run 期间被中断,此时需要调用handlePossibleCancellationInterrupt方法来处理中断逻辑,确保任何中断(例如cancel(true))只停留在当前run或runAndReset的任务中,源码如下:
private void handlePossibleCancellationInterrupt(int s) {
//在中断者中断线程之前可能会延迟,所以我们只需要让出CPU时间片自旋等待
if (s == INTERRUPTING)
while (state == INTERRUPTING)
Thread.yield(); // wait out pending interrupt
}
核心方法:get
//获取执行结果
public V get() throws InterruptedException, ExecutionException {
int s = state;
if (s <= COMPLETING)
s = awaitDone(false, 0L);
return report(s);
}
FutureTask 通过get()方法获取任务执行结果。如果任务处于未完成的状态(state <= COMPLETING
),就调用awaitDone方法(后面单独讲解)等待任务完成。任务完成后,通过report方法获取执行结果或抛出执行期间的异常。report源码如下:
//返回执行结果或抛出异常
private V report(int s) throws ExecutionException {
Object x = outcome;
if (s == NORMAL)
return (V)x;
if (s >= CANCELLED)
throw new CancellationException();
throw new ExecutionException((Throwable)x);
}
核心方法:awaitDone(boolean timed, long nanos)
private int awaitDone(boolean timed, long nanos)
throws InterruptedException {
final long deadline = timed ? System.nanoTime() + nanos : 0L;
WaitNode q = null;
boolean queued = false;
for (;;) {//自旋
if (Thread.interrupted()) {//获取并清除中断状态
removeWaiter(q);//移除等待WaitNode
throw new InterruptedException();
}
int s = state;
if (s > COMPLETING) {
if (q != null)
q.thread = null;//置空等待节点的线程
return s;
}
else if (s == COMPLETING) // cannot time out yet
Thread.yield();
else if (q == null)
q = new WaitNode();
else if (!queued)
//CAS修改waiter
queued = UNSAFE.compareAndSwapObject(this, waitersOffset,
q.next = waiters, q);
else if (timed) {
nanos = deadline - System.nanoTime();
if (nanos <= 0L) {
removeWaiter(q);//超时,移除等待节点
return state;
}
LockSupport.parkNanos(this, nanos);//阻塞当前线程
}
else
LockSupport.park(this);//阻塞当前线程
}
}
awaitDone 用于等待任务完成,或任务因为中断或超时而终止。返回任务的完成状态。函数执行逻辑如下:
private void removeWaiter(WaitNode node) {
if (node != null) {
node.thread = null;//首先置空线程
retry:
for (;;) { // restart on removeWaiter race
//依次遍历查找
for (WaitNode pred = null, q = waiters, s; q != null; q = s) {
s = q.next;
if (q.thread != null)
pred = q;
else if (pred != null) {
pred.next = s;
if (pred.thread == null) // check for race
continue retry;
}
else if (!UNSAFE.compareAndSwapObject(this, waitersOffset,q, s)) //cas替换
continue retry;
}
break;
}
}
}
- 加入当前线程状态为结束(state>COMPLETING),则根据需要置空等待节点的线程,并返回 Future 状态;
- 如果当前状态为正在完成(COMPLETING),说明此时 Future 还不能做出超时动作,为任务让出CPU执行时间片;
- 如果state为NEW,先新建一个WaitNode,然后CAS修改当前waiters;
- 如果等待超时,则调用removeWaiter移除等待节点,返回任务状态;如果设置了超时时间但是尚未超时,则park阻塞当前线程;
- 其他情况直接阻塞当前线程。
核心方法:cancel(boolean mayInterruptIfRunning)
public boolean cancel(boolean mayInterruptIfRunning) {
//如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED
if (!(state == NEW &&
UNSAFE.compareAndSwapInt(this, stateOffset, NEW,
mayInterruptIfRunning ? INTERRUPTING : CANCELLED)))
return false;
try { // in case call to interrupt throws exception
if (mayInterruptIfRunning) {//可以在运行时中断
try {
Thread t = runner;
if (t != null)
t.interrupt();
} finally { // final state
UNSAFE.putOrderedInt(this, stateOffset, INTERRUPTED);
}
}
} finally {
finishCompletion();//移除并唤醒所有等待线程
}
return true;
}
尝试取消任务。如果任务已经完成或已经被取消,此操作会失败。
- 如果当前Future状态为NEW,根据参数修改Future状态为INTERRUPTING或CANCELLED。
- 如果当前状态不为NEW,则根据参数mayInterruptIfRunning决定是否在任务运行中也可以中断。中断操作完成后,调用finishCompletion移除并唤醒所有等待线程。
应用实例
Future & ExecutorService
public class FutureDemo {
public static void main(String[] args) {
ExecutorService executorService = Executors.newCachedThreadPool();
Future future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
Long start = System.currentTimeMillis();
while (true) {
Long current = System.currentTimeMillis();
if ((current - start) > 1000) {
return 1;
}
}
}
});
try {
Integer result = (Integer)future.get();
System.out.println(result);
}catch (Exception e){
e.printStackTrace();
}
}
}
FutureTask & ExecutorService
ExecutorService executor = Executors.newCachedThreadPool();
Task task = new Task();
FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
executor.submit(futureTask);
executor.shutdown();
Future & Thread
import java.util.concurrent.*;
public class CallDemo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 2. 新建FutureTask,需要一个实现了Callable接口的类的实例作为构造函数参数
FutureTask<Integer> futureTask = new FutureTask<Integer>(new Task());
// 3. 新建Thread对象并启动
Thread thread = new Thread(futureTask);
thread.setName("Task thread");
thread.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");
// 4. 调用isDone()判断任务是否结束
if(!futureTask.isDone()) {
System.out.println("Task is not done");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
int result = 0;
try {
// 5. 调用get()方法获取任务结果,如果任务没有执行完成则阻塞等待
result = futureTask.get();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("result is " + result);
}
// 1. 继承Callable接口,实现call()方法,泛型参数为要返回的类型
static class Task implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("Thread [" + Thread.currentThread().getName() + "] is running");
int result = 0;
for(int i = 0; i < 100;++i) {
result += i;
}
Thread.sleep(3000);
return result;
}
}
}
1.3.21 - CH21-ThreadPoolExecutor
线程池的作用
- 降低资源消耗:线程无限制的创建,使用完毕后消耗
- 提高响应速度:无需频繁新建线程
- 提高线程的可管理性
应用详解
线程池即一个线程集合 workerSet 和一个阻塞队列 workQueue。
当用户向线程池提交一个任务时,线程池会先将任务放入 workQueue 中。
workerSet 中的线程会不断的从 workQueue 中获取任务并执行。
当 workQueue 中没有任务时,worker 则会阻塞,直到队列中有任务了再开始执行。
Executor 原理
当一个线程提交至线程池后:
- 线程池首先判断当前运行的线程数量是否少于与 corePoolSize。如果是则新建工作线程来执行任务,否则进入 2。
- 判断 BlockingQueue 是否已满,如果没满,则将任务放入 BlockingQueue,否则进入 3。
- 如果新建线程会使当前线程珊瑚粮超过 maximumPoolSize,则交给 RejectedExecutionHandler 来处理。
当 ThreadPoolExecutor 新建线程时,通过 CAS 来更新线程池的状态 ctl。
参数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler)
- corePoolSize:核心线程数。
- 当提交一个任务时,线程池新建线程来执行任务,直到线程数量等于 corePoolSize,即使存在空闲线程。
- 如果当前线程数量等于 corePoolSize,提交的任务将会被保存到阻塞队列,等待执行。
- 如果执行了线程池的 prestartAllCoreThreads 方法,线程池会提前创建并开启所有核心线程。
- workQueue:用于保存需要被执行的任务,可选的队列类型有:
- ArrayBlockingQueue:基于数组结构,按 FIFO 排序任务。
- LinkedBlockingQueue:基于链表结构,按 FIFO 排序任务,吞吐量高于 ArrayBlockingQueue。
- 比 ArrayBlockingQueue 在插入、删除元素时性能更优,但 put、take 时均需加锁。
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作必须等待另一个线程调用移除操作,否则插入操作将一直阻塞,吞吐量高于 LinkedBlockingQueue。
- 使用无锁算法,基于节点状态执行判断,无需使用锁,核心是 Transfer.transfer。
- PriorityBlockingQueue:具有优先级的无界阻塞队列。
- maximumPoolSize:允许的最大线程数量。
- 如果阻塞队列已满后继续提交任务,则需创建新的线程来执行任务,前提是线程数小于最大允许数量。
- 当阻塞队列是无界队列时,则最大允许数量不起作用。
- keepAliveTime:线程空闲存活时间。
- 即当线程没有执行任务时,该线程继续存活的时间。
- 默认情况下,该参数只有在线程数量大于 corePoolSize 时才起效。
- 超过空闲存活时间的现场将被终止。
- unit:线程空闲存活时间的时间单位。
- threadFactory:创建线程的工厂,通过自定义工厂可以设置线程的属性,如名称、demaon。
- handler:线程池饱和策略。如果队列已满且没有空闲线程,如果继续提交任务,必须采取一种策略来处理该任务,共有四种策略:
- AbortPolicy:直接抛出异常,默认策略。
- CallerRunPolicy:用调用者线程来执行任务。
- DiscardOldestPolicy:丢弃队列中较靠前的任务,以执行当前任务。
- DiscardPolicy:直接丢弃任务。
- 支持自定义饱和策略,比如记录日志会持久化存储任务信息。
类型
newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
- 固定线程数量(corePoolSize)。
- 即使线程池没有可执行的任务,也不会终止线程。
- 采用无界队列 LinkedBlockingQueue(Integer.MAX_VALUE),潜在问题:
- 线程数量不会超过 corePoolSize,导致 maximumPoolSize 和 keepAliveTIme 参数失效。
- 采用无界队列导致永远不会拒绝提交的任务,导致饱和策略失效。
newSingleThreadExecutor
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
- 初始化的线程池中仅一个线程,如果该线程异常结束,会新建线程以继续执行任务。
- 该唯一线程可以保证顺序处理队列中的任务。
- 基于无界队列,因此饱和策略失效。
newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
- 线程数最多可达 Integer.MAX_VALUE。
- 内部使用 SynchronousQueue 作为阻塞队列。
- 线程空间时间超过最大空闲时长会终止线程。
- 如果提交任务没有可用线程,则新建线程。
- 执行过程与前两个线程池不同:
- 主线程调用 SynchronousQueue.offer 添加 task,如果此时线程池中有空闲线程尝试读取队列中的任务,即调用 SynchronousQueue.poll,则主线程将该 task 交给空闲线程。否则进入下一步。
- 当线程池为空或没有空闲线程,则新建线程。
- 执行完任务的线程如果在 60 秒内空间,则被终止,因此长时间空闲的线程池不会持有任何线程资源。
关闭线程池
遍历线程池中的所有线程,然后逐个调用线程的 interrupt 方法来中断线程。
关闭方式:shutdown
将线程池里的线程状态设置成SHUTDOWN状态, 然后中断所有没有正在执行任务的线程。
关闭方式:shutdownNow
将线程池里的线程状态设置成STOP状态, 然后停止所有正在执行或暂停任务的线程。
只要调用这两个关闭方法中的任意一个, isShutDown() 返回true. 当所有任务都成功关闭了, isTerminated()返回true。
ThreadPoolExecutor
关键属性
//这个属性是用来存放 当前运行的worker数量以及线程池状态的
//int是32位的,这里把int的高3位拿来充当线程池状态的标志位,后29位拿来充当当前运行worker的数量
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
//存放任务的阻塞队列
private final BlockingQueue<Runnable> workQueue;
//worker的集合,用set来存放
private final HashSet<Worker> workers = new HashSet<Worker>();
//历史达到的worker数最大值
private int largestPoolSize;
//当队列满了并且worker的数量达到maxSize的时候,执行具体的拒绝策略
private volatile RejectedExecutionHandler handler;
//超出coreSize的worker的生存时间
private volatile long keepAliveTime;
//常驻worker的数量
private volatile int corePoolSize;
//最大worker的数量,一般当workQueue满了才会用到这个参数
private volatile int maximumPoolSize;
内部状态
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int CAPACITY = (1 << COUNT_BITS) - 1;
// runState is stored in the high-order bits
private static final int RUNNING = -1 << COUNT_BITS;
private static final int SHUTDOWN = 0 << COUNT_BITS;
private static final int STOP = 1 << COUNT_BITS;
private static final int TIDYING = 2 << COUNT_BITS;
private static final int TERMINATED = 3 << COUNT_BITS;
// Packing and unpacking ctl
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c) { return c & CAPACITY; }
private static int ctlOf(int rs, int wc) { return rs | wc; }
其中AtomicInteger变量ctl的功能非常强大: 利用低29位表示线程池中线程数,通过高3位表示线程池的运行状态:
- RUNNING: -1 « COUNT_BITS,即高3位为111,该状态的线程池会接收新任务,并处理阻塞队列中的任务;
- SHUTDOWN: 0 « COUNT_BITS,即高3位为000,该状态的线程池不会接收新任务,但会处理阻塞队列中的任务;
- STOP : 1 « COUNT_BITS,即高3位为001,该状态的线程不会接收新任务,也不会处理阻塞队列中的任务,而且会中断正在运行的任务;
- TIDYING : 2 « COUNT_BITS,即高3位为010, 所有的任务都已经终止;
- TERMINATED: 3 « COUNT_BITS,即高3位为011, terminated()方法已经执行完成
执行过程
execute –> addWorker –> runworker(getTask)
- 线程池的工作线程通过Woker类实现,在ReentrantLock锁的保证下,把Woker实例插入到HashSet后,并启动Woker中的线程。
- 从Woker类的构造方法实现可以发现: 线程工厂在创建线程thread时,将Woker实例本身this作为参数传入,当执行start方法启动线程thread时,本质是执行了Worker的runWorker方法。
- firstTask执行完成之后,通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;
execute 方法
ThreadPoolExecutor.execute(task)实现了Executor.execute(task)
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
/*
* Proceed in 3 steps:
*
* 1. If fewer than corePoolSize threads are running, try to
* start a new thread with the given command as its first
* task. The call to addWorker atomically checks runState and
* workerCount, and so prevents false alarms that would add
* threads when it shouldn't, by returning false.
*
* 2. If a task can be successfully queued, then we still need
* to double-check whether we should have added a thread
* (because existing ones died since last checking) or that
* the pool shut down since entry into this method. So we
* recheck state and if necessary roll back the enqueuing if
* stopped, or start a new thread if there are none.
*
* 3. If we cannot queue task, then we try to add a new
* thread. If it fails, we know we are shut down or saturated
* and so reject the task.
*/
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
//workerCountOf获取线程池的当前线程数;小于corePoolSize,执行addWorker创建新线程执行command任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// double check: c, recheck
// 线程池处于RUNNING状态,把提交的任务成功放入阻塞队列中
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// recheck and if necessary 回滚到入队操作前,即倘若线程池shutdown状态,就remove(command)
//如果线程池没有RUNNING,成功从阻塞队列中删除任务,执行reject方法处理任务
if (! isRunning(recheck) && remove(command))
reject(command);
//线程池处于running状态,但是没有线程,则创建线程
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 往线程池中创建新的线程失败,则reject任务
else if (!addWorker(command, false))
reject(command);
}
- 为什么需要double check线程池的状态?
在多线程环境下,线程池的状态时刻在变化,而ctl.get()是非原子操作,很有可能刚获取了线程池状态后线程池状态就改变了。判断是否将command加入workque是线程池之前的状态。倘若没有double check,万一线程池处于非running状态(在多线程环境下很有可能发生),那么command永远不会执行。
addWorker 方法
从方法execute的实现可以看出: addWorker主要负责创建新的线程并执行任务。
线程池创建新线程执行任务时,需要获取全局锁:
private final ReentrantLock mainLock = new ReentrantLock();
private boolean addWorker(Runnable firstTask, boolean core) {
// CAS更新线程池数量
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
// 线程池重入锁
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start(); // 线程启动,执行任务(Worker.thread(firstTask).start());
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
Worker.runWorker 方法
private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this); // 创建线程
}
/** Delegates main run loop to outer runWorker */
public void run() {
runWorker(this);
}
// ...
}
- 继承了AQS类,可以方便的实现工作线程的中止操作;
- 实现了Runnable接口,可以将自身作为一个任务在工作线程中执行;
- 当前提交的任务firstTask作为参数传入Worker的构造方法;
一些属性还有构造方法:
//运行的线程,前面addWorker方法中就是直接通过启动这个线程来启动这个worker
final Thread thread;
//当一个worker刚创建的时候,就先尝试执行这个任务
Runnable firstTask;
//记录完成任务的数量
volatile long completedTasks;
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
//创建一个Thread,将自己设置给他,后面这个thread启动的时候,也就是执行worker的run方法
this.thread = getThreadFactory().newThread(this);
}
runWorker方法是线程池的核心:
- 线程启动之后,通过unlock方法释放锁,设置AQS的state为0,表示运行可中断;
- Worker执行firstTask或从workQueue中获取任务
- 进行加锁操作,保证thread不被其他线程中断(除非线程池被中断)
- 检查线程池状态,倘若线程池处于中断状态,当前线程将中断。
- 执行beforeExecute
- 执行任务的run方法
- 执行afterExecute方法
- 解锁操作
通过getTask方法从阻塞队列中获取等待的任务,如果队列中没有任务,getTask方法会被阻塞并挂起,不会占用cpu资源;
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
w.unlock(); // allow interrupts
boolean completedAbruptly = true;
try {
// 先执行firstTask,再从workerQueue中取task(getTask())
while (task != null || (task = getTask()) != null) {
w.lock();
// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted. This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
(Thread.interrupted() &&
runStateAtLeast(ctl.get(), STOP))) &&
!wt.isInterrupted())
wt.interrupt();
try {
beforeExecute(wt, task);
Throwable thrown = null;
try {
task.run();
} catch (RuntimeException x) {
thrown = x; throw x;
} catch (Error x) {
thrown = x; throw x;
} catch (Throwable x) {
thrown = x; throw new Error(x);
} finally {
afterExecute(task, thrown);
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
completedAbruptly = false;
} finally {
processWorkerExit(w, completedAbruptly);
}
}
getTask 方法
下面来看一下getTask()方法,这里面涉及到keepAliveTime的使用,从这个方法我们可以看出先吃池是怎么让超过corePoolSize的那部分worker销毁的。
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
注意这里一段代码是keepAliveTime起作用的关键:
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
- allowCoreThreadTimeOut为false,线程即使空闲也不会被销毁;
- 倘若为ture,在keepAliveTime内仍空闲则会被销毁。
如果线程允许空闲等待而不被销毁timed == false,workQueue.take任务:
如果阻塞队列为空,当前线程会被挂起等待;
当队列中有任务加入时,线程被唤醒,take方法返回任务,并执行;
如果线程不允许无休止空闲timed == true, workQueue.poll任务: 如果在keepAliveTime时间内,阻塞队列还是没有任务,则返回null;
提交过程
- submit任务,等待线程池execute
- 执行FutureTask类的get方法时,会把主线程封装成WaitNode节点并保存在waiters链表中, 并阻塞等待运行结果;
- FutureTask任务执行完成后,通过UNSAFE设置waiters相应的waitNode为null,并通过LockSupport类unpark方法唤醒主线程;
在实际业务场景中,Future和Callable基本是成对出现的,Callable负责产生结果,Future负责获取结果。
- Callable接口类似于Runnable,只是Runnable没有返回值。
- Callable任务除了返回正常结果之外,如果发生异常,该异常也会被返回,即Future可以拿到异步执行任务各种结果;
- Future.get方法会导致主线程阻塞,直到Callable任务执行完成;
submit 方法
AbstractExecutorService.submit()实现了ExecutorService.submit() 可以获取执行完的返回值, 而ThreadPoolExecutor 是AbstractExecutorService.submit()的子类,所以submit方法也是ThreadPoolExecutor的方法。
// submit()在ExecutorService中的定义
<T> Future<T> submit(Callable<T> task);
<T> Future<T> submit(Runnable task, T result);
Future<?> submit(Runnable task);
// submit方法在AbstractExecutorService中的实现
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。
RunnableFuture<Void> ftask = newTaskFor(task, null);
execute(ftask);
return ftask;
}
通过submit方法提交的Callable任务会被封装成了一个FutureTask对象。通过Executor.execute方法提交FutureTask到线程池中等待被执行,最终执行的是FutureTask的run方法;
关闭过程
shutdown方法会将线程池的状态设置为SHUTDOWN,线程池进入这个状态后,就拒绝再接受任务,然后会将剩余的任务全部执行完:
public void shutdown() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//检查是否可以关闭线程
checkShutdownAccess();
//设置线程池状态
advanceRunState(SHUTDOWN);
//尝试中断worker
interruptIdleWorkers();
//预留方法,留给子类实现
onShutdown(); // hook for ScheduledThreadPoolExecutor
} finally {
mainLock.unlock();
}
tryTerminate();
}
private void interruptIdleWorkers() {
interruptIdleWorkers(false);
}
private void interruptIdleWorkers(boolean onlyOne) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有的worker
for (Worker w : workers) {
Thread t = w.thread;
//先尝试调用w.tryLock(),如果获取到锁,就说明worker是空闲的,就可以直接中断它
//注意的是,worker自己本身实现了AQS同步框架,然后实现的类似锁的功能
//它实现的锁是不可重入的,所以如果worker在执行任务的时候,会先进行加锁,这里tryLock()就会返回false
if (!t.isInterrupted() && w.tryLock()) {
try {
t.interrupt();
} catch (SecurityException ignore) {
} finally {
w.unlock();
}
}
if (onlyOne)
break;
}
} finally {
mainLock.unlock();
}
}
shutdownNow做的比较绝,它先将线程池状态设置为STOP,然后拒绝所有提交的任务。最后中断左右正在运行中的worker,然后清空任务队列。
public List<Runnable> shutdownNow() {
List<Runnable> tasks;
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
checkShutdownAccess();
//检测权限
advanceRunState(STOP);
//中断所有的worker
interruptWorkers();
//清空任务队列
tasks = drainQueue();
} finally {
mainLock.unlock();
}
tryTerminate();
return tasks;
}
private void interruptWorkers() {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
//遍历所有worker,然后调用中断方法
for (Worker w : workers)
w.interruptIfStarted();
} finally {
mainLock.unlock();
}
}
配置线程池需要考虑因素
从任务的优先级,任务的执行时间长短,任务的性质(CPU密集/ IO密集),任务的依赖关系这四个角度来分析。并且近可能地使用有界的工作队列。
性质不同的任务可用使用不同规模的线程池分开处理:
- CPU密集型: 尽可能少的线程,Ncpu+1
- IO密集型: 尽可能多的线程, Ncpu*2,比如数据库连接池
- 混合型: CPU密集型的任务与IO密集型任务的执行时间差别较小,拆分为两个线程池;否则没有必要拆分。
监控线程池的状态
可以使用ThreadPoolExecutor以下方法:
- getTaskCount
- getCompletedTaskCount
- getLargestPoolSize
- getPoolSize
- getActiveCount
参考
1.3.22 - CH22-ScheduledThreadPoolExecutor
概览
继承自 ThreadPoolExecutor,为任务提供延迟或周期执行,属于线程池的一种。相比 ThreadPoolExecutor 具有以下特性:
- 使用专门的任务类型—ScheduledFutureTask 来执行周期任务,也可以接收无需时间调度的任务(这些任务通过 ExecutorService 直接执行)。
- 使用专门的存储队列—DelayedWorkQueue 来存储任务,DelayedWorkQueue 是无界延迟队列 DelayedQueue 的一种。相比 ThreadPoolExecutor 简化了执行机制。
- 支持可选的 run-after-shutdown 参数,在池被关闭(shutdown)之后支持可选的逻辑来决定是否继续运行周期或延迟任务。并且当任务的(重新)提交操作与 shutdown 操作重叠时,复查逻辑也不相同。
层级结构
ScheduledThreadPoolExecutor 内部构造了两个类:
- ScheduledFutureTask:
- 继承 FutureTask,说明是一个异步运算任务。
- 实现 Rnnable、Future、Delayed 接口,说明是一个可以延迟执行的异步运算任务。
- DelayedWorkQueue:
- 专用于存储周期或延迟任务而定义的延迟队列,继承了 AbstractQueue,为了契合 ThreadPoolExecutor 也实现了 BlockingQueue 接口。
- 内部只允许存储 RunnableScheduledFuture 类型的任务。
- 与 DelayQueue 的不同之处在于它只允许存放 RunnableScheduledFuture 对象,并且自己实现了二叉堆(DelayQueue 利用了 PriorityQueue 的二叉堆结构)。
源码分析
内部类:ScheduledFutureTask
属性:
//为相同延时任务提供的顺序编号
private final long sequenceNumber;
//任务可以执行的时间,纳秒级
private long time;
//重复任务的执行周期时间,纳秒级。
private final long period;
//重新入队的任务
RunnableScheduledFuture<V> outerTask = this;
//延迟队列的索引,以支持更快的取消操作
int heapIndex;
- sequenceNumber:当两个任务具有相同的延迟时间时,按照 FIFO 的顺序入队,用于给这些任务编号。
- time:任务可以执行的时间点,纳秒单位,通过 triggerTime 方法计算得出。
- period:任务执行的周期间隔,那秒单位。
- 正数表示固定速率执行(为 scheduleAtFixedRate 提供服务)
- 负数表示固定延迟执行(为 scheduleWithFixedDelay 提供服务)
- 0 表示不重复执行。
- outerTask:重新入队的任务,通过 reExecutePeriodic 方法入队重新排序。
核心方法:run
public void run() {
boolean periodic = isPeriodic();//是否为周期任务
if (!canRunInCurrentRunState(periodic))//当前状态是否可以执行
cancel(false);
else if (!periodic)
//不是周期任务,直接执行
ScheduledFutureTask.super.run();
else if (ScheduledFutureTask.super.runAndReset()) {
setNextRunTime();//设置下一次运行时间
reExecutePeriodic(outerTask);//重排序一个周期任务
}
}
ScheduledFutureTask 的 run 方法重写了 FutureTask 的版本,以便执行周期任务时重置、重排任务。任务的执行通过父类 FutureTask.run 实现。
内部有两个针对周期任务的方法:
setNextRunTime:用于设置下一次运行的时间:
//设置下一次执行任务的时间 private void setNextRunTime() { long p = period; if (p > 0) //固定速率执行,scheduleAtFixedRate time += p; else time = triggerTime(-p); //固定延迟执行,scheduleWithFixedDelay } //计算固定延迟任务的执行时间 long triggerTime(long delay) { return now() + ((delay < (Long.MAX_VALUE >> 1)) ? delay : overflowFree(delay)); }
reExecutePeriodic:周期任务重新入队等待下一次执行:
//重排序一个周期任务 void reExecutePeriodic(RunnableScheduledFuture<?> task) { if (canRunInCurrentRunState(true)) {//池关闭后可继续执行 super.getQueue().add(task);//任务入列 //重新检查run-after-shutdown参数,如果不能继续运行就移除队列任务,并取消任务的执行 if (!canRunInCurrentRunState(true) && remove(task)) task.cancel(false); else ensurePrestart();//启动一个新的线程等待任务 } }
reExecutePeriodic 与 delayedExecute 的执行策略一致,只不过 reExecutePeriodic 不会执行拒绝策略而是直接丢弃任务。
cancel
public boolean cancel(boolean mayInterruptIfRunning) {
boolean cancelled = super.cancel(mayInterruptIfRunning);
if (cancelled && removeOnCancel && heapIndex >= 0)
remove(this);
return cancelled;
}
ScheduledFutureTask.cancel 本质上由其父类 FutureTask.cancel 实现。取消任务成功后会根据 removeOnCancel 参数来决定是否从队列中移除该任务。
核心属性
//关闭后继续执行已经存在的周期任务
private volatile boolean continueExistingPeriodicTasksAfterShutdown;
//关闭后继续执行已经存在的延时任务
private volatile boolean executeExistingDelayedTasksAfterShutdown = true;
//取消任务后移除
private volatile boolean removeOnCancel = false;
//为相同延时的任务提供的顺序编号,保证任务之间的FIFO顺序
private static final AtomicLong sequencer = new AtomicLong();
- 前两个参数是由 ScheduledThreadPoolExecutor 定义的 run-after-shutdown 参数,用于控制池关闭后任务的执行逻辑。
- removeOnCancel:用于控制取消任务后是否从队列中移除。
- 当一个已经提交的周期或延迟任务在运行之前被取消,那么它之后将不会再被执行。
- 默认配置下,这种已经取消的任务在届期之前不会被移除。
- 通过这种机制,可以方便检查和监控线程池状态,但也可能导致已经取消的任务无线滞留。
- 为了避免无线滞留,可以通过 setRemoveOnCancelPolicy 来设置移除策略,将参数 removeOnCancel 设为 true 可以在任务取消后立即从队列中移除。
- sequencer 是为具有相同延时时间的任务提供顺序编号,保证任务之间的 FIFO 顺序。与 ScheduledFutureTask 内部的 sequenceNumber 参数作用一致。
构造函数
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}
- 核心线程数
- 线程工厂
- 拒绝策略
核心方法:schedule
public <V> ScheduledFuture<V> schedule(Callable<V> callable,
long delay,
TimeUnit unit) {
if (callable == null || unit == null)
throw new NullPointerException();
RunnableScheduledFuture<V> t = decorateTask(callable,
new ScheduledFutureTask<V>(callable, triggerTime(delay, unit)));//构造ScheduledFutureTask任务
delayedExecute(t);//任务执行主方法
return t;
}
主要用于执行一次性延迟任务,执行逻辑分为两步:
封装 Callable/Runnable:通过 triggerTime 计算出任务的延迟执行时加点,然后通过 ScheduledFutureTask 的构造函数将 Runnable/Callable 任务构造为 ScheduledThreadPoolExecutor 可以执行的任务类型,最后调用 decorateTask 方法执行用户自定义的任务逻辑。
- decorateTask 是一个用户可以自定义扩展的方法,默认实现下直接返回封装的 RunnableScheduledFuture:
protected <V> RunnableScheduledFuture<V> decorateTask( Runnable runnable, RunnableScheduledFuture<V> task) { return task; }
执行任务:通过 delayedExecute 实现:
private void delayedExecute(RunnableScheduledFuture<?> task) { if (isShutdown()) reject(task);//池已关闭,执行拒绝策略 else { super.getQueue().add(task);//任务入队 if (isShutdown() && !canRunInCurrentRunState(task.isPeriodic()) &&//判断run-after-shutdown参数 remove(task))//移除任务 task.cancel(false); else ensurePrestart();//启动一个新的线程等待任务 } }
- 如果池已经关闭(ctr <= SHUTDOWN),执行任务拒绝策略。
- 池正在运行,首先将任务入队排序,然后重新检查池的关闭状态,执行如下逻辑:
- 如果池正在运行,或者 run-after-shutdown 参数为 true,则调用父类方法 ensurePrestart 启动新线程等待执行任务。
- 如果池已经关闭,并且 run-after-shutdown 参数为 false,则执行父类(ThreadPoolExecutor)方法 remove 移除队列中的指定任务,成功移除后调用 ScheduledFutureTask.cancel 取消任务。
ensurePrestart 源码如下:
void ensurePrestart() {
int wc = workerCountOf(ctl.get());
if (wc < corePoolSize)
addWorker(null, true);
else if (wc == 0)
addWorker(null, false);
}
ensurePrestart是父类 ThreadPoolExecutor 的方法,用于启动一个新的工作线程等待执行任务,即使corePoolSize为0也会安排一个新线程。
核心方法:scheduleAtFixedRate、scheduleWithFixedDelay
/**
* 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
* 之后每隔period执行一次,不等待第一次执行完成就开始计时
*/
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command,
long initialDelay,
long period,
TimeUnit unit) {
if (command == null || unit == null)
throw new NullPointerException();
if (period <= 0)
throw new IllegalArgumentException();
//构建RunnableScheduledFuture任务类型
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),//计算任务的延迟时间
unit.toNanos(period));//计算任务的执行周期
RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
delayedExecute(t);//执行任务
return t;
}
/**
* 创建一个周期执行的任务,第一次执行延期时间为initialDelay,
* 在第一次执行完之后延迟delay后开始下一次执行
*/
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,
long initialDelay,
long delay,
TimeUnit unit) {
if (command == null || unit == null)
throw new NullPointerException();
if (delay <= 0)
throw new IllegalArgumentException();
//构建RunnableScheduledFuture任务类型
ScheduledFutureTask<Void> sft =
new ScheduledFutureTask<Void>(command,
null,
triggerTime(initialDelay, unit),//计算任务的延迟时间
unit.toNanos(-delay));//计算任务的执行周期
RunnableScheduledFuture<Void> t = decorateTask(command, sft);//执行用户自定义逻辑
sft.outerTask = t;//赋值给outerTask,准备重新入队等待下一次执行
delayedExecute(t);//执行任务
return t;
}
二者的区别在于 unit.toNanos,scheduleAtFixedRate 传的是正值,而 scheduleWithFixedDelay 传的则是负值,这个值就是 ScheduledFutureTask 的 period 属性。
核心方法:shutdown
public void shutdown() {
super.shutdown();
}
//取消并清除由于关闭策略不应该运行的所有任务
@Override void onShutdown() {
BlockingQueue<Runnable> q = super.getQueue();
//获取run-after-shutdown参数
boolean keepDelayed =
getExecuteExistingDelayedTasksAfterShutdownPolicy();
boolean keepPeriodic =
getContinueExistingPeriodicTasksAfterShutdownPolicy();
if (!keepDelayed && !keepPeriodic) {//池关闭后不保留任务
//依次取消任务
for (Object e : q.toArray())
if (e instanceof RunnableScheduledFuture<?>)
((RunnableScheduledFuture<?>) e).cancel(false);
q.clear();//清除等待队列
}
else {//池关闭后保留任务
// Traverse snapshot to avoid iterator exceptions
//遍历快照以避免迭代器异常
for (Object e : q.toArray()) {
if (e instanceof RunnableScheduledFuture) {
RunnableScheduledFuture<?> t =
(RunnableScheduledFuture<?>)e;
if ((t.isPeriodic() ? !keepPeriodic : !keepDelayed) ||
t.isCancelled()) { // also remove if already cancelled
//如果任务已经取消,移除队列中的任务
if (q.remove(t))
t.cancel(false);
}
}
}
}
tryTerminate(); //终止线程池
}
池关闭方法调用了父类ThreadPoolExecutor的shutdown。
在shutdown方法中调用了关闭钩子onShutdown方法,它的主要作用是在关闭线程池后取消并清除由于关闭策略不应该运行的所有任务,这里主要是根据 run-after-shutdown 参数(continueExistingPeriodicTasksAfterShutdown和executeExistingDelayedTasksAfterShutdown)来决定线程池关闭后是否关闭已经存在的任务。
深入理解
为什么ThreadPoolExecutor 的调整策略却不适用于 ScheduledThreadPoolExecutor?
例如: 由于 ScheduledThreadPoolExecutor 是一个固定核心线程数大小的线程池,并且使用了一个无界队列,所以调整maximumPoolSize对其没有任何影响(所以 ScheduledThreadPoolExecutor 没有提供可以调整最大线程数的构造函数,默认最大线程数固定为Integer.MAX_VALUE)。此外,设置corePoolSize为0或者设置核心线程空闲后清除(allowCoreThreadTimeOut)同样也不是一个好的策略,因为一旦周期任务到达某一次运行周期时,可能导致线程池内没有线程去处理这些任务。
Executors 提供了哪几种方法来构造 ScheduledThreadPoolExecutor?
- newScheduledThreadPool: 可指定核心线程数的线程池。
- newSingleThreadScheduledExecutor: 只有一个工作线程的线程池。如果内部工作线程由于执行周期任务异常而被终止,则会新建一个线程替代它的位置。
注意: newScheduledThreadPool(1, threadFactory) 不等价于newSingleThreadScheduledExecutor。newSingleThreadScheduledExecutor创建的线程池保证内部只有一个线程执行任务,并且线程数不可扩展;而通过newScheduledThreadPool(1, threadFactory)创建的线程池可以通过setCorePoolSize方法来修改核心线程数。
1.3.23 - CH23-ForkJoin.md
概览
ForkJoin 框架是 JUC 中一种可以将大任务拆分为多个小任务并异步执行的工具,由 JDK 1.7 引入。
层级结构
主要包含 3 个模块:
- 任务对象:ForkJoinTask
- RecursiveTask
- RecursiveAction
- CountedCompleter
- 执行线程:ForkJoinWorkerThread
- 线程池:ForkJoinPool
ForkJoinPool 可以通过池中的 ForkJoinThread 来处理 ForkJoinTask。
// from 《A Java Fork/Join Framework》Dong Lea
Result solve(Problem problem) {
if (problem is small)
directly solve problem
else {
split problem into independent parts
fork new subtasks to solve each part
join all subtasks
compose result from subresults
}
}
- ForkJoinPool 只接收 ForkJoinTask 任务(在实际使用中,也可以接收 Runnable/Callable 任务,但在真正运行时,也会把这些任务封装成 ForkJoinTask 类型的任务)。
- RecursiveTask 是 ForkJoinTask 的子类,是一个可以递归执行的 ForkJoinTask。
- RecursiveAction 是一个无返回值的 RecursiveTask。
- CountedCompleter 在任务完成执行后会触发执行一个自定义的钩子函数。
在实际运用中,我们一般都会继承 RecursiveTask
、RecursiveAction
或 CountedCompleter
来实现我们的业务需求,而不会直接继承 ForkJoinTask 类。
核心思想:分治算法
分治算法(Divide-and-Conquer)把任务递归的拆分为各个子任务,这样可以更好的利用系统资源,尽可能的使用所有可用的计算能力来提升应用性能。首先看一下 Fork/Join 框架的任务运行机制:
核心思想:work-stealing
work-stealing(工作窃取)算法: 线程池内的所有工作线程都尝试找到并执行已经提交的任务,或者是被其他活动任务创建的子任务(如果不存在就阻塞等待)。
这种特性使得 ForkJoinPool 在运行多个可以产生子任务的任务,或者是提交的许多小任务时效率更高。尤其是构建异步模型的 ForkJoinPool 时,对不需要合并(join)的事件类型任务也非常适用。
在 ForkJoinPool 中,线程池中每个工作线程(ForkJoinWorkerThread)都对应一个任务队列(WorkQueue),工作线程优先处理来自自身队列的任务(LIFO或FIFO顺序,参数 mode 决定),然后以FIFO的顺序随机窃取其他队列中的任务。
具体思路如下:
- 每个线程都有自己的 WorkQueue,这是一个双端队列。
- 队列支持三个功能:push、pop、poll。
- push、pop 只能被队列的拥有线程调用,而 poll 可以被其他线程调用。
- 划分的子任务调用 fork 时,都会被 push 到自己的队列中。
- 默认情况下,工作线程从自己的双端队列中获取任务并执行。
- 当工作线程自己的队列已空,会随机从另一个线程的队列的尾部调用 poll 方法窃取任务。
执行流程
上图可以看出任务有两类:
- 直接通过 ForkJoinPool 提交的外部任务,存放在 workQueues 的偶数槽位。
- 通过内部 fork 分割的子任务,存放在 workQueues 的奇数槽位。
类层级
继承关系
内部类
- ForkJoinWorkerThreadFactory:内部线程工厂接口,用于创建工作线程 ForkJoinWorkerThread。
- DefautFaorkJoinWorkerThreadFactory:默认线程工厂。
- InnocuousForkJoinWorkerThreadFactory:无许可线程工厂,当系统变量中存在系统安全相关的属性时,使用该线程工厂。
- EmptyTask:内部占位类,用户替换队列中 join 的任务。
- ManagedBlocker:为 ForkJoinPool 中的任务提供扩展管理并行数的接口,一般用于可能会阻塞的任务。
- WorkQueue:ForkJoinPool 的核心数据结构,本质上是 work-stealing 模式的双端任务队列,内部存放 ForkJoinTask 任务对象,使用 @Contented 注解修饰防止伪共享。
- 工作线程在运行中产生新的任务(通常是应为调用了 fork)时,可以把 WorkQueue 的数据结构视为一个栈,新的任务放入栈顶;工作线程在处理自己队列中的任务时,按照 LIFO 的顺序。
- 工作线程在处理自己的工作队列的同时,会尝试窃取一个任务(可能来自刚刚提交到池的任务,或是来之其他工作线程的队列任务),此时可以把 WorkQueue 的数据结构视为一个 FIFO 队列,窃取任务唯一其他线程的工作队列的队首。
- 伪共享状态:缓存系统中是以缓存行(cache line)为单位存储的。缓存行是 2 的整数幂个连续字节,一般为 32-256 个字节。最常见的缓存行大小是 64 字节。但多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,即伪共享。
ForkJoinTask 继承关系
- ForkJoinTask 实现了 Future 接口,说明它也是一个可取消的异步运算任务。
- 实际上ForkJoinTask 是 Future 的轻量级实现,主要用在纯粹是计算的函数式任务或者操作完全独立的对象计算任务。
- fork 是主运行方法,用于异步执行;而 join 方法在任务结果计算完毕之后才会运行,用来合并或返回计算结果。
- 其内部类都比较简单:
- ExceptionNode 是用于存储任务执行期间的异常信息的单向链表;
- 其余四个类是为 Runnable/Callable 任务提供的适配器类,用于把 Runnable/Callable 转化为 ForkJoinTask 类型的任务(因为 ForkJoinPool 只可以运行 ForkJoinTask 类型的任务)。
源码解析
- 提交流程:外部任务提交
- 提交流程:子任务提交
- 执行过程:ForkJoinWorkerThread.run -> ForkJoinTask.doExec
- 结果获取:ForkJoinTask.join -> ForkJoinTask.invoke
ForkJoinPool
核心参数
在后面的源码解析中,我们会看到大量的位运算,这些位运算都是通过我们接下来介绍的一些常量参数来计算的。
例如,如果要更新活跃线程数,使用公式(UC_MASK & (c + AC_UNIT)) | (SP_MASK & c);c 代表当前 ctl,UC_MASK 和 SP_MASK 分别是高位和低位掩码,AC_UNIT 为活跃线程的增量数,使用(UC_MASK & (c + AC_UNIT))就可以计算出高32位,然后再加上低32位(SP_MASK & c),就拼接成了一个新的ctl。
ForkJoinPool 与 内部类 WorkQueue 共享的一些常量:
// Constants shared across ForkJoinPool and WorkQueue
// 限定参数
static final int SMASK = 0xffff; // 低位掩码,也是最大索引位
static final int MAX_CAP = 0x7fff; // 工作线程最大容量
static final int EVENMASK = 0xfffe; // 偶数低位掩码
static final int SQMASK = 0x007e; // workQueues 数组最多64个槽位
// ctl 子域和 WorkQueue.scanState 的掩码和标志位
static final int SCANNING = 1; // 标记是否正在运行任务
static final int INACTIVE = 1 << 31; // 失活状态 负数
static final int SS_SEQ = 1 << 16; // 版本戳,防止ABA问题
// ForkJoinPool.config 和 WorkQueue.config 的配置信息标记
static final int MODE_MASK = 0xffff << 16; // 模式掩码
static final int LIFO_QUEUE = 0; //LIFO队列
static final int FIFO_QUEUE = 1 << 16;//FIFO队列
static final int SHARED_QUEUE = 1 << 31; // 共享模式队列,负数
ForkJoinPool 中的相关常量和实例字段:
// 低位和高位掩码
private static final long SP_MASK = 0xffffffffL;
private static final long UC_MASK = ~SP_MASK;
// 活跃线程数
private static final int AC_SHIFT = 48;
private static final long AC_UNIT = 0x0001L << AC_SHIFT; //活跃线程数增量
private static final long AC_MASK = 0xffffL << AC_SHIFT; //活跃线程数掩码
// 工作线程数
private static final int TC_SHIFT = 32;
private static final long TC_UNIT = 0x0001L << TC_SHIFT; //工作线程数增量
private static final long TC_MASK = 0xffffL << TC_SHIFT; //掩码
private static final long ADD_WORKER = 0x0001L << (TC_SHIFT + 15); // 创建工作线程标志
// 池状态
private static final int RSLOCK = 1;
private static final int RSIGNAL = 1 << 1;
private static final int STARTED = 1 << 2;
private static final int STOP = 1 << 29;
private static final int TERMINATED = 1 << 30;
private static final int SHUTDOWN = 1 << 31;
// 实例字段
volatile long ctl; // 主控制参数
volatile int runState; // 运行状态锁
final int config; // 并行度|模式
int indexSeed; // 用于生成工作线程索引
volatile WorkQueue[] workQueues; // 主对象注册信息,workQueue
final ForkJoinWorkerThreadFactory factory;// 线程工厂
final UncaughtExceptionHandler ueh; // 每个工作线程的异常信息
final String workerNamePrefix; // 用于创建工作线程的名称
volatile AtomicLong stealCounter; // 偷取任务总数,也可作为同步监视器
/** 静态初始化字段 */
//线程工厂
public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;
//启动或杀死线程的方法调用者的权限
private static final RuntimePermission modifyThreadPermission;
// 公共静态pool
static final ForkJoinPool common;
//并行度,对应内部common池
static final int commonParallelism;
//备用线程数,在tryCompensate中使用
private static int commonMaxSpares;
//创建workerNamePrefix(工作线程名称前缀)时的序号
private static int poolNumberSequence;
//线程阻塞等待新的任务的超时值(以纳秒为单位),默认2秒
private static final long IDLE_TIMEOUT = 2000L * 1000L * 1000L; // 2sec
//空闲超时时间,防止timer未命中
private static final long TIMEOUT_SLOP = 20L * 1000L * 1000L; // 20ms
//默认备用线程数
private static final int DEFAULT_COMMON_MAX_SPARES = 256;
//阻塞前自旋的次数,用在在awaitRunStateLock和awaitWork中
private static final int SPINS = 0;
//indexSeed的增量
private static final int SEED_INCREMENT = 0x9e3779b9;
ForkJoinPool 的内部状态都是通过一个64位的 long 型 变量ctl来存储,它由四个16位的子域组成:
- AC: 正在运行工作线程数减去目标并行度,高16位
- TC: 总工作线程数减去目标并行度,中高16位
- SS: 栈顶等待线程的版本计数和状态,中低16位
- ID: 栈顶 WorkQueue 在池中的索引(poolIndex),低16位
某些地方也提取了ctl的低32位(sp=(int)ctl)来检查工作线程状态,例如,当sp不为0时说明当前还有空闲工作线程。
ForkJoinPool.WorkQueue 中的相关属性:
//初始队列容量,2的幂
static final int INITIAL_QUEUE_CAPACITY = 1 << 13;
//最大队列容量
static final int MAXIMUM_QUEUE_CAPACITY = 1 << 26; // 64M
// 实例字段
volatile int scanState; // Woker状态, <0: inactive; odd:scanning
int stackPred; // 记录前一个栈顶的ctl
int nsteals; // 偷取任务数
int hint; // 记录偷取者索引,初始为随机索引
int config; // 池索引和模式
volatile int qlock; // 1: locked, < 0: terminate; else 0
volatile int base; //下一个poll操作的索引(栈底/队列头)
int top; // 下一个push操作的索引(栈顶/队列尾)
ForkJoinTask<?>[] array; // 任务数组
final ForkJoinPool pool; // the containing pool (may be null)
final ForkJoinWorkerThread owner; // 当前工作队列的工作线程,共享模式下为null
volatile Thread parker; // 调用park阻塞期间为owner,其他情况为null
volatile ForkJoinTask<?> currentJoin; // 记录被join过来的任务
volatile ForkJoinTask<?> currentSteal; // 记录从其他工作队列偷取过来的任务
ForkJoinTask
核心参数
/** 任务运行状态 */
volatile int status; // 任务运行状态
static final int DONE_MASK = 0xf0000000; // 任务完成状态标志位
static final int NORMAL = 0xf0000000; // must be negative
static final int CANCELLED = 0xc0000000; // must be < NORMAL
static final int EXCEPTIONAL = 0x80000000; // must be < CANCELLED
static final int SIGNAL = 0x00010000; // must be >= 1 << 16 等待信号
static final int SMASK = 0x0000ffff; // 低位掩码
ForkJoinPool:构造函数
public ForkJoinPool(int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode) {
this(checkParallelism(parallelism),
checkFactory(factory),
handler,
asyncMode ? FIFO_QUEUE : LIFO_QUEUE,
"ForkJoinPool-" + nextPoolId() + "-worker-");
checkPermission();
}
- parallelism: 并行度,默认为CPU数,最小为1
- factory: 工作线程工厂
- handler: 处理工作线程运行任务时的异常情况类,默认为null
- asyncMode: 是否为异步模式,默认为 false。
- 如果为true,表示子任务的执行遵循 FIFO 顺序并且任务不能被合并(join),这种模式适用于工作线程只运行事件类型的异步任务。
在多数场景使用时,如果没有太强的业务需求,我们一般直接使用 ForkJoinPool 中的common池,在JDK1.8之后提供了ForkJoinPool.commonPool()方法可以直接使用common池,来看一下它的构造:
private static ForkJoinPool makeCommonPool() {
int parallelism = -1;
ForkJoinWorkerThreadFactory factory = null;
UncaughtExceptionHandler handler = null;
try { // ignore exceptions in accessing/parsing
String pp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.parallelism");//并行度
String fp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.threadFactory");//线程工厂
String hp = System.getProperty
("java.util.concurrent.ForkJoinPool.common.exceptionHandler");//异常处理类
if (pp != null)
parallelism = Integer.parseInt(pp);
if (fp != null)
factory = ((ForkJoinWorkerThreadFactory) ClassLoader.
getSystemClassLoader().loadClass(fp).newInstance());
if (hp != null)
handler = ((UncaughtExceptionHandler) ClassLoader.
getSystemClassLoader().loadClass(hp).newInstance());
} catch (Exception ignore) {
}
if (factory == null) {
if (System.getSecurityManager() == null)
factory = defaultForkJoinWorkerThreadFactory;
else // use security-managed default
factory = new InnocuousForkJoinWorkerThreadFactory();
}
if (parallelism < 0 && // default 1 less than #cores
(parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)
parallelism = 1;//默认并行度为1
if (parallelism > MAX_CAP)
parallelism = MAX_CAP;
return new ForkJoinPool(parallelism, factory, handler, LIFO_QUEUE,
"ForkJoinPool.commonPool-worker-");
}
使用common pool的优点就是我们可以通过指定系统参数的方式定义“并行度、线程工厂和异常处理类”;并且它使用的是同步模式,也就是说可以支持任务合并(join)。
ForkJoinPool:外部任务提交
向 ForkJoinPool 提交任务有三种方式:
- invoke()会等待任务计算完毕并返回计算结果;
- execute()是直接向池提交一个任务来异步执行,无返回结果;
- submit()也是异步执行,但是会返回提交的任务,在适当的时候可通过 task.get() 获取执行结果。
这三种提交方式都都是调用externalPush()方法来完成,所以接下来我们将从externalPush()方法开始逐步分析外部任务的执行过程。
externalPush
//添加给定任务到submission队列中
final void externalPush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue q;
int m;
int r = ThreadLocalRandom.getProbe();//探针值,用于计算WorkQueue槽位索引
int rs = runState;
if ((ws = workQueues) != null && (m = (ws.length - 1)) >= 0 &&
(q = ws[m & r & SQMASK]) != null && r != 0 && rs > 0 && //获取随机偶数槽位的workQueue
U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定workQueue
ForkJoinTask<?>[] a;
int am, n, s;
if ((a = q.array) != null &&
(am = a.length - 1) > (n = (s = q.top) - q.base)) {
int j = ((am & s) << ASHIFT) + ABASE;//计算任务索引位置
U.putOrderedObject(a, j, task);//任务入列
U.putOrderedInt(q, QTOP, s + 1);//更新push slot
U.putIntVolatile(q, QLOCK, 0);//解除锁定
if (n <= 1)
signalWork(ws, q);//任务数小于1时尝试创建或激活一个工作线程
return;
}
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
externalSubmit(task);//初始化workQueues及相关属性
}
externalPush和externalSubmit两个方法的联系:
- 它们的作用都是把任务放到队列中等待执行。
- 不同的是,externalSubmit可以说是完整版的externalPush,在任务首次提交时,需要初始化workQueues及其他相关属性,这个初始化操作就是externalSubmit来完成的;
- 而后再向池中提交的任务都是通过简化版的externalSubmit-externalPush来完成。
externalPush的执行流程很简单: 首先找到一个随机偶数槽位的 workQueue,然后把任务放入这个 workQueue 的任务数组中,并更新top位。如果队列的剩余任务数小于1,则尝试创建或激活一个工作线程来运行任务(防止在externalSubmit初始化时发生异常导致工作线程创建失败)。
externalSubmit
//任务提交
private void externalSubmit(ForkJoinTask<?> task) {
//初始化调用线程的探针值,用于计算WorkQueue索引
int r; // initialize caller's probe
if ((r = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
r = ThreadLocalRandom.getProbe();
}
for (; ; ) {
WorkQueue[] ws;
WorkQueue q;
int rs, m, k;
boolean move = false;
if ((rs = runState) < 0) {// 池已关闭
tryTerminate(false, false); // help terminate
throw new RejectedExecutionException();
}
//初始化workQueues
else if ((rs & STARTED) == 0 || // initialize
((ws = workQueues) == null || (m = ws.length - 1) < 0)) {
int ns = 0;
rs = lockRunState();//锁定runState
try {
//初始化
if ((rs & STARTED) == 0) {
//初始化stealCounter
U.compareAndSwapObject(this, STEALCOUNTER, null,
new AtomicLong());
//创建workQueues,容量为2的幂次方
// create workQueues array with size a power of two
int p = config & SMASK; // ensure at least 2 slots
int n = (p > 1) ? p - 1 : 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
n = (n + 1) << 1;
workQueues = new WorkQueue[n];
ns = STARTED;
}
} finally {
unlockRunState(rs, (rs & ~RSLOCK) | ns);//解锁并更新runState
}
} else if ((q = ws[k = r & m & SQMASK]) != null) {//获取随机偶数槽位的workQueue
if (q.qlock == 0 && U.compareAndSwapInt(q, QLOCK, 0, 1)) {//锁定 workQueue
ForkJoinTask<?>[] a = q.array;//当前workQueue的全部任务
int s = q.top;
boolean submitted = false; // initial submission or resizing
try { // locked version of push
if ((a != null && a.length > s + 1 - q.base) ||
(a = q.growArray()) != null) {//扩容
int j = (((a.length - 1) & s) << ASHIFT) + ABASE;
U.putOrderedObject(a, j, task);//放入给定任务
U.putOrderedInt(q, QTOP, s + 1);//修改push slot
submitted = true;
}
} finally {
U.compareAndSwapInt(q, QLOCK, 1, 0);//解除锁定
}
if (submitted) {//任务提交成功,创建或激活工作线程
signalWork(ws, q);//创建或激活一个工作线程来运行任务
return;
}
}
move = true; // move on failure 操作失败,重新获取探针值
} else if (((rs = runState) & RSLOCK) == 0) { // create new queue
q = new WorkQueue(this, null);
q.hint = r;
q.config = k | SHARED_QUEUE;
q.scanState = INACTIVE;
rs = lockRunState(); // publish index
if (rs > 0 && (ws = workQueues) != null &&
k < ws.length && ws[k] == null)
ws[k] = q; // 更新索引k位值的workQueue
//else terminated
unlockRunState(rs, rs & ~RSLOCK);
} else
move = true; // move if busy
if (move)
r = ThreadLocalRandom.advanceProbe(r);//重新获取线程探针值
}
}
externalSubmit是externalPush的完整版本,主要用于第一次提交任务时初始化workQueues及相关属性,并且提交给定任务到队列中。具体执行步骤如下:
如果池为终止状态(runState<0),调用tryTerminate来终止线程池,并抛出任务拒绝异常;
如果尚未初始化,就为 FJP 执行初始化操作: 初始化stealCounter、创建workerQueues,然后继续自旋;
初始化完成后,执行在externalPush中相同的操作: 获取 workQueue,放入指定任务。任务提交成功后调用signalWork方法创建或激活线程;
如果在步骤3中获取到的 workQueue 为null,会在这一步中创建一个 workQueue,创建成功继续自旋执行第三步操作;
如果非上述情况,或者有线程争用资源导致获取锁失败,就重新获取线程探针值继续自旋。
signalWork
final void signalWork(WorkQueue[] ws, WorkQueue q) {
long c;
int sp, i;
WorkQueue v;
Thread p;
while ((c = ctl) < 0L) { // too few active
if ((sp = (int) c) == 0) { // no idle workers
if ((c & ADD_WORKER) != 0L) // too few workers
tryAddWorker(c);//工作线程太少,添加新的工作线程
break;
}
if (ws == null) // unstarted/terminated
break;
if (ws.length <= (i = sp & SMASK)) // terminated
break;
if ((v = ws[i]) == null) // terminating
break;
//计算ctl,加上版本戳SS_SEQ避免ABA问题
int vs = (sp + SS_SEQ) & ~INACTIVE; // next scanState
int d = sp - v.scanState; // screen CAS
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & v.stackPred);
if (d == 0 && U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs; // activate v
if ((p = v.parker) != null)
U.unpark(p);//唤醒阻塞线程
break;
}
if (q != null && q.base == q.top) // no more work
break;
}
}
新建或唤醒一个工作线程,在externalPush、externalSubmit、workQueue.push、scan中调用。如果还有空闲线程,则尝试唤醒索引到的 WorkQueue 的parker线程;如果工作线程过少((ctl & ADD_WORKER) != 0L),则调用tryAddWorker添加一个新的工作线程。
tryAddWorker
private void tryAddWorker(long c) {
boolean add = false;
do {
long nc = ((AC_MASK & (c + AC_UNIT)) |
(TC_MASK & (c + TC_UNIT)));
if (ctl == c) {
int rs, stop; // check if terminating
if ((stop = (rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);
unlockRunState(rs, rs & ~RSLOCK);//释放锁
if (stop != 0)
break;
if (add) {
createWorker();//创建工作线程
break;
}
}
} while (((c = ctl) & ADD_WORKER) != 0L && (int)c == 0);
}
尝试添加一个新的工作线程,首先更新ctl中的工作线程数,然后调用createWorker()创建工作线程。
createWorker
private boolean createWorker() {
ForkJoinWorkerThreadFactory fac = factory;
Throwable ex = null;
ForkJoinWorkerThread wt = null;
try {
if (fac != null && (wt = fac.newThread(this)) != null) {
wt.start();
return true;
}
} catch (Throwable rex) {
ex = rex;
}
deregisterWorker(wt, ex);//线程创建失败处理
return false;
}
createWorker首先通过线程工厂创一个新的ForkJoinWorkerThread,然后启动这个工作线程(wt.start())。如果期间发生异常,调用deregisterWorker处理线程创建失败的逻辑(deregisterWorker在后面再详细说明)。
ForkJoinWorkerThread 的构造函数如下:
protected ForkJoinWorkerThread(ForkJoinPool pool) {
// Use a placeholder until a useful name can be set in registerWorker
super("aForkJoinWorkerThread");
this.pool = pool;
this.workQueue = pool.registerWorker(this);
}
可以看到 ForkJoinWorkerThread 在构造时首先调用父类 Thread 的方法,然后为工作线程注册pool和workQueue,而workQueue的注册任务由ForkJoinPool.registerWorker来完成。
registerWorker
final WorkQueue registerWorker(ForkJoinWorkerThread wt) {
UncaughtExceptionHandler handler;
//设置为守护线程
wt.setDaemon(true); // configure thread
if ((handler = ueh) != null)
wt.setUncaughtExceptionHandler(handler);
WorkQueue w = new WorkQueue(this, wt);//构造新的WorkQueue
int i = 0; // assign a pool index
int mode = config & MODE_MASK;
int rs = lockRunState();
try {
WorkQueue[] ws;
int n; // skip if no array
if ((ws = workQueues) != null && (n = ws.length) > 0) {
//生成新建WorkQueue的索引
int s = indexSeed += SEED_INCREMENT; // unlikely to collide
int m = n - 1;
i = ((s << 1) | 1) & m; // Worker任务放在奇数索引位 odd-numbered indices
if (ws[i] != null) { // collision 已存在,重新计算索引位
int probes = 0; // step by approx half n
int step = (n <= 4) ? 2 : ((n >>> 1) & EVENMASK) + 2;
//查找可用的索引位
while (ws[i = (i + step) & m] != null) {
if (++probes >= n) {//所有索引位都被占用,对workQueues进行扩容
workQueues = ws = Arrays.copyOf(ws, n <<= 1);//workQueues 扩容
m = n - 1;
probes = 0;
}
}
}
w.hint = s; // use as random seed
w.config = i | mode;
w.scanState = i; // publication fence
ws[i] = w;
}
} finally {
unlockRunState(rs, rs & ~RSLOCK);
}
wt.setName(workerNamePrefix.concat(Integer.toString(i >>> 1)));
return w;
}
registerWorker是 ForkJoinWorkerThread 构造器的回调函数,用于创建和记录工作线程的 WorkQueue。比较简单,就不多赘述了。注意在此为工作线程创建的 WorkQueue 是放在奇数索引的(代码行: i = ((s « 1) | 1) & m;)
总结
在createWorker()中启动工作线程后(wt.start()),当为线程分配到CPU执行时间片之后会运行 ForkJoinWorkerThread 的run方法开启线程来执行任务。工作线程执行任务的流程我们在讲完内部任务提交之后会统一讲解。
ForkJoinPool:子任务提交
子任务的提交相对比较简单,由任务的fork()方法完成。通过上面的流程图可以看到任务被分割(fork)之后调用了ForkJoinPool.WorkQueue.push()方法直接把任务放到队列中等待被执行。
ForkJoinTask.fork()
public final ForkJoinTask<V> fork() {
Thread t;
if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
((ForkJoinWorkerThread)t).workQueue.push(this);
else
ForkJoinPool.common.externalPush(this);
return this;
}
- 如果当前线程是 Worker 线程,说明当前任务是fork分割的子任务,通过ForkJoinPool.workQueue.push()方法直接把任务放到自己的等待队列中;
- 否则调用ForkJoinPool.externalPush()提交到一个随机的等待队列中(外部任务)。
ForkJoinPool.WorkQueue.push()
final void push(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
ForkJoinPool p;
int b = base, s = top, n;
if ((a = array) != null) { // ignore if queue removed
int m = a.length - 1; // fenced write for task visibility
U.putOrderedObject(a, ((m & s) << ASHIFT) + ABASE, task);
U.putOrderedInt(this, QTOP, s + 1);
if ((n = s - b) <= 1) {//首次提交,创建或唤醒一个工作线程
if ((p = pool) != null)
p.signalWork(p.workQueues, this);
} else if (n >= m)
growArray();
}
}
首先把任务放入等待队列并更新top位;如果当前 WorkQueue 为新建的等待队列(top-base<=1),则调用signalWork方法为当前 WorkQueue 新建或唤醒一个工作线程;如果 WorkQueue 中的任务数组容量过小,则调用growArray()方法对其进行两倍扩容,growArray()方法源码如下:
final ForkJoinTask<?>[] growArray() {
ForkJoinTask<?>[] oldA = array;//获取内部任务列表
int size = oldA != null ? oldA.length << 1 : INITIAL_QUEUE_CAPACITY;
if (size > MAXIMUM_QUEUE_CAPACITY)
throw new RejectedExecutionException("Queue capacity exceeded");
int oldMask, t, b;
//新建一个两倍容量的任务数组
ForkJoinTask<?>[] a = array = new ForkJoinTask<?>[size];
if (oldA != null && (oldMask = oldA.length - 1) >= 0 &&
(t = top) - (b = base) > 0) {
int mask = size - 1;
//从老数组中拿出数据,放到新的数组中
do { // emulate poll from old array, push to new array
ForkJoinTask<?> x;
int oldj = ((b & oldMask) << ASHIFT) + ABASE;
int j = ((b & mask) << ASHIFT) + ABASE;
x = (ForkJoinTask<?>) U.getObjectVolatile(oldA, oldj);
if (x != null &&
U.compareAndSwapObject(oldA, oldj, x, null))
U.putObjectVolatile(a, j, x);
} while (++b != t);
}
return a;
}
总结
到此,两种任务的提交流程都已经解析完毕,下一节我们来一起看看任务提交之后是如何被运行的。
ForkJoinPool:任务执行
回到我们开始时的流程图,在ForkJoinPool .createWorker()方法中创建工作线程后,会启动工作线程,系统为工作线程分配到CPU执行时间片之后会执行 ForkJoinWorkerThread 的run()方法正式开始执行任务。
ForkJoinWorkerThread.run()
public void run() {
if (workQueue.array == null) { // only run once
Throwable exception = null;
try {
onStart();//钩子方法,可自定义扩展
pool.runWorker(workQueue);
} catch (Throwable ex) {
exception = ex;
} finally {
try {
onTermination(exception);//钩子方法,可自定义扩展
} catch (Throwable ex) {
if (exception == null)
exception = ex;
} finally {
pool.deregisterWorker(this, exception);//处理异常
}
}
}
}
在工作线程运行前后会调用自定义钩子函数(onStart和onTermination),任务的运行则是调用了ForkJoinPool.runWorker()。如果全部任务执行完毕或者期间遭遇异常,则通过ForkJoinPool.deregisterWorker关闭工作线程并处理异常信息(deregisterWorker方法我们后面会详细讲解)。
ForkJoinPool.runWorker(WorkQueue w)
final void runWorker(WorkQueue w) {
w.growArray(); // allocate queue
int seed = w.hint; // initially holds randomization hint
int r = (seed == 0) ? 1 : seed; // avoid 0 for xorShift
for (ForkJoinTask<?> t; ; ) {
if ((t = scan(w, r)) != null)//扫描任务执行
w.runTask(t);
else if (!awaitWork(w, r))
break;
r ^= r << 13;
r ^= r >>> 17;
r ^= r << 5; // xorshift
}
}
runWorker是 ForkJoinWorkerThread 的主运行方法,用来依次执行当前工作线程中的任务。函数流程很简单: 调用scan方法依次获取任务,然后调用WorkQueue .runTask运行任务;如果未扫描到任务,则调用awaitWork等待,直到工作线程/线程池终止或等待超时。
ForkJoinPool.scan(WorkQueue w, int r)
private ForkJoinTask<?> scan(WorkQueue w, int r) {
WorkQueue[] ws;
int m;
if ((ws = workQueues) != null && (m = ws.length - 1) > 0 && w != null) {
int ss = w.scanState; // initially non-negative
//初始扫描起点,自旋扫描
for (int origin = r & m, k = origin, oldSum = 0, checkSum = 0; ; ) {
WorkQueue q;
ForkJoinTask<?>[] a;
ForkJoinTask<?> t;
int b, n;
long c;
if ((q = ws[k]) != null) {//获取workQueue
if ((n = (b = q.base) - q.top) < 0 &&
(a = q.array) != null) { // non-empty
//计算偏移量
long i = (((a.length - 1) & b) << ASHIFT) + ABASE;
if ((t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i))) != null && //取base位置任务
q.base == b) {//stable
if (ss >= 0) { //scanning
if (U.compareAndSwapObject(a, i, t, null)) {//
q.base = b + 1;//更新base位
if (n < -1) // signal others
signalWork(ws, q);//创建或唤醒工作线程来运行任务
return t;
}
} else if (oldSum == 0 && // try to activate 尝试激活工作线程
w.scanState < 0)
tryRelease(c = ctl, ws[m & (int) c], AC_UNIT);//唤醒栈顶工作线程
}
//base位置任务为空或base位置偏移,随机移位重新扫描
if (ss < 0) // refresh
ss = w.scanState;
r ^= r << 1;
r ^= r >>> 3;
r ^= r << 10;
origin = k = r & m; // move and rescan
oldSum = checkSum = 0;
continue;
}
checkSum += b;//队列任务为空,记录base位
}
//更新索引k 继续向后查找
if ((k = (k + 1) & m) == origin) { // continue until stable
//运行到这里说明已经扫描了全部的 workQueues,但并未扫描到任务
if ((ss >= 0 || (ss == (ss = w.scanState))) &&
oldSum == (oldSum = checkSum)) {
if (ss < 0 || w.qlock < 0) // already inactive
break;// 已经被灭活或终止,跳出循环
//对当前WorkQueue进行灭活操作
int ns = ss | INACTIVE; // try to inactivate
long nc = ((SP_MASK & ns) |
(UC_MASK & ((c = ctl) - AC_UNIT)));//计算ctl为INACTIVE状态并减少活跃线程数
w.stackPred = (int) c; // hold prev stack top
U.putInt(w, QSCANSTATE, ns);//修改scanState为inactive状态
if (U.compareAndSwapLong(this, CTL, c, nc))//更新scanState为灭活状态
ss = ns;
else
w.scanState = ss; // back out
}
checkSum = 0;//重置checkSum,继续循环
}
}
}
return null;
}
扫描并尝试偷取一个任务。使用w.hint进行随机索引 WorkQueue,也就是说并不一定会执行当前 WorkQueue 中的任务,而是偷取别的Worker的任务来执行。
函数的大致流程如下:
- 取随机位置的一个 WorkQueue;
- 获取base位的 ForkJoinTask,成功取到后更新base位并返回任务;如果取到的 WorkQueue 中任务数大于1,则调用signalWork创建或唤醒其他工作线程;
- 如果当前工作线程处于不活跃状态(INACTIVE),则调用tryRelease尝试唤醒栈顶工作线程来执行。
tryRelease源码如下:
private boolean tryRelease(long c, WorkQueue v, long inc) {
int sp = (int) c, vs = (sp + SS_SEQ) & ~INACTIVE;
Thread p;
//ctl低32位等于scanState,说明可以唤醒parker线程
if (v != null && v.scanState == sp) { // v is at top of stack
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
long nc = (UC_MASK & (c + inc)) | (SP_MASK & v.stackPred);
if (U.compareAndSwapLong(this, CTL, c, nc)) {
v.scanState = vs;
if ((p = v.parker) != null)
U.unpark(p);//唤醒线程
return true;
}
}
return false;
}
- 如果base位任务为空或发生偏移,则对索引位进行随机移位,然后重新扫描;
- 如果扫描整个workQueues之后没有获取到任务,则设置当前工作线程为INACTIVE状态;然后重置checkSum,再次扫描一圈之后如果还没有任务则跳出循环返回null。
ForkJoinPool.awaitWork(WorkQueue w, int r)
private boolean awaitWork(WorkQueue w, int r) {
if (w == null || w.qlock < 0) // w is terminating
return false;
for (int pred = w.stackPred, spins = SPINS, ss; ; ) {
if ((ss = w.scanState) >= 0)//正在扫描,跳出循环
break;
else if (spins > 0) {
r ^= r << 6;
r ^= r >>> 21;
r ^= r << 7;
if (r >= 0 && --spins == 0) { // randomize spins
WorkQueue v;
WorkQueue[] ws;
int s, j;
AtomicLong sc;
if (pred != 0 && (ws = workQueues) != null &&
(j = pred & SMASK) < ws.length &&
(v = ws[j]) != null && // see if pred parking
(v.parker == null || v.scanState >= 0))
spins = SPINS; // continue spinning
}
} else if (w.qlock < 0) // 当前workQueue已经终止,返回false recheck after spins
return false;
else if (!Thread.interrupted()) {//判断线程是否被中断,并清除中断状态
long c, prevctl, parkTime, deadline;
int ac = (int) ((c = ctl) >> AC_SHIFT) + (config & SMASK);//活跃线程数
if ((ac <= 0 && tryTerminate(false, false)) || //无active线程,尝试终止
(runState & STOP) != 0) // pool terminating
return false;
if (ac <= 0 && ss == (int) c) { // is last waiter
//计算活跃线程数(高32位)并更新为下一个栈顶的scanState(低32位)
prevctl = (UC_MASK & (c + AC_UNIT)) | (SP_MASK & pred);
int t = (short) (c >>> TC_SHIFT); // shrink excess spares
if (t > 2 && U.compareAndSwapLong(this, CTL, c, prevctl))//总线程过量
return false; // else use timed wait
//计算空闲超时时间
parkTime = IDLE_TIMEOUT * ((t >= 0) ? 1 : 1 - t);
deadline = System.nanoTime() + parkTime - TIMEOUT_SLOP;
} else
prevctl = parkTime = deadline = 0L;
Thread wt = Thread.currentThread();
U.putObject(wt, PARKBLOCKER, this); // emulate LockSupport
w.parker = wt;//设置parker,准备阻塞
if (w.scanState < 0 && ctl == c) // recheck before park
U.park(false, parkTime);//阻塞指定的时间
U.putOrderedObject(w, QPARKER, null);
U.putObject(wt, PARKBLOCKER, null);
if (w.scanState >= 0)//正在扫描,说明等到任务,跳出循环
break;
if (parkTime != 0L && ctl == c &&
deadline - System.nanoTime() <= 0L &&
U.compareAndSwapLong(this, CTL, c, prevctl))//未等到任务,更新ctl,返回false
return false; // shrink pool
}
}
return true;
}
回到runWorker方法,如果scan方法未扫描到任务,会调用awaitWork等待获取任务。函数的具体执行流程大家看源码,这里简单说一下:
- 在等待获取任务期间,如果工作线程或线程池已经终止则直接返回false。
- 如果当前无 active 线程,尝试终止线程池并返回false,如果终止失败并且当前是最后一个等待的 Worker,就阻塞指定的时间(IDLE_TIMEOUT);等到届期或被唤醒后如果发现自己是scanning(scanState >= 0)状态,说明已经等到任务,跳出等待返回true继续 scan,否则的更新ctl并返回false。
WorkQueue.runTask()
final void runTask(ForkJoinTask<?> task) {
if (task != null) {
scanState &= ~SCANNING; // mark as busy
(currentSteal = task).doExec();//更新currentSteal并执行任务
U.putOrderedObject(this, QCURRENTSTEAL, null); // release for GC
execLocalTasks();//依次执行本地任务
ForkJoinWorkerThread thread = owner;
if (++nsteals < 0) // collect on overflow
transferStealCount(pool);//增加偷取任务数
scanState |= SCANNING;
if (thread != null)
thread.afterTopLevelExec();//执行钩子函数
}
}
在scan方法扫描到任务之后,调用WorkQueue.runTask()来执行获取到的任务,大概流程如下:
- 标记scanState为正在执行状态;
- 更新currentSteal为当前获取到的任务并执行它,任务的执行调用了ForkJoinTask.doExec()方法,源码如下:
//ForkJoinTask.doExec()
final int doExec() {
int s; boolean completed;
if ((s = status) >= 0) {
try {
completed = exec();//执行我们定义的任务
} catch (Throwable rex) {
return setExceptionalCompletion(rex);
}
if (completed)
s = setCompletion(NORMAL);
}
return s;
}
- 调用execLocalTasks依次执行当前WorkerQueue中的任务,源码如下:
//执行并移除所有本地任务
final void execLocalTasks() {
int b = base, m, s;
ForkJoinTask<?>[] a = array;
if (b - (s = top - 1) <= 0 && a != null &&
(m = a.length - 1) >= 0) {
if ((config & FIFO_QUEUE) == 0) {//FIFO模式
for (ForkJoinTask<?> t; ; ) {
if ((t = (ForkJoinTask<?>) U.getAndSetObject
(a, ((m & s) << ASHIFT) + ABASE, null)) == null)//FIFO执行,取top任务
break;
U.putOrderedInt(this, QTOP, s);
t.doExec();//执行
if (base - (s = top - 1) > 0)
break;
}
} else
pollAndExecAll();//LIFO模式执行,取base任务
}
}
- 更新窃取次数。
- 还原scanState并执行钩子函数。
ForkJoinPool.deregisterWorker(ForkJoinWorkerThread wt, Throwable ex)
final void deregisterWorker(ForkJoinWorkerThread wt, Throwable ex) {
WorkQueue w = null;
//1.移除workQueue
if (wt != null && (w = wt.workQueue) != null) {//获取ForkJoinWorkerThread的等待队列
WorkQueue[] ws; // remove index from array
int idx = w.config & SMASK;//计算workQueue索引
int rs = lockRunState();//获取runState锁和当前池运行状态
if ((ws = workQueues) != null && ws.length > idx && ws[idx] == w)
ws[idx] = null;//移除workQueue
unlockRunState(rs, rs & ~RSLOCK);//解除runState锁
}
//2.减少CTL数
long c; // decrement counts
do {} while (!U.compareAndSwapLong
(this, CTL, c = ctl, ((AC_MASK & (c - AC_UNIT)) |
(TC_MASK & (c - TC_UNIT)) |
(SP_MASK & c))));
//3.处理被移除workQueue内部相关参数
if (w != null) {
w.qlock = -1; // ensure set
w.transferStealCount(this);
w.cancelAll(); // cancel remaining tasks
}
//4.如果线程未终止,替换被移除的workQueue并唤醒内部线程
for (;;) { // possibly replace
WorkQueue[] ws; int m, sp;
//尝试终止线程池
if (tryTerminate(false, false) || w == null || w.array == null ||
(runState & STOP) != 0 || (ws = workQueues) == null ||
(m = ws.length - 1) < 0) // already terminating
break;
//唤醒被替换的线程,依赖于下一步
if ((sp = (int)(c = ctl)) != 0) { // wake up replacement
if (tryRelease(c, ws[sp & m], AC_UNIT))
break;
}
//创建工作线程替换
else if (ex != null && (c & ADD_WORKER) != 0L) {
tryAddWorker(c); // create replacement
break;
}
else // don't need replacement
break;
}
//5.处理异常
if (ex == null) // help clean on way out
ForkJoinTask.helpExpungeStaleExceptions();
else // rethrow
ForkJoinTask.rethrow(ex);
}
deregisterWorker方法用于工作线程运行完毕之后终止线程或处理工作线程异常,主要就是清除已关闭的工作线程或回滚创建线程之前的操作,并把传入的异常抛给 ForkJoinTask 来处理。具体步骤见源码注释。
ForkJoinPool:获取结果
- join
//合并任务结果
public final V join() {
int s;
if ((s = doJoin() & DONE_MASK) != NORMAL)
reportException(s);
return getRawResult();
}
//join, get, quietlyJoin的主实现方法
private int doJoin() {
int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
return (s = status) < 0 ? s :
((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
(w = (wt = (ForkJoinWorkerThread)t).workQueue).
tryUnpush(this) && (s = doExec()) < 0 ? s :
wt.pool.awaitJoin(w, this, 0L) :
externalAwaitDone();
}
- invoke
//执行任务,并等待任务完成并返回结果
public final V invoke() {
int s;
if ((s = doInvoke() & DONE_MASK) != NORMAL)
reportException(s);
return getRawResult();
}
//invoke, quietlyInvoke的主实现方法
private int doInvoke() {
int s; Thread t; ForkJoinWorkerThread wt;
return (s = doExec()) < 0 ? s :
((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
(wt = (ForkJoinWorkerThread)t).pool.
awaitJoin(wt.workQueue, this, 0L) :
externalAwaitDone();
}
join()方法一把是在任务fork()之后调用,用来获取(或者叫“合并”)任务的执行结果。
ForkJoinTask的join()和invoke()方法都可以用来获取任务的执行结果(另外还有get方法也是调用了doJoin来获取任务结果,但是会响应运行时异常),它们对外部提交任务的执行方式一致,都是通过externalAwaitDone方法等待执行结果。
不同的是invoke()方法会直接执行当前任务;而join()方法则是在当前任务在队列 top 位时(通过tryUnpush方法判断)才能执行,如果当前任务不在 top 位或者任务执行失败调用ForkJoinPool.awaitJoin方法帮助执行或阻塞当前 join 任务。(所以在官方文档中建议了我们对ForkJoinTask任务的调用顺序,一对 fork-join操作一般按照如下顺序调用: a.fork(); b.fork(); b.join(); a.join();。因为任务 b 是后面进入队列,也就是说它是在栈顶的(top 位),在它fork()之后直接调用join()就可以直接执行而不会调用ForkJoinPool.awaitJoin方法去等待。)
在这些方法中,join()相对比较全面,所以之后的讲解我们将从join()开始逐步向下分析,首先看一下join()的执行流程:
后面的源码分析中,我们首先讲解比较简单的外部 join 任务(externalAwaitDone),然后再讲解内部 join 任务(从ForkJoinPool.awaitJoin()开始)。
ForkJoinTask.externalAwaitDone()
private int externalAwaitDone() {
//执行任务
int s = ((this instanceof CountedCompleter) ? // try helping
ForkJoinPool.common.externalHelpComplete( // CountedCompleter任务
(CountedCompleter<?>)this, 0) :
ForkJoinPool.common.tryExternalUnpush(this) ? doExec() : 0); // ForkJoinTask任务
if (s >= 0 && (s = status) >= 0) {//执行失败,进入等待
boolean interrupted = false;
do {
if (U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) { //更新state
synchronized (this) {
if (status >= 0) {//SIGNAL 等待信号
try {
wait(0L);
} catch (InterruptedException ie) {
interrupted = true;
}
}
else
notifyAll();
}
}
} while ((s = status) >= 0);
if (interrupted)
Thread.currentThread().interrupt();
}
return s;
}
如果当前join为外部调用,则调用此方法执行任务,如果任务执行失败就进入等待。方法本身是很简单的,需要注意的是对不同的任务类型分两种情况:
- 如果我们的任务为 CountedCompleter 类型的任务,则调用externalHelpComplete方法来执行任务。
- 其他类型的 ForkJoinTask 任务调用tryExternalUnpush来执行,源码如下:
//为外部提交者提供 tryUnpush 功能(给定任务在top位时弹出任务)
final boolean tryExternalUnpush(ForkJoinTask<?> task) {
WorkQueue[] ws;
WorkQueue w;
ForkJoinTask<?>[] a;
int m, s;
int r = ThreadLocalRandom.getProbe();
if ((ws = workQueues) != null && (m = ws.length - 1) >= 0 &&
(w = ws[m & r & SQMASK]) != null &&
(a = w.array) != null && (s = w.top) != w.base) {
long j = (((a.length - 1) & (s - 1)) << ASHIFT) + ABASE; //取top位任务
if (U.compareAndSwapInt(w, QLOCK, 0, 1)) { //加锁
if (w.top == s && w.array == a &&
U.getObject(a, j) == task &&
U.compareAndSwapObject(a, j, task, null)) { //符合条件,弹出
U.putOrderedInt(w, QTOP, s - 1); //更新top
U.putOrderedInt(w, QLOCK, 0); //解锁,返回true
return true;
}
U.compareAndSwapInt(w, QLOCK, 1, 0); //当前任务不在top位,解锁返回false
}
}
return false;
}
tryExternalUnpush的作用就是判断当前任务是否在top位,如果是则弹出任务,然后在externalAwaitDone中调用doExec()执行任务。
ForkJoinPool.awaitJoin()
final int awaitJoin(WorkQueue w, ForkJoinTask<?> task, long deadline) {
int s = 0;
if (task != null && w != null) {
ForkJoinTask<?> prevJoin = w.currentJoin; //获取给定Worker的join任务
U.putOrderedObject(w, QCURRENTJOIN, task); //把currentJoin替换为给定任务
//判断是否为CountedCompleter类型的任务
CountedCompleter<?> cc = (task instanceof CountedCompleter) ?
(CountedCompleter<?>) task : null;
for (; ; ) {
if ((s = task.status) < 0) //已经完成|取消|异常 跳出循环
break;
if (cc != null)//CountedCompleter任务由helpComplete来完成join
helpComplete(w, cc, 0);
else if (w.base == w.top || w.tryRemoveAndExec(task)) //尝试执行
helpStealer(w, task); //队列为空或执行失败,任务可能被偷,帮助偷取者执行该任务
if ((s = task.status) < 0) //已经完成|取消|异常,跳出循环
break;
//计算任务等待时间
long ms, ns;
if (deadline == 0L)
ms = 0L;
else if ((ns = deadline - System.nanoTime()) <= 0L)
break;
else if ((ms = TimeUnit.NANOSECONDS.toMillis(ns)) <= 0L)
ms = 1L;
if (tryCompensate(w)) {//执行补偿操作
task.internalWait(ms);//补偿执行成功,任务等待指定时间
U.getAndAddLong(this, CTL, AC_UNIT);//更新活跃线程数
}
}
U.putOrderedObject(w, QCURRENTJOIN, prevJoin);//循环结束,替换为原来的join任务
}
return s;
}
如果当前 join 任务不在Worker等待队列的top位,或者任务执行失败,调用此方法来帮助执行或阻塞当前 join 的任务。函数执行流程如下:
- 由于每次调用awaitJoin都会优先执行当前join的任务,所以首先会更新currentJoin为当前join任务;
- 进入自旋
- 首先检查任务是否已经完成(通过task.status < 0判断),如果给定任务执行完毕|取消|异常 则跳出循环返回执行状态s;
- 如果是 CountedCompleter 任务类型,调用helpComplete方法来完成join操作(后面笔者会开新篇来专门讲解CountedCompleter,本篇暂时不做详细解析);
- 非 CountedCompleter 任务类型调用WorkQueue.tryRemoveAndExec尝试执行任务;
- 如果给定 WorkQueue 的等待队列为空或任务执行失败,说明任务可能被偷,调用helpStealer帮助偷取者执行任务(也就是说,偷取者帮我执行任务,我去帮偷取者执行它的任务);
- 再次判断任务是否执行完毕(task.status < 0),如果任务执行失败,计算一个等待时间准备进行补偿操作;
- 调用tryCompensate方法为给定 WorkQueue 尝试执行补偿操作。在执行补偿期间,如果发现 资源争用|池处于unstable状态|当前Worker已终止,则调用ForkJoinTask.internalWait()方法等待指定的时间,任务唤醒之后继续自旋,ForkJoinTask.internalWait()源码如下:
final void internalWait(long timeout) {
int s;
if ((s = status) >= 0 && // force completer to issue notify
U.compareAndSwapInt(this, STATUS, s, s | SIGNAL)) {//更新任务状态为SIGNAL(等待唤醒)
synchronized (this) {
if (status >= 0)
try { wait(timeout); } catch (InterruptedException ie) { }
else
notifyAll();
}
}
}
在awaitJoin中,我们总共调用了三个比较复杂的方法: tryRemoveAndExec、helpStealer和tryCompensate,下面我们依次讲解。
WorkQueue.tryRemoveAndExec(ForkJoinTask task)
final boolean tryRemoveAndExec(ForkJoinTask<?> task) {
ForkJoinTask<?>[] a;
int m, s, b, n;
if ((a = array) != null && (m = a.length - 1) >= 0 &&
task != null) {
while ((n = (s = top) - (b = base)) > 0) {
//从top往下自旋查找
for (ForkJoinTask<?> t; ; ) { // traverse from s to b
long j = ((--s & m) << ASHIFT) + ABASE;//计算任务索引
if ((t = (ForkJoinTask<?>) U.getObject(a, j)) == null) //获取索引到的任务
return s + 1 == top; // shorter than expected
else if (t == task) { //给定任务为索引任务
boolean removed = false;
if (s + 1 == top) { // pop
if (U.compareAndSwapObject(a, j, task, null)) { //弹出任务
U.putOrderedInt(this, QTOP, s); //更新top
removed = true;
}
} else if (base == b) // replace with proxy
removed = U.compareAndSwapObject(
a, j, task, new EmptyTask()); //join任务已经被移除,替换为一个占位任务
if (removed)
task.doExec(); //执行
break;
} else if (t.status < 0 && s + 1 == top) { //给定任务不是top任务
if (U.compareAndSwapObject(a, j, t, null)) //弹出任务
U.putOrderedInt(this, QTOP, s);//更新top
break; // was cancelled
}
if (--n == 0) //遍历结束
return false;
}
if (task.status < 0) //任务执行完毕
return false;
}
}
return true;
}
从top位开始自旋向下找到给定任务,如果找到把它从当前 Worker 的任务队列中移除并执行它。注意返回的参数: 如果任务队列为空或者任务未执行完毕返回true;任务执行完毕返回false。
ForkJoinPool.helpStealer(WorkQueue w, ForkJoinTask task)
private void helpStealer(WorkQueue w, ForkJoinTask<?> task) {
WorkQueue[] ws = workQueues;
int oldSum = 0, checkSum, m;
if (ws != null && (m = ws.length - 1) >= 0 && w != null &&
task != null) {
do { // restart point
checkSum = 0; // for stability check
ForkJoinTask<?> subtask;
WorkQueue j = w, v; // v is subtask stealer
descent:
for (subtask = task; subtask.status >= 0; ) {
//1. 找到给定WorkQueue的偷取者v
for (int h = j.hint | 1, k = 0, i; ; k += 2) {//跳两个索引,因为Worker在奇数索引位
if (k > m) // can't find stealer
break descent;
if ((v = ws[i = (h + k) & m]) != null) {
if (v.currentSteal == subtask) {//定位到偷取者
j.hint = i;//更新stealer索引
break;
}
checkSum += v.base;
}
}
//2. 帮助偷取者v执行任务
for (; ; ) { // help v or descend
ForkJoinTask<?>[] a; //偷取者内部的任务
int b;
checkSum += (b = v.base);
ForkJoinTask<?> next = v.currentJoin;//获取偷取者的join任务
if (subtask.status < 0 || j.currentJoin != subtask ||
v.currentSteal != subtask) // stale
break descent; // stale,跳出descent循环重来
if (b - v.top >= 0 || (a = v.array) == null) {
if ((subtask = next) == null) //偷取者的join任务为null,跳出descent循环
break descent;
j = v;
break; //偷取者内部任务为空,可能任务也被偷走了;跳出本次循环,查找偷取者的偷取者
}
int i = (((a.length - 1) & b) << ASHIFT) + ABASE;//获取base偏移地址
ForkJoinTask<?> t = ((ForkJoinTask<?>)
U.getObjectVolatile(a, i));//获取偷取者的base任务
if (v.base == b) {
if (t == null) // stale
break descent; // stale,跳出descent循环重来
if (U.compareAndSwapObject(a, i, t, null)) {//弹出任务
v.base = b + 1; //更新偷取者的base位
ForkJoinTask<?> ps = w.currentSteal;//获取调用者偷来的任务
int top = w.top;
//首先更新给定workQueue的currentSteal为偷取者的base任务,然后执行该任务
//然后通过检查top来判断给定workQueue是否有自己的任务,如果有,
// 则依次弹出任务(LIFO)->更新currentSteal->执行该任务(注意这里是自己偷自己的任务执行)
do {
U.putOrderedObject(w, QCURRENTSTEAL, t);
t.doExec(); // clear local tasks too
} while (task.status >= 0 &&
w.top != top && //内部有自己的任务,依次弹出执行
(t = w.pop()) != null);
U.putOrderedObject(w, QCURRENTSTEAL, ps);//还原给定workQueue的currentSteal
if (w.base != w.top)//给定workQueue有自己的任务了,帮助结束,返回
return; // can't further help
}
}
}
}
} while (task.status >= 0 && oldSum != (oldSum = checkSum));
}
}
如果队列为空或任务执行失败,说明任务可能被偷,调用此方法来帮助偷取者执行任务。基本思想是: 偷取者帮助我执行任务,我去帮助偷取者执行它的任务。 函数执行流程如下:
循环定位偷取者,由于Worker是在奇数索引位,所以每次会跳两个索引位。定位到偷取者之后,更新调用者 WorkQueue 的hint为偷取者的索引,方便下次定位; 定位到偷取者后,开始帮助偷取者执行任务。从偷取者的base索引开始,每次偷取一个任务执行。在帮助偷取者执行任务后,如果调用者发现本身已经有任务(w.top != top),则依次弹出自己的任务(LIFO顺序)并执行(也就是说自己偷自己的任务执行)。
ForkJoinPool.tryCompensate(WorkQueue w)
//执行补偿操作: 尝试缩减活动线程量,可能释放或创建一个补偿线程来准备阻塞
private boolean tryCompensate(WorkQueue w) {
boolean canBlock;
WorkQueue[] ws;
long c;
int m, pc, sp;
if (w == null || w.qlock < 0 || // caller terminating
(ws = workQueues) == null || (m = ws.length - 1) <= 0 ||
(pc = config & SMASK) == 0) // parallelism disabled
canBlock = false; //调用者已终止
else if ((sp = (int) (c = ctl)) != 0) // release idle worker
canBlock = tryRelease(c, ws[sp & m], 0L);//唤醒等待的工作线程
else {//没有空闲线程
int ac = (int) (c >> AC_SHIFT) + pc; //活跃线程数
int tc = (short) (c >> TC_SHIFT) + pc;//总线程数
int nbusy = 0; // validate saturation
for (int i = 0; i <= m; ++i) { // two passes of odd indices
WorkQueue v;
if ((v = ws[((i << 1) | 1) & m]) != null) {//取奇数索引位
if ((v.scanState & SCANNING) != 0)//没有正在运行任务,跳出
break;
++nbusy;//正在运行任务,添加标记
}
}
if (nbusy != (tc << 1) || ctl != c)
canBlock = false; // unstable or stale
else if (tc >= pc && ac > 1 && w.isEmpty()) {//总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空,不需要补偿
long nc = ((AC_MASK & (c - AC_UNIT)) |
(~AC_MASK & c)); // uncompensated
canBlock = U.compareAndSwapLong(this, CTL, c, nc);//更新活跃线程数
} else if (tc >= MAX_CAP ||
(this == common && tc >= pc + commonMaxSpares))//超出最大线程数
throw new RejectedExecutionException(
"Thread limit exceeded replacing blocked worker");
else { // similar to tryAddWorker
boolean add = false;
int rs; // CAS within lock
long nc = ((AC_MASK & c) |
(TC_MASK & (c + TC_UNIT)));//计算总线程数
if (((rs = lockRunState()) & STOP) == 0)
add = U.compareAndSwapLong(this, CTL, c, nc);//更新总线程数
unlockRunState(rs, rs & ~RSLOCK);
//运行到这里说明活跃工作线程数不足,需要创建一个新的工作线程来补偿
canBlock = add && createWorker(); // throws on exception
}
}
return canBlock;
}
具体的执行看源码及注释,这里我们简单总结一下需要和不需要补偿的几种情况:
- 需要补偿 :
- 调用者队列不为空,并且有空闲工作线程,这种情况会唤醒空闲线程(调用tryRelease方法)
- 池尚未停止,活跃线程数不足,这时会新建一个工作线程(调用createWorker方法)
- 不需要补偿 :
- 调用者已终止或池处于不稳定状态
- 总线程数大于并行度 && 活动线程数大于1 && 调用者任务队列为空
注意事项
避免不必要的fork()
划分成两个子任务后,不要同时调用两个子任务的fork()方法。
表面上看上去两个子任务都fork(),然后join()两次似乎更自然。但事实证明,直接调用compute()效率更高。因为直接调用子任务的compute()方法实际上就是在当前的工作线程进行了计算(线程重用),这比“将子任务提交到工作队列,线程又从工作队列中拿任务”快得多。
当一个大任务被划分成两个以上的子任务时,尽可能使用前面说到的三个衍生的invokeAll方法,因为使用它们能避免不必要的fork()。
注意fork()、compute()、join()的顺序
为了两个任务并行,三个方法的调用顺序需要万分注意。
right.fork(); // 计算右边的任务
long leftAns = left.compute(); // 计算左边的任务(同时右边任务也在计算)
long rightAns = right.join(); // 等待右边的结果
return leftAns + rightAns;
选择合适的子任务粒度
选择划分子任务的粒度(顺序执行的阈值)很重要,因为使用Fork/Join框架并不一定比顺序执行任务的效率高: 如果任务太大,则无法提高并行的吞吐量;如果任务太小,子任务的调度开销可能会大于并行计算的性能提升,我们还要考虑创建子任务、fork()子任务、线程调度以及合并子任务处理结果的耗时以及相应的内存消耗。
官方文档给出的粗略经验是: 任务应该执行100~10000
个基本的计算步骤。决定子任务的粒度的最好办法是实践,通过实际测试结果来确定这个阈值才是“上上策”。
和其他Java代码一样,Fork/Join框架测试时需要“预热”或者说执行几遍才会被JIT(Just-in-time)编译器优化,所以测试性能之前跑几遍程序很重要。
避免重量级任务划分与结果合并
Fork/Join的很多使用场景都用到数组或者List等数据结构,子任务在某个分区中运行,最典型的例子如并行排序和并行查找。拆分子任务以及合并处理结果的时候,应该尽量避免System.arraycopy这样耗时耗空间的操作,从而最小化任务的处理开销
有哪些JDK源码中使用了Fork/Join思想?
我们常用的数组工具类 Arrays 在JDK 8之后新增的并行排序方法(parallelSort)就运用了 ForkJoinPool 的特性,还有 ConcurrentHashMap 在JDK 8之后添加的函数式方法(如forEach等)也有运用。
使用Executors工具类创建ForkJoinPool
Java8在Executors工具类中新增了两个工厂方法:
// parallelism定义并行级别
public static ExecutorService newWorkStealingPool(int parallelism);
// 默认并行级别为JVM可用的处理器个数
// Runtime.getRuntime().availableProcessors()
public static ExecutorService newWorkStealingPool();
关于Fork/Join异常处理
Java的受检异常机制一直饱受诟病,所以在ForkJoinTask的invoke()、join()方法及其衍生方法中都没有像get()方法那样抛出个ExecutionException的受检异常。
所以你可以在ForkJoinTask中看到内部把受检异常转换成了运行时异常。
static void rethrow(Throwable ex) {
if (ex != null)
ForkJoinTask.<RuntimeException>uncheckedThrow(ex);
}
@SuppressWarnings("unchecked")
static <T extends Throwable> void uncheckedThrow(Throwable t) throws T {
throw (T)t; // rely on vacuous cast
}
应用实例
计算1+2+3+…+10000的结果
public class Test {
static final class SumTask extends RecursiveTask<Integer> {
private static final long serialVersionUID = 1L;
final int start; //开始计算的数
final int end; //最后计算的数
SumTask(int start, int end) {
this.start = start;
this.end = end;
}
@Override
protected Integer compute() {
//如果计算量小于1000,那么分配一个线程执行if中的代码块,并返回执行结果
if(end - start < 1000) {
System.out.println(Thread.currentThread().getName() + " 开始执行: " + start + "-" + end);
int sum = 0;
for(int i = start; i <= end; i++)
sum += i;
return sum;
}
//如果计算量大于1000,那么拆分为两个任务
SumTask task1 = new SumTask(start, (start + end) / 2);
SumTask task2 = new SumTask((start + end) / 2 + 1, end);
//执行任务
task1.fork();
task2.fork();
//获取任务执行的结果
return task1.join() + task2.join();
}
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
ForkJoinPool pool = new ForkJoinPool();
ForkJoinTask<Integer> task = new SumTask(1, 10000);
pool.submit(task);
System.out.println(task.get());
}
}
斐波那契数列
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool(4); // 最大并发数4
Fibonacci fibonacci = new Fibonacci(20);
long startTime = System.currentTimeMillis();
Integer result = forkJoinPool.invoke(fibonacci);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
//以下为官方API文档示例
static class Fibonacci extends RecursiveTask<Integer> {
final int n;
Fibonacci(int n) {
this.n = n;
}
@Override
protected Integer compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
参考资料
1.3.24 - CH24-CountDownLatch
概览
其底层是由AQS提供支持,所以其数据结构可以参考AQS的数据结构,而AQS的数据结构核心就是两个虚拟队列: 同步队列sync queue 和条件队列condition queue,不同的条件会有不同的条件队列。
CountDownLatch典型的用法是将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每一个任务完成时,都会在这个锁存器上调用countDown,等待问题被解决的任务调用这个锁存器的await,将他们自己拦住,直至锁存器计数结束。
源码分析
层级结构
CountDownLatch没有显示继承哪个父类或者实现哪个父接口, 它底层是AQS是通过内部类Sync来实现的。
内部类
CountDownLatch类存在一个内部类Sync,继承自AbstractQueuedSynchronizer,其源代码如下。
private static final class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 4982264981922014374L;
// 构造器
Sync(int count) {
setState(count);
}
// 返回当前计数
int getCount() {
return getState();
}
// 试图在共享模式下获取对象状态
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
// 试图设置状态来反映共享模式下的一个释放
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}
}
对CountDownLatch方法的调用会转发到对Sync或AQS的方法的调用,所以,AQS对CountDownLatch提供支持。
类属性
可以看到CountDownLatch类的内部只有一个Sync类型的属性:
public class CountDownLatch {
// 同步队列
private final Sync sync;
}
构造函数
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
// 初始化状态数
this.sync = new Sync(count);
}
该构造函数可以构造一个用给定计数初始化的CountDownLatch,并且构造函数内完成了sync的初始化,并设置了状态数。
核心函数:await
此函数将会使当前线程在锁存器倒计数至零之前一直等待,除非线程被中断。其源码如下
public void await() throws InterruptedException {
// 转发到sync对象上
sync.acquireSharedInterruptibly(1);
}
对CountDownLatch对象的await的调用会转发为对Sync的acquireSharedInterruptibly(从AQS继承的方法)方法的调用。
- acquireSharedInterruptibly源码如下:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
acquireSharedInterruptibly又调用了CountDownLatch的内部类Sync的tryAcquireShared和AQS的doAcquireSharedInterruptibly函数。
- tryAcquireShared函数的源码如下:
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
该函数只是简单的判断AQS的state是否为0,为0则返回1,不为0则返回-1。
- doAcquireSharedInterruptibly函数的源码如下:
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
// 添加节点至等待队列
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) { // 无限循环
// 获取node的前驱节点
final Node p = node.predecessor();
if (p == head) { // 前驱节点为头结点
// 试图在共享模式下获取对象状态
int r = tryAcquireShared(arg);
if (r >= 0) { // 获取成功
// 设置头结点并进行繁殖
setHeadAndPropagate(node, r);
// 设置节点next域
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // 在获取失败后是否需要禁止线程并且进行中断检查
// 抛出异常
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
在AQS的doAcquireSharedInterruptibly中可能会再次调用CountDownLatch的内部类Sync的tryAcquireShared方法和AQS的setHeadAndPropagate方法。
- setHeadAndPropagate方法源码如下
private void setHeadAndPropagate(Node node, int propagate) {
// 获取头结点
Node h = head; // Record old head for check below
// 设置头结点
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
// 进行判断
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
// 获取节点的后继
Node s = node.next;
if (s == null || s.isShared()) // 后继为空或者为共享模式
// 以共享模式进行释放
doReleaseShared();
}
}
该方法设置头结点并且释放头结点后面的满足条件的结点,该方法中可能会调用到AQS的doReleaseShared方法,其源码如下。
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
// 无限循环
for (;;) {
// 保存头结点
Node h = head;
if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
// 获取头结点的等待状态
int ws = h.waitStatus;
if (ws == Node.SIGNAL) { // 状态为SIGNAL
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
continue; // loop to recheck cases
// 释放后继结点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
continue; // loop on failed CAS
}
if (h == head) // 若头结点改变,继续循环
break;
}
}
该方法在共享模式下释放,具体的流程再之后会通过一个示例给出。
所以,对CountDownLatch的await调用大致会有如下的调用链。
上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。
核心函数:countDown
此函数将递减锁存器的计数,如果计数到达零,则释放所有等待的线程
public void countDown() {
sync.releaseShared(1);
}
对countDown的调用转换为对Sync对象的releaseShared(从AQS继承而来)方法的调用。
- releaseShared源码如下
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
此函数会以共享模式释放对象,并且在函数中会调用到CountDownLatch的tryReleaseShared函数,并且可能会调用AQS的doReleaseShared函数。
- tryReleaseShared源码如下
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
// 无限循环
for (;;) {
// 获取状态
int c = getState();
if (c == 0) // 没有被线程占有
return false;
// 下一个状态
int nextc = c-1;
if (compareAndSetState(c, nextc)) // 比较并且设置成功
return nextc == 0;
}
}
此函数会试图设置状态来反映共享模式下的一个释放。具体的流程在下面的示例中会进行分析。
- AQS的doReleaseShared的源码如下
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
// 无限循环
for (;;) {
// 保存头结点
Node h = head;
if (h != null && h != tail) { // 头结点不为空并且头结点不为尾结点
// 获取头结点的等待状态
int ws = h.waitStatus;
if (ws == Node.SIGNAL) { // 状态为SIGNAL
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) // 不成功就继续
continue; // loop to recheck cases
// 释放后继结点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) // 状态为0并且不成功,继续
continue; // loop on failed CAS
}
if (h == head) // 若头结点改变,继续循环
break;
}
}
此函数在共享模式下释放资源。
所以,对CountDownLatch的countDown调用大致会有如下的调用链。
上图给出了可能会调用到的主要方法,并非一定会调用到,之后,会通过一个示例给出详细的分析。
应用示例
import java.util.concurrent.CountDownLatch;
class MyThread extends Thread {
private CountDownLatch countDownLatch;
public MyThread(String name, CountDownLatch countDownLatch) {
super(name);
this.countDownLatch = countDownLatch;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " doing something");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " finish");
countDownLatch.countDown();
}
}
public class CountDownLatchDemo {
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(2);
MyThread t1 = new MyThread("t1", countDownLatch);
MyThread t2 = new MyThread("t2", countDownLatch);
t1.start();
t2.start();
System.out.println("Waiting for t1 thread and t2 thread to finish");
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " continue");
}
}
Waiting for t1 thread and t2 thread to finish
t1 doing something
t2 doing something
t1 finish
t2 finish
main continue
本程序首先计数器初始化为2。根据结果,可能会存在如下的一种时序图。
首先main线程会调用await操作,此时main线程会被阻塞,等待被唤醒,之后t1线程执行了countDown操作,最后,t2线程执行了countDown操作,此时main线程就被唤醒了,可以继续运行。下面,进行详细分析。
- main线程执行countDownLatch.await操作,主要调用的函数如下。
在最后,main线程就被park了,即禁止运行了。此时Sync queue(同步队列)中有两个节点,AQS的state为2,包含main线程的结点的nextWaiter指向SHARED结点。
- t1线程执行countDownLatch.countDown操作,主要调用的函数如下。
此时,Sync queue队列里的结点个数未发生变化,但是此时,AQS的state已经变为1了。
- t2线程执行countDownLatch.countDown操作,主要调用的函数如下。
经过调用后,AQS的state为0,并且此时,main线程会被unpark,可以继续运行。当main线程获取cpu资源后,继续运行。
- main线程获取cpu资源,继续运行,由于main线程是在parkAndCheckInterrupt函数中被禁止的,所以此时,继续在parkAndCheckInterrupt函数运行.
main线程恢复,继续在parkAndCheckInterrupt函数中运行,之后又会回到最终达到的状态为AQS的state为0,并且head与tail指向同一个结点,该节点的额nextWaiter域还是指向SHARED结点。
1.3.25 - CH25-CyclicBarrier
源码解析
层级结构
CyclicBarrier没有显示继承哪个父类或者实现哪个父接口, 所有AQS和重入锁不是通过继承实现的,而是通过组合实现的。
public class CyclicBarrier {}
```
### 类的内部类
CyclicBarrier类存在一个内部类Generation,每一次使用的CycBarrier可以当成Generation的实例,其源代码如下
```java
private static class Generation {
boolean broken = false;
}
Generation类有一个属性broken,用来表示当前屏障是否被损坏。
类属性
public class CyclicBarrier {
/** The lock for guarding barrier entry */
// 可重入锁
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
// 条件队列
private final Condition trip = lock.newCondition();
/** The number of parties */
// 参与的线程数量
private final int parties;
/* The command to run when tripped */
// 由最后一个进入 barrier 的线程执行的操作
private final Runnable barrierCommand;
/** The current generation */
// 当前代
private Generation generation = new Generation();
// 正在等待进入屏障的线程数量
private int count;
}
该属性有一个为ReentrantLock对象,有一个为Condition对象,而Condition对象又是基于AQS的,所以,归根到底,底层还是由AQS提供支持。
构造函数
- CyclicBarrier(int, Runnable)型构造函数
public CyclicBarrier(int parties, Runnable barrierAction) {
// 参与的线程数量小于等于0,抛出异常
if (parties <= 0) throw new IllegalArgumentException();
// 设置parties
this.parties = parties;
// 设置count
this.count = parties;
// 设置barrierCommand
this.barrierCommand = barrierAction;
}
该构造函数可以指定关联该CyclicBarrier的线程数量,并且可以指定在所有线程都进入屏障后的执行动作,该执行动作由最后一个进行屏障的线程执行。
- CyclicBarrier(int)型构造函数
public CyclicBarrier(int parties) {
// 调用含有两个参数的构造函数
this(parties, null);
}
该构造函数仅仅执行了关联该CyclicBarrier的线程数量,没有设置执行动作。
核心函数:dowait
此函数为CyclicBarrier类的核心函数,CyclicBarrier类对外提供的await函数在底层都是调用该了doawait函数,其源代码如下。
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException,
TimeoutException {
// 保存当前锁
final ReentrantLock lock = this.lock;
// 锁定
lock.lock();
try {
// 保存当前代
final Generation g = generation;
if (g.broken) // 屏障被破坏,抛出异常
throw new BrokenBarrierException();
if (Thread.interrupted()) { // 线程被中断
// 损坏当前屏障,并且唤醒所有的线程,只有拥有锁的时候才会调用
breakBarrier();
// 抛出异常
throw new InterruptedException();
}
// 减少正在等待进入屏障的线程数量
int index = --count;
if (index == 0) { // 正在等待进入屏障的线程数量为0,所有线程都已经进入
// 运行的动作标识
boolean ranAction = false;
try {
// 保存运行动作
final Runnable command = barrierCommand;
if (command != null) // 动作不为空
// 运行
command.run();
// 设置ranAction状态
ranAction = true;
// 进入下一代
nextGeneration();
return 0;
} finally {
if (!ranAction) // 没有运行的动作
// 损坏当前屏障
breakBarrier();
}
}
// loop until tripped, broken, interrupted, or timed out
// 无限循环
for (;;) {
try {
if (!timed) // 没有设置等待时间
// 等待
trip.await();
else if (nanos > 0L) // 设置了等待时间,并且等待时间大于0
// 等待指定时长
nanos = trip.awaitNanos(nanos);
} catch (InterruptedException ie) {
if (g == generation && ! g.broken) { // 等于当前代并且屏障没有被损坏
// 损坏当前屏障
breakBarrier();
// 抛出异常
throw ie;
} else { // 不等于当前带后者是屏障被损坏
// We're about to finish waiting even if we had not
// been interrupted, so this interrupt is deemed to
// "belong" to subsequent execution.
// 中断当前线程
Thread.currentThread().interrupt();
}
}
if (g.broken) // 屏障被损坏,抛出异常
throw new BrokenBarrierException();
if (g != generation) // 不等于当前代
// 返回索引
return index;
if (timed && nanos <= 0L) { // 设置了等待时间,并且等待时间小于0
// 损坏屏障
breakBarrier();
// 抛出异常
throw new TimeoutException();
}
}
} finally {
// 释放锁
lock.unlock();
}
}
该方法的逻辑会进行一系列的判断,大致流程如下:
核心函数:nextGeneration
此函数在所有线程进入屏障后会被调用,即生成下一个版本,所有线程又可以重新进入到屏障中,其源代码如下:
private void nextGeneration() {
// signal completion of last generation
// 唤醒所有线程
trip.signalAll();
// set up next generation
// 恢复正在等待进入屏障的线程数量
count = parties;
// 新生一代
generation = new Generation();
}
在此函数中会调用AQS的signalAll方法,即唤醒所有等待线程。如果所有的线程都在等待此条件,则唤醒所有线程。其源代码如下:
public final void signalAll() {
if (!isHeldExclusively()) // 不被当前线程独占,抛出异常
throw new IllegalMonitorStateException();
// 保存condition队列头结点
Node first = firstWaiter;
if (first != null) // 头结点不为空
// 唤醒所有等待线程
doSignalAll(first);
}
此函数判断头结点是否为空,即条件队列是否为空,然后会调用doSignalAll函数,doSignalAll函数源码如下:
private void doSignalAll(Node first) {
// condition队列的头结点尾结点都设置为空
lastWaiter = firstWaiter = null;
// 循环
do {
// 获取first结点的nextWaiter域结点
Node next = first.nextWaiter;
// 设置first结点的nextWaiter域为空
first.nextWaiter = null;
// 将first结点从condition队列转移到sync队列
transferForSignal(first);
// 重新设置first
first = next;
} while (first != null);
}
此函数会依次将条件队列中的节点转移到同步队列中,会调用到transferForSignal函数,其源码如下:
final boolean transferForSignal(Node node) {
/*
* If cannot change waitStatus, the node has been cancelled.
*/
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
return false;
/*
* Splice onto queue and try to set waitStatus of predecessor to
* indicate that thread is (probably) waiting. If cancelled or
* attempt to set waitStatus fails, wake up to resync (in which
* case the waitStatus can be transiently and harmlessly wrong).
*/
Node p = enq(node);
int ws = p.waitStatus;
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
LockSupport.unpark(node.thread);
return true;
}
此函数的作用就是将处于条件队列中的节点转移到同步队列中,并设置结点的状态信息,其中会调用到enq函数,其源代码如下。
private Node enq(final Node node) {
for (;;) { // 无限循环,确保结点能够成功入队列
// 保存尾结点
Node t = tail;
if (t == null) { // 尾结点为空,即还没被初始化
if (compareAndSetHead(new Node())) // 头结点为空,并设置头结点为新生成的结点
tail = head; // 头结点与尾结点都指向同一个新生结点
} else { // 尾结点不为空,即已经被初始化过
// 将node结点的prev域连接到尾结点
node.prev = t;
if (compareAndSetTail(t, node)) { // 比较结点t是否为尾结点,若是则将尾结点设置为node
// 设置尾结点的next域为node
t.next = node;
return t; // 返回尾结点
}
}
}
}
此函数完成了结点插入同步队列的过程,也很好理解。
综合上面的分析可知,newGeneration函数的主要方法的调用如下,之后会通过一个例子详细讲解:
核心函数:breakBarrier
此函数的作用是损坏当前屏障,会唤醒所有在屏障中的线程。源代码如下:
private void breakBarrier() {
// 设置状态
generation.broken = true;
// 恢复正在等待进入屏障的线程数量
count = parties;
// 唤醒所有线程
trip.signalAll();
}
可以看到,此函数也调用了AQS的signalAll函数,由signal函数提供支持。
应用示例
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
class MyThread extends Thread {
private CyclicBarrier cb;
public MyThread(String name, CyclicBarrier cb) {
super(name);
this.cb = cb;
}
public void run() {
System.out.println(Thread.currentThread().getName() + " going to await");
try {
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class CyclicBarrierDemo {
public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
CyclicBarrier cb = new CyclicBarrier(3, new Thread("barrierAction") {
public void run() {
System.out.println(Thread.currentThread().getName() + " barrier action");
}
});
MyThread t1 = new MyThread("t1", cb);
MyThread t2 = new MyThread("t2", cb);
t1.start();
t2.start();
System.out.println(Thread.currentThread().getName() + " going to await");
cb.await();
System.out.println(Thread.currentThread().getName() + " continue");
}
}
t1 going to await
main going to await
t2 going to await
t2 barrier action
t2 continue
t1 continue
main continue
根据结果可知,可能会存在如下的调用时序。
由上图可知,假设t1线程的cb.await是在main线程的cb.barrierAction动作是由最后一个进入屏障的线程执行的。根据时序图,进一步分析出其内部工作流程。
- main(主)线程执行cb.await操作,主要调用的函数如下。
由于ReentrantLock的默认采用非公平策略,所以在dowait函数中调用的是ReentrantLock.NonfairSync的lock函数,由于此时AQS的状态是0,表示还没有被任何线程占用,故main线程可以占用,之后在dowait中会调用trip.await函数,最终的结果是条件队列中存放了一个包含main线程的结点,并且被禁止运行了,同时,main线程所拥有的资源也被释放了,可以供其他线程获取。
- t1线程执行cb.await操作,其中假设t1线程的lock.lock操作在main线程释放了资源之后,则其主要调用的函数如下。
可以看到,之后condition queue(条件队列)里面有两个节点,包含t1线程的结点插入在队列的尾部,并且t1线程也被禁止了,因为执行了park操作,此时两个线程都被禁止了。
- t2线程执行cb.await操作,其中假设t2线程的lock.lock操作在t1线程释放了资源之后,则其主要调用的函数如下。
由上图可知,在t2线程执行await操作后,会直接执行command.run方法,不是重新开启一个线程,而是最后进入屏障的线程执行。同时,会将Condition queue中的所有节点都转移到Sync queue中,并且最后main线程会被unpark,可以继续运行。main线程获取cpu资源,继续运行。
- main线程获取cpu资源,继续运行,下图给出了主要的方法调用:
其中,由于main线程是在AQS.CO的wait中被park的,所以恢复时,会继续在该方法中运行。运行过后,t1线程被unpark,它获得cpu资源可以继续运行。
- t1线程获取cpu资源,继续运行,下图给出了主要的方法调用。
其中,由于t1线程是在AQS.CO的wait方法中被park,所以恢复时,会继续在该方法中运行。运行过后,Sync queue中保持着一个空节点。头结点与尾节点均指向它。
注意: 在线程await过程中中断线程会抛出异常,所有进入屏障的线程都将被释放。至于CyclicBarrier的其他用法,读者可以自行查阅API,不再累赘。
对比 CountDownLatch
CountDownLatch减计数,CyclicBarrier加计数。
CountDownLatch是一次性的,CyclicBarrier可以重用。
CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;
而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。
1.3.26 - CH26-Semaphore
概览
Semaphore底层基于 AQS。Semaphore称为计数信号量,它允许n个任务同时访问某个资源,可以将信号量看做是在向外分发使用资源的许可证,只有成功获取许可证,才能使用资源。
源码分析
层级结构
public class Semaphore implements java.io.Serializable {}
内部类
Semaphore总共有三个内部类,并且三个内部类是紧密相关的,下面先看三个类的关系。
Semaphore与ReentrantLock的内部类的结构相同,类内部总共存在Sync、NonfairSync、FairSync三个类,NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类。
内部类:Sync
// 内部类,继承自AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
// 版本号
private static final long serialVersionUID = 1192457210091910933L;
// 构造函数
Sync(int permits) {
// 设置状态数
setState(permits);
}
// 获取许可
final int getPermits() {
return getState();
}
// 共享模式下非公平策略获取
final int nonfairTryAcquireShared(int acquires) {
for (;;) { // 无限循环
// 获取许可数
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) // 许可小于0或者比较并且设置状态成功
return remaining;
}
}
// 共享模式下进行释放
protected final boolean tryReleaseShared(int releases) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return true;
}
}
// 根据指定的缩减量减小可用许可的数目
final void reducePermits(int reductions) {
for (;;) { // 无限循环
// 获取许可
int current = getState();
// 可用的许可
int next = current - reductions;
if (next > current) // underflow
throw new Error("Permit count underflow");
if (compareAndSetState(current, next)) // 比较并进行设置成功
return;
}
}
// 获取并返回立即可用的所有许可
final int drainPermits() {
for (;;) { // 无限循环
// 获取许可
int current = getState();
if (current == 0 || compareAndSetState(current, 0)) // 许可为0或者比较并设置成功
return current;
}
}
}
Sync类的属性相对简单,只有一个版本号,Sync类存在如下方法和作用如下。
内部类:NonfairSync
NonfairSync类继承了Sync类,表示采用非公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下:
static final class NonfairSync extends Sync {
// 版本号
private static final long serialVersionUID = -2694183684443567898L;
// 构造函数
NonfairSync(int permits) {
super(permits);
}
// 共享模式下获取
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
}
从tryAcquireShared方法的源码可知,其会调用父类Sync的nonfairTryAcquireShared方法,表示按照非公平策略进行资源的获取。
内部类:FairSync
FairSync类继承了Sync类,表示采用公平策略获取资源,其只有一个tryAcquireShared方法,重写了AQS的该方法,其源码如下。
protected int tryAcquireShared(int acquires) {
for (;;) { // 无限循环
if (hasQueuedPredecessors()) // 同步队列中存在其他节点
return -1;
// 获取许可
int available = getState();
// 剩余的许可
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining)) // 剩余的许可小于0或者比较设置成功
return remaining;
}
}
从tryAcquireShared方法的源码可知,它使用公平策略来获取资源,它会判断同步队列中是否存在其他的等待节点。
类属性
public class Semaphore implements java.io.Serializable {
// 版本号
private static final long serialVersionUID = -3222578661600680210L;
// 属性
private final Sync sync;
}
Semaphore自身只有两个属性,最重要的是sync属性,基于Semaphore对象的操作绝大多数都转移到了对sync的操作。
构造函数
- Semaphore(int)型构造函数
public Semaphore(int permits) {
sync = new NonfairSync(permits);
}
该构造函数会创建具有给定的许可数和非公平的公平设置的Semaphore。
- Semaphore(int, boolean)型构造函数
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
该构造函数会创建具有给定的许可数和给定的公平设置的Semaphore。
核心函数:acquire
此方法从信号量获取一个(多个)许可,在提供一个许可前一直将线程阻塞,或者线程被中断,其源码如下
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
该方法中将会调用Sync对象的acquireSharedInterruptibly(从AQS继承而来的方法)方法,而acquireSharedInterruptibly方法在上一篇CountDownLatch中已经进行了分析,在此不再累赘。
最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示。
上图只是给出了大体会调用到的方法,和具体的示例可能会有些差别,之后会根据具体的示例进行分析。
核心函数:release
此方法释放一个(多个)许可,将其返回给信号量,源码如下。
public void release() {
sync.releaseShared(1);
}
该方法中将会调用Sync对象的releaseShared(从AQS继承而来的方法)方法,而releaseShared方法在上一篇CountDownLatch中已经进行了分析,在此不再累赘。
最终可以获取大致的方法调用序列(假设使用非公平策略)。如下图所示:
应用实例
import java.util.concurrent.Semaphore;
class MyThread extends Thread {
private Semaphore semaphore;
public MyThread(String name, Semaphore semaphore) {
super(name);
this.semaphore = semaphore;
}
public void run() {
int count = 3;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(count);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(count);
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}
public class SemaphoreDemo {
public final static int SEM_SIZE = 10;
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(SEM_SIZE);
MyThread t1 = new MyThread("t1", semaphore);
MyThread t2 = new MyThread("t2", semaphore);
t1.start();
t2.start();
int permits = 5;
System.out.println(Thread.currentThread().getName() + " trying to acquire");
try {
semaphore.acquire(permits);
System.out.println(Thread.currentThread().getName() + " acquire successfully");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release();
System.out.println(Thread.currentThread().getName() + " release successfully");
}
}
}
main trying to acquire
main acquire successfully
t1 trying to acquire
t1 acquire successfully
t2 trying to acquire
t1 release successfully
main release successfully
t2 acquire successfully
t2 release successfully
首先,生成一个信号量,信号量有10个许可,然后,main,t1,t2三个线程获取许可运行,根据结果,可能存在如下的一种时序。
如上图所示,首先,main线程执行acquire操作,并且成功获得许可,之后t1线程执行acquire操作,成功获得许可,之后t2执行acquire操作,由于此时许可数量不够,t2线程将会阻塞,直到许可可用。之后t1线程释放许可,main线程释放许可,此时的许可数量可以满足t2线程的要求,所以,此时t2线程会成功获得许可运行,t2运行完成后释放许可。下面进行详细分析。
- main线程执行semaphore.acquire操作。主要的函数调用如下图所示。
说明: 此时,可以看到只是AQS的state变为了5,main线程并没有被阻塞,可以继续运行。
- t1线程执行semaphore.acquire操作。主要的函数调用如下图所示。
说明: 此时,可以看到只是AQS的state变为了2,t1线程并没有被阻塞,可以继续运行。
- t2线程执行semaphore.acquire操作。主要的函数调用如下图所示。
说明: 此时,t2线程获取许可不会成功,之后会导致其被禁止运行,值得注意的是,AQS的state还是为2。
- t1执行semaphore.release操作。主要的函数调用如下图所示。
说明: 此时,t2线程将会被unpark,并且AQS的state为5,t2获取cpu资源后可以继续运行。
- main线程执行semaphore.release操作。主要的函数调用如下图所示。
说明: 此时,t2线程还会被unpark,但是不会产生影响,此时,只要t2线程获得CPU资源就可以运行了。此时,AQS的state为10。
- t2获取CPU资源,继续运行,此时t2需要恢复现场,回到parkAndCheckInterrupt函数中,也是在should继续运行。主要的函数调用如下图所示。
说明: 此时,可以看到,Sync queue中只有一个结点,头结点与尾节点都指向该结点,在setHeadAndPropagate的函数中会设置头结点并且会unpark队列中的其他结点。
- t2线程执行semaphore.release操作。主要的函数调用如下图所示。
说明: t2线程经过release后,此时信号量的许可又变为10个了,此时Sync queue中的结点还是没有变化。
深入理解
单独使用Semaphore是不会使用到AQS的条件队列的
不同于CyclicBarrier和ReentrantLock,单独使用Semaphore是不会使用到AQS的条件队列的,其实,只有进行await操作才会进入条件队列,其他的都是在同步队列中,只是当前线程会被park。
场景问题
semaphore初始化有10个令牌,11个线程同时各调用1次acquire方法,会发生什么?
答案:拿不到令牌的线程阻塞,不会继续往下运行。
semaphore初始化有10个令牌,一个线程重复调用11次acquire方法,会发生什么?
答案:线程阻塞,不会继续往下运行。可能你会考虑类似于锁的重入的问题,很好,但是,令牌没有重入的概念。你只要调用一次acquire方法,就需要有一个令牌才能继续运行。
semaphore初始化有1个令牌,1个线程调用一次acquire方法,然后调用两次release方法,之后另外一个线程调用acquire(2)方法,此线程能够获取到足够的令牌并继续运行吗?
答案:能,原因是release方法会添加令牌,并不会以初始化的大小为准。
semaphore初始化有2个令牌,一个线程调用1次release方法,然后一次性获取3个令牌,会获取到吗?
答案:能,原因是release会添加令牌,并不会以初始化的大小为准。Semaphore中release方法的调用并没有限制要在acquire后调用。
1.3.27 - CH27-Phaser
概览
Phaser是JDK 7新增的一个同步辅助类,它可以实现CyclicBarrier和CountDownLatch类似的功能,而且它支持对任务的动态调整,并支持分层结构来达到更高的吞吐量。
运行机制
注册:Registration
跟其他barrier不同,在phaser上注册的parties会随着时间的变化而变化。任务可以随时注册(使用方法register,bulkRegister注册,或者由构造器确定初始parties),并且在任何抵达点可以随意地撤销注册(方法arriveAndDeregister)。就像大多数基本的同步结构一样,注册和撤销只影响内部count;不会创建更深的内部记录,所以任务不能查询他们是否已经注册。(不过,可以通过继承来实现类似的记录)
同步:Synchronization
和CyclicBarrier一样,Phaser也可以重复await。方法arriveAndAwaitAdvance的效果类似CyclicBarrier.await。phaser的每一代都有一个相关的phase number,初始值为0,当所有注册的任务都到达phaser时phase+1,到达最大值(Integer.MAX_VALUE)之后清零。使用phase number可以独立控制 到达phaser 和 等待其他线程 的动作,通过下面两种类型的方法:
到达机制:Arrival
arrive和arriveAndDeregister方法记录到达状态。这些方法不会阻塞,但是会返回一个相关的arrival phase number;也就是说,phase number用来确定到达状态。当所有任务都到达给定phase时,可以执行一个可选的函数,这个函数通过重写onAdvance方法实现,通常可以用来控制终止状态。重写此方法类似于为CyclicBarrier提供一个barrierAction,但比它更灵活。
等待机制:Waiting
awaitAdvance方法需要一个表示arrival phase number的参数,并且在phaser前进到与给定phase不同的phase时返回。和CyclicBarrier不同,即使等待线程已经被中断,awaitAdvance方法也会一直等待。中断状态和超时时间同样可用,但是当任务等待中断或超时后未改变phaser的状态时会遭遇异常。如果有必要,在方法forceTermination之后可以执行这些异常的相关的handler进行恢复操作,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务。
终止:Termination
可以用isTerminated方法检查phaser的终止状态。在终止时,所有同步方法立刻返回一个负值。在终止时尝试注册也没有效果。当调用onAdvance返回true时Termination被触发。当deregistration操作使已注册的parties变为0时,onAdvance的默认实现就会返回true。也可以重写onAdvance方法来定义终止动作。forceTermination方法也可以释放等待线程并且允许它们终止。
分层:Tiering
Phaser支持分层结构(树状构造)来减少竞争。注册了大量parties的Phaser可能会因为同步竞争消耗很高的成本, 因此可以设置一些子Phaser来共享一个通用的parent。这样的话即使每个操作消耗了更多的开销,但是会提高整体吞吐量。 在一个分层结构的phaser里,子节点phaser的注册和取消注册都通过父节点管理。子节点phaser通过构造或方法register、bulkRegister进行首次注册时,在其父节点上注册。子节点phaser通过调用arriveAndDeregister进行最后一次取消注册时,也在其父节点上取消注册。
监控:Monitoring
由于同步方法可能只被已注册的parties调用,所以phaser的当前状态也可能被任何调用者监控。在任何时候,可以通过getRegisteredParties获取parties数,其中getArrivedParties方法返回已经到达当前phase的parties数。当剩余的parties(通过方法getUnarrivedParties获取)到达时,phase进入下一代。这些方法返回的值可能只表示短暂的状态,所以一般来说在同步结构里并没有啥卵用。
源码分析
核心属性
private volatile long state;
/**
* The parent of this phaser, or null if none
*/
private final Phaser parent;
/**
* The root of phaser tree. Equals this if not in a tree.
*/
private final Phaser root;
//等待线程的栈顶元素,根据phase取模定义为一个奇数header和一个偶数header
private final AtomicReference<QNode> evenQ;
private final AtomicReference<QNode> oddQ;
Phaser使用一个long型state值来标识内部状态:
- 低0-15位表示未到达parties数;
- 中16-31位表示等待的parties数;
- 中32-62位表示phase当前代;
- 高63位表示当前phaser的终止状态。
子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。这里在后面源码分析的reconcileState方法里会讲解。 Qnode是Phaser定义的内部等待队列,用于在阻塞时记录等待线程及相关信息。实现了ForkJoinPool的一个内部接口ManagedBlocker,上面已经说过,Phaser也可能被ForkJoinPool中的任务使用,这样在其他任务阻塞等待一个phase时可以保证足够的并行度来执行任务(通过内部实现方法isReleasable和block)。
函数列表
//构造方法
public Phaser() {
this(null, 0);
}
public Phaser(int parties) {
this(null, parties);
}
public Phaser(Phaser parent) {
this(parent, 0);
}
public Phaser(Phaser parent, int parties)
//注册一个新的party
public int register()
//批量注册
public int bulkRegister(int parties)
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive()
//使当前线程到达phaser并撤销注册,返回arrival phase number
public int arriveAndDeregister()
/*
* 使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。
* 如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。
* 如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。
*/
public int arriveAndAwaitAdvance()
//等待给定phase数,返回下一个 arrival phase number
public int awaitAdvance(int phase)
//阻塞等待,直到phase前进到下一代,返回下一代的phase number
public int awaitAdvance(int phase)
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase) throws InterruptedException
public int awaitAdvanceInterruptibly(int phase, long timeout, TimeUnit unit)
throws InterruptedException, TimeoutException
//使当前phaser进入终止状态,已注册的parties不受影响,如果是分层结构,则终止所有phaser
public void forceTermination()
方法:register
//注册一个新的party
public int register() {
return doRegister(1);
}
private int doRegister(int registrations) {
// adjustment to state
long adjust = ((long)registrations << PARTIES_SHIFT) | registrations;
final Phaser parent = this.parent;
int phase;
for (;;) {
long s = (parent == null) ? state : reconcileState();
int counts = (int)s;
int parties = counts >>> PARTIES_SHIFT;//获取已注册parties数
int unarrived = counts & UNARRIVED_MASK;//未到达数
if (registrations > MAX_PARTIES - parties)
throw new IllegalStateException(badRegister(s));
phase = (int)(s >>> PHASE_SHIFT);//获取当前代
if (phase < 0)
break;
if (counts != EMPTY) { // not 1st registration
if (parent == null || reconcileState() == s) {
if (unarrived == 0) // wait out advance
root.internalAwaitAdvance(phase, null);//等待其他任务到达
else if (UNSAFE.compareAndSwapLong(this, stateOffset,
s, s + adjust))//更新注册的parties数
break;
}
}
else if (parent == null) { // 1st root registration
long next = ((long)phase << PHASE_SHIFT) | adjust;
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, next))//更新phase
break;
}
else {
//分层结构,子phaser首次注册用父节点管理
synchronized (this) { // 1st sub registration
if (state == s) { // recheck under lock
phase = parent.doRegister(1);//分层结构,使用父节点注册
if (phase < 0)
break;
// finish registration whenever parent registration
// succeeded, even when racing with termination,
// since these are part of the same "transaction".
//由于在同一个事务里,即使phaser已终止,也会完成注册
while (!UNSAFE.compareAndSwapLong
(this, stateOffset, s,
((long)phase << PHASE_SHIFT) | adjust)) {//更新phase
s = state;
phase = (int)(root.state >>> PHASE_SHIFT);
// assert (int)s == EMPTY;
}
break;
}
}
}
}
return phase;
}
register方法为phaser添加一个新的party,如果onAdvance正在运行,那么这个方法会等待它运行结束再返回结果。如果当前phaser有父节点,并且当前phaser上没有已注册的party,那么就会交给父节点注册。
register和bulkRegister都由doRegister实现,大概流程如下:
- 如果当前操作不是首次注册,那么直接在当前phaser上更新注册parties数
- 如果是首次注册,并且当前phaser没有父节点,说明是root节点注册,直接更新phase
- 如果当前操作是首次注册,并且当前phaser由父节点,则注册操作交由父节点,并更新当前phaser的phase
- 上面说过,子Phaser的phase在没有被真正使用之前,允许滞后于它的root节点。非首次注册时,如果Phaser有父节点,则调用reconcileState()方法解决root节点的phase延迟传递问题, 源码如下:
private long reconcileState() {
final Phaser root = this.root;
long s = state;
if (root != this) {
int phase, p;
// CAS to root phase with current parties, tripping unarrived
while ((phase = (int)(root.state >>> PHASE_SHIFT)) !=
(int)(s >>> PHASE_SHIFT) &&
!UNSAFE.compareAndSwapLong
(this, stateOffset, s,
s = (((long)phase << PHASE_SHIFT) |
((phase < 0) ? (s & COUNTS_MASK) :
(((p = (int)s >>> PARTIES_SHIFT) == 0) ? EMPTY :
((s & PARTIES_MASK) | p))))))
s = state;
}
return s;
}
当root节点的phase已经advance到下一代,但是子节点phaser还没有,这种情况下它们必须通过更新未到达parties数 完成它们自己的advance操作(如果parties为0,重置为EMPTY状态)。
回到register方法的第一步,如果当前未到达数为0,说明上一代phase正在进行到达操作,此时调用internalAwaitAdvance()方法等待其他任务完成到达操作,源码如下:
//阻塞等待phase到下一代
private int internalAwaitAdvance(int phase, QNode node) {
// assert root == this;
releaseWaiters(phase-1); // ensure old queue clean
boolean queued = false; // true when node is enqueued
int lastUnarrived = 0; // to increase spins upon change
int spins = SPINS_PER_ARRIVAL;
long s;
int p;
while ((p = (int)((s = state) >>> PHASE_SHIFT)) == phase) {
if (node == null) { // spinning in noninterruptible mode
int unarrived = (int)s & UNARRIVED_MASK;//未到达数
if (unarrived != lastUnarrived &&
(lastUnarrived = unarrived) < NCPU)
spins += SPINS_PER_ARRIVAL;
boolean interrupted = Thread.interrupted();
if (interrupted || --spins < 0) { // need node to record intr
//使用node记录中断状态
node = new QNode(this, phase, false, false, 0L);
node.wasInterrupted = interrupted;
}
}
else if (node.isReleasable()) // done or aborted
break;
else if (!queued) { // push onto queue
AtomicReference<QNode> head = (phase & 1) == 0 ? evenQ : oddQ;
QNode q = node.next = head.get();
if ((q == null || q.phase == phase) &&
(int)(state >>> PHASE_SHIFT) == phase) // avoid stale enq
queued = head.compareAndSet(q, node);
}
else {
try {
ForkJoinPool.managedBlock(node);//阻塞给定node
} catch (InterruptedException ie) {
node.wasInterrupted = true;
}
}
}
if (node != null) {
if (node.thread != null)
node.thread = null; // avoid need for unpark()
if (node.wasInterrupted && !node.interruptible)
Thread.currentThread().interrupt();
if (p == phase && (p = (int)(state >>> PHASE_SHIFT)) == phase)
return abortWait(phase); // possibly clean up on abort
}
releaseWaiters(phase);
return p;
}
简单介绍下第二个参数node,如果不为空,则说明等待线程需要追踪中断状态或超时状态。以doRegister中的调用为例,不考虑线程争用,internalAwaitAdvance大概流程如下:
- 首先调用releaseWaiters唤醒上一代所有等待线程,确保旧队列中没有遗留的等待线程。
- 循环SPINS_PER_ARRIVAL指定的次数或者当前线程被中断,创建node记录等待线程及相关信息。
- 继续循环调用ForkJoinPool.managedBlock运行被阻塞的任务
- 继续循环,阻塞任务运行成功被释放,跳出循环
- 最后唤醒当前phase的线程
方法:arrive
//使当前线程到达phaser,不等待其他任务到达。返回arrival phase number
public int arrive() {
return doArrive(ONE_ARRIVAL);
}
private int doArrive(int adjust) {
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
//获取未到达数
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s, s-=adjust)) {//更新state
if (unarrived == 1) {//当前为最后一个未到达的任务
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (root == this) {
if (onAdvance(phase, nextUnarrived))//检查是否需要终止phaser
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;
n |= (long)nextPhase << PHASE_SHIFT;
UNSAFE.compareAndSwapLong(this, stateOffset, s, n);
releaseWaiters(phase);//释放等待phase的线程
}
//分层结构,使用父节点管理arrive
else if (nextUnarrived == 0) { //propagate deregistration
phase = parent.doArrive(ONE_DEREGISTER);
UNSAFE.compareAndSwapLong(this, stateOffset,
s, s | EMPTY);
}
else
phase = parent.doArrive(ONE_ARRIVAL);
}
return phase;
}
}
}
arrive方法手动调整到达数,使当前线程到达phaser。arrive和arriveAndDeregister都调用了doArrive实现,大概流程如下:
- 首先更新state(state - adjust);
- 如果当前不是最后一个未到达的任务,直接返回phase
- 如果当前是最后一个未到达的任务:
- 如果当前是root节点,判断是否需要终止phaser,CAS更新phase,最后释放等待的线程;
- 如果是分层结构,并且已经没有下一代未到达的parties,则交由父节点处理doArrive逻辑,然后更新state为EMPTY。
方法:arriveAndAwaitAdvance
public int arriveAndAwaitAdvance() {
// Specialization of doArrive+awaitAdvance eliminating some reads/paths
final Phaser root = this.root;
for (;;) {
long s = (root == this) ? state : reconcileState();
int phase = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
int counts = (int)s;
int unarrived = (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);//获取未到达数
if (unarrived <= 0)
throw new IllegalStateException(badArrive(s));
if (UNSAFE.compareAndSwapLong(this, stateOffset, s,
s -= ONE_ARRIVAL)) {//更新state
if (unarrived > 1)
return root.internalAwaitAdvance(phase, null);//阻塞等待其他任务
if (root != this)
return parent.arriveAndAwaitAdvance();//子Phaser交给父节点处理
long n = s & PARTIES_MASK; // base of next state
int nextUnarrived = (int)n >>> PARTIES_SHIFT;
if (onAdvance(phase, nextUnarrived))//全部到达,检查是否可销毁
n |= TERMINATION_BIT;
else if (nextUnarrived == 0)
n |= EMPTY;
else
n |= nextUnarrived;
int nextPhase = (phase + 1) & MAX_PHASE;//计算下一代phase
n |= (long)nextPhase << PHASE_SHIFT;
if (!UNSAFE.compareAndSwapLong(this, stateOffset, s, n))//更新state
return (int)(state >>> PHASE_SHIFT); // terminated
releaseWaiters(phase);//释放等待phase的线程
return nextPhase;
}
}
}
说明: 使当前线程到达phaser并等待其他任务到达,等价于awaitAdvance(arrive())。如果需要等待中断或超时,可以使用awaitAdvance方法完成一个类似的构造。如果需要在到达后取消注册,可以使用awaitAdvance(arriveAndDeregister())。效果类似于CyclicBarrier.await。大概流程如下:
- 更新state(state - 1);
- 如果未到达数大于1,调用internalAwaitAdvance阻塞等待其他任务到达,返回当前phase
- 如果为分层结构,则交由父节点处理arriveAndAwaitAdvance逻辑
- 如果未到达数<=1,判断phaser终止状态,CAS更新phase到下一代,最后释放等待当前phase的线程,并返回下一代phase。
方法:awaitAdvance(int phase)
public int awaitAdvance(int phase) {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase)
return root.internalAwaitAdvance(phase, null);
return p;
}
//响应中断版awaitAdvance
public int awaitAdvanceInterruptibly(int phase)
throws InterruptedException {
final Phaser root = this.root;
long s = (root == this) ? state : reconcileState();
int p = (int)(s >>> PHASE_SHIFT);
if (phase < 0)
return phase;
if (p == phase) {
QNode node = new QNode(this, phase, true, false, 0L);
p = root.internalAwaitAdvance(phase, node);
if (node.wasInterrupted)
throw new InterruptedException();
}
return p;
}
awaitAdvance用于阻塞等待线程到达,直到phase前进到下一代,返回下一代的phase number。方法很简单,不多赘述。awaitAdvanceInterruptibly方法是响应中断版的awaitAdvance,不同之处在于,调用阻塞时会记录线程的中断状态。
1.3.28 - CH28-Exchanger
概览
Exchanger是用于线程协作的工具类, 主要用于两个线程之间的数据交换。
它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange()方法交换数据,当一个线程先执行exchange()方法后,它会一直等待第二个线程也执行exchange()方法,当这两个线程到达同步点时,这两个线程就可以交换数据了。
实现机制
for (;;) {
if (slot is empty) { // offer
// slot为空时,将item 设置到Node 中
place item in a Node;
if (can CAS slot from empty to node) {
// 当将node通过CAS交换到slot中时,挂起线程等待被唤醒
wait for release;
// 被唤醒后返回node中匹配到的item
return matching item in node;
}
} else if (can CAS slot from node to empty) { // release
// 将slot设置为空
// 获取node中的item,将需要交换的数据设置到匹配的item
get the item in node;
set matching item in node;
// 唤醒等待的线程
release waiting thread;
}
// else retry on CAS failure
}
比如有2条线程A和B,A线程交换数据时,发现slot为空,则将需要交换的数据放在slot中等待其它线程进来交换数据,等线程B进来,读取A设置的数据,然后设置线程B需要交换的数据,然后唤醒A线程,原理就是这么简单。但是当多个线程之间进行交换数据时就会出现问题,所以Exchanger加入了slot数组。
源码解析
内部类:Node
@sun.misc.Contended static final class Node {
// arena的下标,多个槽位的时候利用
int index;
// 上一次记录的Exchanger.bound
int bound;
// 在当前bound下CAS失败的次数;
int collides;
// 用于自旋;
int hash;
// 这个线程的当前项,也就是需要交换的数据;
Object item;
//做releasing操作的线程传递的项;
volatile Object match;
//挂起时设置线程值,其他情况下为null;
volatile Thread parked;
}
在Node定义中有两个变量值得思考:bound以及collides。前面提到了数组area是为了避免竞争而产生的,如果系统不存在竞争问题,那么完全没有必要开辟一个高效的arena来徒增系统的复杂性。
首先通过单个slot的exchanger来交换数据,当探测到竞争时将安排不同的位置的slot来保存线程Node,并且可以确保没有slot会在同一个缓存行上。
如何来判断会有竞争呢? CAS替换slot失败,如果失败,则通过记录冲突次数来扩展arena的尺寸,我们在记录冲突的过程中会跟踪“bound”的值,以及会重新计算冲突次数在bound的值被改变时。
核心属性
private final Participant participant;
private volatile Node[] arena;
private volatile Node slot;
- 为什么会有
arena数组槽
?
slot为单个槽,arena为数组槽, 他们都是Node类型。在这里可能会感觉到疑惑,slot作为Exchanger交换数据的场景,应该只需要一个就可以了啊? 为何还多了一个Participant 和数组类型的arena呢?
一个slot交换场所原则上来说应该是可以的,但实际情况却不是如此,多个参与者使用同一个交换场所时,会存在严重伸缩性问题。既然单个交换场所存在问题,那么我们就安排多个,也就是数组arena。通过数组arena来安排不同的线程使用不同的slot来降低竞争问题,并且可以保证最终一定会成对交换数据。但是Exchanger不是一来就会生成arena数组来降低竞争,只有当产生竞争是才会生成arena数组。
- 那么怎么将Node与当前线程绑定呢?
Participant,Participant 的作用就是为每个线程保留唯一的一个Node节点,它继承ThreadLocal,同时在Node节点中记录在arena中的下标index。
构造函数
/**
* Creates a new Exchanger.
*/
public Exchanger() {
participant = new Participant();
}
核心方法:exchange(V x)
等待另一个线程到达此交换点(除非当前线程被中断),然后将给定的对象传送给该线程,并接收该线程的对象。
public V exchange(V x) throws InterruptedException {
Object v;
// 当参数为null时需要将item设置为空的对象
Object item = (x == null) ? NULL_ITEM : x; // translate null args
// 注意到这里的这个表达式是整个方法的核心
if ((arena != null ||
(v = slotExchange(item, false, 0 L)) == null) &&
((Thread.interrupted() || // disambiguates null return
(v = arenaExchange(item, false, 0 L)) == null)))
throw new InterruptedException();
return (v == NULL_ITEM) ? null : (V) v;
}
这个方法比较好理解:arena为数组槽,如果为null,则执行slotExchange()方法,否则判断线程是否中断,如果中断值抛出InterruptedException异常,没有中断则执行arenaExchange()方法。整套逻辑就是:如果slotExchange(Object item, boolean timed, long ns)方法执行失败了就执行arenaExchange(Object item, boolean timed, long ns)方法,最后返回结果V。
NULL_ITEM 为一个空节点,其实就是一个Object对象而已,slotExchange()为单个slot交换。
slotExchange(Object item, boolean timed, long ns)
private final Object slotExchange(Object item, boolean timed, long ns) {
// 获取当前线程node对象
Node p = participant.get();
// 当前线程
Thread t = Thread.currentThread();
// 若果线程被中断,就直接返回null
if (t.isInterrupted()) // preserve interrupt status so caller can recheck
return null;
// 自旋
for (Node q;;) {
// 将slot值赋给q
if ((q = slot) != null) {
// slot 不为null,即表示已有线程已经把需要交换的数据设置在slot中了
// 通过CAS将slot设置成null
if (U.compareAndSwapObject(this, SLOT, q, null)) {
// CAS操作成功后,将slot中的item赋值给对象v,以便返回。
// 这里也是就读取之前线程要交换的数据
Object v = q.item;
// 将当前线程需要交给的数据设置在q中的match
q.match = item;
// 获取被挂起的线程
Thread w = q.parked;
if (w != null)
// 如果线程不为null,唤醒它
U.unpark(w);
// 返回其他线程给的V
return v;
}
// create arena on contention, but continue until slot null
// CAS 操作失败,表示有其它线程竞争,在此线程之前将数据已取走
// NCPU:CPU的核数
// bound == 0 表示arena数组未初始化过,CAS操作bound将其增加SEQ
if (NCPU > 1 && bound == 0 &&
U.compareAndSwapInt(this, BOUND, 0, SEQ))
// 初始化arena数组
arena = new Node[(FULL + 2) << ASHIFT];
}
// 上面分析过,只有当arena不为空才会执行slotExchange方法的
// 所以表示刚好已有其它线程加入进来将arena初始化
else if (arena != null)
// 这里就需要去执行arenaExchange
return null; // caller must reroute to arenaExchange
else {
// 这里表示当前线程是以第一个线程进来交换数据
// 或者表示之前的数据交换已进行完毕,这里可以看作是第一个线程
// 将需要交换的数据先存放在当前线程变量p中
p.item = item;
// 将需要交换的数据通过CAS设置到交换区slot
if (U.compareAndSwapObject(this, SLOT, null, p))
// 交换成功后跳出自旋
break;
// CAS操作失败,表示有其它线程刚好先于当前线程将数据设置到交换区slot
// 将当前线程变量中的item设置为null,然后自旋获取其它线程存放在交换区slot的数据
p.item = null;
}
}
// await release
// 执行到这里表示当前线程已将需要的交换的数据放置于交换区slot中了,
// 等待其它线程交换数据然后唤醒当前线程
int h = p.hash;
long end = timed ? System.nanoTime() + ns : 0 L;
// 自旋次数
int spins = (NCPU > 1) ? SPINS : 1;
Object v;
// 自旋等待直到p.match不为null,也就是说等待其它线程将需要交换的数据放置于交换区slot
while ((v = p.match) == null) {
// 下面的逻辑主要是自旋等待,直到spins递减到0为止
if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10;
if (h == 0)
h = SPINS | (int) t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield();
} else if (slot != p)
spins = SPINS;
// 此处表示未设置超时或者时间未超时
else if (!t.isInterrupted() && arena == null &&
(!timed || (ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this);
// 给p挂机线程的值赋值
p.parked = t;
if (slot == p)
// 如果slot还没有被置为null,也就表示暂未有线程过来交换数据,需要将当前线程挂起
U.park(false, ns);
// 线程被唤醒,将被挂起的线程设置为null
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
// 不是以上条件时(可能是arena已不为null或者超时)
} else if (U.compareAndSwapObject(this, SLOT, p, null)) {
// arena不为null则v为null,其它为超时则v为超市对象TIMED_OUT,并且跳出循环
v = timed && ns <= 0 L && !t.isInterrupted() ? TIMED_OUT : null;
break;
}
}
// 取走match值,并将p中的match置为null
U.putOrderedObject(p, MATCH, null);
// 设置item为null
p.item = null;
p.hash = h;
// 返回交换值
return v;
}
程序首先通过participant获取当前线程节点Node。检测是否中断,如果中断return null,等待后续抛出InterruptedException异常。
- 如果slot不为null,则进行slot消除,成功直接返回数据V,否则失败,则创建arena消除数组。
- 如果slot为null,但arena不为null,则返回null,进入arenaExchange逻辑。
- 如果slot为null,且arena也为null,则尝试占领该slot,失败重试,成功则跳出循环进入spin+block(自旋+阻塞)模式。
在自旋+阻塞模式中,首先取得结束时间和自旋次数。如果match(做releasing操作的线程传递的项)为null,其首先尝试spins+随机次自旋(改自旋使用当前节点中的hash,并改变之)和退让。当自旋数为0后,假如slot发生了改变(slot != p)则重置自旋数并重试。否则假如:当前未中断&arena为null&(当前不是限时版本或者限时版本+当前时间未结束):阻塞或者限时阻塞。假如:当前中断或者arena不为null或者当前为限时版本+时间已经结束:不限时版本:置v为null;限时版本:如果时间结束以及未中断则TIMED_OUT;否则给出null(原因是探测到arena非空或者当前线程中断)。
match不为空时跳出循环。
arenaExchange(Object item, boolean timed, long ns)
此方法被执行时表示多个线程进入交换区交换数据,arena数组已被初始化,此方法中的一些处理方式和slotExchange比较类似,它是通过遍历arena数组找到需要交换的数据。
// timed 为true表示设置了超时时间,ns为>0的值,反之没有设置超时时间
private final Object arenaExchange(Object item, boolean timed, long ns) {
Node[] a = arena;
// 获取当前线程中的存放的node
Node p = participant.get();
//index初始值0
for (int i = p.index;;) { // access slot at i
// 遍历,如果在数组中找到数据则直接交换并唤醒线程,如未找到则将需要交换给其它线程的数据放置于数组中
int b, m, c;
long j; // j is raw array offset
// 其实这里就是向右遍历数组,只是用到了元素在内存偏移的偏移量
// q实际为arena数组偏移(i + 1) * 128个地址位上的node
Node q = (Node) U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 如果q不为null,并且CAS操作成功,将下标j的元素置为null
if (q != null && U.compareAndSwapObject(a, j, q, null)) {
// 表示当前线程已发现有交换的数据,然后获取数据,唤醒等待的线程
Object v = q.item; // release
q.match = item;
Thread w = q.parked;
if (w != null)
U.unpark(w);
return v;
// q 为null 并且 i 未超过数组边界
} else if (i <= (m = (b = bound) & MMASK) && q == null) {
// 将需要给其它线程的item赋予给p中的item
p.item = item; // offer
if (U.compareAndSwapObject(a, j, null, p)) {
// 交换成功
long end = (timed && m == 0) ? System.nanoTime() + ns : 0 L;
Thread t = Thread.currentThread(); // wait
// 自旋直到有其它线程进入,遍历到该元素并与其交换,同时当前线程被唤醒
for (int h = p.hash, spins = SPINS;;) {
Object v = p.match;
if (v != null) {
// 其它线程设置的需要交换的数据match不为null
// 将match设置null,item设置为null
U.putOrderedObject(p, MATCH, null);
p.item = null; // clear for next use
p.hash = h;
return v;
} else if (spins > 0) {
h ^= h << 1;
h ^= h >>> 3;
h ^= h << 10; // xorshift
if (h == 0) // initialize hash
h = SPINS | (int) t.getId();
else if (h < 0 && // approx 50% true
(--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // two yields per wait
} else if (U.getObjectVolatile(a, j) != p)
// 和slotExchange方法中的类似,arena数组中的数据已被CAS设置
// match值还未设置,让其再自旋等待match被设置
spins = SPINS; // releaser hasn't set match yet
else if (!t.isInterrupted() && m == 0 &&
(!timed ||
(ns = end - System.nanoTime()) > 0 L)) {
// 设置线程t被当前对象阻塞
U.putObject(t, BLOCKER, this); // emulate LockSupport
// 线程t赋值
p.parked = t; // minimize window
if (U.getObjectVolatile(a, j) == p)
// 数组中对象还相等,表示线程还未被唤醒,唤醒线程
U.park(false, ns);
p.parked = null;
// 设置线程t未被任何对象阻塞
U.putObject(t, BLOCKER, null);
} else if (U.getObjectVolatile(a, j) == p &&
U.compareAndSwapObject(a, j, p, null)) {
// 这里给bound增加加一个SEQ
if (m != 0) // try to shrink
U.compareAndSwapInt(this, BOUND, b, b + SEQ - 1);
p.item = null;
p.hash = h;
i = p.index >>>= 1; // descend
if (Thread.interrupted())
return null;
if (timed && m == 0 && ns <= 0 L)
return TIMED_OUT;
break; // expired; restart
}
}
} else
// 交换失败,表示有其它线程更改了arena数组中下标i的元素
p.item = null; // clear offer
} else {
// 此时表示下标不在bound & MMASK或q不为null但CAS操作失败
// 需要更新bound变化后的值
if (p.bound != b) { // stale; reset
p.bound = b;
p.collides = 0;
// 反向遍历
i = (i != m || m == 0) ? m : m - 1;
} else if ((c = p.collides) < m || m == FULL ||
!U.compareAndSwapInt(this, BOUND, b, b + SEQ + 1)) {
// 记录CAS失败的次数
p.collides = c + 1;
// 循环遍历
i = (i == 0) ? m : i - 1; // cyclically traverse
} else
// 此时表示bound值增加了SEQ+1
i = m + 1; // grow
// 设置下标
p.index = i;
}
}
}
首先通过participant取得当前节点Node,然后根据当前节点Node的index去取arena中相对应的节点node。
- 前面提到过arena可以确保不同的slot在arena中是不会相冲突的,那么是怎么保证的呢?
arena = new Node[(FULL + 2) << ASHIFT];
// 这个arena到底有多大呢? 我们先看FULL 和ASHIFT的定义:
static final int FULL = (NCPU >= (MMASK << 1)) ? MMASK : NCPU >>> 1;
private static final int ASHIFT = 7;
private static final int NCPU = Runtime.getRuntime().availableProcessors();
private static final int MMASK = 0xff; // 255
// 假如我的机器NCPU = 8 ,则得到的是768大小的arena数组。然后通过以下代码取得在arena中的节点:
Node q = (Node)U.getObjectVolatile(a, j = (i << ASHIFT) + ABASE);
// 它仍然是通过右移ASHIFT位来取得Node的,ABASE定义如下:
Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak) + (1 << ASHIFT);
// U.arrayBaseOffset获取对象头长度,数组元素的大小可以通过unsafe.arrayIndexScale(T[].class) 方法获取到。这也就是说要访问类型为T的第N个元素的话,你的偏移量offset应该是arrayOffset+N*arrayScale。也就是说BASE = arrayOffset+ 128 。
- 用@sun.misc.Contended来规避伪共享?
伪共享说明:假设一个类的两个相互独立的属性a和b在内存地址上是连续的(比如FIFO队列的头尾指针),那么它们通常会被加载到相同的cpu cache line里面。并发情况下,如果一个线程修改了a,会导致整个cache line失效(包括b),这时另一个线程来读b,就需要从内存里再次加载了,这种多线程频繁修改ab的情况下,虽然a和b看似独立,但它们会互相干扰,非常影响性能。
我们再看Node节点的定义, 在Java 8 中我们是可以利用sun.misc.Contended来规避伪共享的。所以说通过 « ASHIFT方式加上sun.misc.Contended,所以使得任意两个可用Node不会再同一个缓存行中。
@sun.misc.Contended static final class Node{
....
}
我们再次回到arenaExchange()。取得arena中的node节点后,如果定位的节点q 不为空,且CAS操作成功,则交换数据,返回交换的数据,唤醒等待的线程。
- 如果q等于null且下标在bound & MMASK范围之内,则尝试占领该位置,如果成功,则采用自旋 + 阻塞的方式进行等待交换数据。
- 如果下标不在bound & MMASK范围之内获取由于q不为null但是竞争失败的时候:消除p。加入bound 不等于当前节点的bond(b != p.bound),则更新p.bound = b,collides = 0 ,i = m或者m - 1。如果冲突的次数不到m 获取m 已经为最大值或者修改当前bound的值失败,则通过增加一次collides以及循环递减下标i的值;否则更新当前bound的值成功:我们令i为m+1即为此时最大的下标。最后更新当前index的值。
深入理解
- SynchronousQueue对比?
Exchanger是一种线程间安全交换数据的机制。可以和之前分析过的SynchronousQueue对比一下:线程A通过SynchronousQueue将数据a交给线程B;线程A通过Exchanger和线程B交换数据,线程A把数据a交给线程B,同时线程B把数据b交给线程A。可见,SynchronousQueue是交给一个数据,Exchanger是交换两个数据。
- 不同JDK实现有何差别?
- 在JDK5中Exchanger被设计成一个容量为1的容器,存放一个等待线程,直到有另外线程到来就会发生数据交换,然后清空容器,等到下一个到来的线程。
- 从JDK6开始,Exchanger用了类似ConcurrentMap的分段思想,提供了多个slot,增加了并发执行时的吞吐量。
应用示例
来一个非常经典的并发问题:你有相同的数据buffer,一个或多个数据生产者,和一个或多个数据消费者。只是Exchange类只能同步2个线程,所以你只能在你的生产者和消费者问题中只有一个生产者和一个消费者时使用这个类。
public class Test {
static class Producer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Producer(String name, Exchanger<Integer> exchanger) {
super("Producer-" + name);
this.exchanger = exchanger;
}
@Override
public void run() {
for (int i=1; i<5; i++) {
try {
TimeUnit.SECONDS.sleep(1);
data = i;
System.out.println(getName()+" 交换前:" + data);
data = exchanger.exchange(data);
System.out.println(getName()+" 交换后:" + data);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class Consumer extends Thread {
private Exchanger<Integer> exchanger;
private static int data = 0;
Consumer(String name, Exchanger<Integer> exchanger) {
super("Consumer-" + name);
this.exchanger = exchanger;
}
@Override
public void run() {
while (true) {
data = 0;
System.out.println(getName()+" 交换前:" + data);
try {
TimeUnit.SECONDS.sleep(1);
data = exchanger.exchange(data);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+" 交换后:" + data);
}
}
}
public static void main(String[] args) throws InterruptedException {
Exchanger<Integer> exchanger = new Exchanger<Integer>();
new Producer("", exchanger).start();
new Consumer("", exchanger).start();
TimeUnit.SECONDS.sleep(7);
System.exit(-1);
}
}
1.3.29 - CH29-ThreadLocal
概览
ThreadLocal是通过线程隔离的方式防止任务在共享资源上产生冲突, 线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程都创建不同的存储。
线程安全的解决思路:
- 互斥同步: synchronized 和 ReentrantLock
- 非阻塞同步: CAS, AtomicXXXX
- 无同步方案: 栈封闭,本地存储(Thread Local),可重入代码
线程安全:是指广义上的共享资源访问安全性,因为线程隔离是通过副本保证本线程访问资源安全性,它不保证线程之间还存在共享关系的狭义上的安全性。
ThreadLocal 的官方解释:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID)
该类提供了线程局部 (thread-local) 变量。这些变量不同于它们的普通对应物,因为访问某个变量(通过其 get 或 set 方法)的每个线程都有自己的局部变量,它独立于变量的初始化副本。ThreadLocal 实例通常是类中的 private static 字段,它们希望将状态与某一个线程(例如,用户 ID 或事务 ID)相关联。
ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类; 当使用ThreadLocal来维护变量时, ThreadLocal会为每个线程创建单独的变量副本, 避免因多线程操作共享变量而导致的数据不一致的情况。
理解
- 如下数据库管理类在单线程使用是没有任何问题的
class ConnectionManager {
private static Connection connect = null;
public static Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}
public static void closeConnection() {
if (connect != null)
connect.close();
}
}
很显然,在多线程中使用会存在线程安全问题:第一,这里面的2个方法都没有进行同步,很可能在openConnection方法中会多次创建connect;第二,由于connect是共享变量,那么必然在调用connect的地方需要使用到同步来保障线程安全,因为很可能一个线程在使用connect进行数据库操作,而另外一个线程调用closeConnection关闭链接。
- 为了解决上述线程安全的问题,第一考虑:互斥同步
你可能会说,将这段代码的两个方法进行同步处理,并且在调用connect的地方需要进行同步处理,比如用Synchronized或者ReentrantLock互斥锁。
- 这里再抛出一个问题:这地方到底需不需要将connect变量进行共享?
事实上,是不需要的。假如每个线程中都有一个connect变量,各个线程之间对connect变量的访问实际上是没有依赖关系的,即一个线程不需要关心其他线程是否对这个connect进行了修改的。即改后的代码可以这样:
class ConnectionManager {
private Connection connect = null;
public Connection openConnection() {
if (connect == null) {
connect = DriverManager.getConnection();
}
return connect;
}
public void closeConnection() {
if (connect != null)
connect.close();
}
}
class Dao {
public void insert() {
ConnectionManager connectionManager = new ConnectionManager();
Connection connection = connectionManager.openConnection();
// 使用connection进行操作
connectionManager.closeConnection();
}
}
这样处理确实也没有任何问题,由于每次都是在方法内部创建的连接,那么线程之间自然不存在线程安全问题。但是这样会有一个致命的影响:导致服务器压力非常大,并且严重影响程序执行性能。由于在方法中需要频繁地开启和关闭数据库连接,这样不仅严重影响程序执行效率,还可能导致服务器压力巨大。
- 这时候ThreadLocal登场了
那么这种情况下使用ThreadLocal是再适合不过的了,因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。下面就是网上出现最多的例子:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class ConnectionManager {
private static final ThreadLocal<Connection> dbConnectionLocal = new ThreadLocal<Connection>() {
@Override
protected Connection initialValue() {
try {
return DriverManager.getConnection("", "", "");
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
};
public Connection getConnection() {
return dbConnectionLocal.get();
}
}
- 再注意下ThreadLocal的修饰符
ThreaLocal的JDK文档中说明:ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread。如果我们希望通过某个类将状态(例如用户ID、事务ID)与线程关联起来,那么通常在这个类中定义private static类型的ThreadLocal 实例。
但是要注意,虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大。
原理
如何实现线程隔离
主要是用到了 Thread 对象中的一个 ThreadLocalMap 类型的变量 threadLocals,负责存储当前线程的关于 Connection 的对象,dbConnectionLocal 变量为 key,以新建的 Connection 对象为 value;这样的话,线程第一次读取的时候如果不存在就会调用 ThreadLocal 的 initialValue 方法创建一个 Connection 对象并返回。
具体关于为线程分配变量副本的代码如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
- 首先获取当前线程对象 t,然后从线程 t 中获取到 ThreadLocalMap 的成员属性 threadLocals
- 如果当前线程的 threadLocals 已经初始化并且存在以当前 ThreadLocal 对象为 Key 的值,则直接返回当前线程要获取的对象,比如上例中的 Connection。
- 如果当前线程的 threadLocals 已经初始化但不存在以当前 ThreadLocal 对象为 key 的对象,那么新建一个 Connection 对象,并且添加到当前线程的 threadLocals map 中,并返回。
- 如果当前线程的 threadLocals 属性尚未初始化,则重新创建一个 ThreadLocalMap 对象,并且创建一个 Connection 对象并添加到 ThreadLocalMap 中并返回。
初始化逻辑:
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
- 首先调用我们提供的 initialValue 方法,创建一个 Conneciton 对象
- 继续查看当前线程的 threadLocals 是否为空,如果 ThreadLocalMap 已经初始化,直接将产生的connection 对象添加到 ThreadLocalMap 中,如果没有初始化,则创建并添加到其中。
同时,ThreadLocal 还提供了直接操作 Thread 对象中 threadLocals 的方法
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
这样我们也可以不实现initialValue, 将初始化工作放到DBConnectionFactory的getConnection方法中:
public Connection getConnection() {
Connection connection = dbConnectionLocal.get();
if (connection == null) {
try {
connection = DriverManager.getConnection("", "", "");
dbConnectionLocal.set(connection);
} catch (SQLException e) {
e.printStackTrace();
}
}
return connection;
}
那么我们看过代码之后就很清晰的知道了为什么ThreadLocal能够实现变量的多线程隔离了; 其实就是用了Map的数据结构给当前线程缓存了, 要使用的时候就从本线程的threadLocals对象中获取就可以了, key就是当前线程;
当然了在当前线程下获取当前线程里面的Map里面的对象并操作肯定没有线程并发问题了, 当然能做到变量的线程间隔离了;
现在我们知道了ThreadLocal到底是什么了, 又知道了如何使用ThreadLocal以及其基本实现原理了是不是就可以结束了呢? 其实还有一个问题就是ThreadLocalMap是个什么对象, 为什么要用这个对象呢?
ThreadLocalMap
本质上来讲, 它就是一个Map, 但是这个ThreadLocalMap与我们平时见到的Map有点不一样:
- 它没有实现Map接口;
- 它没有public的方法, 最多有一个default的构造方法, 因为这个ThreadLocalMap的方法仅仅在ThreadLocal类中调用, 属于静态内部类
- ThreadLocalMap的Entry实现继承了WeakReference<ThreadLocal>
- 该方法仅仅用了一个Entry数组来存储Key, Value; Entry并不是链表形式, 而是每个bucket里面仅仅放一个Entry;
要了解ThreadLocalMap的实现, 我们先从入口开始, 就是往该Map中添加一个值:
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
先进行简单的分析, 对该代码表层意思进行解读:
- 看下当前threadLocal的在数组中的索引位置 比如:
i = 2
, 看i = 2
位置上面的元素(Entry)的Key
是否等于threadLocal 这个 Key, 如果等于就很好说了, 直接将该位置上面的Entry的Value替换成最新的就可以了; - 如果当前位置上面的 Entry 的 Key为空, 说明ThreadLocal对象已经被回收了, 那么就调用replaceStaleEntry
- 如果清理完无用条目(ThreadLocal被回收的条目)、并且数组中的数据大小 > 阈值的时候对当前的Table进行重新哈希 所以, 该HashMap是处理冲突检测的机制是向后移位, 清除过期条目 最终找到合适的位置;
后面就是Get方法了:
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
先找到ThreadLocal的索引位置, 如果索引位置处的entry不为空并且键与threadLocal是同一个对象, 则直接返回; 否则去后面的索引位置继续查找。
内存泄露
如果用线程池来操作ThreadLocal 对象确实会造成内存泄露, 因为对于线程池里面不会销毁的线程, 里面总会存在着<ThreadLocal, LocalVariable>
的强引用, 因为final static 修饰的 ThreadLocal 并不会释放, 而ThreadLocalMap 对于 Key 虽然是弱引用, 但是强引用不会释放, 弱引用当然也会一直有值, 同时创建的LocalVariable对象也不会释放, 就造成了内存泄露; 如果LocalVariable对象不是一个大对象的话, 其实泄露的并不严重, 泄露的内存 = 核心线程数 * LocalVariable
对象的大小;
所以, 为了避免出现内存泄露的情况, ThreadLocal提供了一个清除线程中对象的方法, 即 remove, 其实内部实现就是调用 ThreadLocalMap 的remove方法:
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
参考资料
1.3.30 - CH30-AllLocks
概览
序号 | 术语 | 应用 |
---|---|---|
1 | 乐观锁 | CAS |
2 | 悲观锁 | synchronized、vector、hashtable |
3 | 自旋锁 | CAS |
4 | 可重入锁 | synchronized、ReentrantLock、Lock |
5 | 读写锁 | ReentrantReadWriteLock、CopyOnWriteLock、CopyOnWriteArraySet |
6 | 公平锁 | ReentrantLock(true) |
7 | 非公平锁 | synchronized、ReentrantLock(false) |
8 | 共享锁 | ReentranReadWriteLock-ReadLock |
9 | 独占锁 | synchronized、vector、hashtable、ReentranReadWriteLock-WriteLock |
10 | 重量级锁 | synchronized |
11 | 轻量级锁 | 锁优化技术 |
12 | 偏向锁 | 锁优化技术 |
13 | 分段锁 | ConcurrentHashMap |
14 | 互斥锁 | synchronized |
15 | 同步锁 | synchronized |
16 | 死锁 | 相互请求对方资源 |
17 | 锁粗化 | 锁优化技术 |
18 | 锁消除 | 锁优化技术 |
1. 乐观锁
即乐观思想,假定当前场景是读多写少、遇到并发写的概览较低,读数据时认为别的线程不会正在修改数据(因此不加锁);写数据时,判断当前与期望值是否相同,如果相同则更新(更新期间加锁,保证原子性)。
Java 中乐观锁的实现是 CAS——比较并交换。比较(主内存中的)当前值,与(当前线程中的)预期值是否一样,一样则更新,否则继续进行 CAS 操作。
可以同时进行读操作,读的时候其他线程不能执行写操作。
2. 悲观锁
即悲观思想,认为写多读少,遇到并发写的可能性高。每次读数据都认为其他线程会在同一时间修改数据,所以每次写数据都会认为其他线程会修改,因此每次都加锁。其他线程想要读写这个数据时都会被该锁阻塞,直到当前写数据的线程是否锁。
Java 中的悲观锁实现有 synchronized 关键字、ReentrantLock。
只有一个线程能够进行读操作或写操作。
3. 自旋锁
自旋指的是一种行为:为了让线程等待,我们只需让该线程循环。
现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
优点:避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。
缺点:占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
Java 中默认的自旋次数为 10,可以通过参数 -XX:PreBlockSpin
来修改。
自适应自旋:自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
Java 中对自旋的应用:CAS 操作中比较操作失败后会执行自旋等待。
4. 可重入锁(递归锁)
可重入指的是:某个线程在获取到锁之后能够再次获取该锁,而不会阻塞。
原理:通过组合自定义同步器来实现锁的获取与释放。
- 再次获取锁:识别获取锁的线程是否为当前持有锁的线程,如果是则再次获取成功,并将技术 +1。
- 释放锁:释放锁并将计数 -1。
作用:避免死锁。
Java 中的实现有:ReentrantLock、synchronized 关键字。
5. 读写锁
读写锁指定是指:为了提高性能,在读的时候使用读锁,写的时候使用写锁,灵活控制。在没有写的时候,读是无阻塞的,在一定程度上提高了程序的执行效率。
读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥。
读锁:允许多个线程同时访问资源。
写锁:同时只允许一个线程访问资源。
Java 中的实现为 ReentrantReadWriteLock。
6. 公平锁
公平锁的思想是:多个线程按照请求所的顺序来依次获取锁。
在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾,按照FIFO的原则从队列中拿到线程,然后占有锁。
7. 非公平锁
非公平锁的思想是:线程尝试获取锁,如果获取不到,则再采用公平锁的方式。多个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁。
非公平锁的性能高于公平锁,但可能导致某个线程总是获取不到锁,即饥饿。
Java 中的实现:synchronized 是非公平锁,ReentrantLock 通过构造函数指定该锁是公平的还是非公平的,默认是非公平的。
8. 共享锁
共享锁的思想是:可以有多个线程获取读锁,以共享的方式持有锁。和乐观锁、读写锁同义。
Java中用到的共享锁: ReentrantReadWriteLock
。
9. 独占锁
独占锁的思想是:只能有一个线程获取锁,以独占的方式持有锁。和悲观锁、互斥锁同义。
Java中用到的独占锁: synchronized,ReentrantLock
10. 重量级锁
重量级锁是一种称谓: synchronized
是通过对象内部的一个叫做监视器锁(monitor
)来实现的,监视器锁本身依赖底层的操作系统的 Mutex Lock
来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高。这种依赖于操作系统 Mutex Lock
来实现的锁称为重量级锁。为了优化synchonized
,引入了轻量级锁
,偏向锁
。
Java中的重量级锁: synchronized
11. 轻量级锁
是JDK6时加入的一种锁优化机制: 轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。轻量级锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。
优点: 如果没有竞争,通过CAS操作成功避免了使用互斥量的开销。
缺点: 如果存在竞争,除了互斥量本身的开销外,还额外产生了CAS操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
12. 偏向锁
是JDK6时加入的一种锁优化机制: 在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
优点: 把整个同步都消除掉,连CAS操作都不去做了,优于轻量级锁。
缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。
13. 分段锁
一种机制: 最好的例子来说明分段锁是ConcurrentHashMap。**ConcurrentHashMap原理:**它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该key-value应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行put操作,只要被加入的key-value不存放在同一个段中,则线程间可以做到真正的并行。
**线程安全:**ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全
14. 互斥锁
互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。
- 读-读互斥
- 读-写互斥
- 写-读互斥
- 写-写互斥
Java中的同步锁: synchronized
15. 同步锁
同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
Java中的同步锁: synchronized
16. 死锁
**死锁是一种现象:**如线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。
Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。
17. 锁粗化
一种优化技术: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。
18. 锁消除
一种优化技术: 就是把锁干掉。当Java虚拟机运行时发现有些共享数据不会被线程竞争时就可以进行锁消除。
那如何判断共享数据不会被线程竞争?
利用逃逸分析技术
:分析对象的作用域,如果对象在A方法中定义后,被作为参数传递到B方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。
在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。
TODO
1.3.31 - CH31-AllQueues
名称 | 类型 | 有界 | 线程安全 | 说明 |
---|---|---|---|---|
Queue | 接口 | — | — | 顶层队列接口 |
BlockingQueue | 接口 | — | — | 阻塞队列接口 |
BlockingDeuque | 接口 | — | — | 双向阻塞队列接口 |
Dequeu | 接口 | — | — | 双向队列接口 |
TransferQueue | 接口 | — | — | 传输队列接口 |
AbstractQueue | 抽象类 | — | — | 队列抽象类 |
PriorityQueue | 实现类 | N | N | 优先级队列 |
ArrayDeque | 实现类 | N | N | 数组双向队列 |
LinkedList | 实现类 | N | N | 链表对象类 |
ConcurrentLinkedQueue | 实现类 | N | Y | 链表结构并发队列 |
ConcurrentLinkedDeque | 实现类 | N | Y | 链表结构双向并发队列 |
ArrayBlockingQueue | 实现类 | Y | Y | 数组结构有界阻塞队列 |
LinkedBlockingQueue | 实现类 | Y | Y | 链表结构有界阻塞队列 |
LinkedBlockingDeque | 实现类 | Y | Y | 链表结构双向有界阻塞队列 |
LinkedTransferQueue | 实现类 | N | Y | 连接结构无界阻塞传输队列 |
SynchronousQueue | 实现类 | Y | Y | 不存储元素的有界阻塞队列 |
PriorityBlockingQueue | 实现类 | N | Y | 支持优先级排序的无界阻塞队列 |
DelayQueue | 实现类 | N | Y | 延时无界阻塞队列 |
层级结构
1. Queue
- Queue接口是一种Collection,被设计用于处理之前临时保存在某处的元素。
- 除了基本的Collection操作之外,队列还提供了额外的插入、提取和检查操作。每一种操作都有两种形式:如果操作失败,则抛出一个异常;如果操作失败,则返回一个特殊值(null或false,取决于是什么操作)。
- 队列通常是以FIFO(先进先出)的方式排序元素,但是这不是必须的。
- 只有优先级队列可以根据提供的比较器对元素进行排序或者是采用正常的排序。无论怎么排序,队列的头将通过调用remove()或poll()方法进行移除。在FIFO队列种,所有新的元素被插入到队尾。其他种类的队列可能使用不同的布局来存放元素。
- 每个Queue必须指定排序属性。
2. Deque
支持两端元素插入和移除的线性集合。名称deque
是双端队列的缩写(Double-Ended queue),通常发音为deck
。大多数实现Deque的类,对它们包含的元素的数量没有固定的限制的,支持有界和无界。
- 该列表包含包含访问deque两端元素的方法,提供了插入,移除和检查元素的方法。
- 这些方法种的每一种都存在两种形式:如果操作失败,则会抛出异常,另一种方法返回一个特殊值(null或false,取决于具体操作)。
- 插入操作的后一种形式专门设计用于容量限制的Deque实现,大多数实现中,插入操作不能失败,所以可以用插入操作的后一种形式。
- Deque接口扩展了Queue接口,当使用deque作为队列时,作为FIFO。元素将添加到deque的末尾,并从头开始删除。
- Deque也可以用作LIFO(后进先出)栈,这个接口优于传统的Stack类。当作为栈使用时,元素被push到deque队列的头,而pop也是从队列的头pop出来。
3. AbstractQueue
AbstractQueue是一个抽象类,继承了Queue接口,提供了一些Queue操作的骨架实现。
方法add、remove、element方法基于offer、poll和peek。也就是说如果不能正常操作,则抛出异常。我们来看下AbstactQueue是怎么做到的。
- AbstractQueue的add方法
public boolean add(E e) {
if (offer(e))
return true;
else
throw new IllegalStateException("Queue full");
}
- AbstractQueue的remove方法
public E remove() {
E x = poll();
if (x != null)
return x;
else
throw new NoSuchElementException();
}
- AbstractQueue的element方法
public E element() {
E x = peek();
if (x != null)
return x;
else
throw new NoSuchElementException();
}
如果继承AbstractQueue抽象类则必须保证offer方法不允许null值插入。
4. BlockingQueue
- BlockQueue满了,PUT操作被阻塞
- BlockQueue为空,Take操作被阻塞
说明:
- BlockingQueue(阻塞队列)也是一种队列,支持阻塞的插入和移除方法。
- 阻塞的插入:当队列满时,队列会阻塞插入元素的线程,直到队列不满。
- 阻塞的移除:当队列为空,获取元素的线程会等待队列变为非空。
- 应用场景:生产者和消费者,生产者线程向队列里添加元素,消费者线程从队列里移除元素,阻塞队列时获取和存放元素的容器。
- 为什么要用阻塞队列:生产者生产和消费者消费的速率不一样,需要用队列来解决速率差问题,当队列满了或空的时候,则需要阻塞生产或消费动作来解决队列满或空的问题。
方法总结:
如何实现的阻塞
- 当往队列里插入一个元素时,如果队列不可用,那么阻塞生产者主要通过LockSupport. park(this)来实现。
- park这个方法会阻塞当前线程,只有以下4种情况中的一种发生时,该方法才会返回。
- 与park对应的unpark执行或已经执行时。“已经执行”是指unpark先执行,然后再执行park的情况。
- 线程被中断时。
- 等待完time参数指定的毫秒数时。
- 异常现象发生时,这个异常现象没有任何原因。
5. BlockingDeque
- BlockingDeque 满了,两端的 put 操作被阻塞
- BlockingDeque 为空,两端的Take操作被阻塞
它是阻塞队列BlockingQueue
和双向队列Deque
接口的结合。有如下方法:
BlockDeque和BlockQueue的对等方法:
6. TransferQueue
如果有消费者正在获取元素,则将队列中的元素传递给消费者。如果没有消费者,则等待消费者消费。必须将任务完成才能返回。
transfer(E e)
- 生产者线程Producer Thread尝试将元素B传给消费者线程,如果没有消费者线程,则将元素B放到尾节点。并且生产者线程等待元素B被消费。当元素B被消费后,生产者线程返回。
- 如果当前有消费者正在等待接收元素(消费者通过take方法或超时限制的poll方法时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。
- 如果没有消费者等待接收元素,transfer方法会将元素放在队列的tail(尾)节点,并等到该元素被消费者消费了才返回。
tryTransfer(E e)
- 试探生产者传入的元素是否能直接传给消费者。
- 如果没有消费者等待接收元素,则返回false。
- 和transfer方法的区别是,无论消费者是否接收,方法立即返回。
tryTransfer(E e, long timeout, TimeUnit unit)
- 带有时间限制的tryTransfer方法。
- 试图把生产者传入的元素直接传给消费者。
- 如果没有消费者消费该元素则等待指定的时间再返回。
- 如果超时了还没有消费元素,则返回false。
- 如果在超时时间内消费了元素,则返回true。
getWaitingConsumerCount()
- 获取通过BlockingQueue.take()方法或超时限制poll方法等待接受元素的消费者数量。近似值。
- 返回等待接收元素的消费者数量。
hasWaitingConsumer()
- 获取是否有通过BlockingQueue.tabke()方法或超时限制poll方法等待接受元素的消费者。
- 返回true则表示至少有一个等待消费者。
7. PriorityQueue
- PriorityQueue是一个支持优先级的无界阻塞队列。
- 默认自然顺序升序排序。
- 可以通过构造参数Comparator来对元素进行排序。
- 自定义实现comapreTo()方法来指定元素排序规则。
- 不允许插入null元素。
- 实现PriorityQueue接口的类,不保证线程安全,除非是PriorityBlockingQueue。
- PriorityQueue的迭代器不能保证以任何特定顺序遍历元素,如果需要有序遍历,请考虑使用
Arrays.sort(pq.toArray)
。 - 进列(
offer
、add
)和出列(poll
、remove()
)的时间复杂度O(log(n))。 - remove(Object) 和 contains(Object)的算法时间复杂度O(n)。
- peek、element、size的算法时间复杂度为O(1)。
8. LinkedList
- LinkedList实现了List和Deque接口,所以是一种双链表结构,可以当作堆栈、队列、双向队列使用。
- 一个双向列表的每一个元素都有三个整数值:元素、向后的节点链接、向前的节点链接
private static class Node<E> {
E item; //元素
Node<E> next; //向后的节点链接
Node<E> prev; //向前的节点链接
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
9. ConcurrentLinkedQueue
- ConcurrentLinked是由链表结构组成的线程安全的先进先出无界队列。
- 当多线程要共享访问集合时,ConcurrentLinkedQueue是一个比较好的选择。
- 不允许插入null元素
- 支持非阻塞地访问并发安全的队列,不会抛出ConcurrentModifiationException异常。
- size方法不是准确的,因为在统计集合的时候,队列可能正在添加元素,导致统计不准。
- 批量操作addAll、removeAll、retainAll、containsAll、equals和toArray不保证原子性(操作不可分割)
- 添加元素happen-before其他线程移除元素。
10. ArrayDeque
- 由数组组成的双端队列。
- 没有容量限制,根据需要扩容。
- 不是线程安全的。
- 禁止插入null元素。
- 当用作栈时,比栈速度快,当用作队列时,速度比LinkList快。
- 大部分方法的算法时间复杂度为O(1)。
- remove、removeFirstOccurrence、removeLastOccurrence、contains、remove 和批量操作的算法时间复杂度O(n)
11. ConcurrentLinkedDeque
- 由链表结构组成的双向无界阻塞队列
- 插入、删除和访问操作可以并发进行,线程安全的类
- 不允许插入null元素
- 在并发场景下,计算队列的大小是不准确的,因为计算时,可能有元素加入队列。
- 批量操作addAll、removeAll、retainAll、containsAll、equals和toArray不保证原子性(操作不可分割)
12. ArrayBlockingQueue
- ArrayBlockingQueue是一个用数组实现的有界阻塞队列。
- 队列满时插入操作被阻塞,队列空时,移除操作被阻塞。
- 按照先进先出(FIFO)原则对元素进行排序。
- 默认不保证线程公平的访问队列。
- 公平访问队列:按照阻塞的先后顺序访问队列,即先阻塞的线程先访问队列。
- 非公平性是对先等待的线程是非公平的,当队列可用时,阻塞的线程都可以争夺访问队列的资格。有可能先阻塞的线程最后才访问访问队列。
- 公平性会降低吞吐量。
13. LinkedBlockinQueue
- LinkedBlockingQueue具有单链表和有界阻塞队列的功能。
- 队列满时插入操作被阻塞,队列空时,移除操作被阻塞。
- 默认和最大长度为Integer.MAX_VALUE,相当于无界(值非常大:2^31-1)。
- 吞吐量通常要高于ArrayBlockingQueue。
- 创建线程池时,参数runnableTaskQueue(任务队列),用于保存等待执行的任务的阻塞队列可以选择LinkedBlockingQueue。
- 静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
14. LinkedBlockingDeque
- 由链LinkedBlockingDeque = 阻塞队列+链表+双端访问
- 线程安全。
- 多线程同时入队时,因多了一端访问入口,所以减少了一半的竞争。
- 默认容量大小为Integer.MAX_VALUE。可指定容量大小。
- 可以用在“工作窃取“模式中。
15. LinkedTransferQueue
LinkedTransferQueue = 阻塞队列+链表结构+TransferQueue
之前我们讲TransferQueue接口时已经介绍过了TransferQueue接口 ,所以LinkedTransferQueue接口跟它相似,只是加入了阻塞插入和移除的功能,以及结构是链表结构。
16. SynchronousQueue
- 我称SynchronousQueue为”传球好手“。想象一下这个场景:小明抱着一个篮球想传给小花,如果小花没有将球拿走,则小明是不能再拿其他球的。
- SynchronousQueue负责把生产者产生的数据传递给消费者线程。
- SynchronousQueue本身不存储数据,调用了put方法后,队列里面也是空的。
- 每一个put操作必须等待一个take操作完成,否则不能添加元素。
- 适合传递性场景。
- 性能高于ArrayBlockingQueue和LinkedBlockingQueue。
- 吞吐量通常要高于LinkedBlockingQueue。
- 创建线程池时,参数runnableTaskQueue(任务队列),用于保存等待执行的任务的阻塞队列可以选择SynchronousQueue。
- 静态工厂方法Executors.newCachedThreadPool()使用了这个队列
17. PriorityBlockQueue
- PriorityBlockQueue = PriorityQueue + BlockingQueue
- 之前我们也讲到了PriorityQueue的原理,支持对元素排序。
- 元素默认自然升序排序。
- 可以自定义CompareTo()方法来指定元素排序规则。
- 可以通过构造函数构造参数Comparator来对元素进行排序。
18. DelayQueue
- DelayQueue = Delayed + BlockingQueue。队列中的元素必须实现Delayed接口。
- 在创建元素时,可以指定多久可以从队列中获取到当前元素。只有在延时期满才能从队列中获取到当前元素。
场景:
- 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期。然后用一个线程循环的查询DelayQueue队列,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
- 定时任务调度:使用DelayQueue队列保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行。比如Java中的TimerQueue就是使用DelayQueue实现的。
1.3.32 - CH32-AllPools
七大属性
- corePoolSize(int):核心线程数量。默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到任务队列当中。线程池将长期保证这些线程处于存活状态,即使线程已经处于闲置状态。除非配置了allowCoreThreadTimeOut=true,核心线程数的线程也将不再保证长期存活于线程池内,在空闲时间超过keepAliveTime后被销毁。
- workQueue:阻塞队列,存放等待执行的任务,线程从workQueue中取任务,若无任务将阻塞等待。当线程池中线程数量达到corePoolSize后,就会把新任务放到该队列当中。JDK提供了四个可直接使用的队列实现,分别是:基于数组的有界队列ArrayBlockingQueue、基于链表的无界队列LinkedBlockingQueue、只有一个元素的同步队列SynchronousQueue、优先级队列PriorityBlockingQueue。在实际使用时一定要设置队列长度。
- maximumPoolSize(int):线程池内的最大线程数量,线程池内维护的线程不得超过该数量,大于核心线程数量小于最大线程数量的线程将在空闲时间超过keepAliveTime后被销毁。当阻塞队列存满后,将会创建新线程执行任务,线程的数量不会大于maximumPoolSize。
- keepAliveTime(long):线程存活时间,若线程数超过了corePoolSize,线程闲置时间超过了存活时间,该线程将被销毁。除非配置了allowCoreThreadTimeOut=true,核心线程数的线程也将不再保证长期存活于线程池内,在空闲时间超过keepAliveTime后被销毁。
- TimeUnit unit:线程存活时间的单位,例如TimeUnit.SECONDS表示秒。
- RejectedExecutionHandler:拒绝策略,当任务队列存满并且线程池个数达到maximunPoolSize后采取的策略。ThreadPoolExecutor中提供了四种拒绝策略,分别是:抛RejectedExecutionException异常的AbortPolicy(如果不指定的默认策略)、使用调用者所在线程来运行任务CallerRunsPolicy、丢弃一个等待执行的任务,然后尝试执行当前任务DiscardOldestPolicy、不动声色的丢弃并且不抛异常DiscardPolicy。项目中如果为了更多的用户体验,可以自定义拒绝策略。
- threadFactory:创建线程的工厂,虽说JDK提供了线程工厂的默认实现DefaultThreadFactory,但还是建议自定义实现最好,这样可以自定义线程创建的过程,例如线程分组、自定义线程名称等。
工作原理
- 通过execute方法提交任务时,当线程池中的线程数小于corePoolSize时,新提交的任务将通过创建一个新线程来执行,即使此时线程池中存在空闲线程。
- 通过execute方法提交任务时,当线程池中线程数量达到corePoolSize时,新提交的任务将被放入workQueue中,等待线程池中线程调度执行。
- 通过execute方法提交任务时,当workQueue已存满,且maximumPoolSize大于corePoolSize时,新提交的任务将通过创建新线程执行。
- 当线程池中的线程执行完任务空闲时,会尝试从workQueue中取头结点任务执行。
- 通过execute方法提交任务,当线程池中线程数达到maxmumPoolSize,并且workQueue也存满时,新提交的任务由RejectedExecutionHandler执行拒绝操作。
- 当线程池中线程数超过corePoolSize,并且未配置allowCoreThreadTimeOut=true,空闲时间超过keepAliveTime的线程会被销毁,保持线程池中线程数为corePoolSize。
- 当设置allowCoreThreadTimeOut=true时,任何空闲时间超过keepAliveTime的线程都会被销毁。
状态切换
1.4 - Java I/0
1.4.1 - CH01-IO分类
基于传输方式
从数据传输方式或者说是运输方式角度看,可以将 IO 类分为:
- 字节流
- 字符流
字节
是个计算机看的,字符
才是给人看的。
字节流
字符流
二者区别
字节流读取单个字节,字符流读取单个字符(一个字符根据编码的不同,对应的字节也不同,如 UTF-8 编码是 3 个字节,中文编码是 2 个字节。)
字节流用来处理二进制文件(图片、MP3、视频文件),字符流用来处理文本文件(可以看做是特殊的二进制文件,使用了某种编码,人可以阅读)。
字节 -> 字符:Input/OutputStreamReader/Writer
编码就是把字符转换为字节,而解码是把字节重新组合成字符。
如果编码和解码过程使用不同的编码方式那么就出现了乱码。
- GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
- UTF-8 编码中,中文字符占 3 个字节,英文字符占 1 个字节;
- UTF-16be 编码中,中文字符和英文字符都占 2 个字节。
UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。
Java 使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。
基于数据操作
从数据来源或操作对象角度看,IO 又可以按如下方式分类:
1.4.2 - CH02-装饰模式
装饰模式
装饰者(Decorator)和具体组件(ConcreteComponent)都继承自组件(Component),具体组件的方法实现不需要依赖于其它对象,而装饰者组合了一个组件,这样它可以装饰其它装饰者或者具体组件。
所谓装饰,就是把这个装饰者套在被装饰者之上,从而动态扩展被装饰者的功能。装饰者的方法有一部分是自己的,这属于它的功能,然后调用被装饰者的方法实现,从而也保留了被装饰者的功能。可以看到,具体组件应当是装饰层次的最低层,因为只有具体组件的方法实现不需要依赖于其它对象。
IO 装饰模式
以 InputStream 为例,
- InputStream 是抽象组件;
- FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
- FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。
实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。
FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。
1.4.3 - CH03-InputStream
层级结构
InputStream 抽象类
public abstract int read()
// 读取数据
public int read(byte b[])
// 将读取到的数据放在 byte 数组中,该方法实际上是根据下面的方法实现的,off 为 0,len 为数组的长度
public int read(byte b[], int off, int len)
// 从第 off 位置读取 len 长度字节的数据放到 byte 数组中,流是以 -1 来判断是否读取结束的
public long skip(long n)
// 跳过指定个数的字节不读取,想想看电影跳过片头片尾
public int available()
// 返回可读的字节数量
public void close()
// 读取完,关闭流,释放资源
public synchronized void mark(int readlimit)
// 标记读取位置,下次还可以从这里开始读取,使用前要看当前流是否支持,可以使用 markSupport() 方法判断
public synchronized void reset()
// 重置读取位置为上次 mark 标记的位置
public boolean markSupported()
// 判断当前流是否支持标记流,和上面两个方法配套使用
源码实现
InputStream
public abstract class InputStream implements Closeable {
private static final int SKIP_BUFFER_SIZE = 2048; //用于skip方法,和skipBuffer相关
private static byte[] skipBuffer; // skipBuffer is initialized in skip(long), if needed.
//从输入流中读取下一个字节,
//正常返回0-255,到达文件的末尾返回-1
//在流中还有数据,但是没有读到时该方法会阻塞(block)
//Java IO和New IO的区别就是阻塞流和非阻塞流
//抽象方法!不同的子类不同的实现!
public abstract int read() throws IOException;
//将流中的数据读入放在byte数组的第off个位置先后的len个位置中
//返回值为放入字节的个数。
//这个方法在利用抽象方法read,某种意义上简单的Templete模式。
public int read(byte b[], int off, int len) throws IOException {
//检查输入是否正常。一般情况下,检查输入是方法设计的第一步
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
//读取下一个字节
int c = read();
//到达文件的末端返回-1
if (c == -1) { return -1; }
//返回的字节downcast
b[off] = (byte)c;
//已经读取了一个字节
int i = 1;
try {
//最多读取len个字节,所以要循环len次
for (; i < len ; i++) {
//每次循环从流中读取一个字节
//由于read方法阻塞,
//所以read(byte[],int,int)也会阻塞
c = read();
//到达末尾,理所当然返回-1
if (c == -1) { break; }
//读到就放入byte数组中
b[off + i] = (byte)c;
}
} catch (IOException ee) { }
return i;
}
//利用上面的方法read(byte[] b)
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
//方法内部使用的、表示要跳过的字节数目,
public long skip(long n) throws IOException {
long remaining = n;
int nr;
if (skipBuffer == null)
//初始化一个跳转的缓存
skipBuffer = new byte[SKIP_BUFFER_SIZE];
//本地化的跳转缓存
byte[] localSkipBuffer = skipBuffer;
//检查输入参数,应该放在方法的开始
if (n <= 0) { return 0; }
//一共要跳过n个,每次跳过部分,循环
while (remaining > 0) {
nr = read(localSkipBuffer, 0, (int) Math.min(SKIP_BUFFER_SIZE, remaining));
//利用上面的read(byte[],int,int)方法尽量读取n个字节
//读到流的末端,则返回
if (nr < 0) { break; }
//没有完全读到需要的,则继续循环
remaining -= nr;
}
return n - remaining;//返回时要么全部读完,要么因为到达文件末端,读取了部分
}
//查询流中还有多少可以读取的字节
//该方法不会block。在java中抽象类方法的实现一般有以下几种方式:
//1.抛出异常(java.util);2.“弱”实现。像上面这种。子类在必要的时候覆盖它。
//3.“空”实现。
public int available() throws IOException {
return 0;
}
//关闭当前流、同时释放与此流相关的资源
//关闭当前流、同时释放与此流相关的资源
public void close() throws IOException {}
//markSupport可以查询当前流是否支持mark
public synchronized void mark(int readlimit) {}
//对mark过的流进行复位。只有当流支持mark时才可以使用此方法。
public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}
//查询是否支持mark
//绝大部分不支持,因此提供默认实现,返回false。子类有需要可以覆盖。
public boolean markSupported() {
return false;
}
}
FilterInputStream
public class FilterInputStream extends InputStream {
//装饰器的代码特征: 被装饰的对象一般是装饰器的成员变量
protected volatile InputStream in; //将要被装饰的字节输入流
protected FilterInputStream(InputStream in) { //通过构造方法传入此被装饰的流
this.in = in;
}
//下面这些方法,完成最小的装饰――0装饰,只是调用被装饰流的方法而已
public int read() throws IOException {
return in.read();
}
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}
public int read(byte b[], int off, int len) throws IOException {
return in.read(b, off, len);
}
public long skip(long n) throws IOException {
return in.skip(n);
}
public int available() throws IOException {
return in.available();
}
public void close() throws IOException {
in.close();
}
public synchronized void mark(int readlimit) {
in.mark(readlimit);
}
public synchronized void reset() throws IOException {
in.reset();
}
public boolean markSupported() {
return in.markSupported();
}
}
ByteArrayInputStream
public class ByteArrayInputStream extends InputStream {
protected byte buf[]; //内部的buffer,一般通过构造器输入
protected int pos; //当前位置的cursor。从0至byte数组的长度。
//byte[pos]就是read方法读取的字节
protected int mark = 0; //mark的位置。
protected int count; //流中字节的数目。
//构造器,从一个byte[]创建一个ByteArrayInputStream
public ByteArrayInputStream(byte buf[]) {
//初始化流中的各个成员变量
this.buf = buf;
this.pos = 0;
this.count = buf.length;
}
//构造器
public ByteArrayInputStream(byte buf[], int offset, int length) {
this.buf = buf;
this.pos = offset; //与上面不同
this.count = Math.min(offset + length, buf.length);
this.mark = offset; //与上面不同
}
//从流中读取下一个字节
public synchronized int read() {
//返回下一个位置的字节//流中没有数据则返回-1
return (pos < count) ? (buf[pos++] & 0xff) : -1;
}
// ByteArrayInputStream要覆盖InputStream中可以看出其提供了该方法的实现
//某些时候,父类不能完全实现子类的功能,父类的实现一般比较通用。
//当子类有更有效的方法时,我们会覆盖这些方法。
public synchronized int read(byte b[], int off, int len) {
//首先检查输入参数的状态是否正确
if(b==null){
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
}
if (pos >= count) { return -1; }
if (pos + len > count) { len = count - pos; }
if (len <= 0) { return 0; }
//java中提供数据复制的方法
//出于速度的原因!他们都用到System.arraycopy方法
System.arraycopy(buf, pos, b, off, len);
pos += len;
return len;
}
//下面这个方法,在InputStream中也已经实现了。
//但是当时是通过将字节读入一个buffer中实现的,好像效率低了一点。
//比InputStream中的方法简单、高效
public synchronized long skip(long n) {
//当前位置,可以跳跃的字节数目
if (pos + n > count) { n = count - pos; }
//小于0,则不可以跳跃
if (n < 0) { return 0; }
//跳跃后,当前位置变化
pos += n;
return n;
}
//查询流中还有多少字节没有读取。
public synchronized int available() {
return count - pos;
}
//ByteArrayInputStream支持mark所以返回true
public boolean markSupported() {
return true;
}
//在流中当前位置mark。
public void mark(int readAheadLimit) {
mark = pos;
}
//重置流。即回到mark的位置。
public synchronized void reset() {
pos = mark;
}
//关闭ByteArrayInputStream不会产生任何动作。
public void close() throws IOException { }
}
BufferedInputStream
public class BufferedInputStream extends FilterInputStream {
private static int defaultBufferSize = 8192; //默认缓存的大小
protected volatile byte buf[]; //内部的缓存
protected int count; //buffer的大小
protected int pos; //buffer中cursor的位置
protected int markpos = -1; //mark的位置
protected int marklimit; //mark的范围
//原子性更新。和一致性编程相关
private static final
AtomicReferenceFieldUpdater<BufferedInputStream, byte[]> bufUpdater =
AtomicReferenceFieldUpdater.newUpdater (BufferedInputStream.class, byte[].class,"buf");
//检查输入流是否关闭,同时返回被包装流
private InputStream getInIfOpen() throws IOException {
InputStream input = in;
if (input == null) throw new IOException("Stream closed");
return input;
}
//检查buffer的状态,同时返回缓存
private byte[] getBufIfOpen() throws IOException {
byte[] buffer = buf;
//不太可能发生的状态
if (buffer == null) throw new IOException("Stream closed");
return buffer;
}
//构造器
public BufferedInputStream(InputStream in) {
//指定默认长度的buffer
this(in, defaultBufferSize);
}
//构造器
public BufferedInputStream(InputStream in, int size) {
super(in);
//检查输入参数
if(size<=0){
throw new IllegalArgumentException("Buffer size <= 0");
}
//创建指定长度的buffer
buf = new byte[size];
}
//从流中读取数据,填充如缓存中。
private void fill() throws IOException {
//得到buffer
byte[] buffer = getBufIfOpen();
if (markpos < 0)
//mark位置小于0,此时pos为0
pos = 0;
//pos大于buffer的长度
else if (pos >= buffer.length)
if (markpos > 0) {
int sz = pos - markpos;
System.arraycopy(buffer, markpos, buffer, 0, sz);
pos = sz;
markpos = 0;
} else if (buffer.length >= marklimit) {
//buffer的长度大于marklimit时,mark失效
markpos = -1;
//丢弃buffer中的内容
pos = 0;
}else{
//buffer的长度小于marklimit时对buffer扩容
int nsz = pos * 2;
if (nsz > marklimit)
nsz = marklimit;//扩容为原来的2倍,太大则为marklimit大小
byte nbuf[] = new byte[nsz];
//将buffer中的字节拷贝如扩容后的buf中
System.arraycopy(buffer, 0, nbuf, 0, pos);
if (!bufUpdater.compareAndSet(this, buffer, nbuf)) {
//在buffer在被操作时,不能取代此buffer
throw new IOException("Stream closed");
}
//将新buf赋值给buffer
buffer = nbuf;
}
count = pos;
int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
if (n > 0) count = n + pos;
}
//读取下一个字节
public synchronized int read() throws IOException {
//到达buffer的末端
if (pos >= count) {
//就从流中读取数据,填充buffer
fill();
//读过一次,没有数据则返回-1
if (pos >= count) return -1;
}
//返回buffer中下一个位置的字节
return getBufIfOpen()[pos++] & 0xff;
}
//将数据从流中读入buffer中
private int read1(byte[] b, int off, int len) throws IOException {
int avail = count - pos; //buffer中还剩的可读字符
//buffer中没有可以读取的数据时
if(avail<=0){
//将输入流中的字节读入b中
if (len >= getBufIfOpen().length && markpos < 0) {
return getInIfOpen().read(b, off, len);
}
fill();//填充
avail = count - pos;
if (avail <= 0) return -1;
}
//从流中读取后,检查可以读取的数目
int cnt = (avail < len) ? avail : len;
//将当前buffer中的字节放入b的末端
System.arraycopy(getBufIfOpen(), pos, b, off, cnt);
pos += cnt;
return cnt;
}
public synchronized int read(byte b[], int off, int len)throws IOException {
getBufIfOpen();
// 检查buffer是否open
//检查输入参数是否正确
if ((off | len | (off + len) | (b.length - (off + len))) < 0) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int n = 0;
for (;;) {
int nread = read1(b, off + n, len - n);
if (nread <= 0) return (n == 0) ? nread : n;
n += nread;
if (n >= len) return n;
InputStream input = in;
if (input != null && input.available() <= 0) return n;
}
}
public synchronized long skip(long n) throws IOException {
// 检查buffer是否关闭
getBufIfOpen();
//检查输入参数是否正确
if (n <= 0) { return 0; }
//buffered中可以读取字节的数目
long avail = count - pos;
//可以读取的小于0,则从流中读取
if (avail <= 0) {
//mark小于0,则mark在流中
if (markpos <0) return getInIfOpen().skip(n);
// 从流中读取数据,填充缓冲区。
fill();
//可以读的取字节为buffer的容量减当前位置
avail = count - pos;
if (avail <= 0) return 0;
}
long skipped = (avail < n) ? avail : n;
pos += skipped;
//当前位置改变
return skipped;
}
//该方法不会block!返回流中可以读取的字节的数目。
//该方法的返回值为缓存中的可读字节数目加流中可读字节数目的和
public synchronized int available() throws IOException {
return getInIfOpen().available() + (count - pos);
}
//当前位置处为mark位置
public synchronized void mark(int readlimit) {
marklimit = readlimit;
markpos = pos;
}
public synchronized void reset() throws IOException {
// 缓冲去关闭了,肯定就抛出异常!程序设计中经常的手段
getBufIfOpen();
if (markpos < 0) throw new IOException("Resetting to invalid mark");
pos = markpos;
}
//该流和ByteArrayInputStream一样都支持mark
public boolean markSupported() {
return true;
}
//关闭当前流同时释放相应的系统资源。
public void close() throws IOException {
byte[] buffer;
while ( (buffer = buf) != null) {
if (bufUpdater.compareAndSet(this, buffer, null)) {
InputStream input = in;
in = null;
if (input != null) input.close();
return;
}
// Else retry in case a new buf was CASed in fill()
}
}
}
PipedInputStream
public class PipedInputStream extends InputStream {
//标识有读取方或写入方关闭
boolean closedByWriter = false;
volatile boolean closedByReader = false;
//是否建立连接
boolean connected = false;
//标识哪个线程
Thread readSide;
Thread writeSide;
//缓冲区的默认大小
protected static final int PIPE_SIZE = 1024;
//缓冲区
protected byte buffer[] = new byte[PIPE_SIZE];
//下一个写入字节的位置。0代表空,in==out代表满
protected int in = -1;
//下一个读取字节的位置
protected int out = 0;
//给定源的输入流
public PipedInputStream(PipedOutputStream src) throws IOException {
connect(src);
}
//默认构造器,下部一定要connect源
public PipedInputStream() { }
//连接输入源
public void connect(PipedOutputStream src) throws IOException {
//调用源的connect方法连接当前对象
src.connect(this);
}
//只被PipedOuputStream调用
protected synchronized void receive(int b) throws IOException {
//检查状态,写入
checkStateForReceive();
//永远是PipedOuputStream
writeSide = Thread.currentThread();
//输入和输出相等,等待空间
if (in == out) awaitSpace();
if (in < 0) {
in = 0;
out = 0;
}
//放入buffer相应的位置
buffer[in++] = (byte)(b & 0xFF);
//in为0表示buffer已空
if (in >= buffer.length) { in = 0; }
}
synchronized void receive(byte b[], int off, int len) throws IOException {
checkStateForReceive();
//从PipedOutputStream可以看出
writeSide = Thread.currentThread();
int bytesToTransfer = len;
while (bytesToTransfer > 0) {
//满了,会通知读取的;空会通知写入
if (in == out) awaitSpace();
int nextTransferAmount = 0;
if (out < in) {
nextTransferAmount = buffer.length - in;
} else if (in < out) {
if (in == -1) {
in = out = 0;
nextTransferAmount = buffer.length - in;
} else {
nextTransferAmount = out - in;
}
}
if (nextTransferAmount > bytesToTransfer) nextTransferAmount = bytesToTransfer;
assert(nextTransferAmount > 0);
System.arraycopy(b, off, buffer, in, nextTransferAmount);
bytesToTransfer -= nextTransferAmount;
off += nextTransferAmount;
in += nextTransferAmount;
if (in >= buffer.length) { in = 0; }
}
}
//检查当前状态,等待输入
private void checkStateForReceive() throws IOException {
if (!connected) {
throw new IOException("Pipe not connected");
} else if (closedByWriter || closedByReader) {
throw new IOException("Pipe closed");
} else if (readSide != null && !readSide.isAlive()) {
throw new IOException("Read end dead");
}
}
//Buffer已满,等待一段时间
private void awaitSpace() throws IOException {
//in==out表示满了,没有空间
while (in == out) {
//检查接受端的状态
checkStateForReceive();
//通知读取端
notifyAll();
try {
wait(1000);
} catch (InterruptedException ex) {
throw new java.io.InterruptedIOException();
}
}
}
//通知所有等待的线程()已经接受到最后的字节
synchronized void receivedLast() {
closedByWriter = true; //
notifyAll();
}
public synchronized int read() throws IOException {
//检查一些内部状态
if (!connected) {
throw new IOException("Pipe not connected");
} else if (closedByReader) {
throw new IOException("Pipe closed");
} else if (writeSide != null && !writeSide.isAlive()&& !closedByWriter && (in < 0)) {
throw new IOException("Write end dead");
}
//当前线程读取
readSide = Thread.currentThread();
//重复两次? ? ?
int trials = 2;
while (in < 0) {
//输入断关闭返回-1
if (closedByWriter) { return -1; }
//状态错误
if ((writeSide != null) && (!writeSide.isAlive()) && (--trials < 0)) {
throw new IOException("Pipe broken");
}
notifyAll(); // 空了,通知写入端可以写入 try {
wait(1000);
} catch (InterruptedException ex) {
throw new java.io.InterruptedIOException();
}
}
int ret = buffer[out++] & 0xFF; if (out >= buffer.length) { out = 0; }
//没有任何字节
if (in == out) { in = -1; }
return ret;
}
public synchronized int read(byte b[], int off, int len) throws IOException {
//检查输入参数的正确性
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
//读取下一个
int c = read();
//已经到达末尾了,返回-1
if (c < 0) { return -1; }
//放入外部buffer中
b[off] = (byte) c;
//return-len
int rlen = 1;
//下一个in存在,且没有到达len
while ((in >= 0) && (--len > 0)) {
//依次放入外部buffer
b[off + rlen] = buffer[out++];
rlen++;
//读到buffer的末尾,返回头部
if (out >= buffer.length) { out = 0; }
//读、写位置一致时,表示没有数据
if (in == out) { in = -1; }
}
//返回填充的长度
return rlen;
}
//返回还有多少字节可以读取
public synchronized int available() throws IOException {
//到达末端,没有字节
if(in < 0)
return 0;
else if(in == out)
//写入的和读出的一致,表示满
return buffer.length;
else if (in > out)
//写入的大于读出
return in - out;
else
//写入的小于读出的
return in + buffer.length - out;
}
//关闭当前流,同时释放与其相关的资源
public void close() throws IOException {
//表示由输入流关闭
closedByReader = true;
//同步化当前对象,in为-1
synchronized (this) { in = -1; }
}
}
1.4.4 - CH04-OutputStream
OutputStream 抽象类
public abstract void write(int b)
// 写入一个字节,可以看到这里的参数是一个 int 类型,对应上面的读方法,int 类型的 32 位,只有低 8 位才写入,高 24 位将舍弃。
public void write(byte b[])
// 将数组中的所有字节写入,和上面对应的 read() 方法类似,实际调用的也是下面的方法。
public void write(byte b[], int off, int len)
// 将 byte 数组从 off 位置开始,len 长度的字节写入
public void flush()
// 强制刷新,将缓冲中的数据写入
public void close()
// 关闭输出流,流被关闭后就不能再输出数据了
源码实现
FilterOutputStream
/**
* This class is the superclass of all classes that filter output
* streams. These streams sit on top of an already existing output
* stream (the <i>underlying</i> output stream) which it uses as its
* basic sink of data, but possibly transforming the data along the
* way or providing additional functionality.
* <p>
* The class <code>FilterOutputStream</code> itself simply overrides
* all methods of <code>OutputStream</code> with versions that pass
* all requests to the underlying output stream. Subclasses of
* <code>FilterOutputStream</code> may further override some of these
* methods as well as provide additional methods and fields.
*
* @author Jonathan Payne
* @since JDK1.0
*/
public
class FilterOutputStream extends OutputStream {
/**
* The underlying output stream to be filtered.
*/
protected OutputStream out;
/**
* Creates an output stream filter built on top of the specified
* underlying output stream.
*
* @param out the underlying output stream to be assigned to
* the field <tt>this.out</tt> for later use, or
* <code>null</code> if this instance is to be
* created without an underlying stream.
*/
public FilterOutputStream(OutputStream out) {
this.out = out;
}
/**
* Writes the specified <code>byte</code> to this output stream.
* <p>
* The <code>write</code> method of <code>FilterOutputStream</code>
* calls the <code>write</code> method of its underlying output stream,
* that is, it performs <tt>out.write(b)</tt>.
* <p>
* Implements the abstract <tt>write</tt> method of <tt>OutputStream</tt>.
*
* @param b the <code>byte</code>.
* @exception IOException if an I/O error occurs.
*/
public void write(int b) throws IOException {
out.write(b);
}
/**
* Writes <code>b.length</code> bytes to this output stream.
* <p>
* The <code>write</code> method of <code>FilterOutputStream</code>
* calls its <code>write</code> method of three arguments with the
* arguments <code>b</code>, <code>0</code>, and
* <code>b.length</code>.
* <p>
* Note that this method does not call the one-argument
* <code>write</code> method of its underlying stream with the single
* argument <code>b</code>.
*
* @param b the data to be written.
* @exception IOException if an I/O error occurs.
* @see java.io.FilterOutputStream#write(byte[], int, int)
*/
public void write(byte b[]) throws IOException {
write(b, 0, b.length);
}
/**
* Writes <code>len</code> bytes from the specified
* <code>byte</code> array starting at offset <code>off</code> to
* this output stream.
* <p>
* The <code>write</code> method of <code>FilterOutputStream</code>
* calls the <code>write</code> method of one argument on each
* <code>byte</code> to output.
* <p>
* Note that this method does not call the <code>write</code> method
* of its underlying input stream with the same arguments. Subclasses
* of <code>FilterOutputStream</code> should provide a more efficient
* implementation of this method.
*
* @param b the data.
* @param off the start offset in the data.
* @param len the number of bytes to write.
* @exception IOException if an I/O error occurs.
* @see java.io.FilterOutputStream#write(int)
*/
public void write(byte b[], int off, int len) throws IOException {
if ((off | len | (b.length - (len + off)) | (off + len)) < 0)
throw new IndexOutOfBoundsException();
for (int i = 0 ; i < len ; i++) {
write(b[off + i]);
}
}
/**
* Flushes this output stream and forces any buffered output bytes
* to be written out to the stream.
* <p>
* The <code>flush</code> method of <code>FilterOutputStream</code>
* calls the <code>flush</code> method of its underlying output stream.
*
* @exception IOException if an I/O error occurs.
* @see java.io.FilterOutputStream#out
*/
public void flush() throws IOException {
out.flush();
}
/**
* Closes this output stream and releases any system resources
* associated with the stream.
* <p>
* The <code>close</code> method of <code>FilterOutputStream</code>
* calls its <code>flush</code> method, and then calls the
* <code>close</code> method of its underlying output stream.
*
* @exception IOException if an I/O error occurs.
* @see java.io.FilterOutputStream#flush()
* @see java.io.FilterOutputStream#out
*/
@SuppressWarnings("try")
public void close() throws IOException {
try (OutputStream ostream = out) {
flush();
}
}
}
ByteArrayOutputStream
/**
* This class implements an output stream in which the data is
* written into a byte array. The buffer automatically grows as data
* is written to it.
* The data can be retrieved using <code>toByteArray()</code> and
* <code>toString()</code>.
* <p>
* Closing a <tt>ByteArrayOutputStream</tt> has no effect. The methods in
* this class can be called after the stream has been closed without
* generating an <tt>IOException</tt>.
*
* @author Arthur van Hoff
* @since JDK1.0
*/
public class ByteArrayOutputStream extends OutputStream {
/**
* The buffer where data is stored.
*/
protected byte buf[];
/**
* The number of valid bytes in the buffer.
*/
protected int count;
/**
* Creates a new byte array output stream. The buffer capacity is
* initially 32 bytes, though its size increases if necessary.
*/
public ByteArrayOutputStream() {
this(32);
}
/**
* Creates a new byte array output stream, with a buffer capacity of
* the specified size, in bytes.
*
* @param size the initial size.
* @exception IllegalArgumentException if size is negative.
*/
public ByteArrayOutputStream(int size) {
if (size < 0) {
throw new IllegalArgumentException("Negative initial size: "
+ size);
}
buf = new byte[size];
}
/**
* Increases the capacity if necessary to ensure that it can hold
* at least the number of elements specified by the minimum
* capacity argument.
*
* @param minCapacity the desired minimum capacity
* @throws OutOfMemoryError if {@code minCapacity < 0}. This is
* interpreted as a request for the unsatisfiably large capacity
* {@code (long) Integer.MAX_VALUE + (minCapacity - Integer.MAX_VALUE)}.
*/
private void ensureCapacity(int minCapacity) {
// overflow-conscious code
if (minCapacity - buf.length > 0)
grow(minCapacity);
}
/**
* The maximum size of array to allocate.
* Some VMs reserve some header words in an array.
* Attempts to allocate larger arrays may result in
* OutOfMemoryError: Requested array size exceeds VM limit
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* Increases the capacity to ensure that it can hold at least the
* number of elements specified by the minimum capacity argument.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = buf.length;
int newCapacity = oldCapacity << 1;
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
buf = Arrays.copyOf(buf, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
/**
* Writes the specified byte to this byte array output stream.
*
* @param b the byte to be written.
*/
public synchronized void write(int b) {
ensureCapacity(count + 1);
buf[count] = (byte) b;
count += 1;
}
/**
* Writes <code>len</code> bytes from the specified byte array
* starting at offset <code>off</code> to this byte array output stream.
*
* @param b the data.
* @param off the start offset in the data.
* @param len the number of bytes to write.
*/
public synchronized void write(byte b[], int off, int len) {
if ((off < 0) || (off > b.length) || (len < 0) ||
((off + len) - b.length > 0)) {
throw new IndexOutOfBoundsException();
}
ensureCapacity(count + len);
System.arraycopy(b, off, buf, count, len);
count += len;
}
/**
* Writes the complete contents of this byte array output stream to
* the specified output stream argument, as if by calling the output
* stream's write method using <code>out.write(buf, 0, count)</code>.
*
* @param out the output stream to which to write the data.
* @exception IOException if an I/O error occurs.
*/
public synchronized void writeTo(OutputStream out) throws IOException {
out.write(buf, 0, count);
}
/**
* Resets the <code>count</code> field of this byte array output
* stream to zero, so that all currently accumulated output in the
* output stream is discarded. The output stream can be used again,
* reusing the already allocated buffer space.
*
* @see java.io.ByteArrayInputStream#count
*/
public synchronized void reset() {
count = 0;
}
/**
* Creates a newly allocated byte array. Its size is the current
* size of this output stream and the valid contents of the buffer
* have been copied into it.
*
* @return the current contents of this output stream, as a byte array.
* @see java.io.ByteArrayOutputStream#size()
*/
public synchronized byte toByteArray()[] {
return Arrays.copyOf(buf, count);
}
/**
* Returns the current size of the buffer.
*
* @return the value of the <code>count</code> field, which is the number
* of valid bytes in this output stream.
* @see java.io.ByteArrayOutputStream#count
*/
public synchronized int size() {
return count;
}
/**
* Converts the buffer's contents into a string decoding bytes using the
* platform's default character set. The length of the new <tt>String</tt>
* is a function of the character set, and hence may not be equal to the
* size of the buffer.
*
* <p> This method always replaces malformed-input and unmappable-character
* sequences with the default replacement string for the platform's
* default character set. The {@linkplain java.nio.charset.CharsetDecoder}
* class should be used when more control over the decoding process is
* required.
*
* @return String decoded from the buffer's contents.
* @since JDK1.1
*/
public synchronized String toString() {
return new String(buf, 0, count);
}
/**
* Converts the buffer's contents into a string by decoding the bytes using
* the named {@link java.nio.charset.Charset charset}. The length of the new
* <tt>String</tt> is a function of the charset, and hence may not be equal
* to the length of the byte array.
*
* <p> This method always replaces malformed-input and unmappable-character
* sequences with this charset's default replacement string. The {@link
* java.nio.charset.CharsetDecoder} class should be used when more control
* over the decoding process is required.
*
* @param charsetName the name of a supported
* {@link java.nio.charset.Charset charset}
* @return String decoded from the buffer's contents.
* @exception UnsupportedEncodingException
* If the named charset is not supported
* @since JDK1.1
*/
public synchronized String toString(String charsetName)
throws UnsupportedEncodingException
{
return new String(buf, 0, count, charsetName);
}
/**
* Creates a newly allocated string. Its size is the current size of
* the output stream and the valid contents of the buffer have been
* copied into it. Each character <i>c</i> in the resulting string is
* constructed from the corresponding element <i>b</i> in the byte
* array such that:
* <blockquote><pre>
* c == (char)(((hibyte & 0xff) << 8) | (b & 0xff))
* </pre></blockquote>
*
* @deprecated This method does not properly convert bytes into characters.
* As of JDK 1.1, the preferred way to do this is via the
* <code>toString(String enc)</code> method, which takes an encoding-name
* argument, or the <code>toString()</code> method, which uses the
* platform's default character encoding.
*
* @param hibyte the high byte of each resulting Unicode character.
* @return the current contents of the output stream, as a string.
* @see java.io.ByteArrayOutputStream#size()
* @see java.io.ByteArrayOutputStream#toString(String)
* @see java.io.ByteArrayOutputStream#toString()
*/
@Deprecated
public synchronized String toString(int hibyte) {
return new String(buf, hibyte, 0, count);
}
/**
* Closing a <tt>ByteArrayOutputStream</tt> has no effect. The methods in
* this class can be called after the stream has been closed without
* generating an <tt>IOException</tt>.
*/
public void close() throws IOException {
}
}
BufferedOutputStream
/**
* The class implements a buffered output stream. By setting up such
* an output stream, an application can write bytes to the underlying
* output stream without necessarily causing a call to the underlying
* system for each byte written.
*
* @author Arthur van Hoff
* @since JDK1.0
*/
public
class BufferedOutputStream extends FilterOutputStream {
/**
* The internal buffer where data is stored.
*/
protected byte buf[];
/**
* The number of valid bytes in the buffer. This value is always
* in the range <tt>0</tt> through <tt>buf.length</tt>; elements
* <tt>buf[0]</tt> through <tt>buf[count-1]</tt> contain valid
* byte data.
*/
protected int count;
/**
* Creates a new buffered output stream to write data to the
* specified underlying output stream.
*
* @param out the underlying output stream.
*/
public BufferedOutputStream(OutputStream out) {
this(out, 8192);
}
/**
* Creates a new buffered output stream to write data to the
* specified underlying output stream with the specified buffer
* size.
*
* @param out the underlying output stream.
* @param size the buffer size.
* @exception IllegalArgumentException if size <= 0.
*/
public BufferedOutputStream(OutputStream out, int size) {
super(out);
if (size <= 0) {
throw new IllegalArgumentException("Buffer size <= 0");
}
buf = new byte[size];
}
/** Flush the internal buffer */
private void flushBuffer() throws IOException {
if (count > 0) {
out.write(buf, 0, count);
count = 0;
}
}
/**
* Writes the specified byte to this buffered output stream.
*
* @param b the byte to be written.
* @exception IOException if an I/O error occurs.
*/
public synchronized void write(int b) throws IOException {
if (count >= buf.length) {
flushBuffer();
}
buf[count++] = (byte)b;
}
/**
* Writes <code>len</code> bytes from the specified byte array
* starting at offset <code>off</code> to this buffered output stream.
*
* <p> Ordinarily this method stores bytes from the given array into this
* stream's buffer, flushing the buffer to the underlying output stream as
* needed. If the requested length is at least as large as this stream's
* buffer, however, then this method will flush the buffer and write the
* bytes directly to the underlying output stream. Thus redundant
* <code>BufferedOutputStream</code>s will not copy data unnecessarily.
*
* @param b the data.
* @param off the start offset in the data.
* @param len the number of bytes to write.
* @exception IOException if an I/O error occurs.
*/
public synchronized void write(byte b[], int off, int len) throws IOException {
if (len >= buf.length) {
/* If the request length exceeds the size of the output buffer,
flush the output buffer and then write the data directly.
In this way buffered streams will cascade harmlessly. */
flushBuffer();
out.write(b, off, len);
return;
}
if (len > buf.length - count) {
flushBuffer();
}
System.arraycopy(b, off, buf, count, len);
count += len;
}
/**
* Flushes this buffered output stream. This forces any buffered
* output bytes to be written out to the underlying output stream.
*
* @exception IOException if an I/O error occurs.
* @see java.io.FilterOutputStream#out
*/
public synchronized void flush() throws IOException {
flushBuffer();
out.flush();
}
}
PipedOutputStream
/**
* A piped output stream can be connected to a piped input stream
* to create a communications pipe. The piped output stream is the
* sending end of the pipe. Typically, data is written to a
* <code>PipedOutputStream</code> object by one thread and data is
* read from the connected <code>PipedInputStream</code> by some
* other thread. Attempting to use both objects from a single thread
* is not recommended as it may deadlock the thread.
* The pipe is said to be <a name=BROKEN> <i>broken</i> </a> if a
* thread that was reading data bytes from the connected piped input
* stream is no longer alive.
*
* @author James Gosling
* @see java.io.PipedInputStream
* @since JDK1.0
*/
public
class PipedOutputStream extends OutputStream {
/* REMIND: identification of the read and write sides needs to be
more sophisticated. Either using thread groups (but what about
pipes within a thread?) or using finalization (but it may be a
long time until the next GC). */
private PipedInputStream sink;
/**
* Creates a piped output stream connected to the specified piped
* input stream. Data bytes written to this stream will then be
* available as input from <code>snk</code>.
*
* @param snk The piped input stream to connect to.
* @exception IOException if an I/O error occurs.
*/
public PipedOutputStream(PipedInputStream snk) throws IOException {
connect(snk);
}
/**
* Creates a piped output stream that is not yet connected to a
* piped input stream. It must be connected to a piped input stream,
* either by the receiver or the sender, before being used.
*
* @see java.io.PipedInputStream#connect(java.io.PipedOutputStream)
* @see java.io.PipedOutputStream#connect(java.io.PipedInputStream)
*/
public PipedOutputStream() {
}
/**
* Connects this piped output stream to a receiver. If this object
* is already connected to some other piped input stream, an
* <code>IOException</code> is thrown.
* <p>
* If <code>snk</code> is an unconnected piped input stream and
* <code>src</code> is an unconnected piped output stream, they may
* be connected by either the call:
* <blockquote><pre>
* src.connect(snk)</pre></blockquote>
* or the call:
* <blockquote><pre>
* snk.connect(src)</pre></blockquote>
* The two calls have the same effect.
*
* @param snk the piped input stream to connect to.
* @exception IOException if an I/O error occurs.
*/
public synchronized void connect(PipedInputStream snk) throws IOException {
if (snk == null) {
throw new NullPointerException();
} else if (sink != null || snk.connected) {
throw new IOException("Already connected");
}
sink = snk;
snk.in = -1;
snk.out = 0;
snk.connected = true;
}
/**
* Writes the specified <code>byte</code> to the piped output stream.
* <p>
* Implements the <code>write</code> method of <code>OutputStream</code>.
*
* @param b the <code>byte</code> to be written.
* @exception IOException if the pipe is <a href=#BROKEN> broken</a>,
* {@link #connect(java.io.PipedInputStream) unconnected},
* closed, or if an I/O error occurs.
*/
public void write(int b) throws IOException {
if (sink == null) {
throw new IOException("Pipe not connected");
}
sink.receive(b);
}
/**
* Writes <code>len</code> bytes from the specified byte array
* starting at offset <code>off</code> to this piped output stream.
* This method blocks until all the bytes are written to the output
* stream.
*
* @param b the data.
* @param off the start offset in the data.
* @param len the number of bytes to write.
* @exception IOException if the pipe is <a href=#BROKEN> broken</a>,
* {@link #connect(java.io.PipedInputStream) unconnected},
* closed, or if an I/O error occurs.
*/
public void write(byte b[], int off, int len) throws IOException {
if (sink == null) {
throw new IOException("Pipe not connected");
} else if (b == null) {
throw new NullPointerException();
} else if ((off < 0) || (off > b.length) || (len < 0) ||
((off + len) > b.length) || ((off + len) < 0)) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return;
}
sink.receive(b, off, len);
}
/**
* Flushes this output stream and forces any buffered output bytes
* to be written out.
* This will notify any readers that bytes are waiting in the pipe.
*
* @exception IOException if an I/O error occurs.
*/
public synchronized void flush() throws IOException {
if (sink != null) {
synchronized (sink) {
sink.notifyAll();
}
}
}
/**
* Closes this piped output stream and releases any system resources
* associated with this stream. This stream may no longer be used for
* writing bytes.
*
* @exception IOException if an I/O error occurs.
*/
public void close() throws IOException {
if (sink != null) {
sink.receivedLast();
}
}
}
1.4.5 - CH05-常用操作.md
File
递归地列出一个目录下所有文件:
public static void listAllFiles(File dir) {
if (dir == null || !dir.exists()) {
return;
}
if (dir.isFile()) {
System.out.println(dir.getName());
return;
}
for (File file : dir.listFiles()) {
listAllFiles(file);
}
}
字节流
public static void copyFile(String src, String dist) throws IOException {
FileInputStream in = new FileInputStream(src);
FileOutputStream out = new FileOutputStream(dist);
byte[] buffer = new byte[20 * 1024];
// read() 最多读取 buffer.length 个字节
// 返回的是实际读取的个数
// 返回 -1 的时候表示读到 eof,即文件尾
while (in.read(buffer, 0, buffer.length) != -1) {
out.write(buffer);
}
in.close();
out.close();
}
逐行打印
public static void readFileContent(String filePath) throws IOException {
FileReader fileReader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
// 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
// 在调用 BufferedReader 的 close() 方法时会去调用 Reader 的 close() 方法
// 因此只要一个 close() 调用即可
bufferedReader.close();
}
序列化
序列化 & Serializable & transient
序列化就是将一个对象转换成字节序列,方便存储和传输。
- 序列化: ObjectOutputStream.writeObject()
- 反序列化: ObjectInputStream.readObject()
不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态。
Serializable
序列化的类需要实现 Serializable 接口,它只是一个标准,没有任何方法需要实现,但是如果不去实现它的话而进行序列化,会抛出异常。
public static void main(String[] args) throws IOException, ClassNotFoundException {
A a1 = new A(123, "abc");
String objectFile = "file/a1";
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
objectOutputStream.writeObject(a1);
objectOutputStream.close();
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
A a2 = (A) objectInputStream.readObject();
objectInputStream.close();
System.out.println(a2);
}
private static class A implements Serializable {
private int x;
private String y;
A(int x, String y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return "x = " + x + " " + "y = " + y;
}
}
transient
transient 关键字可以使一些属性不会被序列化。
ArrayList 中存储数据的数组 elementData 是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用,因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。
private transient Object[] elementData;
网络
InetAddress
没有公有的构造函数,只能通过静态方法来创建实例。
InetAddress.getByName(String host);
InetAddress.getByAddress(byte[] address);
URL
可以直接从 URL 中读取字节流数据。
public static void main(String[] args) throws IOException {
URL url = new URL("http://www.baidu.com");
/* 字节流 */
InputStream is = url.openStream();
/* 字符流 */
InputStreamReader isr = new InputStreamReader(is, "utf-8");
/* 提供缓存功能 */
BufferedReader br = new BufferedReader(isr);
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
br.close();
}
Sockets
- ServerSocket: 服务器端类
- Socket: 客户端类
- 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。
Datagram
- DatagramSocket: 通信类
- DatagramPacket: 数据包类
1.4.6 - CH06-IO实现
概览
说到 I/O,想必大家都不会陌生, I/O 英语全称:Input/Output,即输入/输出,通常指数据在内部存储器和外部存储器或其他周边设备之间的输入和输出。
比如我们常用的 SD卡、U盘、移动硬盘等等存储文件的硬件设备,当我们将其插入电脑的 usb 硬件接口时,我们就可以从电脑中读取设备中的信息或者写入信息,这个过程就涉及到 I/O 的操作。
当然,涉及 I/O 的操作,不仅仅局限于硬件设备的读写,还要网络数据的传输,比如,我们在电脑上用浏览器搜索互联网上的信息,这个过程也涉及到 I/O 的操作。
无论是从磁盘中读写文件,还是在网络中传输数据,可以说 I/O 主要为处理人机交互、机与机交互中获取和交换信息提供的一套解决方案。
在 Java 的 IO 体系中,类将近有 80 个,位于java.io
包下,感觉很复杂,但是这些类大致可以分成四组:
- 基于字节操作的 I/O 接口:InputStream 和 OutputStream
- 基于字符操作的 I/O 接口:Writer 和 Reader
- 基于磁盘操作的 I/O 接口:File
- 基于网络操作的 I/O 接口:Socket
前两组主要从传输数据的数据格式不同,进行分组;后两组主要从传输数据的方式不同,进行分组。
虽然 Socket 类并不在 java.io
包下,但是我们仍然把它们划分在一起,因为 I/O 的核心问题,要么是数据格式影响 I/O 操作,要么是传输方式影响 I/O 操作,也就是将什么样的数据写到什么地方的问题,I/O 只是人与机器或者机器与机器交互的手段,除了在它们能够完成这个交互功能外,我们关注的就是如何提高它的运行效率了,而数据格式和传输方式是影响效率最关键的因素。
字节格式
基于字节的输入和输出操作接口分别是:InputStream 和 OutputStream 。
字节输入流
InputStream 输入流的类继承层次如下图所示:
输入流根据数据节点类型和处理方式,分别可以划分出了若干个子类,如下图:
字节输出流
OutputStream 输出流的类继承层次如下图所示:
输出流根据数据节点类型和处理方式,也分别可以划分出了若干个子类,如下图:
在这里就不详细的介绍各个子类的使用方法,有兴趣的朋友可以查看 JDK 的 API 说明文档,笔者也会在后期的文章会进行详细的介绍,这里只是重点想说一下,无论是输入还是输出,操作数据的方式可以组合使用,各个处理流的类并不是只操作固定的节点流,比如如下输出方式:
//将文件输出流包装到序列化输出流中,再将序列化输出流包装到缓冲中 OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream(new File("fileName")));
另外,输出流最终写到什么地方必须要指定,要么是写到硬盘中,要么是写到网络中,从图中可以发现,写网络实际上也是写文件,只不过写到网络中,需要经过底层操作系统将数据发送到其他的计算机中,而不是写入到本地硬盘中。
字符格式
不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,所以 I/O 操作的都是字节而不是字符,但是为什么要有操作字符的 I/O 接口呢?
这是因为我们的程序中通常操作的数据都是以字符形式,为了程序操作更方便而提供一个直接写字符的 I/O 接口,仅此而已。
基于字符的输入和输出操作接口分别是:Reader 和 Writer ,下图是字符的 I/O 操作接口涉及到的类结构图。
字符输入流
Reader 输入流的类继承层次如下图所示:
同样的,输入流根据数据节点类型和处理方式,分别可以划分出了若干个子类,如下图:
字符输出流
Writer 输出流的类继承层次如下图所示:
同样的,输出流根据数据节点类型和处理方式分类,分别可以划分出了若干个子类,如下图:
不管是 Reader 还是 Writer 类,它们都只定义了读取或写入数据字符的方式,也就是说要么是读要么是写,但是并没有规定数据要写到哪去,写到哪去就是我们后面要讨论的基于磁盘或网络的工作机制。
字节与字符的转化
刚刚我们说到,不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符,设计字符的原因是为了程序操作更方便,那么怎么将字符转化成字节或者将字节转化成字符呢?
InputStreamReader 和 OutputStreamWriter 就是转化桥梁。
输入流转化过程
输入流字符解码相关类结构的转化过程如下图所示:
从图上可以看到,InputStreamReader 类是字节到字符的转化桥梁, 其中StreamDecoder
指的是一个解码操作类,Charset
指的是字符集。
InputStream 到 Reader 的过程需要指定编码字符集,否则将采用操作系统默认字符集,很可能会出现乱码问题,StreamDecoder 则是完成字节到字符的解码的实现类。
打开源码部分,InputStream 到 Reader 转化过程,如下图:
输出流转化过程
输出流转化过程也是类似,如下图所示:
通过 OutputStreamWriter 类完成字符到字节的编码过程,由 StreamEncoder
完成编码过程。
源码部分,Writer 到 OutputStream 转化过程,如下图:
磁盘传输
前面介绍了Java I/O 的操作接口,这些接口主要定义了如何操作数据,以及介绍了操作数据格式的方式:字节流和字符流。
还有一个关键问题就是数据写到何处,其中一个主要的处理方式就是将数据持久化到物理磁盘。
我们知道数据在磁盘的唯一最小描述就是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的一个最小单元。
- 在 Java I/O 体系中,File 类是唯一代表磁盘文件本身的对象。
- File 类定义了一些与平台无关的方法来操作文件,包括检查一个文件是否存在、创建、删除文件、重命名文件、判断文件的读写权限是否存在、设置和查询文件的最近修改时间等等操作。
值得注意的是 Java 中通常的 File 并不代表一个真实存在的文件对象,当你通过指定一个路径描述符时,它就会返回一个代表这个路径相关联的一个虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。
例如,读取一个文件内容,程序如下:
以上面的程序为例,从硬盘中读取一段文本字符,操作流程如下图:
当我们传入一个指定的文件名来创建 File 对象,通过 FileReader 来读取文件内容时,会自动创建一个FileInputStream
对象来读取文件内容,也就是我们上文中所说的字节流来读取文件。
紧接着,会创建一个FileDescriptor
的对象,其实这个对象就是真正代表一个存在的文件对象的描述。可以通过FileInputStream
对象调用getFD()
方法获取真正与底层操作系统关联的文件描述。
由于我们需要读取的是字符格式,所以需要 StreamDecoder
类将byte
解码为char
格式,至于如何从磁盘驱动器上读取一段数据,由操作系统帮我们完成。
网络传输
继续来说说数据写到何处的另一种处理方式:将数据写入互联网中以供其他电脑能访问。
Socket简介
在现实中,Socket 这个概念没有一个具体的实体,它是描述计算机之间完成相互通信一种抽象定义。
打个比方,可以把 Socket 比作为两个城市之间的交通工具,有了它,就可以在城市之间来回穿梭了。并且,交通工具有多种,每种交通工具也有相应的交通规则。Socket 也一样,也有多种。大部分情况下我们使用的都是基于 TCP/IP 的流套接字,它是一种稳定的通信协议。
典型的基于 Socket 通信的应用程序场景,如下图:
主机 A 的应用程序要想和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。
建立通信链路
我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过 TCP 或 UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例代表唯一一个主机上的一个应用程序的通信链路了。
为了准确无误地把数据送达目标处,TCP 协议采用了三次握手策略,如下图:
SYN 全称为 Synchronize Sequence Numbers,表示同步序列编号,是 TCP/IP 建立连接时使用的握手信号。
ACK 全称为 Acknowledge character,即确认字符,表示发来的数据已确认接收无误。
在客户机和服务器之间建立正常的 TCP 网络连接时,客户机首先发出一个 SYN 消息,服务器使用 SYN + ACK 应答表示接收到了这个消息,最后客户机再以 ACK 消息响应。
这样在客户机和服务器之间才能建立起可靠的 TCP 连接,数据才可以在客户机和服务器之间传递。
- 发送端 –(发送带有 SYN 标志的数据包 )–> 接受端(第一次握手);
- 接受端 –(发送带有 SYN + ACK 标志的数据包)–> 发送端(第二次握手);
- 发送端 –(发送带有 ACK 标志的数据包) –> 接受端(第三次握手);
完成三次握手之后,客户端应用程序与服务器应用程序就可以开始传送数据了。
传输数据
当客户端要与服务端通信时,客户端首先要创建一个 Socket 实例,默认操作系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并创建一个包含本地、远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。
与之对应的服务端,也将创建一个 ServerSocket 实例,ServerSocket 创建比较简单,只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为 ServerSocket 实例创建一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是*
即监听所有地址。
之后当调用 accept() 方法时,将进入阻塞(等待)状态,等待客户端的请求。
我们先启动服务端程序,再运行客户端,服务端收到客户端发送的信息,服务端打印结果如下:
服务端收到客户端发送的消息:Hello,我是客户端!
注意,客户端只有与服务端建立三次握手成功之后,才会发送数据,而 TCP/IP 握手过程,底层操作系统已经帮我们实现了!
- 当连接已经建立成功,服务端和客户端都会拥有一个 Socket 实例,每个 Socket 实例都有一个 InputStream 和 OutputStream,正如我们前面所说的,网络 I/O 都是以字节流传输的,Socket 正是通过这两个对象来交换数据。
- 当 Socket 对象创建时,操作系统将会为 InputStream 和 OutputStream 分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。
- 写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另一端 InputStream 的 RecvQ 队列中,如果这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。
值得特别注意的是,缓存区的大小以及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能会发生阻塞,所以网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,如果两边同时传送数据时可能会产生死锁的问题。
如何提高网络 IO 传输效率、保证数据传输的可靠,已经成了工程师们急需解决的问题。
IO 工作方式
在计算机中,IO 传输数据有三种工作方式,分别是 BIO、NIO、AIO。
在讲解 BIO、NIO、AIO 之前,我们先来回顾一下这几个概念:同步与异步,阻塞与非阻塞。
同步与异步的区别
- 同步就是发起一个请求后,接受者未处理完请求之前,不返回结果。
- 异步就是发起一个请求后,立刻得到接受者的回应表示已接收到请求,但是接受者并没有处理完,接受者通常依靠事件回调等机制来通知请求者其处理结果。
阻塞和非阻塞的区别
- 阻塞就是请求者发起一个请求,一直等待其请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
- 非阻塞就是请求者发起一个请求,不用一直等着结果返回,可以先去干其他事情,当条件就绪的时候,就自动回来。
而我们要讲的 BIO、NIO、AIO 就是同步与异步、阻塞与非阻塞的组合。
- BIO:同步阻塞 IO;
- NIO:同步非阻塞 IO;
- AIO:异步非阻塞 IO;
BIO
BIO 俗称同步阻塞 IO,一种非常传统的 IO 模型,比如我们上面所举的那个程序例子,就是一个典型的**同步阻塞 IO **的工作方式。
采用 BIO 通信模型的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。
我们一般在服务端通过while(true)
循环中会调用accept()
方法等待监听客户端的连接,一旦接收到一个连接请求,就可以建立通信套接字进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成, 不过可以通过多线程来支持多个客户端的连接。
客户端多线程操作,程序如下:
服务端多线程操作,程序如下:
服务端运行结果,如下:
服务端收到客户端发送的消息:Hello,我是第 2 个,客户端!
服务端收到客户端发送的消息:Hello,我是第 4 个,客户端!
服务端收到客户端发送的消息:Hello,我是第 3 个,客户端!
服务端收到客户端发送的消息:Hello,我是第 0 个,客户端!
服务端收到客户端发送的消息:Hello,我是第 1 个,客户端!
如果要让 BIO 通信模型能够同时处理多个客户端请求,就必须使用多线程,也就是说它在接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。
这就是典型的一请求一应答通信模型 。
如果出现100、1000、甚至10000个用户同时访问服务器,这个时候,如果使用这种模型,那么服务端也会创建与之相同的线程数量,线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终导致进程宕机或者僵死,不能对外提供服务。
当然,我们可以通过使用 Java 中 ThreadPoolExecutor 线程池机制来改善,让线程的创建和回收成本相对较低,保证了系统有限的资源的控制,实现了 N (客户端请求数量)大于 M (处理客户端请求的线程数量)的伪异步 I/O 模型。
伪异步 BIO
为了解决同步阻塞 I/O 面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化,后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数 M:线程池最大线程数 N 的比例关系,其中 M 可以远远大于 N,通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致资源耗尽。
伪异步IO模型图,如下图:
采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架,当有新的客户端接入时,将客户端的 Socket 封装成一个 Task 投递到后端的线程池中进行处理。
Java 的线程池维护一个消息队列和 N 个活跃线程,对消息队列中的任务进行处理。
客户端,程序如下:
服务端,程序如下:
先启动服务端程序,再启动客户端程序,看看运行结果!
服务端,运行结果如下:
客户端,运行结果如下:
本例中测试的客户端数量是 30,服务端使用 java 线程池来处理任务,线程数量为 5 个,服务端不用为每个客户端都创建一个线程,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
在活动连接数不是特别高的情况下,这种模型是还不错,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。
但是,它的底层仍然是同步阻塞的 BIO 模型,当面对十万甚至百万级连接的时候,传统的 BIO 模型真的是无能为力的,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
NIO
NIO 中的 N 可以理解为 Non-blocking,一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入,对应的在
java.nio
包下。NIO 新增了 Channel、Selector、Buffer 等抽象概念,支持面向缓冲、基于通道的 I/O 操作方法。
NIO 提供了与传统 BIO 模型中的
Socket
和ServerSocket
相对应的SocketChannel
和ServerSocketChannel
两种不同的套接字通道实现。NIO 这两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。
对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发效率和更好的维护性;
对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。
我们先看一下 NIO 涉及到的核心关联类图,如下:
上图中有三个关键类:Channel 、Selector 和 Buffer,它们是 NIO 中的核心概念。
- Channel:可以理解为通道;
- Selector:可以理解为选择器;
- Buffer:可以理解为数据缓冲流;
我们还是用前面的城市交通工具来继续形容 NIO 的工作方式,这里的 Channel 要比 Socket 更加具体,它可以比作为某种具体的交通工具,如汽车或是高铁、飞机等,而 Selector 可以比作为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态:是已经出站还是在路上等等,也就是说它可以轮询每个 Channel 的状态。
还有一个 Buffer 类,你可以将它看作为 IO 中 Stream,但是它比 IO 中的 Stream 更加具体化,我们可以将它比作为车上的座位,Channel 如果是汽车的话,那么 Buffer 就是汽车上的座位,Channel 如果是高铁,那么 Buffer 就是高铁上的座位,它始终是一个具体的概念,这一点与 Stream 不同。
Socket 中的 Stream 只能代表是一个座位,至于是什么座位由你自己去想象,也就是说你在上车之前并不知道这个车上是否还有没有座位,也不知道上的是什么车,因为你并不能选择,这些信息都已经被封装在了运输工具(Socket)里面了。
NIO 引入了 Channel、Buffer 和 Selector 就是想把 IO 传输过程中涉及到的信息具体化,让程序员有机会去控制它们。
当我们进行传统的网络 IO 操作时,比如调用 write() 往 Socket 中的 SendQ 队列写数据时,当一次写的数据超过 SendQ 长度时,操作系统会按照 SendQ 的长度进行分割的,这个过程中需要将用户空间数据和内核地址空间进行切换,而这个切换不是程序员可以控制的,由底层操作系统来帮我们处理。
而在 Buffer 中,我们可以控制 Buffer 的 capacity(容量),并且是否扩容以及如何扩容都可以控制。
还是以上面的操作为例子,为了方便观看结果,本次的客户端线程请求数改成15个。
客户端,程序如下:
服务端,程序如下:
先启动服务端程序,再启动客户端程序,看看运行结果!
服务端,运行结果如下:
客户端,运行结果如下:
当然,客户端也不仅仅只限制于 IO 的写法,还可以使用SocketChannel
来操作客户端,程序如下:
从操作上可以看到,NIO 的操作比传统的 IO 操作要复杂的多!
Selector 被称为选择器 ,当然你也可以翻译为多路复用器 。它是Java NIO 核心组件中的一个,用于检查一个或多个 Channel(通道)的状态是否处于连接就绪、接受就绪、可读就绪、可写就绪。
如此可以实现单线程管理多个 channels,也就是可以管理多个网络连接。
使用 Selector 的好处在于: 相比传统方式使用多个线程来管理 IO,Selector 使用了更少的线程就可以处理通道了,并且实现网络高效传输!
虽然 java 中的 nio 传输比较快,为什么大家都不愿意用 JDK 原生 NIO 进行开发呢?
从上面的代码中大家都可以看出来,除了编程复杂、编程模型难之外,还有几个让人诟病的问题:
- JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%!
- 项目庞大之后,自行实现的 NIO 很容易出现各类 bug,维护成本较高!
但是,Google 的 Netty 框架的出现,很大程度上改善了 JDK 原生 NIO 所存在的一些让人难以忍受的问题。
AIO
最后就是 AIO 了,全称 Asynchronous I/O,可以理解为异步 IO,也被称为 NIO 2,在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型,也就是我们现在所说的 AIO。
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
客户端,程序示例:
服务端,程序示例:
这种组合方式用起来比较复杂,只有在一些非常复杂的分布式情况下使用,像集群之间的消息同步机制一般用这种 I/O 组合方式。如 Cassandra 的 Gossip 通信机制就是采用异步非阻塞的方式。
参考资料
1.4.7 - CH07-IO模型
用户空间与内核空间
我们知道现在的操作系统都是采用虚拟存储器,那么对 32 位操作系统来说,它的寻址空间即虚拟存储空间为 4G,2 的 32 次方。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核,保证内核的的安全,操作系统将虚拟内存空间划分为两部分,一部分是内核空间,一部分是用户空间。
针对 Linux 操作系统而言,将最高的 1G 字节,即从虚拟地址 0xC0000000 到 0xFFFFFFFF 供内核使用,称为内核空间。而较低的 3G 字节,即从虚拟地址 0x00000000 到 0xBFFFFFFF,供进程使用,称为用户空间。每个进程都可以通过系统调用进入内核,因此 Linux 内核由系统内的所有进程共享。于是,从具体进程的角度看,每个进程可以拥有 4G 字节的虚拟空间。
有了用户空间和内核空间,整个 Linux 内部结构可以分为三个部分,从最底层到最上层依次是:硬件、内核空间、用户空间。
需要注意的细节是,从上图可以看出内核的组成:
- 内核空间中存放的是内核代码和数据,而进程的用户空间存放的是用户程序的代码和数据。不管是内核空间还是用户空间,都处于虚拟空间之中。
- Linux 使用两级保护机制:0 级供内核使用,3 级供用户程序使用。
服务端处理网络请求的流程
为了 OS 的安全性等考虑,进程是无法直接操作 IO 设备的,其必须通过系统调用来请求内核以协助完成 IO 动作,而内核会为每个 IO 设备维护一个 buffer。
整个请求过程为:
- 用户进程发起请求;
- 内核接收到请求后;
- 从 IO 设备中获取数据到 buffer 中;
- 再将 buffer 中的数据 copy 到用户进程的地址空间;
- 该用户进程获取到数据后再响应客户端。
服务端处理网络请求的典型流程图如下:
在请求过程中,数据从 IO 设备输入至 buffer 需要时间,从 buffer 复制将数据复制到用户进程也需要时间。因此根据在这两段时间内等待方式的不同,IO 动作可以分为以下五种:
- 阻塞 IO,Blocking IO
- 非阻塞 IO,Non-Blocking IO
- IO 复用,IO Multiplexing
- 信号驱动的 IO,Signal Driven IO
- 异步 IO,Asynchrnous IO
更多细节参考 <Unix 网络编程>,6.2 节 “IO Models”。
设计服务端并发模型时,主要有如下两个关键点:
- 服务器如何管理连接,获取请求数据。
- 服务器如何处理请求。
以上两个关键点最终都与操作系统的 I/O 模型以及线程(进程)模型相关,下面详细介绍这两个模型。
阻塞/非阻塞、同步/异步
阻塞/非阻塞:
- 阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
区别:
- 两者的最大区别在于被调用方在收到请求到返回结果之前的这段时间内,调用方是否一直在等待。
- 阻塞是指调用方一直在等待而且别的事情什么都不做;非阻塞是指调用方先去忙别的事情。
同步/异步:
- 同步处理是指被调用方得到最终结果之后才返回给调用方;
- 异步处理是指被调用方先返回应答,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。
区别与联系
阻塞、非阻塞和同步、异步其实针对的对象是不一样的:
- 阻塞、非阻塞的讨论对象是调用者。
- 同步、异步的讨论对象是被调用者。
Linux 网络 I/O 模型
recvfrom 函数
recvfrom 函数(经 Socket 接收数据),这里把它视为系统调用。一个输入操作通常包括两个不同的阶段:
- 等待数据准就绪。
- 从内核向应用进程复制数据。
对于一个套接字上的输入操作,第一步通常涉及等待数据从网络中到达。当所等待分组到达时,它被复制到内核中的某个缓冲区。第二步就是把数据从内核缓冲区复制到应用进程缓冲区。
实际应用程序在通过系统调用完成上面的 2 步操作时,调用方式的阻塞、非阻塞,操作系统在处理应用程序请求时处理方式的同步、异步,可以分为 5 种 I/O 模型。
阻塞式 IO
在阻塞式 IO 模型中,应用程序从调用 recvfrom 开始到它返回有数据报准备好这段时间是阻塞的,recvfrom 返回成功后,应用程序开始处理数据报。
- 优点:程序实现简单,在阻塞等待数据期间,进程、线程挂起,基本不会占用 CPU 资源。
- 每个连接需要独立的进程、线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销很大,这种模型在实际生产中很少使用。
非阻塞 IO
在非阻塞 IO 模型中,应用程序把一个套接口设置为非阻塞,就是告诉内核,当所有请求的 IO 操作无法完成时,不要将进程睡眠。
而是返回一个错误,应用程序基于 IO 操作函数,将会不断的轮询数据是否已经准备就绪,直到数据准备就绪。
- 优点:不会阻塞在内核的等待数据过程,每次发起的 IO 请求可以立即返回,不会阻塞等待,实时性比较好。
- 缺点:轮询将会不断的询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不会使用这种 IO 模型。
IO 多路复用
在 IO 复用模型中,会用到 Select、Poll、Epoll 函数,这些函数会使进程阻塞,但是和阻塞 IO 有所不同。
这些函数可以同时阻塞多个 IO 操作,而且可以同时对多个读、写操作的 IO 函数进行检测,直到有数据可读或可写时,才会真正调用 IO 操作函数。
- 优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。
- 当连接数较少时效率比“多线程+阻塞IO”的模式效率低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会增加。
信号驱动 IO
在信号驱动 IO 模型中,应用程序使用套接口进行信号驱动 IO,并安装一个信号处理函数,进程继续运行并不阻塞。
当数据准备好时,进程会收到一个 SIGIO 信号,可以在信号处理函数中调用 IO 操作函数处理数据。
- 优点:线程没有在等待数据时被阻塞,可以提高资源利用率。
- 缺点:信号 IO 模式在大量 IO 操作时可能会因为信号队列溢出而导致无法通知。
信号驱动 IO 尽管对于处理 UDP 套接字来说有用,即这种信号通知意味着到达了一个数据报,或者返回一个异步错误。
但是,对于 TCP 而言,信号驱动 IO 方式近乎无用。因为导致这种通知的条件为数众多,逐个进行判断会消耗很大的资源,与前几种方式相比优势尽失。
异步 IO
由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核拷贝到应用程序的缓冲区)通知应用程序。
这种模型与信号驱动模型的主要区别在于:信号驱动 IO 是由内核通知应用程序合适启动一个 IO 操作,而异步 IO 模型是由内核通知应用程序 IO 操作合适完成。
- 优点:异步 IO 能够充分利用 DMA 特性,让 IO 操作与计算重叠。
- 缺点:需要实现真正的异步 IO,操作系统需要做大量的工作。当前 Windows 下通过 IOCP 实现了真正的异步 IO。
而在 Linux 系统下直到 2.6 版本才引入,目前 AIO 并不完善,因此在 Linux 下实现并发网络编程时都是以 IO 复用模型为主。
IO 模型对比
从上图可以看出,越往后,阻塞越少,理论上效率也最优。
这五种模型中,前四种属于同步 IO,因为其中真正的 IO 操作(recvfrom 函数调用)将阻塞进程/线程,只有异步 IO 模型才与 POSIX 定义的异步 IO 相匹配。
进程/线程模型
介绍完服务器如何基于 IO 模型管理连接、获取输入数据,下面介绍服务器如何基于进程、线程模型来处理请求。
传统阻塞 IO 服务模型
特点:
- 采用阻塞式 IO 模型获取输入数据。
- 每个连接都需要独立的线程完成数据输入的读取、业务处理、数据返回操作。
存在问题:
- 当请求的并发数较大时,需要创建大量线程来处理连接,系统资源占用较大。
- 当连接建立后,如果当前线程暂时没有数据可读,则线程就阻塞在 Read 操作上,造成线程资源浪费。
Reactor 模式
针对传统阻塞 IO 服务模型的 2 个缺点,比较常见的有如下解决方案:
- 基于 IO 复用模型,多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。
- 当某条连接有新的数据可处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
- 基于线程池复用线程资源,不必再为每个连接创建线程,将连接完成后的业务处理任务分配给线程进行处理,一个线程可以多个连接的业务。
IO 复用模式结合线程池,就是 Reactor 模式的基本设计思想,如下图:
Reactor 模式,是指通过一个或多个输入同时传递给服务器来处理服务请求的事件驱动处理模式。
服务端程序处理传入的多路请求,并将它们同步分派给请求对应的处理线程,Reactor 模式也叫 Dispatcher 模式。
即 IO 多路复用以统一的方式监听事件,收到事件后分发(Dispatch 给某线程),是编写高性能服务器的必备技术之一。
Reactor 模式有两个关键组件构成:
- Reactor:在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序对 IO 事件做出反应。它就像公司的电话接线员,接听来自客户的电话并将线路转移给适当的联系人。
- Handlers:处理程序执行 IO 事件需要完成的实际组件,类似于客户想要与之交谈的客服坐席。Reactor 通过调度适当的处理程序来响应 IO 事件,处理程序执行非阻塞操作。
根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:
- 单 Reactor 单线程
- 单 Reactor 多线程
- 主从 Reactor 多线程
单 Reactor 单线程
其中,Select 是前面 IO 复用模型介绍的标准网络编程 API,可以实现应用程序通过一个阻塞多向监听多路连接请求,其他方案的示意图也类似。
方案说明:
Reactor 对象通过 Select 监听客户端请求事件,收到事件后通过 Dispatch 进行分发。
如果是“建立连接”请求事件,则由 Acceptor 通过 Accept 处理连接请求,同时创建一个 Handler 对象来处理连接完成后的后续业务处理。
如果不是“建立连接”事件,则 Reactor 会分发调用“连接”对应的 Handler 来响应。
Handler 会完成 “Read->业务处理->Send” 的完整业务流程。
优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。
缺点:性能问题,只有一个线程,无法完全发挥多个 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。
可靠性问题、线程意外跑飞、进入死循环,或导致整个系统的通信模块不可用,不能接收或处理外部消息,造成节点故障。
应用场景:客户端的数量有限,业务处理非常快,比如 Redis,业务处理的时间复杂度为 O(1)。
单 Reactor 多线程
Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发。
如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,同时创建一个 Handler 对象处理连接完成后续的各种事件。
如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应。
Handler 只负责响应事件,不做具体业务处理,通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理。
Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理。
Handler 收到响应结果后通过 Send 将响应结果返回给 Client。
优点:可以充分利用多核 CPU 的处理能力。
缺点:
- 多线程数据共享和访问比较复杂;
- Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。
主从 Reactor 多线程
针对单 Reactor 多线程模型中,Reactor 在单线程中运行,高并发场景下容易成为性能瓶颈,可以让 Reactor 在多线程中运行。
Reactor 主线程 MainReactor 对象通过 Select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件。
Acceptor 处理建立连接事件后,MainReactor 将连接分配 Reactor 子线程给 SubReactor 进行处理。
SubReactor 将连接加入连接队列进行监听,并创建一个 Handler 用于处理各种连接事件。
当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应。
Handler 通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理。
Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理。
Handler 收到响应结果后通过 Send 将响应结果返回给 Client。
优点:父线程与子线程的数据交互简单、职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
父线程与子线程的数据交互简单,Reactor 主线程只需要把新连接传递给子线程即可,子线程无需返回数据。
这种模型在很多项目中广泛使用,包括 Nginx 主从 Reactor 多线程模型,Memcached 主从多线程。
Reactor 模式总结
三种模式可以用一个比喻来理解:餐厅常常雇佣接待员负责迎接顾客,当顾客入座后,侍应生专门为这张桌子服务。
- 单 Reactor 单线程:接待员和侍应生是同一个人,全程为顾客服务。
- 单 Reactor 多线程:一个接待员、多个侍应生,接待员只负责接待。
- 主从 Reactor:多个接待员,多个侍应生。
Reactor 模式具有如下的优点:
- 响应快:不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的。
- 编程相对简单:可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程的切换开销。
- 可扩展性:可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源。
- 可复用性:Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。
Proactor 模型
在 Reactor 模式中,Reactor 等待某个事件、可应用或操作的状态发生(比如文件描述符可读、Socket 可读写)。
然后把该事件传递给事先注册的 Handler(事件处理函数或回调函数),由后者来做实际的读写操作。
其中的读写操作都需要应用程序同步操作,所以 Reactor 是非阻塞同步网络模型。
如果把 IO 操作改为异步,即交给操作系统来完成 IO 操作,就能进一步提升性能,这就是异步网络模型 Proactor。
Proactor 是和异步 I/O 相关的,详细方案如下:
- ProactorInitiator 创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 AsyOptProcessor(Asynchronous Operation Processor) 注册到内核。
- AsyOptProcessor 处理注册请求,并处理 I/O 操作。
- AsyOptProcessor 完成 I/O 操作后通知 Proactor。
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理。
- Handler 完成业务处理。
可以看出 Proactor 和 Reactor 的区别:
- Reactor 是在事件发生时就通知事先注册的事件(读写在应用程序线程中处理完成)。
- Proactor 是在事件发生时基于异步 I/O 完成读写操作(由内核完成),待 I/O 操作完成后才回调应用程序的处理器来进行业务处理。
理论上 Proactor 比 Reactor 效率更高,异步 I/O 更加充分发挥 DMA(Direct Memory Access,直接内存存取)的优势,但是有如下缺点:
- 编程复杂性:由于异步操作流程的事件的初始化和事件完成在时间和空间上都是相互分离的,因此开发异步应用程序更加复杂。应用程序还可能因为反向的流控而变得更加难以 Debug。
- 内存使用:缓冲区在读或写操作的时间段内必须保持住,可能造成持续的不确定性,并且每个并发操作都要求有独立的缓存,相比 Reactor 模式,在 Socket 已经准备好读或写前,是不要求开辟缓存的。
- 操作系统支持,Windows 下通过 IOCP 实现了真正的异步 I/O,而在 Linux 系统下,Linux 2.6 才引入,目前异步 I/O 还不完善。
因此在 Linux 下实现高并发网络编程都是以 Reactor 模型为主。
1.4.8 - CH08-BIO原理
概览
BIO就是: blocking IO。最容易理解、最容易实现的IO工作方式,应用程序向操作系统请求网络IO操作,这时应用程序会一直等待;另一方面,操作系统收到请求后,也会等待,直到网络上有数据传到监听端口;操作系统在收集数据后,会把数据发送给应用程序;最后应用程序受到数据,并解除等待状态。
概念
阻塞IO
和非阻塞IO
这两个概念是
程序级别
的。主要描述的是程序请求操作系统IO操作后,如果IO资源没有准备好,那么程序该如何处理的问题: 前者等待;后者继续执行(并且使用线程一直轮询,直到有IO资源准备好了)同步IO
和非同步IO
这两个概念是
操作系统级别
的。主要描述的是操作系统在收到程序请求IO操作后,如果IO资源没有准备好,该如何相应程序的问题: 前者不响应,直到IO资源准备好以后;后者返回一个标记(好让程序和自己知道以后的数据往哪里通知),当IO资源准备好以后,再用事件机制返回给程序。
BIO通信方式
以前大多数网络通信方式都是阻塞模式的,即:
- 客户端向服务器端发出请求后,客户端会一直等待(不会再做其他事情),直到服务器端返回结果或者网络出现问题。
- 服务器端同样的,当在处理某个客户端A发来的请求时,另一个客户端B发来的请求会等待,直到服务器端的这个处理线程完成上一个处理。
传统的BIO的问题
- 同一时间,服务器只能接受来自于客户端A的请求信息;虽然客户端A和客户端B的请求是同时进行的,但客户端B发送的请求信息只能等到服务器接受完A的请求数据后,才能被接受。
- 由于服务器一次只能处理一个客户端请求,当处理完成并返回后(或者异常时),才能进行第二次请求的处理。很显然,这样的处理方式在高并发的情况下,是不能采用的。
多线程方式 - 伪异步方式
上面说的情况是服务器只有一个线程的情况,那么读者会直接提出我们可以使用多线程技术来解决这个问题:
- 当服务器收到客户端X的请求后,(读取到所有请求数据后)将这个请求送入一个独立线程进行处理,然后主线程继续接受客户端Y的请求。
- 客户端一侧,也可以使用一个子线程和服务器端进行通信。这样客户端主线程的其他工作就不受影响了,当服务器端有响应信息的时候再由这个子线程通过 监听模式/观察模式(等其他设计模式)通知主线程。
但是使用线程来解决这个问题实际上是有局限性的:
- 虽然在服务器端,请求的处理交给了一个独立线程进行,但是操作系统通知accept()的方式还是单个的。也就是,实际上是服务器接收到数据报文后的“业务处理过程”可以多线程,但是数据报文的接受还是需要一个一个的来(下文的示例代码和debug过程我们可以明确看到这一点)
- 在linux系统中,可以创建的线程是有限的。我们可以通过cat /proc/sys/kernel/threads-max 命令查看可以创建的最大线程数。当然这个值是可以更改的,但是线程越多,CPU切换所需的时间也就越长,用来处理真正业务的需求也就越少。
- 创建一个线程是有较大的资源消耗的。JVM创建一个线程的时候,即使这个线程不做任何的工作,JVM都会分配一个堆栈空间。这个空间的大小默认为128K,您可以通过-Xss参数进行调整。当然您还可以使用ThreadPoolExecutor线程池来缓解线程的创建问题,但是又会造成BlockingQueue积压任务的持续增加,同样消耗了大量资源。
- 另外,如果您的应用程序大量使用长连接的话,线程是不会关闭的。这样系统资源的消耗更容易失控。 那么,如果你真想单纯使用线程解决阻塞的问题,那么您自己都可以算出来您一个服务器节点可以一次接受多大的并发了。看来,单纯使用线程解决这个问题不是最好的办法。
深入分析
BIO的问题关键不在于是否使用了多线程(包括线程池)处理这次请求,而在于accept()、read()的操作点都是被阻塞。要测试这个问题,也很简单。我们模拟了20个客户端(用20根线程模拟),利用JAVA的同步计数器CountDownLatch,保证这20个客户都初始化完成后然后同时向服务器发送请求,然后我们来观察一下Server这边接受信息的情况。
模拟20个客户端并发请求,服务器端使用单线程:
客户端代码(SocketClientDaemon)
package testBSocket;
import java.util.concurrent.CountDownLatch;
public class SocketClientDaemon {
public static void main(String[] args) throws Exception {
Integer clientNumber = 20;
CountDownLatch countDownLatch = new CountDownLatch(clientNumber);
//分别开始启动这20个客户端
for(int index = 0 ; index < clientNumber ; index++ , countDownLatch.countDown()) {
SocketClientRequestThread client = new SocketClientRequestThread(countDownLatch, index);
new Thread(client).start();
}
//这个wait不涉及到具体的实验逻辑,只是为了保证守护线程在启动所有线程后,进入等待状态
synchronized (SocketClientDaemon.class) {
SocketClientDaemon.class.wait();
}
}
}
客户端代码(SocketClientRequestThread模拟请求)
package testBSocket;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.concurrent.CountDownLatch;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.log4j.BasicConfigurator;
/**
* 一个SocketClientRequestThread线程模拟一个客户端请求。
* @author yinwenjie
*/
public class SocketClientRequestThread implements Runnable {
static {
BasicConfigurator.configure();
}
/**
* 日志
*/
private static final Log LOGGER = LogFactory.getLog(SocketClientRequestThread.class);
private CountDownLatch countDownLatch;
/**
* 这个线层的编号
* @param countDownLatch
*/
private Integer clientIndex;
/**
* countDownLatch是java提供的同步计数器。
* 当计数器数值减为0时,所有受其影响而等待的线程将会被激活。这样保证模拟并发请求的真实性
* @param countDownLatch
*/
public SocketClientRequestThread(CountDownLatch countDownLatch , Integer clientIndex) {
this.countDownLatch = countDownLatch;
this.clientIndex = clientIndex;
}
@Override
public void run() {
Socket socket = null;
OutputStream clientRequest = null;
InputStream clientResponse = null;
try {
socket = new Socket("localhost",83);
clientRequest = socket.getOutputStream();
clientResponse = socket.getInputStream();
//等待,直到SocketClientDaemon完成所有线程的启动,然后所有线程一起发送请求
this.countDownLatch.await();
//发送请求信息
clientRequest.write(("这是第" + this.clientIndex + " 个客户端的请求。").getBytes());
clientRequest.flush();
//在这里等待,直到服务器返回信息
SocketClientRequestThread.LOGGER.info("第" + this.clientIndex + "个客户端的请求发送完成,等待服务器返回信息");
int maxLen = 1024;
byte[] contextBytes = new byte[maxLen];
int realLen;
String message = "";
//程序执行到这里,会一直等待服务器返回信息(注意,前提是in和out都不能close,如果close了就收不到服务器的反馈了)
while((realLen = clientResponse.read(contextBytes, 0, maxLen)) != -1) {
message += new String(contextBytes , 0 , realLen);
}
SocketClientRequestThread.LOGGER.info("接收到来自服务器的信息:" + message);
} catch (Exception e) {
SocketClientRequestThread.LOGGER.error(e.getMessage(), e);
} finally {
try {
if(clientRequest != null) {
clientRequest.close();
}
if(clientResponse != null) {
clientResponse.close();
}
} catch (IOException e) {
SocketClientRequestThread.LOGGER.error(e.getMessage(), e);
}
}
}
}
服务器端(SocketServer1)单个线程
package testBSocket;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.log4j.BasicConfigurator;
public class SocketServer1 {
static {
BasicConfigurator.configure();
}
/**
* 日志
*/
private static final Log LOGGER = LogFactory.getLog(SocketServer1.class);
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket(83);
try {
while(true) {
Socket socket = serverSocket.accept();
//下面我们收取信息
InputStream in = socket.getInputStream();
OutputStream out = socket.getOutputStream();
Integer sourcePort = socket.getPort();
int maxLen = 2048;
byte[] contextBytes = new byte[maxLen];
//这里也会被阻塞,直到有数据准备好
int realLen = in.read(contextBytes, 0, maxLen);
//读取信息
String message = new String(contextBytes , 0 , realLen);
//下面打印信息
SocketServer1.LOGGER.info("服务器收到来自于端口: " + sourcePort + "的信息: " + message);
//下面开始发送信息
out.write("回发响应信息!".getBytes());
//关闭
out.close();
in.close();
socket.close();
}
} catch(Exception e) {
SocketServer1.LOGGER.error(e.getMessage(), e);
} finally {
if(serverSocket != null) {
serverSocket.close();
}
}
}
}
多线程来优化服务器端
客户端代码和上文一样,最主要是更改服务器端的代码:
package testBSocket;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.log4j.BasicConfigurator;
public class SocketServer2 {
static {
BasicConfigurator.configure();
}
private static final Log LOGGER = LogFactory.getLog(SocketServer2.class);
public static void main(String[] args) throws Exception{
ServerSocket serverSocket = new ServerSocket(83);
try {
while(true) {
Socket socket = serverSocket.accept();
//当然业务处理过程可以交给一个线程(这里可以使用线程池),并且线程的创建是很耗资源的。
//最终改变不了.accept()只能一个一个接受socket的情况,并且被阻塞的情况
SocketServerThread socketServerThread = new SocketServerThread(socket);
new Thread(socketServerThread).start();
}
} catch(Exception e) {
SocketServer2.LOGGER.error(e.getMessage(), e);
} finally {
if(serverSocket != null) {
serverSocket.close();
}
}
}
}
/**
* 当然,接收到客户端的socket后,业务的处理过程可以交给一个线程来做。
* 但还是改变不了socket被一个一个的做accept()的情况。
* @author yinwenjie
*/
class SocketServerThread implements Runnable {
/**
* 日志
*/
private static final Log LOGGER = LogFactory.getLog(SocketServerThread.class);
private Socket socket;
public SocketServerThread (Socket socket) {
this.socket = socket;
}
@Override
public void run() {
InputStream in = null;
OutputStream out = null;
try {
//下面我们收取信息
in = socket.getInputStream();
out = socket.getOutputStream();
Integer sourcePort = socket.getPort();
int maxLen = 1024;
byte[] contextBytes = new byte[maxLen];
//使用线程,同样无法解决read方法的阻塞问题,
//也就是说read方法处同样会被阻塞,直到操作系统有数据准备好
int realLen = in.read(contextBytes, 0, maxLen);
//读取信息
String message = new String(contextBytes , 0 , realLen);
//下面打印信息
SocketServerThread.LOGGER.info("服务器收到来自于端口: " + sourcePort + "的信息: " + message);
//下面开始发送信息
out.write("回发响应信息!".getBytes());
} catch(Exception e) {
SocketServerThread.LOGGER.error(e.getMessage(), e);
} finally {
//试图关闭
try {
if(in != null) {
in.close();
}
if(out != null) {
out.close();
}
if(this.socket != null) {
this.socket.close();
}
} catch (IOException e) {
SocketServerThread.LOGGER.error(e.getMessage(), e);
}
}
}
}
优化效果
我们主要看一看服务器使用多线程处理时的情况:
问题根源
那么重点的问题并不是“是否使用了多线程”,而是为什么accept()、read()方法会被阻塞。即: 异步IO模式 就是为了解决这样的并发性存在的。但是为了说清楚异步IO模式,在介绍IO模式的时候,我们就要首先了解清楚,什么是 阻塞式同步、非阻塞式同步、多路复用同步模式。
API文档中对于 serverSocket.accept() 方法的使用描述:
Listens for a connection to be made to this socket and accepts it. The method blocks until a connection is made.
serverSocket.accept()会被阻塞? 这里涉及到阻塞式同步IO的工作原理:
- 服务器线程发起一个accept动作,询问操作系统 是否有新的socket套接字信息从端口X发送过来。
- 注意,是询问操作系统。也就是说socket套接字的IO模式支持是基于操作系统的,那么自然同步IO/异步IO的支持就是需要操作系统级别的了。如下图:
如果操作系统没有发现有套接字从指定的端口X来,那么操作系统就会等待。这样serverSocket.accept()方法就会一直等待。这就是为什么accept()方法为什么会阻塞: 它内部的实现是使用的操作系统级别的同步IO。
1.4.9 - CH09-NIO基础
Standard IO是对字节流的读写,在进行IO之前,首先创建一个流对象,流对象进行读写操作都是按字节 ,一个字节一个字节的来读或写。而NIO把IO抽象成块,类似磁盘的读写,每次IO操作的单位都是一个块,块被读入内存之后就是一个byte[],NIO一次可以读或写多个字节。
流与块
I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
面向流的 I/O 一次处理一个字节数据: 一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。
通道与缓冲区
通道
通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。
通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。
通道包括以下类型:
- FileChannel: 从文件中读写数据;
- DatagramChannel: 通过 UDP 读写网络中数据;
- SocketChannel: 通过 TCP 读写网络中数据;
- ServerSocketChannel: 可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。
缓冲区
发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。
缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
缓冲区包括以下类型:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
缓冲区状态变量
- capacity: 最大容量;
- position: 当前已经读写的字节数;
- limit: 还可以读写的字节数。
状态变量的改变过程举例:
① 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。
② 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 移动设置为 5,limit 保持不变。
③ 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。
④ 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。
⑤ 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。
文件 NIO 实例
以下展示了使用 NIO 快速复制文件的实例:
public static void fastCopy(String src, String dist) throws IOException {
/* 获得源文件的输入字节流 */
FileInputStream fin = new FileInputStream(src);
/* 获取输入字节流的文件通道 */
FileChannel fcin = fin.getChannel();
/* 获取目标文件的输出字节流 */
FileOutputStream fout = new FileOutputStream(dist);
/* 获取输出字节流的通道 */
FileChannel fcout = fout.getChannel();
/* 为缓冲区分配 1024 个字节 */
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while (true) {
/* 从输入通道中读取数据到缓冲区中 */
int r = fcin.read(buffer);
/* read() 返回 -1 表示 EOF */
if (r == -1) {
break;
}
/* 切换读写 */
buffer.flip();
/* 把缓冲区的内容写入输出文件中 */
fcout.write(buffer);
/* 清空缓冲区 */
buffer.clear();
}
}
选择器
NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。
NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。
通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件具有更好的性能。
应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。
1. 创建选择器
Selector selector = Selector.open();
2. 将通道注册到选择器上
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。
在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
它们在 SelectionKey 的定义如下:
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
监听事件
int num = selector.select();
使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。
4. 获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
5. 事件循环
因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。
while (true) {
int num = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}
}
套接字 NIO 实例
public class NIOServer {
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
ServerSocket serverSocket = ssChannel.socket();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
serverSocket.bind(address);
while (true) {
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();
// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);
// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel sChannel = (SocketChannel) key.channel();
System.out.println(readDataFromSocketChannel(sChannel));
sChannel.close();
}
keyIterator.remove();
}
}
}
private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder data = new StringBuilder();
while (true) {
buffer.clear();
int n = sChannel.read(buffer);
if (n == -1) {
break;
}
buffer.flip();
int limit = buffer.limit();
char[] dst = new char[limit];
for (int i = 0; i < limit; i++) {
dst[i] = (char) buffer.get(i);
}
data.append(dst);
buffer.clear();
}
return data.toString();
}
}
public class NIOClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("127.0.0.1", 8888);
OutputStream out = socket.getOutputStream();
String s = "hello world";
out.write(s.getBytes());
out.close();
}
}
内存映射文件
内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快得多。
向内存映射文件写入可能是危险的,只是改变数组的单个元素这样的简单操作,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。
下面代码行将文件的前 1024 个字节映射到内存中,map() 方法返回一个 MappedByteBuffer,它是 ByteBuffer 的子类。因此,可以像使用其他任何 ByteBuffer 一样使用新映射的缓冲区,操作系统会在需要时负责执行映射。
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, 1024);
对比
NIO 与普通 I/O 的区别主要有以下两点:
- NIO 是非阻塞的
- NIO 面向块,I/O 面向流
1.4.10 - CH10-NIO原理
现实场景
我们试想一下这样的现实场景:
一个餐厅同时有100位客人到店,当然到店后第一件要做的事情就是点菜。但是问题来了,餐厅老板为了节约人力成本目前只有一位大堂服务员拿着唯一的一本菜单等待客人进行服务。
- 那么最笨(但是最简单)的方法是(方法A),无论有多少客人等待点餐,服务员都把仅有的一份菜单递给其中一位客人,然后站在客人身旁等待这个客人完成点菜过程。在记录客人点菜内容后,把点菜记录交给后堂厨师。然后是第二位客人。。。。然后是第三位客人。很明显,只有脑袋被门夹过的老板,才会这样设置服务流程。因为随后的80位客人,再等待超时后就会离店(还会给差评)。
- 于是还有一种办法(方法B),老板马上新雇佣99名服务员,同时印制99本新的菜单。每一名服务员手持一本菜单负责一位客人(关键不只在于服务员,还在于菜单。因为没有菜单客人也无法点菜)。在客人点完菜后,记录点菜内容交给后堂厨师(当然为了更高效,后堂厨师最好也有100名)。这样每一位客人享受的就是VIP服务咯,当然客人不会走,但是人力成本可是一个大头哦(亏死你)。
- 另外一种办法(方法C),就是改进点菜的方式,当客人到店后,自己申请一本菜单。想好自己要点的才后,就呼叫服务员。服务员站在自己身边后记录客人的菜单内容。将菜单递给厨师的过程也要进行改进,并不是每一份菜单记录好以后,都要交给后堂厨师。服务员可以记录号多份菜单后,同时交给厨师就行了。那么这种方式,对于老板来说人力成本是最低的;对于客人来说,虽然不再享受VIP服务并且要进行一定的等待,但是这些都是可接受的;对于服务员来说,基本上她的时间都没有浪费,基本上被老板压杆了最后一滴油水。
如果您是老板,您会采用哪种方式呢?
到店情况: 并发量。到店情况不理想时,一个服务员一本菜单,当然是足够了。所以不同的老板在不同的场合下,将会灵活选择服务员和菜单的配置。
- 客人: 客户端请求
- 点餐内容: 客户端发送的实际数据
- 老板: 操作系统
- 人力成本: 系统资源
- 菜单: 文件状态描述符。操作系统对于一个进程能够同时持有的文件状态描述符的个数是有限制的,在linux系统中$ulimit -n查看这个限制值,当然也是可以(并且应该)进行内核参数调整的。
- 服务员: 操作系统内核用于IO操作的线程(内核线程)
- 厨师: 应用程序线程(当然厨房就是应用程序进程咯)
- 餐单传递方式: 包括了阻塞式和非阻塞式两种。
- 方法A: 阻塞式/非阻塞式 同步IO
- 方法B: 使用线程进行处理的 阻塞式/非阻塞式 同步IO
- 方法C: 阻塞式/非阻塞式 多路复用IO
典型的多路复用IO实现
目前流程的多路复用IO实现主要包括四种: select
、poll
、epoll
、kqueue
。下表是他们的一些重要特性的比较:
IO模型 | 相对性能 | 关键思路 | 操作系统 | JAVA支持情况 |
---|---|---|---|---|
select | 较高 | Reactor | windows/Linux | 支持,Reactor模式(反应器设计模式)。Linux操作系统的 kernels 2.4内核版本之前,默认使用select;而目前windows下对同步IO的支持,都是select模型 |
poll | 较高 | Reactor | Linux | Linux下的JAVA NIO框架,Linux kernels 2.6内核版本之前使用poll进行支持。也是使用的Reactor模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6内核版本及以后使用epoll进行支持;Linux kernels 2.6内核版本之前使用poll进行支持;另外一定注意,由于Linux下没有Windows下的IOCP技术提供真正的 异步IO 支持,所以Linux下使用epoll模拟异步IO |
kqueue | 高 | Proactor | Linux | 目前JAVA的版本不支持 |
多路复用IO技术最适用的是“高并发”场景,所谓高并发是指1毫秒内至少同时有上千个连接请求准备好。其他情况下多路复用IO技术发挥不出来它的优势。另一方面,使用JAVA NIO进行功能实现,相对于传统的Socket套接字实现要复杂一些,所以实际应用中,需要根据自己的业务需求进行技术选择。
Reactor模型和Proactor模型
JAVA对多路复用IO的支持
重要概念: Channel
通道,被建立的一个应用程序和操作系统交互事件、传递内容的渠道(注意是连接到操作系统)。一个通道会有一个专属的文件状态描述符。那么既然是和操作系统进行内容的传递,那么说明应用程序可以通过通道读取数据,也可以通过通道向操作系统写数据。
JDK API中的Channel的描述是:
A channel represents an open connection to an entity such as a hardware device, a file, a network socket, or a program component that is capable of performing one or more distinct I/O operations, for example reading or writing.
A channel is either open or closed. A channel is open upon creation, and once closed it remains closed. Once a channel is closed, any attempt to invoke an I/O operation upon it will cause a ClosedChannelException to be thrown. Whether or not a channel is open may be tested by invoking its isOpen method.
JAVA NIO 框架中,自有的Channel通道包括:
所有被Selector(选择器)注册的通道,只能是继承了SelectableChannel类的子类。如上图所示
- ServerSocketChannel: 应用服务器程序的监听通道。只有通过这个通道,应用程序才能向操作系统注册支持“多路复用IO”的端口监听。同时支持UDP协议和TCP协议。
- ScoketChannel: TCP Socket套接字的监听通道,一个Socket套接字对应了一个客户端IP: 端口 到 服务器IP: 端口的通信连接。
- DatagramChannel: UDP 数据报文的监听通道。
重要概念: Buffer
数据缓存区: 在JAVA NIO 框架中,为了保证每个通道的数据读写速度JAVA NIO 框架为每一种需要支持数据读写的通道集成了Buffer的支持。
这句话怎么理解呢? 例如ServerSocketChannel通道它只支持对OP_ACCEPT事件的监听,所以它是不能直接进行网络数据内容的读写的。所以ServerSocketChannel是没有集成Buffer的。
Buffer有两种工作模式: 写模式和读模式。在读模式下,应用程序只能从Buffer中读取数据,不能进行写操作。但是在写模式下,应用程序是可以进行读操作的,这就表示可能会出现脏读的情况。所以一旦您决定要从Buffer中读取数据,一定要将Buffer的状态改为读模式。
如下图:
- position: 缓存区目前这在操作的数据块位置
- limit: 缓存区最大可以进行操作的位置。缓存区的读写状态正式由这个属性控制的。
- capacity: 缓存区的最大容量。这个容量是在缓存区创建时进行指定的。由于高并发时通道数量往往会很庞大,所以每一个缓存区的容量最好不要过大。
在下文JAVA NIO框架的代码实例中,我们将进行Buffer缓存区操作的演示。
重要概念: Selector
Selector的英文含义是“选择器”,不过根据我们详细介绍的Selector的岗位职责,您可以把它称之为“轮询代理器”、“事件订阅器”、“channel容器管理机”都行。
- 事件订阅和Channel管理
应用程序将向Selector对象注册需要它关注的Channel,以及具体的某一个Channel会对哪些IO事件感兴趣。Selector中也会维护一个“已经注册的Channel”的容器。以下代码来自WindowsSelectorImpl实现类中,对已经注册的Channel的管理容器:
// Initial capacity of the poll array
private final int INIT_CAP = 8;
// Maximum number of sockets for select().
// Should be INIT_CAP times a power of 2
private final static int MAX_SELECTABLE_FDS = 1024;
// The list of SelectableChannels serviced by this Selector. Every mod
// MAX_SELECTABLE_FDS entry is bogus, to align this array with the poll
// array, where the corresponding entry is occupied by the wakeupSocket
private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[INIT_CAP];
- 轮询代理
应用层不再通过阻塞模式或者非阻塞模式直接询问操作系统“事件有没有发生”,而是由Selector代其询问。
- 实现不同操作系统的支持
之前已经提到过,多路复用IO技术 是需要操作系统进行支持的,其特点就是操作系统可以同时扫描同一个端口上不同网络连接的事件。所以作为上层的JVM,必须要为 不同操作系统的多路复用IO实现 编写不同的代码。同样我使用的测试环境是Windows,它对应的实现类是sun.nio.ch.WindowsSelectorImpl:
JAVA NIO 框架简要设计分析
通过上文的描述,我们知道了多路复用IO技术是操作系统的内核实现。在不同的操作系统,甚至同一系列操作系统的版本中所实现的多路复用IO技术都是不一样的。那么作为跨平台的JAVA JVM来说如何适应多种多样的多路复用IO技术实现呢? 面向对象的威力就显现出来了: 无论使用哪种实现方式,他们都会有“选择器”、“通道”、“缓存”这几个操作要素,那么可以为不同的多路复用IO技术创建一个统一的抽象组,并且为不同的操作系统进行具体的实现。JAVA NIO中对各种多路复用IO的支持,主要的基础是java.nio.channels.spi.SelectorProvider抽象类,其中的几个主要抽象方法包括:
- public abstract DatagramChannel openDatagramChannel(): 创建和这个操作系统匹配的UDP 通道实现。
- public abstract AbstractSelector openSelector(): 创建和这个操作系统匹配的NIO选择器,就像上文所述,不同的操作系统,不同的版本所默认支持的NIO模型是不一样的。
- public abstract ServerSocketChannel openServerSocketChannel(): 创建和这个NIO模型匹配的服务器端通道。
- public abstract SocketChannel openSocketChannel(): 创建和这个NIO模型匹配的TCP Socket套接字通道(用来反映客户端的TCP连接)
由于JAVA NIO框架的整个设计是很大的,所以我们只能还原一部分我们关心的问题。这里我们以JAVA NIO框架中对于不同多路复用IO技术的选择器 进行实例化创建的方式作为例子,以点窥豹观全局:
很明显,不同的SelectorProvider实现对应了不同的 选择器。由具体的SelectorProvider实现进行创建。另外说明一下,实际上netty底层也是通过这个设计获得具体使用的NIO模型,我们后文讲解Netty时,会讲到这个问题。以下代码是Netty 4.0中NioServerSocketChannel进行实例化时的核心代码片段:
private static ServerSocketChannel newSocket(SelectorProvider provider) {
try {
/**
* Use the {@link SelectorProvider} to open {@link SocketChannel} and so remove condition in
* {@link SelectorProvider#provider()} which is called by each ServerSocketChannel.open() otherwise.
*
* See <a href="See https://github.com/netty/netty/issues/2308">#2308</a>.
*/
return provider.openServerSocketChannel();
} catch (IOException e) {
throw new ChannelException(
"Failed to open a server socket.", e);
}
}
多路复用IO的优缺点
不用再使用多线程来进行IO处理了(包括操作系统内核IO管理模块和应用程序进程而言)。当然实际业务的处理中,应用程序进程还是可以引入线程池技术的
同一个端口可以处理多种协议,例如,使用ServerSocketChannel测测的服务器端口监听,既可以处理TCP协议又可以处理UDP协议。
操作系统级别的优化: 多路复用IO技术可以是操作系统级别在一个端口上能够同时接受多个客户端的IO事件。同时具有之前我们讲到的阻塞式同步IO和非阻塞式同步IO的所有特点。Selector的一部分作用更相当于“轮询代理器”。
都是同步IO: 目前我们介绍的 阻塞式IO、非阻塞式IO甚至包括多路复用IO,这些都是基于操作系统级别对“同步IO”的实现。我们一直在说“同步IO”,一直都没有详细说,什么叫做“同步IO”。实际上一句话就可以说清楚: 只有上层(包括上层的某种代理机制)系统询问我是否有某个事件发生了,否则我不会主动告诉上层系统事件发生了。
1.4.11 - CH11-AIO原理
异步IO
上面两篇文章中,我们分别讲解了阻塞式同步IO、非阻塞式同步IO、多路复用IO 这三种IO模型,以及JAVA对于这三种IO模型的支持。重点说明了IO模型是由操作系统提供支持,且这三种IO模型都是同步IO,都是采用的“应用程序不询问我,我绝不会主动通知”的方式。
异步IO则是采用“订阅-通知”模式: 即应用程序向操作系统注册IO监听,然后继续做自己的事情。当操作系统发生IO事件,并且准备好数据后,在主动通知应用程序,触发相应的函数:
和同步IO一样,异步IO也是由操作系统进行支持的。微软的windows系统提供了一种异步IO技术: IOCP(I/O Completion Port,I/O完成端口);
Linux下由于没有这种异步IO技术,所以使用的是epoll(上文介绍过的一种多路复用IO技术的实现)对异步IO进行模拟。
JAVA AIO框架简析
这里通过这个结构分析要告诉各位读者JAVA AIO中类设计和操作系统的相关性
在文中我们一再说明JAVA AIO框架在windows下使用windows IOCP技术,在Linux下使用epoll多路复用IO技术模拟异步IO,这个从JAVA AIO框架的部分类设计上就可以看出来。例如框架中,在Windows下负责实现套接字通道的具体类是“sun.nio.ch.WindowsAsynchronousSocketChannelImpl”,其引用的IOCP类型文档注释如是:
/**
* Windows implementation of AsynchronousChannelGroup encapsulating an I/O
* completion port.
*/
如果您感兴趣,当然可以去看看全部完整代码(建议从“java.nio.channels.spi.AsynchronousChannelProvider”这个类看起)。
特别说明一下,请注意图中的“java.nio.channels.NetworkChannel”接口,这个接口同样被JAVA NIO框架实现了,如下图所示:
要点讲解
注意在JAVA NIO框架中,我们说到了一个重要概念“selector”(选择器)。它负责代替应用查询中所有已注册的通道到操作系统中进行IO事件轮询、管理当前注册的通道集合,定位发生事件的通道等操操作;但是在JAVA AIO框架中,由于应用程序不是“轮询”方式,而是订阅-通知方式,所以不再需要“selector”(选择器)了,改由channel通道直接到操作系统注册监听。
JAVA AIO框架中,只实现了两种网络IO通道“AsynchronousServerSocketChannel”(服务器监听通道)、“AsynchronousSocketChannel”(socket套接字通道)。但是无论哪种通道他们都有独立的fileDescriptor(文件标识符)、attachment(附件,附件可以使任意对象,类似“通道上下文”),并被独立的SocketChannelReadHandle类实例引用。我们通过debug操作来看看它们的引用结构:
在测试过程中,我们启动了两个客户端(客户端用什么语言来写都行,用阻塞或者非阻塞方式也都行,只要是支持 TCP Socket套接字的就行,然后我们观察服务器端对这两个客户端通道的处理情况:
可以看到,在服务器端分别为客户端1和客户端2创建的两个WindowsAsynchronousSocketChannelImpl对象为:
客户端1: WindowsAsynchronousSocketChannelImpl: 760 | FileDescriptor: 762
客户端2: WindowsAsynchronousSocketChannelImpl: 792 | FileDescriptor: 797
接下来,我们让两个客户端发送信息到服务器端,并观察服务器端的处理情况。客户端1发来的消息和客户端2发来的消息,在服务器端的处理情况如下图所示:
客户端1: WindowsAsynchronousSocketChannelImpl: 760 | FileDescriptor: 762 | SocketChannelReadHandle: 803 | HeapByteBuffer: 808
客户端2: WindowsAsynchronousSocketChannelImpl: 792 | FileDescriptor: 797 | SocketChannelReadHandle: 828 | HeapByteBuffer: 833
可以明显看到,服务器端处理每一个客户端通道所使用的SocketChannelReadHandle(处理器)对象都是独立的,并且所引用的SocketChannel对象都是独立的。
JAVA NIO和JAVA AIO框架,除了因为操作系统的实现不一样而去掉了Selector外,其他的重要概念都是存在的,例如上文中提到的Channel的概念,还有演示代码中使用的Buffer缓存方式。实际上JAVA NIO和JAVA AIO框架您可以看成是一套完整的“高并发IO处理”的实现。
还有改进可能
当然,以上代码是示例代码,目标是为了让您了解JAVA AIO框架的基本使用。所以它还有很多改造的空间,例如:
在生产环境下,我们需要记录这个通道上“用户的登录信息”。那么这个需求可以使用JAVA AIO中的“附件”功能进行实现。
记住JAVA AIO 和 JAVA NIO 框架都是要使用线程池的(当然您也可以不用),线程池的使用原则,一定是只有业务处理部分才使用,使用后马上结束线程的执行(还回线程池或者消灭它)。JAVA AIO框架中还有一个线程池,是拿给“通知处理器”使用的,这是因为JAVA AIO框架是基于“订阅-通知”模型的,“订阅”操作可以由主线程完成,但是您总不能要求在应用程序中并发的“通知”操作也在主线程上完成吧^_^。
最好的改进方式,当然就是使用Netty或者Mina咯
为什么还有Netty
- 那么有的读者可能就会问,既然JAVA NIO / JAVA AIO已经实现了各主流操作系统的底层支持,那么为什么现在主流的JAVA NIO技术会是Netty和MINA呢? 答案很简单: 因为更好用,这里举几个方面的例子:
- 虽然JAVA NIO 和 JAVA AIO框架提供了 多路复用IO/异步IO的支持,但是并没有提供上层“信息格式”的良好封装。例如前两者并没有提供针对 Protocol Buffer、JSON这些信息格式的封装,但是Netty框架提供了这些数据格式封装(基于责任链模式的编码和解码功能)
- 要编写一个可靠的、易维护的、高性能的(注意它们的排序)NIO/AIO 服务器应用。除了框架本身要兼容实现各类操作系统的实现外。更重要的是它应该还要处理很多上层特有服务,例如: 客户端的权限、还有上面提到的信息格式封装、简单的数据读取。这些Netty框架都提供了响应的支持。
- JAVA NIO框架存在一个poll/epoll bug: Selector doesn’t block on Selector.select(timeout),不能block意味着CPU的使用率会变成100%(这是底层JNI的问题,上层要处理这个异常实际上也好办)。当然这个bug只有在Linux内核上才能重现。
- 这个问题在JDK 1.7版本中还没有被完全解决: http://bugs.java.com/bugdatabase/view_bug.do?bug_id=2147719。虽然Netty 4.0中也是基于JAVA NIO框架进行封装的(上文中已经给出了Netty中NioServerSocketChannel类的介绍),但是Netty已经将这个bug进行了处理。
1.4.12 - CH12-零拷贝
CPU 并不执行将数据从一个存储区域拷贝到另一个存储区域这样的任务。通常用于在网络传输文件时节省 CPU 周期和内存带宽。
缓存 IO
缓存 IO 又被称为标准 IO,大多数文件系统的默认 IO 操作都是缓存 IO。在 Linux 的缓存 IO 机制中,操作系统会将 IO 的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 IO 的缺点:数据在传输过程中需要在应用程序地址空间和内核间进行多次数据复制操作,这些数据复制所带来的 CPU 及内存开销是非常大的。
零拷贝技术分类
零拷贝技术的发展很多样化,现有的零拷贝技术种类也非常多,而当前并没有一个适合于所有场景的零拷贝技术出现。对于 Linux 来说,现有的零拷贝技术也比较多,这些零拷贝技术大部分存在于不同的 Linux 内核版本,有些旧的技术在不同的 Linux 内核版本间得到了很大的发展或者已经渐渐被新的技术所代替。
本文针对这些零拷贝技术所适用的不同场景对它们进行了划分。概括起来,Linux 中的零拷贝技术主要有下面这几种:
- 直接 I/O:对于这种数据传输方式来说,应用程序可以直接访问硬件存储,操作系统内核只是辅助数据传输:这类零拷贝技术针对的是操作系统内核并不需要对数据进行直接处理的情况,数据可以在应用程序地址空间的缓冲区和磁盘之间直接进行传输,完全不需要 Linux 操作系统内核提供的页缓存的支持。
- 在数据传输的过程中,避免数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间进行拷贝。有的时候,应用程序在数据进行传输的过程中不需要对数据进行访问,那么,将数据从 Linux 的页缓存拷贝到用户进程的缓冲区中就可以完全避免,传输的数据在页缓存中就可以得到处理。在某些特殊的情况下,这种零拷贝技术可以获得较好的性能。Linux 中提供类似的系统调用主要有 mmap(),sendfile() 以及 splice()。
- 对数据在 Linux 的页缓存和用户进程的缓冲区之间的传输过程进行优化。该零拷贝技术侧重于灵活地处理数据在用户进程的缓冲区和操作系统的页缓存之间的拷贝操作。这种方法延续了传统的通信方式,但是更加灵活。在Linux 中,该方法主要利用了写时复制技术。
前两类方法的目的主要是为了避免应用程序地址空间和操作系统内核地址空间这两者之间的缓冲区拷贝操作。这两类零拷贝技术通常适用在某些特殊的情况下,比如要传送的数据不需要经过操作系统内核的处理或者不需要经过应用程序的处理。第三类方法则继承了传统的应用程序地址空间和操作系统内核地址空间之间数据传输的概念,进而针对数据传输本身进行优化。我们知道,硬件和软件之间的数据传输可以通过使用 DMA 来进行,DMA 进行数据传输的过程中几乎不需要CPU参与,这样就可以把 CPU 解放出来去做更多其他的事情,但是当数据需要在用户地址空间的缓冲区和 Linux 操作系统内核的页缓存之间进行传输的时候,并没有类似DMA 这种工具可以使用,CPU 需要全程参与到这种数据拷贝操作中,所以这第三类方法的目的是可以有效地改善数据在用户地址空间和操作系统内核地址空间之间传递的效率。
当应用程序访问某块数据时,操作系统首先会检查,是不是最近访问过此文件,文件内容是否缓存在内核缓冲区,如果是,操作系统则直接根据read系统调用提供的buf地址,将内核缓冲区的内容拷贝到buf所指定的用户空间缓冲区中去。如果不是,操作系统则首先将磁盘上的数据拷贝的内核缓冲区,这一步目前主要依靠DMA来传输,然后再把内核缓冲区上的内容拷贝到用户缓冲区中。 接下来,write系统调用再把用户缓冲区的内容拷贝到网络堆栈相关的内核缓冲区中,最后socket再把内核缓冲区的内容发送到网卡上。
从上图中可以看出,共产生了四次数据拷贝,即使使用了DMA来处理了与硬件的通讯,CPU仍然需要处理两次数据拷贝,与此同时,在用户态与内核态也发生了多次上下文切换,无疑也加重了CPU负担。
在此过程中,我们没有对文件内容做任何修改,那么在内核空间和用户空间来回拷贝数据无疑就是一种浪费,而零拷贝主要就是为了解决这种低效性。
mmap:让数据传输不需要经过user space
我们减少拷贝次数的一种方法是调用mmap()来代替read调用:
buf = mmap(diskfd, len);
write(sockfd, buf, len);
应用程序调用 mmap()
,磁盘上的数据会通过 DMA被拷贝的内核缓冲区,接着操作系统会把这段内核缓冲区与应用程序共享,这样就不需要把内核缓冲区的内容往用户空间拷贝。应用程序再调用 write(),操作系统直接将内核缓冲区的内容拷贝到 socket缓冲区中,这一切都发生在内核态,最后, socket缓冲区再把数据发到网卡去。
如下图:
使用mmap替代read很明显减少了一次拷贝,当拷贝数据量很大时,无疑提升了效率。但是使用 mmap是有代价的。当你使用 mmap时,你可能会遇到一些隐藏的陷阱。例如,当你的程序 map了一个文件,但是当这个文件被另一个进程截断(truncate)时, write系统调用会因为访问非法地址而被 SIGBUS信号终止。 SIGBUS信号默认会杀死你的进程并产生一个 coredump,如果你的服务器这样被中止了,那会产生一笔损失。
通常我们使用以下解决方案避免这种问题:
- 为SIGBUS信号建立信号处理程序:当遇到 SIGBUS信号时,信号处理程序简单地返回, write系统调用在被中断之前会返回已经写入的字节数,并且 errno会被设置成success,但是这是一种糟糕的处理办法,因为你并没有解决问题的实质核心。
- 使用文件租借锁:通常我们使用这种方法,在文件描述符上使用租借锁,我们为文件向内核申请一个租借锁,当其它进程想要截断这个文件时,内核会向我们发送一个实时的 RT_SIGNAL_LEASE信号,告诉我们内核正在破坏你加持在文件上的读写锁。这样在程序访问非法内存并且被 SIGBUS杀死之前,你的 write系统调用会被中断。 write会返回已经写入的字节数,并且置 errno为success。 我们应该在 mmap文件之前加锁,并且在操作完文件后解锁:
if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1){
perror("kernel lease set signal");
return -1;
}
/* l_type can be F_RDLCK_F_WRLCK 加锁 */
/* l_type can be F_UNLCK 解锁 */
if(fcntl(diskfd, F_SETLEASE, l_type)){
perror("kernel lease set_type");
return -1;
}
参考资料
1.4.13 - CH13-内存映射
内存映射文件非常特别,它允许Java程序直接从内存中读取文件内容,通过将整个或部分文件映射到内存,由操作系统来处理加载请求和写入文件,应用只需要和内存打交道,这使得IO操作非常快。加载内存映射文件所使用的内存在Java堆区之外。Java编程语言支持内存映射文件,通过java.nio包和MappedByteBuffer 可以从内存直接读写文件。
内存映射文件
内存映射文件,是由一个文件到一块内存的映射。Win32提供了允许应用程序把文件映射到一个进程的函数 (CreateFileMapping)。内存映射文件与虚拟内存有些类似,通过内存映射文件可以保留一个地址空间的区域,同时将物理存储器提交给此区域,内存文件映射的物理存储器来自一个已经存在于磁盘上的文件,而且在对该文件进行操作之前必须首先对文件进行映射。使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。
内存映射 IO
在传统的文件IO操作中,我们都是调用操作系统提供的底层标准IO系统调用函数 read()、write() ,此时调用此函数的进程(在JAVA中即java进程)由当前的用户态切换到内核态,然后OS的内核代码负责将相应的文件数据读取到内核的IO缓冲区,然 后再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作。这么做是为了减少磁盘的IO操作,为了提高性能而考虑的,因为我们的程序访问一般都带有局部性,也就是所谓的局部性原理,在这里主要是指的空间局部性,即我们访问了文件的某一段数据,那么接下去很可能还会访问接下去的一段数据,由于磁盘IO操作的速度比直接 访问内存慢了好几个数量级,所以OS根据局部性原理会在一次 read()系统调用过程中预读更多的文件数据缓存在内核IO缓冲区中,当继续访问的文件数据在缓冲区中时便直接拷贝数据到进程私有空间,避免了再次的低 效率磁盘IO操作。其过程如下
内存映射文件和之前说的 标准IO操作最大的不同之处就在于它虽然最终也是要从磁盘读取数据,但是它并不需要将数据读取到OS内核缓冲区,而是直接将进程的用户私有地址空间中的一 部分区域与文件对象建立起映射关系,就好像直接从内存中读、写文件一样,速度当然快了。
内存映射的优缺点
内存映射IO最大的优点可能在于性能,这对于建立高频电子交易系统尤其重要。内存映射文件通常比标准通过正常IO访问文件要快。另一个巨大的优势是内存映 射IO允许加载不能直接访问的潜在巨大文件 。经验表明,内存映射IO在大文件处理方面性能更加优异。尽管它也有不足——增加了页面错误的数目。由于操作系统只将一部分文件加载到内存,如果一个请求 页面没有在内存中,它将导致页面错误。同样它可以被用来在两个进程中共享数据。
操作系统支持
大多数主流操作系统比如Windows平台,UNIX,Solaris和其他类UNIX操作系统都支持内存映射IO和64位架构,你几乎可以将所有文件映射到内存并通过JAVA编程语言直接访问。
Java 内存映射 IO 的要点
- java通过java.nio包来支持内存映射IO。
- 内存映射文件主要用于性能敏感的应用,例如高频电子交易平台。
- 通过使用内存映射IO,你可以将大文件加载到内存。
- 内存映射文件可能导致页面请求错误,如果请求页面不在内存中的话。
- 映射文件区域的能力取决于于内存寻址的大小。在32位机器中,你不能访问超过4GB或2 ^ 32(以上的文件)。
- 内存映射IO比起Java中的IO流要快的多。
- 加载文件所使用的内存是Java堆区之外,并驻留共享内存,允许两个不同进程共享文件。
- 内存映射文件读写由操作系统完成,所以即使在将内容写入内存后java程序崩溃了,它将仍然会将它写入文件直到操作系统恢复。
- 出于性能考虑,推荐使用直接字节缓冲而不是非直接缓冲。
- 不要频繁调用MappedByteBuffer.force()方法,这个方法意味着强制操作系统将内存中的内容写入磁盘,所以如果你每次写入内存映射文件都调用force()方法,你将不会体会到使用映射字节缓冲的好处,相反,它(的性能)将类似于磁盘IO的性能。
- 万一发生了电源故障或主机故障,将会有很小的机率发生内存映射文件没有写入到磁盘,这意味着你可能会丢失关键数据。
public static void readFile3(String path) {
long start = System.currentTimeMillis();//开始时间
long fileLength = 0;
final int BUFFER_SIZE = 0x300000;// 3M的缓冲
File file = new File(path);
fileLength = file.length();
try {
MappedByteBuffer inputBuffer = new RandomAccessFile(file, "r").getChannel().map(FileChannel.MapMode.READ_ONLY, 0, fileLength);// 读取大文件
byte[] dst = new byte[BUFFER_SIZE];// 每次读出3M的内容
for (int offset = 0; offset < fileLength; offset += BUFFER_SIZE) {
if (fileLength - offset >= BUFFER_SIZE) {
for (int i = 0; i < BUFFER_SIZE; i++)
dst[i] = inputBuffer.get(offset + i);
} else {
for (int i = 0; i < fileLength - offset; i++)
dst[i] = inputBuffer.get(offset + i);
}
// 将得到的3M内容给Scanner,这里的XXX是指Scanner解析的分隔符
Scanner scan = new Scanner(new ByteArrayInputStream(dst)).useDelimiter(" ");
while (scan.hasNext()) {
// 这里为对读取文本解析的方法
System.out.print(scan.next() + " ");
}
scan.close();
}
System.out.println();
long end = System.currentTimeMillis();//结束时间
System.out.println("NIO 内存映射读大文件,总共耗时:"+(end - start)+"ms");
} catch (Exception e) {
e.printStackTrace();
}
}
1.5 - Java Effective
1.6 - Java Debug
1.6.1 - 内存溢出
Heap 堆内存溢出
在 Java 堆内存中要不断的创建对象,如果 GC-Roots 到对象之间存在引用链,JVM 就不会回收对象。
如果将 -Xms 和 -Xmx (最小堆和最大堆)设置为一样的值,就会禁止 JVM 自动扩展堆内存。
当使用一个 while(true)
循环来不断创建对象就会发生 OMM 异常,还可以使用 -XX:+HeapDumpOutofMemoryError
在发生 OOM 时自动将堆栈信息 dump 到文件中,以便排查分析。
public static void main(String[] args) {
List<String> list = new ArrayList<>(10) ;
while (true){
list.add("1") ;
}
}
当出现 OOM 时可以通过工具来分析 GC-Roots
,查看对象和 GC-Roots
是如何进行关联的,是否存在对象的生命周期过长,或者是这些对象确实改存在的,那就要考虑将堆内存调大了。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at com.crossoverjie.oom.HeapOOM.main(HeapOOM.java:18)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
Process finished with exit code 1
java.lang.OutOfMemoryError: Java heap space
表示堆内存溢出。
MetaSpace 元数据内存溢出
JDK 8 中将永久代移除,使用 MetaSpace 来保存类加载之后的类信息,字符串常量池也被移动到了堆内存之中。
PermSize
和 MaxPermSize
已经不能使用了,在 JDK8 中配置这两个参数将会发出警告。
JDK 8 中将类信息移到到了本地堆内存(Native Heap)中,将原有的永久代移动到了本地堆中成为 MetaSpace
,如果不指定该区域的大小,JVM 将会动态的调整。
可以使用 -XX:MaxMetaspaceSize=10M
来限制最大元数据。这样当不停的·创建类时将会占满该区域并出现 OOM
。
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer() ;
enhancer.setSuperclass(HeapOOM.class);
enhancer.setUseCache(false) ;
enhancer.setCallback(new MethodInterceptor() {
@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invoke(o,objects) ;
}
});
enhancer.create() ;
}
}
使用 cglib
不停的创建新类,最终会抛出:
Caused by: java.lang.reflect.InvocationTargetException
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:459)
at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:336)
... 11 more
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
... 16 more
注意: 这里的 OOM 伴随的是 java.lang.OutOfMemoryError: Metaspace
也就是元数据溢出。
1.6.2 - 溢出分析
Thread Dump
什么是 Thread Dump
Thread Dump 是非常有用的用于诊断 Java 应用问题的工具。每一个 Java 虚拟机都有及时生成所有线程在某一点状态的 thread-dump 的能力,虽然各个 Java虚拟机打印的thread dump略有不同,但是 大多都提供了当前活动线程的快照,及 JVM 中所有 Java 线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法,如果可能的话还有源代码的行数。
Thread Dump 特点
- 能在各种操作系统下使用;
- 能在各种Java应用服务器下使用;
- 能在生产环境下使用而不影响系统的性能;
- 能将问题直接定位到应用程序的代码行上;
Thread Dump 抓取
一般当服务器挂起,崩溃或者性能低下时,就需要抓取服务器的线程堆栈(Thread Dump)用于后续的分析。在实际运行中,往往一次 dump的信息,还不足以确认问题。为了反映线程状态的动态变化,需要接连多次做thread dump,每次间隔10-20s,建议至少产生三次 dump信息,如果每次 dump都指向同一个问题,我们才确定问题的典型性。
系统命令获取 ThreadDump
ps –ef | grep java
kill -3 <pid>
注意:kill -9 命令会杀死进程。
JVM 自带工具获取 ThreadDump
jps 或 ps –ef | grep java (获取PID)
jstack [-l ] <pid> | tee -a jstack.log(获取ThreadDump)
Thread Dump 分析
头部信息:时间,JVM信息
2011-11-02 19:05:06
Full thread dump Java HotSpot(TM) Server VM (16.3-b01 mixed mode):
线程 INFO 信息
1. "Timer-0" daemon prio=10 tid=0xac190c00 nid=0xaef in Object.wait() [0xae77d000]
# 线程名称:Timer-0;线程类型:daemon;优先级: 10,默认是5;
# JVM线程id:tid=0xac190c00,JVM内部线程的唯一标识(通过java.lang.Thread.getId()获取,通常用自增方式实现)。
# 对应系统线程id(NativeThread ID):nid=0xaef,和top命令查看的线程pid对应,不过一个是10进制,一个是16进制。(通过命令:top -H -p pid,可以查看该进程的所有线程信息)
# 线程状态:in Object.wait();
# 起始栈地址:[0xae77d000],对象的内存地址,通过JVM内存查看工具,能够看出线程是在哪儿个对象上等待;
2. java.lang.Thread.State: TIMED_WAITING (on object monitor)
3. at java.lang.Object.wait(Native Method)
4. -waiting on <0xb3885f60> (a java.util.TaskQueue) # 继续wait
5. at java.util.TimerThread.mainLoop(Timer.java:509)
6. -locked <0xb3885f60> (a java.util.TaskQueue) # 已经locked
7. at java.util.TimerThread.run(Timer.java:462)
Java thread statck trace:是上面2-7行的信息。到目前为止这是最重要的数据,Java stack trace提供了大部分信息来精确定位问题根源。
Thread Stack Trace
堆栈信息应该逆向解读:程序先执行的是第7行,然后是第6行,依次类推。
- locked <0xb3885f60> (a java.util.ArrayList)
- waiting on <0xb3885f60> (a java.util.ArrayList)
也就是说对象先上锁,锁住对象0xb3885f60,然后释放该对象锁,进入waiting状态。为啥会出现这样的情况呢?看看下面的java代码示例,就会明白:
synchronized(obj) {
.........
obj.wait();
.........
}
如上,线程的执行过程,先用 synchronized
获得了这个对象的 Monitor(对应于 locked <0xb3885f60>
)。当执行到 obj.wait()
,线程即放弃了 Monitor的所有权,进入 “wait set”队列(对应于 waiting on <0xb3885f60>
)。
在堆栈的第一行信息中,进一步标明了线程在代码级的状态,例如:
java.lang.Thread.State: TIMED_WAITING (parking)
解释如下:
|blocked|
> This thread tried to enter asynchronized block, but the lock was taken by another thread. This thread isblocked until the lock gets released.
|blocked (on thin lock)|
> This is the same state asblocked, but the lock in question is a thin lock.
|waiting|
> This thread calledObject.wait() on an object. The thread will remain there until some otherthread sends a notification to that object.
|sleeping|
> This thread calledjava.lang.Thread.sleep().
|parked|
> This thread calledjava.util.concurrent.locks.LockSupport.park().
|suspended|
> The thread's execution wassuspended by java.lang.Thread.suspend() or a JVMTI agent call.
Thread State
线程的状态是一个很重要的东西,因此thread dump中会显示这些状态,通过对这些状态的分析,能够得出线程的运行状况,进而发现可能存在的问题。线程的状态在Thread.State这个枚举类型中定义:
public enum State {
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
NEW:
- 每一个线程,在堆内存中都有一个对应的Thread对象。Thread t = new Thread();当刚刚在堆内存中创建Thread对象,还没有调用t.start()方法之前,线程就处在NEW状态。在这个状态上,线程与普通的java对象没有什么区别,就仅仅是一个堆内存中的对象。
RUNNABLE:
- 该状态表示线程具备所有运行条件,在运行队列中准备操作系统的调度,或者正在运行。 这个状态的线程比较正常,但如果线程长时间停留在在这个状态就不正常了,这说明线程运行的时间很长(存在性能问题),或者是线程一直得不得执行的机会(存在线程饥饿的问题)。
BLOCKED:
- 线程正在等待获取java对象的监视器(也叫内置锁),即线程正在等待进入由synchronized保护的方法或者代码块。synchronized用来保证原子性,任意时刻最多只能由一个线程进入该临界区域,其他线程只能排队等待。
WAITING:
- 处在该线程的状态,正在等待某个事件的发生,只有特定的条件满足,才能获得执行机会。而产生这个特定的事件,通常都是另一个线程。也就是说,如果不发生特定的事件,那么处在该状态的线程一直等待,不能获取执行的机会。比如:
- A线程调用了obj对象的obj.wait()方法,如果没有线程调用obj.notify或obj.notifyAll,那么A线程就没有办法恢复运行; 如果A线程调用了LockSupport.park(),没有别的线程调用LockSupport.unpark(A),那么A没有办法恢复运行。 TIMED_WAITING:
- J.U.C中很多与线程相关类,都提供了限时版本和不限时版本的API。TIMED_WAITING意味着线程调用了限时版本的API,正在等待时间流逝。当等待时间过去后,线程一样可以恢复运行。如果线程进入了WAITING状态,一定要特定的事件发生才能恢复运行;而处在TIMED_WAITING的线程,如果特定的事件发生或者是时间流逝完毕,都会恢复运行。
TERMINATED:
- 线程执行完毕,执行完run方法正常返回,或者抛出了运行时异常而结束,线程都会停留在这个状态。这个时候线程只剩下Thread对象了,没有什么用了。
关键状态分析
Wait on condition:
The thread is either sleeping or waiting to be notified by another thread.
该状态说明它在等待另一个条件的发生,来把自己唤醒,或者干脆它是调用了 sleep(n)。
此时线程状态大致为以下几种:
java.lang.Thread.State: WAITING (parking):一直等那个条件发生;
java.lang.Thread.State: TIMED_WAITING (parking或sleeping):定时的,那个条件不到来,也将定时唤醒自己。
Waiting for Monitor Entry 和 in Object.wait():
The thread is waiting to get the lock for an object (some other thread may be holding the lock). This happens if two or more threads try to execute synchronized code. Note that the lock is always for an object and not for individual methods.
在多线程的JAVA程序中,实现线程之间的同步,就要说说 Monitor。Monitor是Java中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者Class的锁。每一个对象都有,也仅有一个 Monitor 。下面这个图,描述了线程和 Monitor之间关系,以及线程的状态转换图:
如上图,每个Monitor在某个时刻,只能被一个线程拥有,该线程就是 “ActiveThread”,而其它线程都是 “Waiting Thread”,分别在两个队列“Entry Set”和“Wait Set”里等候。在“Entry Set”中等待的线程状态是“Waiting for monitor entry”,而在“Wait Set”中等待的线程状态是“in Object.wait()”。
先看“Entry Set”里面的线程。我们称被 synchronized保护起来的代码段为临界区。当一个线程申请进入临界区时,它就进入了“Entry Set”队列。对应的 code就像:
synchronized(obj) {
.........
}
这时有两种可能性:
- 该 monitor不被其它线程拥有, Entry Set 里面也没有其它等待线程。本线程即成为相应类或者对象的 Monitor的 Owner,执行临界区的代码。
- 该 monitor被其它线程拥有,本线程在 Entry Set 队列中等待。
在第一种情况下,线程将处于 “Runnable”的状态,而第二种情况下,线程 DUMP 会显示处于 “waiting for monitor entry”。如下:
"Thread-0" prio=10 tid=0x08222eb0 nid=0x9 waiting for monitor entry [0xf927b000..0xf927bdb8]
at testthread.WaitThread.run(WaitThread.java:39)
- waiting to lock <0xef63bf08> (a java.lang.Object)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
临界区的设置,是为了保证其内部的代码执行的原子性和完整性。但是因为临界区在任何时间只允许线程串行通过,这和我们多线程的程序的初衷是相反的。如果在多线程的程序中,大量使用 synchronized,或者不适当的使用了它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在线程 DUMP中发现了这个情况,应该审查源码,改进程序。
再看“Wait Set”里面的线程。当线程获得了 Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(一般就是被 synchronized 的对象)的 wait() 方法,放弃 Monitor,进入 “Wait Set”队列。只有当别的线程在该对象上调用了 notify() 或者 notifyAll(),“Wait Set”队列中线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。在 “Wait Set”中的线程, DUMP中表现为: in Object.wait()。如下:
"Thread-1" prio=10 tid=0x08223250 nid=0xa in Object.wait() [0xef47a000..0xef47aa38]
at java.lang.Object.wait(Native Method)
- waiting on <0xef63beb8> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:474)
at testthread.MyWaitThread.run(MyWaitThread.java:40)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
综上,一般CPU很忙时,则关注runnable的线程,CPU很闲时,则关注waiting for monitor entry的线程。
- JDK 5.0 的 Lock
上面提到如果 synchronized和 monitor机制运用不当,可能会造成多线程程序的性能问题。在 JDK 5.0中,引入了 Lock 机制,从而使开发者能更灵活的开发高性能的并发多线程程序,可以替代以往 JDK中的 synchronized和 Monitor的 机制。但是,要注意的是,因为 Lock类只是一个普通类,JVM无从得知 Lock对象的占用情况,所以在线程 DUMP中,也不会包含关于 Lock的信息, 关于死锁等问题,就不如用 synchronized的编程方式容易识别。
关键状态示例
显示BLOCKED状态
public class BlockedState {
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
先获取object的线程会执行5分钟,这5分钟内会一直持有object的监视器,另一个线程无法执行处在BLOCKED状态:
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1314 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7a800 nid=0x1350 waiting for monitor entry [0x2833f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at jstack.BlockedState$1.run(BlockedState.java:17)
- waiting to lock <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d79400 nid=0x1338 runnable [0x282ef000]
java.lang.Thread.State: RUNNABLE
at jstack.BlockedState$1.run(BlockedState.java:22)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
通过thread dump可以看到:t2线程确实处在BLOCKED (on object monitor)。waiting for monitor entry 等待进入synchronized保护的区域。
显示WAITING状态
package jstack;
public class WaitingState
{
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
try
{
// 进入等待的同时,会进入释放监视器
object.wait();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1734 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7e000 nid=0x17f4 in Object.wait() [0x2833f000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d7d400 nid=0x17f0 in Object.wait() [0x282ef000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
可以发现t1和t2都处在WAITING (on object monitor),进入等待状态的原因是调用了in Object.wait()。通过J.U.C包下的锁和条件队列,也是这个效果,大家可以自己实践下。
显示TIMED_WAITING状态
package jstack;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TimedWaitingState
{
// java的显示锁,类似java对象内置的监视器
private static Lock lock = new ReentrantLock();
// 锁关联的条件队列(类似于object.wait)
private static Condition condition = lock.newCondition();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
// 加锁,进入临界区
lock.lock();
try
{
condition.await(5, TimeUnit.MINUTES);
} catch (InterruptedException e)
{
e.printStackTrace();
}
// 解锁,退出临界区
lock.unlock();
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x169c waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7d800 nid=0xc30 waiting on condition [0x2833f000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x280d0c00 nid=0x16e0 waiting on condition [0x282ef000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
可以看到t1和t2线程都处在java.lang.Thread.State: TIMED_WAITING (parking),这个parking代表是调用的JUC下的工具类,而不是java默认的监视器。
重要线程
Attach Listener Attach Listener
线程是负责接收到外部的命令,而对该命令进行执行的并把结果返回给发送者。通常我们会用一些命令去要求JVM给我们一些反馈信息,如:java -version、jmap、jstack等等。 如果该线程在JVM启动的时候没有初始化,那么,则会在用户第一次执行JVM命令时,得到启动。
Signal Dispatcher
前面提到Attach Listener线程的职责是接收外部JVM命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部JVM命令时,进行初始化工作。
CompilerThread0
用来调用JITing,实时编译装卸class 。 通常,JVM会启动多个线程来处理这部分工作,线程名称后面的数字也会累加,例如:CompilerThread1。
Concurrent Mark-Sweep GC Thread
并发标记清除垃圾回收器(就是通常所说的CMS GC)线程, 该线程主要针对于老年代垃圾回收。ps:启用该垃圾回收器,需要在JVM启动参数中加上:-XX:+UseConcMarkSweepGC。
DestroyJavaVM
执行main()的线程,在main执行完后调用JNI中的 jni_DestroyJavaVM() 方法唤起DestroyJavaVM 线程,处于等待状态,等待其它线程(Java线程和Native线程)退出时通知它卸载JVM。每个线程退出时,都会判断自己当前是否是整个JVM中最后一个非deamon线程,如果是,则通知DestroyJavaVM 线程卸载JVM。
Finalizer Thread
这个线程也是在main线程之后创建的,其优先级为10,主要用于在垃圾收集前,调用对象的finalize()方法;关于Finalizer线程的几点:1) 只有当开始一轮垃圾收集时,才会开始调用finalize()方法;因此并不是所有对象的finalize()方法都会被执行;2) 该线程也是daemon线程,因此如果虚拟机中没有其他非daemon线程,不管该线程有没有执行完finalize()方法,JVM也会退出;3) JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收;4) JVM为什么要单独用一个线程来执行finalize()方法呢?如果JVM的垃圾收集线程自己来做,很有可能由于在finalize()方法中误操作导致GC线程停止或不可控,这对GC线程来说是一种灾难;
Low Memory Detector
这个线程是负责对可使用内存进行检测,如果发现可用内存低,分配新的内存空间。
Reference Handler
JVM在创建main线程后就创建Reference Handler线程,其优先级最高,为10,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题 。
VM Thread
JVM里面的线程母体,根据hotspot源码(vmThread.hpp)里面的注释,它是一个单个的对象(最原始的线程)会产生或触发所有其他的线程,这个单个的VM线程是会被其他线程所使用来做一些VM操作(如:清扫垃圾等)。
1.6.3 - Linux 命令
文本操作
文本查找 - grep
grep常用命令:
# 基本使用
grep yoursearchkeyword f.txt #文件查找
grep 'KeyWord otherKeyWord' f.txt cpf.txt #多文件查找, 含空格加引号
grep 'KeyWord' /home/admin -r -n #目录下查找所有符合关键字的文件
grep 'keyword' /home/admin -r -n -i # -i 忽略大小写
grep 'KeyWord' /home/admin -r -n --include *.{vm,java} #指定文件后缀
grep 'KeyWord' /home/admin -r -n --exclude *.{vm,java} #反匹配
# cat + grep
cat f.txt | grep -i keyword # 查找所有keyword且不分大小写
cat f.txt | grep -c 'KeyWord' # 统计Keyword次数
# seq + grep
seq 10 | grep 5 -A 3 #上匹配
seq 10 | grep 5 -B 3 #下匹配
seq 10 | grep 5 -C 3 #上下匹配,平时用这个就妥了
文本分析 - awk
awk基本命令:
# 基本使用
awk '{print $4,$6}' f.txt
awk '{print NR,$0}' f.txt cpf.txt
awk '{print FNR,$0}' f.txt cpf.txt
awk '{print FNR,FILENAME,$0}' f.txt cpf.txt
awk '{print FILENAME,"NR="NR,"FNR="FNR,"$"NF"="$NF}' f.txt cpf.txt
echo 1:2:3:4 | awk -F: '{print $1,$2,$3,$4}'
# 匹配
awk '/ldb/ {print}' f.txt #匹配ldb
awk '!/ldb/ {print}' f.txt #不匹配ldb
awk '/ldb/ && /LISTEN/ {print}' f.txt #匹配ldb和LISTEN
awk '$5 ~ /ldb/ {print}' f.txt #第五列匹配ldb
内建变量:
`NR`: NR表示从awk开始执行后,按照记录分隔符读取的数据次数,默认的记录分隔符为换行符,因此默认的就是读取的数据行数,NR可以理解为Number of Record的缩写。
`FNR`: 在awk处理多个输入文件的时候,在处理完第一个文件后,NR并不会从1开始,而是继续累加,因此就出现了FNR,每当处理一个新文件的时候,FNR就从1开始计数,FNR可以理解为File Number of Record。
`NF`: NF表示目前的记录被分割的字段的数目,NF可以理解为Number of Field。
文本处理 - sed
sed常用:
# 文本打印
sed -n '3p' xxx.log #只打印第三行
sed -n '$p' xxx.log #只打印最后一行
sed -n '3,9p' xxx.log #只查看文件的第3行到第9行
sed -n -e '3,9p' -e '=' xxx.log #打印3-9行,并显示行号
sed -n '/root/p' xxx.log #显示包含root的行
sed -n '/hhh/,/omc/p' xxx.log # 显示包含"hhh"的行到包含"omc"的行之间的行
# 文本替换
sed -i 's/root/world/g' xxx.log # 用world 替换xxx.log文件中的root; s==search 查找并替换, g==global 全部替换, -i: implace
# 文本插入
sed '1,4i hahaha' xxx.log # 在文件第一行和第四行的每行下面添加hahaha
sed -e '1i happy' -e '$a new year' xxx.log #【界面显示】在文件第一行添加happy,文件结尾添加new year
sed -i -e '1i happy' -e '$a new year' xxx.log #【真实写入文件】在文件第一行添加happy,文件结尾添加new year
# 文本删除
sed '3,9d' xxx.log # 删除第3到第9行,只是不显示而已
sed '/hhh/,/omc/d' xxx.log # 删除包含"hhh"的行到包含"omc"的行之间的行
sed '/omc/,10d' xxx.log # 删除包含"omc"的行到第十行的内容
# 与find结合
find . -name "*.txt" |xargs sed -i 's/hhhh/\hHHh/g'
find . -name "*.txt" |xargs sed -i 's#hhhh#hHHh#g'
find . -name "*.txt" -exec sed -i 's/hhhh/\hHHh/g' {} \;
find . -name "*.txt" |xargs cat
文件操作
文件监听 - tail
最常用的tail -f filename
# 基本使用
tail -f xxx.log # 循环监听文件
tail -300f xxx.log #倒数300行并追踪文件
tail +20 xxx.log #从第 20 行至文件末尾显示文件内容
# tailf使用
tailf xxx.log #等同于tail -f -n 10 打印最后10行,然后追踪文件
tail -f 与tail F 与tailf三者区别
`tail -f ` 等于--follow=descriptor,根据文件描述进行追踪,当文件改名或删除后,停止追踪。
`tail -F` 等于 --follow=name ==retry,根据文件名字进行追踪,当文件改名或删除后,保持重试,当有新的文件和他同名时,继续追踪
`tailf` 等于tail -f -n 10(tail -f或-F默认也是打印最后10行,然后追踪文件),与tail -f不同的是,如果文件不增长,它不会去访问磁盘文件,所以tailf特别适合那些便携机上跟踪日志文件,因为它减少了磁盘访问,可以省电。
tail的参数
-f 循环读取
-q 不显示处理信息
-v 显示详细的处理信息
-c<数目> 显示的字节数
-n<行数> 显示文件的尾部 n 行内容
--pid=PID 与-f合用,表示在进程ID,PID死掉之后结束
-q, --quiet, --silent 从不输出给出文件名的首部
-s, --sleep-interval=S 与-f合用,表示在每次反复的间隔休眠S秒
文件查找 - find
sudo -u admin find /home/admin /tmp /usr -name \*.log(多个目录去找)
find . -iname \*.txt(大小写都匹配)
find . -type d(当前目录下的所有子目录)
find /usr -type l(当前目录下所有的符号链接)
find /usr -type l -name "z*" -ls(符号链接的详细信息 eg:inode,目录)
find /home/admin -size +250000k(超过250000k的文件,当然+改成-就是小于了)
find /home/admin f -perm 777 -exec ls -l {} \; (按照权限查询文件)
find /home/admin -atime -1 1天内访问过的文件
find /home/admin -ctime -1 1天内状态改变过的文件
find /home/admin -mtime -1 1天内修改过的文件
find /home/admin -amin -1 1分钟内访问过的文件
find /home/admin -cmin -1 1分钟内状态改变过的文件
find /home/admin -mmin -1 1分钟内修改过的文件
pgm
批量查询vm-shopbase满足条件的日志
pgm -A -f vm-shopbase 'cat /home/admin/shopbase/logs/shopbase.log.2017-01-17|grep 2069861630'
查看网络和进程
查看所有网络接口的属性
# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.31.165.194 netmask 255.255.240.0 broadcast 172.31.175.255
ether 00:16:3e:08:c1:ea txqueuelen 1000 (Ethernet)
RX packets 21213152 bytes 2812084823 (2.6 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 25264438 bytes 46566724676 (43.3 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
loop txqueuelen 1000 (Local Loopback)
RX packets 502 bytes 86350 (84.3 KiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 502 bytes 86350 (84.3 KiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
查看防火墙设置
# iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
查看路由表
# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 172.31.175.253 0.0.0.0 UG 0 0 0 eth0
169.254.0.0 0.0.0.0 255.255.0.0 U 1002 0 0 eth0
172.31.160.0 0.0.0.0 255.255.240.0 U 0 0 0 eth0
netstat
查看所有监听端口
# netstat -lntp
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 970/nginx: master p
tcp 0 0 0.0.0.0:9999 0.0.0.0:* LISTEN 1249/java
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 970/nginx: master p
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1547/sshd
tcp6 0 0 :::3306 :::* LISTEN 1894/mysqld
查看所有已经建立的连接
# netstat -antp
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:443 0.0.0.0:* LISTEN 970/nginx: master p
tcp 0 0 0.0.0.0:9999 0.0.0.0:* LISTEN 1249/java
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 970/nginx: master p
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1547/sshd
tcp 0 0 172.31.165.194:53874 100.100.30.25:80 ESTABLISHED 18041/AliYunDun
tcp 0 64 172.31.165.194:22 xxx.194.1.200:2649 ESTABLISHED 32516/sshd: root@pt
tcp6 0 0 :::3306 :::* LISTEN 1894/m
查看当前连接
[root@pdai.tech ~]# netstat -nat|awk '{print $6}'|sort|uniq -c|sort -rn
5 LISTEN
2 ESTABLISHED
1 Foreign
1 established)
查看网络统计信息进程
# netstat -s
Ip:
21017132 total packets received
0 forwarded
0 incoming packets discarded
21017131 incoming packets delivered
25114367 requests sent out
324 dropped because of missing route
Icmp:
18088 ICMP messages received
692 input ICMP message failed.
ICMP input histogram:
destination unreachable: 4241
timeout in transit: 19
echo requests: 13791
echo replies: 4
timestamp request: 33
13825 ICMP messages sent
0 ICMP messages failed
ICMP output histogram:
destination unreachable: 1
echo replies: 13791
timestamp replies: 33
IcmpMsg:
InType0: 4
InType3: 4241
InType8: 13791
InType11: 19
InType13: 33
OutType0: 13791
OutType3: 1
OutType14: 33
Tcp:
12210 active connections openings
208820 passive connection openings
54198 failed connection attempts
9805 connection resets received
...
查看所有进程
# ps -ef | grep java
root 1249 1 0 Nov04 ? 00:58:05 java -jar /opt/tech_doc/bin/tech_arch-0.0.1-RELEASE.jar --server.port=9999
root 32718 32518 0 08:36 pts/0 00:00:00 grep --color=auto java
top
top除了看一些基本信息之外,剩下的就是配合来查询vm的各种问题了
# top -H -p pid
top - 08:37:51 up 45 days, 18:45, 1 user, load average: 0.01, 0.03, 0.05
Threads: 28 total, 0 running, 28 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.7 us, 0.7 sy, 0.0 ni, 98.6 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 1882088 total, 74608 free, 202228 used, 1605252 buff/cache
KiB Swap: 2097148 total, 1835392 free, 261756 used. 1502036 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1347 root 20 0 2553808 113752 1024 S 0.3 6.0 48:46.74 VM Periodic Tas
1249 root 20 0 2553808 113752 1024 S 0.0 6.0 0:00.00 java
1289 root 20 0 2553808 113752 1024 S 0.0 6.0 0:03.74 java
...
查看磁盘和内存相关
查看内存使用 - free -m
[root@pdai.tech ~]# free -m
total used free shared buff/cache available
Mem: 1837 196 824 0 816 1469
Swap: 2047 255 1792
查看各分区使用情况
[root@pdai.tech ~]# df -h
Filesystem Size Used Avail Use% Mounted on
devtmpfs 909M 0 909M 0% /dev
tmpfs 919M 0 919M 0% /dev/shm
tmpfs 919M 452K 919M 1% /run
tmpfs 919M 0 919M 0% /sys/fs/cgroup
/dev/vda1 40G 15G 23G 40% /
tmpfs 184M 0 184M 0% /run/user/0
查看指定目录的大小
[root@pdai.tech ~]# du -sh
803M
查看内存总量
[root@pdai.tech ~]# grep MemTotal /proc/meminfo
MemTotal: 1882088 kB
查看空闲内存量
[root@pdai.tech ~]# grep MemFree /proc/meminfo
MemFree: 74120 kB
查看系统负载磁盘和分区
[root@pdai.tech ~]# grep MemFree /proc/meminfo
MemFree: 74120 kB
查看系统负载磁盘和分区
[root@pdai.tech ~]# cat /proc/loadavg
0.01 0.04 0.05 2/174 32751
查看挂接的分区状态
[root@pdai.tech ~]# mount | column -t
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
devtmpfs on /dev type devtmpfs (rw,nosuid,size=930732k,nr_inodes=232683,mode=755)
securityfs on /sys/kernel/security type securityfs (rw,nosuid,nodev,noexec,relatime)
...
查看所有分区
# fdisk -l
Disk /dev/vda: 42.9 GB, 42949672960 bytes, 83886080 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk label type: dos
Disk identifier: 0x0008d73a
Device Boot Start End Blocks Id System
/dev/vda1 * 2048 83884031 41940992 83 Linux
查看所有交换分区
[root@pdai.tech ~]# swapon -s
Filename Type Size Used Priority
/etc/swap file 2097148 261756 -2
查看硬盘大小
[root@pdai.tech ~]# fdisk -l |grep Disk
Disk /dev/vda: 42.9 GB, 42949672960 bytes, 83886080 sectors
Disk label type: dos
Disk identifier: 0x0008d73a
查看用户和组相关
查看活动用户
# w
08:47:20 up 45 days, 18:54, 1 user, load average: 0.01, 0.03, 0.05
USER TTY FROM LOGIN@ IDLE JCPU PCPU WHAT
root pts/0 xxx.194.1.200 08:32 0.00s 0.32s 0.32s -bash
查看指定用户信息
# id
uid=0(root) gid=0(root) groups=0(root)
查看用户登录日志
# last
root pts/0 xxx.194.1.200 Fri Dec 20 08:32 still logged in
root pts/0 xxx.73.164.60 Thu Dec 19 21:47 - 00:28 (02:41)
root pts/0 xxx.106.236.255 Thu Dec 19 16:00 - 18:24 (02:23)
root pts/1 xxx.194.3.173 Tue Dec 17 13:35 - 17:37 (04:01)
root pts/0 xxx.194.3.173 Tue Dec 17 13:35 - 17:37 (04:02)
...
查看系统所有用户
[root@pdai.tech ~]# cut -d: -f1 /etc/passwd
root
bin
daemon
adm
...
查看系统所有组
cut -d: -f1 /etc/group
查看服务,模块和包相关
# 查看当前用户的计划任务服务
crontab -l
# 列出所有系统服务
chkconfig –list
# 列出所有启动的系统服务程序
chkconfig –list | grep on
# 查看所有安装的软件包
rpm -qa
# 列出加载的内核模块
lsmod
查看系统,设备,环境信息
# 常用
env # 查看环境变量资源
uptime # 查看系统运行时间、用户数、负载
lsusb -tv # 列出所有USB设备的linux系统信息命令
lspci -tv # 列出所有PCI设备
head -n 1 /etc/issue # 查看操作系统版本,是数字1不是字母L
uname -a # 查看内核/操作系统/CPU信息的linux系统信息命令
# /proc/
cat /proc/cpuinfo :查看CPU相关参数的linux系统命令
cat /proc/partitions :查看linux硬盘和分区信息的系统信息命令
cat /proc/meminfo :查看linux系统内存信息的linux系统命令
cat /proc/version :查看版本,类似uname -r
cat /proc/ioports :查看设备io端口
cat /proc/interrupts :查看中断
cat /proc/pci :查看pci设备的信息
cat /proc/swaps :查看所有swap分区的信息
cat /proc/cpuinfo |grep "model name" && cat /proc/cpuinfo |grep "physical id"
tsar
tsar是淘宝开源的的采集工具。很好用, 将历史收集到的数据持久化在磁盘上,所以我们快速来查询历史的系统数据。当然实时的应用情况也是可以查询的啦。大部分机器上都有安装。
tsar ##可以查看最近一天的各项指标
tsar --live ##可以查看实时指标,默认五秒一刷
tsar -d 20161218 ##指定查看某天的数据,貌似最多只能看四个月的数据
tsar --mem
tsar --load
tsar --cpu ##当然这个也可以和-d参数配合来查询某天的单个指标的情况
1.6.4 - 调试工具集
自带工具
jps
jps是jdk提供的一个查看当前java进程的小工具, 可以看做是JavaVirtual Machine Process Status Tool的缩写。
jps常用命令
jps # 显示进程的ID 和 类的名称
jps –l # 输出输出完全的包名,应用主类名,jar的完全路径名
jps –v # 输出jvm参数
jps –q # 显示java进程号
jps -m # main 方法
jps -l xxx.xxx.xx.xx # 远程查看
jps参数
-q:仅输出VM标识符,不包括classname,jar name,arguments in main method
-m:输出main method的参数
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出jvm参数
-V:输出通过flag文件传递到JVM中的参数(.hotspotrc文件或-XX:Flags=所指定的文件
-Joption:传递参数到vm,例如:-J-Xms512m
jps原理
java程序在启动以后,会在java.io.tmpdir指定的目录下,就是临时文件夹里,生成一个类似于hsperfdata_User的文件夹,这个文件夹里(在Linux中为/tmp/hsperfdata_{userName}/),有几个文件,名字就是java进程的pid,因此列出当前运行的java进程,只是把这个目录里的文件名列一下而已。 至于系统的参数什么,就可以解析这几个文件获得。
更多请参考 jps - Java Virtual Machine Process Status Tool (opens new window)
jstack
jstack是jdk自带的线程堆栈分析工具,使用该命令可以查看或导出 Java 应用程序中线程堆栈信息。
jstack常用命令:
# 基本
jstack 2815
# java和native c/c++框架的所有栈信息
jstack -m 2815
# 额外的锁信息列表,查看是否死锁
jstack -l 2815
jstack参数:
-l 长列表. 打印关于锁的附加信息,例如属于java.util.concurrent 的 ownable synchronizers列表.
-F 当’jstack [-l] pid’没有相应的时候强制打印栈信息
-m 打印java和native c/c++框架的所有栈信息.
-h | -help 打印帮助信息
jinfo
jinfo 是 JDK 自带的命令,可以用来查看正在运行的 java 应用程序的扩展参数,包括Java System属性和JVM命令行参数;也可以动态的修改正在运行的 JVM 一些参数。当系统崩溃时,jinfo可以从core文件里面知道崩溃的Java应用程序的配置信息
jinfo常用命令:
# 输出当前 jvm 进程的全部参数和系统属性
jinfo 2815
# 输出所有的参数
jinfo -flags 2815
# 查看指定的 jvm 参数的值
jinfo -flag PrintGC 2815
# 开启/关闭指定的JVM参数
jinfo -flag +PrintGC 2815
# 设置flag的参数
jinfo -flag name=value 2815
# 输出当前 jvm 进行的全部的系统属性
jinfo -sysprops 2815
jinfo参数:
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+|-]name 开启或者关闭对应名称的参数
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
jmap
命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。
两个用途
# 查看堆的情况
jmap -heap 2815
# dump
jmap -dump:live,format=b,file=/tmp/heap2.bin 2815
jmap -dump:format=b,file=/tmp/heap3.bin 2815
# 查看堆的占用
jmap -histo 2815 | head -10
jmap 参数
no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
heap: 显示Java堆详细信息
histo[:live]: 显示堆中对象的统计信息
clstats:打印类加载器信息
finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
dump:<dump-options>:生成堆转储快照
F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
help:打印帮助信息
J<flag>:指定传递给运行jmap的JVM的参数
jstat
利用JVM内建的指令对Java应用程序的资源和性能进行实时的命令行的监控,包括了对Heap size和垃圾回收状况的监控。
jstat 是用于见识虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、jit编译等运行数据,它是线上定位jvm性能的首选工具。
jstat -gcutil 2815 1000
jdb
jdb可以用来预发debug,假设你预发的java_home是/opt/java/,远程调试端口是8000.那么
jdb -attach 8000
出现以上代表jdb启动成功。后续可以进行设置断点进行调试。
具体参数可见oracle官方说明jdb - The Java Debugger (opens new window)
CHLSDB
CHLSDB感觉很多情况下可以看到更好玩的东西,不详细叙述了。 查询资料听说jstack和jmap等工具就是基于它的。
java -classpath /opt/taobao/java/lib/sa-jdi.jar sun.jvm.hotspot.CLHSDB
进阶工具
btrace
首当其冲的要说的是btrace。
- 查看当前谁调用了ArrayList的add方法,同时只打印当前ArrayList的size大于500的线程调用栈
@OnMethod(clazz = "java.util.ArrayList", method="add", location = @Location(value = Kind.CALL, clazz = "/./", method = "/./"))
public static void m(@ProbeClassName String probeClass, @ProbeMethodName String probeMethod, @TargetInstance Object instance, @TargetMethodOrField String method) {
if(getInt(field("java.util.ArrayList", "size"), instance) > 479){
println("check who ArrayList.add method:" + probeClass + "#" + probeMethod + ", method:" + method + ", size:" + getInt(field("java.util.ArrayList", "size"), instance));
jstack();
println();
println("===========================");
println();
}
}
- 监控当前服务方法被调用时返回的值以及请求的参数
@OnMethod(clazz = "com.taobao.sellerhome.transfer.biz.impl.C2CApplyerServiceImpl", method="nav", location = @Location(value = Kind.RETURN))
public static void mt(long userId, int current, int relation, String check, String redirectUrl, @Return AnyType result) {
println("parameter# userId:" + userId + ", current:" + current + ", relation:" + relation + ", check:" + check + ", redirectUrl:" + redirectUrl + ", result:" + result);
}
btrace 具体可以参考这里:https://github.com/btraceio/btrace
注意:
- 经过观察,1.3.9的release输出不稳定,要多触发几次才能看到正确的结果
- 正则表达式匹配trace类时范围一定要控制,否则极有可能出现跑满CPU导致应用卡死的情况
- 由于是字节码注入的原理,想要应用恢复到正常情况,需要重启应用。
Greys
Greys是@杜琨的大作。说几个挺棒的功能(部分功能和btrace重合):
sc -df xxx
: 输出当前类的详情,包括源码位置和classloader结构trace class method
: 打印出当前方法调用的耗时情况,细分到每个方法, 对排查方法性能时很有帮助。
Arthas
Arthas是基于Greys。
javOSize
就说一个功能:
classes
:通过修改了字节码,改变了类的内容,即时生效。 所以可以做到快速的在某个地方打个日志看看输出,缺点是对代码的侵入性太大。但是如果自己知道自己在干嘛,的确是不错的玩意儿。
其他功能 Greys 和 btrace 都能很轻易做的到,不说了。
JProfiler
之前判断许多问题要通过JProfiler,但是现在Greys和btrace基本都能搞定了。再加上出问题的基本上都是生产环境(网络隔离),所以基本不怎么使用了,但是还是要标记一下。
dmesg
如果发现自己的java进程悄无声息的消失了,几乎没有留下任何线索,那么dmesg一发,很有可能有你想要的。
sudo dmesg|grep -i kill|less 去找关键字oom_killer。找到的结果类似如下:
[6710782.021013] java invoked oom-killer: gfp_mask=0xd0, order=0, oom_adj=0, oom_scoe_adj=0
[6710782.070639] [<ffffffff81118898>] ? oom_kill_process+0x68/0x140
[6710782.257588] Task in /LXC011175068174 killed as a result of limit of /LXC011175068174
[6710784.698347] Memory cgroup out of memory: Kill process 215701 (java) score 854 or sacrifice child
[6710784.707978] Killed process 215701, UID 679, (java) total-vm:11017300kB, anon-rss:7152432kB, file-rss:1232kB
以上表明,对应的java进程被系统的OOM Killer给干掉了,得分为854. 解释一下OOM killer(Out-Of-Memory killer),该机制会监控机器的内存资源消耗。当机器内存耗尽前,该机制会扫描所有的进程(按照一定规则计算,内存占用,时间等),挑选出得分最高的进程,然后杀死,从而保护机器。
dmesg日志时间转换公式: log实际时间=格林威治1970-01-01+(当前时间秒数-系统启动至今的秒数+dmesg打印的log时间)秒数:
date -d “1970-01-01 UTC echo "$(date +%s)-$(cat /proc/uptime|cut -f 1 -d' ')+12288812.926194"|bc
seconds” 剩下的,就是看看为什么内存这么大,触发了OOM-Killer了。
Flight Recorder
是Oracle官方推出的商业环境的性能调优利器;其本身对运行期系统的侵入性很小,同时又能提供相对准确和丰富的运行期信息。
JFR本身是基于周期性对JVM虚拟机的运行状况进行采样记录的,其采样的频率可以通过其参数传入;只是需要留意的是,采样间隔越小对系统的性能干扰就越大。 和传统的JProfiler/VisualVM这些基于JMX的工具所不同的是,JFR记录的信息是近似而非精确的;当然大部分情况下这些模糊性信息就足够说明问题了。 对于大部分场景下,这些近似信息反而可以更容易发现一些真正的问题。
1.6.5 - Java Agent
在平时的开发中,我们不可避免的会使用到Debug工具,JVM作为一个单独的进程,我们使用的Debug工具可以获取JVM运行时的相关的信息,查看变量值,甚至加入断点控制,还有我们平时使用JDK自带的JMAP、JSTACK等工具,可以在JVM运行时动态的dump内存、查询线程信息,甚至一些第三方的工具,比如说京东内部使用的JEX、pfinder,阿里巴巴的Arthas,优秀的开源的框架skywalking等等,也可以做到这些,那么这些工具究竟是通过什么技术手段来实现对JVM的监控和动态修改呢?本文会进行介绍和简单的原理分析,同时附带一些样例代码来进行分析。
从 JVMTI 说起
JVM在设计之初,就考虑到了虚拟机状态的监控、debug、线程和内存分析等功能,在 JDK5.0 之前,JVM 规范就定义了 JVMPI(Java Virtual Machine Profiler Interface)也就是 JVM 分析接口以及 JVMDI(Java Virtual Machine Debug Interface)也就是 JVM 调试接口,JDK5 以及以后的版本,这两套接口合并成了一套,也就是 Java Virtual Machine Tool Interface,就是我们这里说的 JVMTI,这里需要注意的是:
- JVMTI 是一套 JVM 的接口规范,不同的 JVM 实现方式可以不同,有的 JVM 提供了拓展性的功能,比如 openJ9,当然也可能存在 JVM 不提供这个接口的实现。
- JVMTI 提供的是 Native 方式调用的 API,也就是常说的 JNI 方式,JVMTI 接口用 C/C++ 的语言提供,最终以动态链接库的形式由 JVM 加载并运行。
使用 JNI 方式调用 JVMTI 接口访问目标虚拟机的大体过程入下图:
jvmti.h 头文件中定义了 JVMTI 接口提供的方法,但是其方法的实现是由 JVM 提供商实现的,比如说 hotspot 虚拟机其实现大部分在 src\share\vm\prims\jvmtiEnv.cpp 这个文件中。
Instrument Agent
在 Jdk1.5 之后,Java 语言中开始提供 Instrumentation 接口(java.lang.instrument)让开发者可以使用 Java 语言编写 Agent,但是其根本实现还是依靠 JVMTI,只不过是 SUN 在工具包(sun.instrument.InstrumentationImpl)编写了一些 native 方法,并且然后在 JDK 里提供了这些 native 方法的实现类(jdk\src\share\instrument\JPLISAgent.c),最终需要调用 jvmti.h 头文件定义的方法,跟前文提到采用 JNI 方式访问 JVMTI 提供的方法并无差异,大体流程如下图:
但是 Instrument agent 仅使用到了 JVMTI 提供部分功能,对开发者来说,主要提供的是对 JVM 加载的类字节码进行插桩操作。
1. JVM启动时Agent
我们知道,JVM 启动时可以指定 -javaagent:xxx.jar 参数来实现启动时代理,这里 xxx.jar 就是需要被代理到目标 JVM 上的 JAR 包,实现一个可以代理到指定 JVM 的 JAR 包需要满足以下条件:
- JAR 包的 MANIFEST.MF 清单文件中定义 Premain-Class 属性,指定一个类,加入 Can-Redefine-Classes 和 Can-Retr ansform-Classes 选项。
- JAR 包中包含清单文件中定义的这个类,类中包含 premain 方法,方法逻辑可以自己实现。
了解到这两点,我们可以定义下列类:
import java.lang.instrument.Instrumentation;
public class AgentMain {
// JVM启动时agent
public static void premain(String args, Instrumentation inst) {
agent0(args, inst);
}
public static void agent0(String args, Instrumentation inst) {
System.out.println("agent is running!");
// 添加一个类转换器
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// JVM加载的所有类会流经这个类转换器
// 这里找到自定义的测试类
if (className.endsWith("WorkerMain")) {
System.out.println("transform class WorkerMain");
}
// 直接返回原本的字节码
return classfileBuffer;
}
});
}
}
JAR 包内对应的清单文件(MANIFEST.MF)需要有如下内容:
PreMain-Class: AgentMain
Can-Redefine-Classes: true
Can-Retransform-Classes: true
-javaagent 所指定 jar 包内 Premain-Class 类的 premain 方法,方法签名可以有两种:
public static void premain(String agentArgs, Instrumentation inst)
public static void premain(String agentArgs)
JVM 会优先加载 1 签名的方法,加载成功忽略 2,如果 1 没有,加载 2 方法。这个逻辑在 sun.instrument.InstrumentationImpl 类中实现。
需要说明的是,addTransformer 方法的作用是添加一个字节码转换器,这个方法的入参对象需要实现 ClassFileTransformer 接口,唯一需要实现的方法就是 transform 方法,这个方法可以用来修改加载类的字节码,目前我们并不对字节码进行修改。
最后定义测试类:package test;
import java.util.Random;
class WorkerMain {
public static void main(String[] args) throws InterruptedException {
for (; ; ) {
int x = new Random().nextInt();
new WorkerMain().test(x);
}
}
public void test(int x) throws InterruptedException {
Thread.sleep(2000);
System.out.println("i'm working " + x);
}
}
启动时添加 -javaagent:xxx.jar 参数,指定 agent 刚刚生成的JAR包,可以看到运行结果:
流程解析
下面尝试结合 JDK 源码对该流程进行浅析:
JVM 开始启动时会解析 -javaagent 参数,如果存在这个参数,就会执行 Agent_OnLoad 方法读取并解析指定 JAR 包后生成 JPLISAgent 对象,然后注册 jvmtiEventCallbacks.VMInit 这个事件,也就是虚拟机初始化事件,并设置该事件的回调函数 eventHandlerVMInit,这些代码逻辑在 jdk\src\share\instrument\InvocationAdapter.c 和 jdk\src\share\instrument\JPLISAgent.c 中实现。
在 JVM 初始化时会调用之前注册的 eventHandlerVMInit 事件的回调函数,进入 processJavaStart 这个函数,首先会在注册另一个 JVM 事件 ClassFileLoadHook,然后会真正的执行我们在 Java 代码层面编写的 premain 方法。当 JVM 开始装载类字节码文件时,会触发之前注册的 ClassFileLoadHook 事件的回调方法 eventHandlerClassFileLoadHook,这个回调函数调用 transformClassFile 方法,生成新的字节码,被 JVM 装载,完成了启动时代理的全部流程。
以上代码逻辑在 jdk\src\share\instrument\JPLISAgent.c 中实现。
2. JVM运行时Agent
在 JDK1.6 版本中,SUN 更进一步,提供了可以在 JVM 运行时代理的能力,和启动时代理类似,只需要满足:
- JAR 包的 MANIFEST.MF 清单文件中定义 Agent-Class 属性,指定一个类,加入 Can-Redefine-Classes 和 Can-Retransform-Classes 选项。
- JAR 包中包含清单文件中定义的这个类,类中包含 agentmain 方法,方法逻辑可以自己实现。
运行时 Agent 可以在 JVM 运行时动态的修改某个类的字节码,然后 JVM 会重定义这个类(不需要创建新的类加载器),但是为了保证 JVM 的正常运行,新定义的类相较于原来的类需要满足:
父类是同一个。
实现的接口数也要相同,并且是相同的接口。
类访问符必须一致。
字段数和字段名要一致。
新增或删除的方法必须是 private static/final 的。
可以修改方法内部代码。
运行时 Agent 需要借助 JVM 的 Attach 机制,简单来说就是 JVM 提供的一种通信机制,JVM 中会存在一个 Attach Listener 线程,监听其他 JVM 的 attach 请求,其通信方式基于 socket,JVM Attach 机制大体流程图如下:
JVM Attach
SUN 在 JDK 中提供了 Attach 机制的 Java 语言工具包(com.sun.tools.attach),方便开发者使用 Java 语言进行操作,这里我们使用其中提供的 loadAgent 方法实现运行中 agent 的能力。
public class AttachUtil {
public static void main(String[] args) throws IOException, AgentLoadException, AgentInitializationException, AttachNotSupportedException {
// 获取运行中的JVM列表
List<VirtualMachineDescriptor> vmList = VirtualMachine.list();
// 需要agent的jar包路径
String agentJar = "xxxx/agent-test.jar";
for (VirtualMachineDescriptor vmd : vmList) {
// 找到测试的JVM
if (vmd.displayName().endsWith("WorkerMain")) {
// attach到目标ID的JVM上
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
// agent指定jar包到已经attach的JVM上
virtualMachine.loadAgent(agentJar);
virtualMachine.detach();
}
}
}
同时对之前启动时 Agent 的代码进行改写:
public class AgentMain {
// JVM启动时agent
public static void premain(String args, Instrumentation inst) {
agent0(args, inst);
}
// JVM运行时agent
public static void agentmain(String args, Instrumentation inst) {
agent0(args, inst);
}
public static void agent0(String args, Instrumentation inst) {
System.out.println("agent is running!");
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
// 打印transform的类名
System.out.println(className);
return classfileBuffer;
}
},true);
try {
// 找到WorkerMain类,对其进行重定义
Class<?> c = Class.forName("test.WorkerMain");
inst.retransformClasses(c);
} catch (Exception e) {
System.out.println("error!");
}
}
}
这里我们也没有对字节码进行修改,还是直接返回原本的字节码。运行AttachUtil类,在目标JVM运行时完成了对其中test.WorkerMain 类的重新定义(虽然并没有修改字节码)。
流程解析
下面从JDK源码层面对整个流程进行浅析:
当 AttachUtil 的 loadAgent 方法调用时,目标 JVM 会调用自身的 Agent_OnAttach 方法,这个方法和之前提到的 Agent_OnLoad 方法类似,会进行 Agent JAR 包的解析,不同的是 Agent_OnAttach 方法会直接注册 ClassFileLoadHook 事件回调函数,然后执行 agentmain 方法添加类转换器。
需要注意的是我们在 Java 代码里调用了 Instrumentation#retransformClasses(Class…) 方法,追踪代码可以发现最终调用了一个 native 方法,而这个 native 方法的实现则在 jdk 的 src\share\instrument\JPLISAgent.c 类中,最终 retransformClasses 会调用到 JVMTI 的 RetransformClasses 方法,这里由于JVM源码实现非常复杂,感兴趣的同学可以自行阅读(hotspot源码路径 src\share\vm\prims\jvmtiEnv.cpp),简单来说在这个方法里,JVM 会触发 ClassFileLoadHook 事件回调完成类字节码的转换,并完成虚拟机内已经加载的类字节码的热替换。
至此,在JVM运行时悄无声息的完成了类的重定义,不得不佩服JDK开发者的高超手段。
运行方法分析
了解到上述机制以后,我们可以通过在目标 JVM 运行时对其中的类进行重新定义,做到运行时插桩代码。
我们知道 ASM 是一个字节码修改框架,因此就可以在类转换器中,对原本类的字节码进行修改,然后再对这个类进行重定义(retransform)。
首先我们实现 ClassFileTransformer 接口,前文中在 transform 方法中并没有对于字节码进行修改,只是单纯的打印了一些信息,既然需要对字目标类的节码进行修改,我们需要了解下 ClassFileTransformer 接口中唯一需要实现的方法 transform,方法签名如下:
byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException;
可以看到方法入参有该类的类加载器、类名、类 Class 对象、类的保护域、以及最重要的 classfileBuffer,也就是这个类的字节码,此时就可以借助 ASM 这个字节码大杀器来为所欲为了。现在我们实现一个字节的类转换器 MyClassTransformer,然后使用 ASM 来对字节码进行修改。
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
// 对类字节码进行操作
// 这里需要注意,不能对classfileBuffer这个数组进行修改操作
try {
// 创建ASM ClassReader对象,导入需要增强的对象字节码
ClassReader reader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 自己实现的代码增强器
MyEnhancer myEnhancer = new MyEnhancer(classWriter);
// 增强字节码
reader.accept(myEnhancer, ClassReader.SKIP_FRAMES);
// 返回MyEnhancer增强后的字节码
return classWriter.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
// return null 则不会对类进行转换
return null;
}
}
至此,我们拼上了 JVM 运行时插桩代码的最后一块拼图,这样就可以理解 Arthas 这类基于 Java Agent 的性能分析工具是如何在 JVM 运行时对你的代码进行了修改。
接着实现一个字节码增强器,借助 ASM 将对方法入参和方法耗时的监控代码织入,这里需要对字节码有一定了解,这里笔者使用到 ASM 提供的 AdviceAdapter 类简化开发。
public class MyEnhancer extends ClassVisitor implements Opcodes {
public MyEnhancer(ClassVisitor classVisitor) {
super(ASM7, classVisitor);
}
/**
* 对字节码中的方法定义进行修改
*/
@Override
public MethodVisitor visitMethod(int access, final String name, String descriptor, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions);
if (isIgnore(mv, access, name)) {
return mv;
}
return new AdviceAdapter(Opcodes.ASM7, new JSRInlinerAdapter(mv, access, name, descriptor, signature, exceptions), access, name, descriptor) {
private final Type METHOD_CONTAINER = Type.getType(MethodContainer.class);
private int timeIdentifier;
private int argsIdentifier;
/**
* 进入方法前
*/
@Override
protected void onMethodEnter() {
// 调用System.nanoTime()方法,将方法出参推入栈顶
invokeStatic(Type.getType(System.class), Method.getMethod("long nanoTime()"));
// 构造一个Long类型的局部变量,然后返回这个变量的标识符
timeIdentifier = newLocal(Type.LONG_TYPE);
// 存储栈顶元素也就是System.nanoTime()返回值,到指定位置本地变量区
storeLocal(timeIdentifier);
// 加载入参数组,将入参数组ref推入栈顶
loadArgArray();
// 构造一个Object[]类型的局部变量,返回这个变量的标识符
argsIdentifier = newLocal(Type.getType(Object[].class));
// 存储入参到指定位置本地变量区
storeLocal(argsIdentifier);
}
@Override
protected void onMethodExit(int opcode) {
// 加载指定位置的本地变量到栈顶
loadLocal(timeIdentifier);
loadLocal(argsIdentifier);
// 相当于调用MethodContainer.showMethod(long, Object[])方法
invokeStatic(METHOD_CONTAINER, Method.getMethod("void showMethod(long,Object[])"));
}
};
}
/**
* 方法是否需要被忽略(静态构造函数和构造函数)
*/
private boolean isIgnore(MethodVisitor mv, int access, String methodName) {
return null == mv
|| isAbstract(access)
|| isFinalMethod(access)
|| "<clinit>".equals(methodName)
|| "<init>".equals(methodName);
}
private boolean isAbstract(int access) {
return (ACC_ABSTRACT & access) == ACC_ABSTRACT;
}
private boolean isFinalMethod(int methodAccess) {
return (ACC_FINAL & methodAccess) == ACC_FINAL;
}
}
由于这里对于字节码的修改是在方法内部,那么实现一些复杂逻辑的最好方式,就是调用外部类的静态方法,虚拟机字节码指令中的 invokestatic 是调用指定类的静态方法的指令,这里我们将方法开始时间和方法入参作为参数调用 MethodContainer.showMethod 方法,方法实现如下:
public class MethodContainer {
// 实现静态方法
public static void showMethod(long startTime, Object[] Args) {
System.out.println("方法耗时:" + (System.nanoTime() - startTime) / 1000000 + "ms, 方法入参:" + Arrays.toString(Args));
}
}
// ASM操作字节码需要一定的学习才能理解,如果把上述字节码增强前后用Java代码表示大体入下:
// ASM代码增强前
public void test(int x) throws InterruptedException {
Thread.sleep(2000L);
System.out.println("i'm working " + x);
}
// ASM代码增强后
public void test(int x) throws InterruptedException {
long var2 = System.nanoTime();
Object[] var4 = new Object[]{new Integer(x)};
Thread.sleep(2000L);
System.out.println("i'm working " + x);
MethodContainer.showMethod(var2, var4);
}
最后运行 AttachUitl,可以看到正在运行中的 JVM 被成功的插入了我们实现的字节码,对于目标虚拟机来说是完全不需要任何实现的,而且被重定义的代码也可以被还原,感兴趣的同学可以自己了解下。
对于 Java 开发者来说,代码插桩是很熟悉的一个概念,而且目前也有很多成熟的方式可以完成,比如说 Spring AOP实现采用的动态代理方式,Lombok 采用的插入式注解处理器方式等。
所谓术业有专攻,Instrument Agent 虽然强大,但也不见得适用所有的场景,对于日志统计、方法监控,动态代理已经能很好的满足这方面的需求,但是对于 JVM 性能监控或方法实时运行分析,Instrument Agent 可以随时插入、随时卸载、随时修改的特性就体现出了极大的优点,同时其基于 Java 代码开发又会相应的降低一些开发难度,这也是业内很多性能分析软件选择这种方式实现的原因。
1.6.6 - 动态调试技术
原文链接:Java 动态调试技术原理及实践
目标问题
断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究Java语言中的动态调试技术,首先概括Java动态调试所涉及的技术基础,接着介绍我们在Java动态调试领域的思考及实践,通过结合实际业务场景,设计并实现了一种具备动态性的断点调试工具Java-debug-tool,显著提高了故障排查效率。
Java Agent
JVMTI (JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。加载Agent的时机可以是目标JVM启动之时,也可以是在目标JVM运行时进行加载,而在目标JVM运行时进行Agent加载具备动态性,对于时机未知的Debug场景来说非常实用。下面将详细分析Java Agent技术的实现细节。
实现模式
JVMTI是一套Native接口,在Java SE 5之前,要实现一个Agent只能通过编写Native代码来实现。从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument)来编写Agent。无论是通过Native的方式还是通过Java Instrumentation接口的方式来编写Agent,它们的工作都是借助JVMTI来进行完成,下面介绍通过Java Instrumentation接口编写Agent的方法。
通过Java Instrumentation API
- 实现 Agent 启动方法
Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:
[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);
JVM将首先寻找[1],如果没有发现[1],再寻找[2]。如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:
[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);
这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。
- 指定 Main-Class
Agent需要打包成一个jar包,在ManiFest属性中指定“Premain-Class”或者“Agent-Class”:
Premain-Class: class
Agent-Class: class
- 挂载到目标 JVM
将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。如果选择在目标JVM启动时加载Agent,则可以使用 “-javaagent:[=
com.sun.tools.attach.VirtualMachine 这个类代表一个JVM抽象,可以通过这个类找到目标JVM,并且将Agent挂载到目标JVM上。下面是使用com.sun.tools.attach.VirtualMachine进行动态挂载Agent的一般实现:
private void attachAgentToTargetJVM() throws Exception {
List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();
VirtualMachineDescriptor targetVM = null;
for (VirtualMachineDescriptor descriptor : virtualMachineDescriptors) {
if (descriptor.id().equals(configure.getPid())) {
targetVM = descriptor;
break;
}
}
if (targetVM == null) {
throw new IllegalArgumentException("could not find the target jvm by process id:" + configure.getPid());
}
VirtualMachine virtualMachine = null;
try {
virtualMachine = VirtualMachine.attach(targetVM);
virtualMachine.loadAgent("{agent}", "{params}");
} catch (Exception e) {
if (virtualMachine != null) {
virtualMachine.detach();
}
}
}
首先通过指定的进程ID找到目标JVM,然后通过Attach挂载到目标JVM上,执行加载Agent操作。VirtualMachine的Attach方法就是用来将Agent挂载到目标JVM上去的,而Detach则是将Agent从目标JVM卸载。关于Agent是如何挂载到目标JVM上的具体技术细节,将在下文中进行分析。
启动时加载 Agent
参数解析
创建JVM时,JVM会进行参数解析,即解析那些用来配置JVM启动的参数,比如堆大小、GC等;本文主要关注解析的参数为-agentlib、 -agentpath、 -javaagent,这几个参数用来指定Agent,JVM会根据这几个参数加载Agent。下面来分析一下JVM是如何解析这几个参数的。
// -agentlib and -agentpath
if (match_option(option, "-agentlib:", &tail) ||
(is_absolute_path = match_option(option, "-agentpath:", &tail))) {
if(tail != NULL) {
const char* pos = strchr(tail, '=');
size_t len = (pos == NULL) ? strlen(tail) : pos - tail;
char* name = strncpy(NEW_C_HEAP_ARRAY(char, len + 1, mtArguments), tail, len);
name[len] = '\0';
char *options = NULL;
if(pos != NULL) {
options = os::strdup_check_oom(pos + 1, mtArguments);
}
#if !INCLUDE_JVMTI
if (valid_jdwp_agent(name, is_absolute_path)) {
jio_fprintf(defaultStream::error_stream(),
"Debugging agents are not supported in this VM\n");
return JNI_ERR;
}
#endif // !INCLUDE_JVMTI
add_init_agent(name, options, is_absolute_path);
}
// -javaagent
} else if (match_option(option, "-javaagent:", &tail)) {
#if !INCLUDE_JVMTI
jio_fprintf(defaultStream::error_stream(),
"Instrumentation agents are not supported in this VM\n");
return JNI_ERR;
#else
if (tail != NULL) {
size_t length = strlen(tail) + 1;
char *options = NEW_C_HEAP_ARRAY(char, length, mtArguments);
jio_snprintf(options, length, "%s", tail);
add_init_agent("instrument", options, false);
// java agents need module java.instrument
if (!create_numbered_property("jdk.module.addmods", "java.instrument", addmods_count++)) {
return JNI_ENOMEM;
}
}
#endif // !INCLUDE_JVMTI
}
上面的代码片段截取自hotspot/src/share/vm/runtime/arguments.cpp中的 Arguments::parse_each_vm_init_arg(const JavaVMInitArgs* args, bool* patch_mod_javabase, Flag::Flags origin) 函数,该函数用来解析一个具体的JVM参数。这段代码的主要功能是解析出需要加载的Agent路径,然后调用add_init_agent函数进行解析结果的存储。下面先看一下add_init_agent函数的具体实现:
// -agentlib and -agentpath arguments
static AgentLibraryList _agentList;
static void add_init_agent(const char* name, char* options, bool absolute_path)
{ _agentList.add(new AgentLibrary(name, options, absolute_path, NULL)); }
AgentLibraryList是一个简单的链表结构,add_init_agent函数将解析好的、需要加载的Agent添加到这个链表中,等待后续的处理。
这里需要注意,解析-javaagent参数有一些特别之处,这个参数用来指定一个我们通过Java Instrumentation API来编写的Agent,Java Instrumentation API底层依赖的是JVMTI,对-JavaAgent的处理也说明了这一点,在调用add_init_agent函数时第一个参数是“instrument”,关于加载Agent这个问题在下一小节进行展开。到此,我们知道在启动JVM时指定的Agent已经被JVM解析完存放在了一个链表结构中。下面来分析一下JVM是如何加载这些Agent的。
执行加载操作
在创建JVM进程的函数中,解析完JVM参数之后,下面的这段代码和加载Agent相关:
// Launch -agentlib/-agentpath and converted -Xrun agents
if (Arguments::init_agents_at_startup()) {
create_vm_init_agents();
}
static bool init_agents_at_startup() {
return !_agentList.is_empty();
}
当JVM判断出上一小节中解析出来的Agent不为空的时候,就要去调用函数create_vm_init_agents来加载Agent,下面来分析一下create_vm_init_agents函数是如何加载Agent的。
void Threads::create_vm_init_agents() {
AgentLibrary* agent;
for (agent = Arguments::agents(); agent != NULL; agent = agent->next()) {
OnLoadEntry_t on_load_entry = lookup_agent_on_load(agent);
if (on_load_entry != NULL) {
// Invoke the Agent_OnLoad function
jint err = (*on_load_entry)(&main_vm, agent->options(), NULL);
}
}
}
create_vm_init_agents这个函数通过遍历Agent链表来逐个加载Agent。通过这段代码可以看出,首先通过lookup_agent_on_load来加载Agent并且找到Agent_OnLoad函数,这个函数是Agent的入口函数。如果没找到这个函数,则认为是加载了一个不合法的Agent,则什么也不做,否则调用这个函数,这样Agent的代码就开始执行起来了。对于使用Java Instrumentation API来编写Agent的方式来说,在解析阶段观察到在add_init_agent函数里面传递进去的是一个叫做”instrument”的字符串,其实这是一个动态链接库。在Linux里面,这个库叫做libinstrument.so,在BSD系统中叫做libinstrument.dylib,该动态链接库在{JAVA_HOME}/jre/lib/目录下。
instrument 动态链接库
libinstrument用来支持使用Java Instrumentation API来编写Agent,在libinstrument中有一个非常重要的类称为:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通过Java Instrumentation API编写的Agent,并且也承担着通过JVMTI实现Java Instrumentation中暴露API的责任。
我们已经知道,在JVM启动的时候,JVM会通过-javaagent参数加载Agent。最开始加载的是libinstrument动态链接库,然后在动态链接库里面找到JVMTI的入口方法:Agent_OnLoad。下面就来分析一下在libinstrument动态链接库中,Agent_OnLoad函数是怎么实现的。
JNIEXPORT jint JNICALL
DEF_Agent_OnLoad(JavaVM *vm, char *tail, void * reserved) {
initerror = createNewJPLISAgent(vm, &agent);
if ( initerror == JPLIS_INIT_ERROR_NONE ) {
if (parseArgumentTail(tail, &jarfile, &options) != 0) {
fprintf(stderr, "-javaagent: memory allocation failure.\n");
return JNI_ERR;
}
attributes = readAttributes(jarfile);
premainClass = getAttribute(attributes, "Premain-Class");
/* Save the jarfile name */
agent->mJarfile = jarfile;
/*
* Convert JAR attributes into agent capabilities
*/
convertCapabilityAttributes(attributes, agent);
/*
* Track (record) the agent class name and options data
*/
initerror = recordCommandLineData(agent, premainClass, options);
}
return result;
}
上述代码片段是经过精简的libinstrument中Agent_OnLoad实现的,大概的流程就是:先创建一个JPLISAgent,然后将ManiFest中设定的一些参数解析出来, 比如(Premain-Class)等。创建了JPLISAgent之后,调用initializeJPLISAgent对这个Agent进行初始化操作。跟进initializeJPLISAgent看一下是如何初始化的:
JPLISInitializationError initializeJPLISAgent(JPLISAgent *agent, JavaVM *vm, jvmtiEnv *jvmtienv) {
/* check what capabilities are available */
checkCapabilities(agent);
/* check phase - if live phase then we don't need the VMInit event */
jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);
/* now turn on the VMInit event */
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.VMInit = &eventHandlerVMInit;
jvmtierror = (*jvmtienv)->SetEventCallbacks(jvmtienv,&callbacks,sizeof(callbacks));
}
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv,JVMTI_ENABLE,JVMTI_EVENT_VM_INIT,NULL);
}
return (jvmtierror == JVMTI_ERROR_NONE)? JPLIS_INIT_ERROR_NONE : JPLIS_INIT_ERROR_FAILURE;
}
这里,我们关注callbacks.VMInit = &eventHandlerVMInit;这行代码,这里设置了一个VMInit事件的回调函数,表示在JVM初始化的时候会回调eventHandlerVMInit函数。下面来看一下这个函数的实现细节,猜测就是在这里调用了Premain方法:
void JNICALL eventHandlerVMInit( jvmtiEnv *jvmtienv,JNIEnv *jnienv,jthread thread) {
// ...
success = processJavaStart( environment->mAgent, jnienv);
// ...
}
jboolean processJavaStart(JPLISAgent *agent,JNIEnv *jnienv) {
result = createInstrumentationImpl(jnienv, agent);
/*
* Load the Java agent, and call the premain.
*/
if ( result ) {
result = startJavaAgent(agent, jnienv, agent->mAgentClassName, agent->mOptionsString, agent->mPremainCaller);
}
return result;
}
jboolean startJavaAgent( JPLISAgent *agent,JNIEnv *jnienv,const char *classname,const char *optionsString,jmethodID agentMainMethod) {
// ...
invokeJavaAgentMainMethod(jnienv,agent->mInstrumentationImpl,agentMainMethod, classNameObject,optionsStringObject);
// ...
}
看到这里,Instrument已经实例化,invokeJavaAgentMainMethod这个方法将我们的premain方法执行起来了。接着,我们就可以根据Instrument实例来做我们想要做的事情了。
运行时加载 Agent
比起JVM启动时加载Agent,运行时加载Agent就比较有诱惑力了,因为运行时加载Agent的能力给我们提供了很强的动态性,我们可以在需要的时候加载Agent来进行一些工作。因为是动态的,我们可以按照需求来加载所需要的Agent,下面来分析一下动态加载Agent的相关技术细节。
AttachListener
Attach机制通过Attach Listener线程来进行相关事务的处理,下面来看一下Attach Listener线程是如何初始化的。
// Starts the Attach Listener thread
void AttachListener::init() {
// 创建线程相关部分代码被去掉了
const char thread_name[] = "Attach Listener";
Handle string = java_lang_String::create_from_str(thread_name, THREAD);
{ MutexLocker mu(Threads_lock);
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
// ...
}
}
我们知道,一个线程启动之后都需要指定一个入口来执行代码,Attach Listener线程的入口是attach_listener_thread_entry,下面看一下这个函数的具体实现:
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
AttachListener::set_initialized();
for (;;) {
AttachOperation* op = AttachListener::dequeue();
// find the function to dispatch too
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
if (strcmp(op->name(), name) == 0) {
info = &(funcs[i]); break;
}}
// dispatch to the function that implements this operation
res = (info->func)(op, &st);
//...
}
}
整个函数执行逻辑,大概是这样的:
- 拉取一个需要执行的任务:AttachListener::dequeue。
- 查询匹配的命令处理函数。
- 执行匹配到的命令执行函数。
其中第二步里面存在一个命令函数表,整个表如下:
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", load_agent },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};
对于加载Agent来说,命令就是“load”。现在,我们知道了Attach Listener大概的工作模式,但是还是不太清楚任务从哪来,这个秘密就藏在AttachListener::dequeue这行代码里面,接下来我们来分析一下dequeue这个函数:
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
// wait for client to connect
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
// get the credentials of the peer and check the effective uid/guid
// - check with jeff on this.
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
::close(s);
continue;
}
// peer credential look okay so we read the request
LinuxAttachOperation* op = read_request(s);
return op;
}
}
这是Linux上的实现,不同的操作系统实现方式不太一样。上面的代码表面,Attach Listener在某个端口监听着,通过accept来接收一个连接,然后从这个连接里面将请求读取出来,然后将请求包装成一个AttachOperation类型的对象,之后就会从表里查询对应的处理函数,然后进行处理。
Attach Listener使用一种被称为“懒加载”的策略进行初始化,也就是说,JVM启动的时候Attach Listener并不一定会启动起来。下面我们来分析一下这种“懒加载”策略的具体实现方案。
// Start Attach Listener if +StartAttachListener or it can't be started lazily
if (!DisableAttachMechanism) {
AttachListener::vm_start();
if (StartAttachListener || AttachListener::init_at_startup()) {
AttachListener::init();
}
}
// Attach Listener is started lazily except in the case when
// +ReduseSignalUsage is used
bool AttachListener::init_at_startup() {
if (ReduceSignalUsage) {
return true;
} else {
return false;
}
}
上面的代码截取自create_vm函数,DisableAttachMechanism、StartAttachListener和ReduceSignalUsage这三个变量默认都是false,所以AttachListener::init();这行代码不会在create_vm的时候执行,而vm_start会执行。下面来看一下这个函数的实现细节:
void AttachListener::vm_start() {
char fn[UNIX_PATH_MAX];
struct stat64 st;
int ret;
int n = snprintf(fn, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
assert(n < (int)UNIX_PATH_MAX, "java_pid file name buffer overflow");
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == 0) {
ret = ::unlink(fn);
if (ret == -1) {
log_debug(attach)("Failed to remove stale attach pid file at %s", fn);
}
}
}
这是在Linux上的实现,是将/tmp/目录下的.java_pid{pid}文件删除,后面在创建Attach Listener线程的时候会创建出来这个文件。上面说到,AttachListener::init()这行代码不会在create_vm的时候执行,这行代码的实现已经在上文中分析了,就是创建Attach Listener线程,并监听其他JVM的命令请求。现在来分析一下这行代码是什么时候被调用的,也就是“懒加载”到底是怎么加载起来的。
// Signal Dispatcher needs to be started before VMInit event is posted
os::signal_init();
这是create_vm中的一段代码,看起来跟信号相关,其实Attach机制就是使用信号来实现“懒加载“的。下面我们来仔细地分析一下这个过程。
void os::signal_init() {
if (!ReduceSignalUsage) {
// Setup JavaThread for processing signals
EXCEPTION_MARK;
Klass* k = SystemDictionary::resolve_or_fail(vmSymbols::java_lang_Thread(), true, CHECK);
instanceKlassHandle klass (THREAD, k);
instanceHandle thread_oop = klass->allocate_instance_handle(CHECK);
const char thread_name[] = "Signal Dispatcher";
Handle string = java_lang_String::create_from_str(thread_name, CHECK);
// Initialize thread_oop to put it into the system threadGroup
Handle thread_group (THREAD, Universe::system_thread_group());
JavaValue result(T_VOID);
JavaCalls::call_special(&result, thread_oop,klass,vmSymbols::object_initializer_name(),vmSymbols::threadgroup_string_void_signature(),
thread_group,string,CHECK);
KlassHandle group(THREAD, SystemDictionary::ThreadGroup_klass());
JavaCalls::call_special(&result,thread_group,group,vmSymbols::add_method_name(),vmSymbols::thread_void_signature(),thread_oop,CHECK);
os::signal_init_pd();
{ MutexLocker mu(Threads_lock);
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
// ...
}
// Handle ^BREAK
os::signal(SIGBREAK, os::user_handler());
}
}
JVM创建了一个新的进程来实现信号处理,这个线程叫“Signal Dispatcher”,一个线程创建之后需要有一个入口,“Signal Dispatcher”的入口是signal_thread_entry:
这段代码截取自signal_thread_entry函数,截取中的内容是和Attach机制信号处理相关的代码。这段代码的意思是,当接收到“SIGBREAK”信号,就执行接下来的代码,这个信号是需要Attach到JVM上的信号发出来,这个后面会再分析。我们先来看一句关键的代码:AttachListener::is_init_trigger():
bool AttachListener::is_init_trigger() {
if (init_at_startup() || is_initialized()) {
return false; // initialized at startup or already initialized
}
char fn[PATH_MAX+1];
sprintf(fn, ".attach_pid%d", os::current_process_id());
int ret;
struct stat64 st;
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == -1) {
log_trace(attach)("Failed to find attach file: %s, trying alternate", fn);
snprintf(fn, sizeof(fn), "%s/.attach_pid%d", os::get_temp_directory(), os::current_process_id());
RESTARTABLE(::stat64(fn, &st), ret);
}
if (ret == 0) {
// simple check to avoid starting the attach mechanism when
// a bogus user creates the file
if (st.st_uid == geteuid()) {
init();
return true;
}
}
return false;
}
首先检查了一下是否在JVM启动时启动了Attach Listener,或者是否已经启动过。如果没有,才继续执行,在/tmp目录下创建一个叫做.attach_pid%d的文件,然后执行AttachListener的init函数,这个函数就是用来创建Attach Listener线程的函数,上面已经提到多次并进行了分析。到此,我们知道Attach机制的奥秘所在,也就是Attach Listener线程的创建依靠Signal Dispatcher线程,Signal Dispatcher是用来处理信号的线程,当Signal Dispatcher线程接收到“SIGBREAK”信号之后,就会执行初始化Attach Listener的工作。
运行时加载 Agent 的实现
我们继续分析,到底是如何将一个Agent挂载到运行着的目标JVM上,在上文中提到了一段代码,用来进行运行时挂载Agent,可以参考上文中展示的关于“attachAgentToTargetJvm”方法的代码。这个方法里面的关键是调用VirtualMachine的attach方法进行Agent挂载的功能。下面我们就来分析一下VirtualMachine的attach方法具体是怎么实现的。
public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException {
if (var0 == null) {
throw new NullPointerException("id cannot be null");
} else {
List var1 = AttachProvider.providers();
if (var1.size() == 0) {
throw new AttachNotSupportedException("no providers installed");
} else {
AttachNotSupportedException var2 = null;
Iterator var3 = var1.iterator();
while(var3.hasNext()) {
AttachProvider var4 = (AttachProvider)var3.next();
try {
return var4.attachVirtualMachine(var0);
} catch (AttachNotSupportedException var6) {
var2 = var6;
}
}
throw var2;
}
}
}
这个方法通过attachVirtualMachine方法进行attach操作,在MacOS系统中,AttachProvider的实现类是BsdAttachProvider。我们来看一下BsdAttachProvider的attachVirtualMachine方法是如何实现的:
public VirtualMachine attachVirtualMachine(String var1) throws AttachNotSupportedException, IOException {
this.checkAttachPermission();
this.testAttachable(var1);
return new BsdVirtualMachine(this, var1);
}
BsdVirtualMachine(AttachProvider var1, String var2) throws AttachNotSupportedException, IOException {
int var3 = Integer.parseInt(var2);
this.path = this.findSocketFile(var3);
if (this.path == null) {
File var4 = new File(tmpdir, ".attach_pid" + var3);
createAttachFile(var4.getPath());
try {
sendQuitTo(var3);
int var5 = 0;
long var6 = 200L;
int var8 = (int)(this.attachTimeout() / var6);
do {
try {
Thread.sleep(var6);
} catch (InterruptedException var21) {
;
}
this.path = this.findSocketFile(var3);
++var5;
} while(var5 <= var8 && this.path == null);
} finally {
var4.delete();
}
}
int var24 = socket();
connect(var24, this.path);
}
private String findSocketFile(int var1) {
String var2 = ".java_pid" + var1;
File var3 = new File(tmpdir, var2);
return var3.exists() ? var3.getPath() : null;
}
findSocketFile方法用来查询目标JVM上是否已经启动了Attach Listener,它通过检查”tmp/“目录下是否存在java_pid{pid}来进行实现。如果已经存在了,则说明Attach机制已经准备就绪,可以接受客户端的命令了,这个时候客户端就可以通过connect连接到目标JVM进行命令的发送,比如可以发送“load”命令来加载Agent。如果java_pid{pid}文件还不存在,则需要通过sendQuitTo方法向目标JVM发送一个“SIGBREAK”信号,让它初始化Attach Listener线程并准备接受客户端连接。可以看到,发送了信号之后客户端会循环等待java_pid{pid}这个文件,之后再通过connect连接到目标JVM上。
load 命令的实现
下面来分析一下,“load”命令在JVM层面的实现:
static jint load_agent(AttachOperation* op, outputStream* out) {
// get agent name and options
const char* agent = op->arg(0);
const char* absParam = op->arg(1);
const char* options = op->arg(2);
// If loading a java agent then need to ensure that the java.instrument module is loaded
if (strcmp(agent, "instrument") == 0) {
Thread* THREAD = Thread::current();
ResourceMark rm(THREAD);
HandleMark hm(THREAD);
JavaValue result(T_OBJECT);
Handle h_module_name = java_lang_String::create_from_str("java.instrument", THREAD);
JavaCalls::call_static(&result,SystemDictionary::module_Modules_klass(),vmSymbols::loadModule_name(),
vmSymbols::loadModule_signature(),h_module_name,THREAD);
}
return JvmtiExport::load_agent_library(agent, absParam, options, out);
}
这个函数先确保加载了java.instrument模块,之后真正执行Agent加载的函数是 load_agent_library ,这个函数的套路就是加载Agent动态链接库,如果是通过Java instrument API实现的Agent,则加载的是libinstrument动态链接库,然后通过libinstrument里面的代码实现运行agentmain方法的逻辑,这一部分内容和libinstrument实现premain方法运行的逻辑其实差不多,这里不再做分析。至此,我们对Java Agent技术已经有了一个全面而细致的了解。
动态替换类字节码技术
动态字节码修改的限制
上文中已经详细分析了Agent技术的实现,我们使用Java Instrumentation API来完成动态类修改的功能,在Instrumentation接口中,通过addTransformer方法来增加一个类转换器,类转换器由类ClassFileTransformer接口实现。ClassFileTransformer接口中唯一的方法transform用于实现类转换,当类被加载的时候,就会调用transform方法,进行类转换。在运行时,我们可以通过Instrumentation的redefineClasses方法进行类重定义,在方法上有一段注释需要特别注意:
* The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.
这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过ASM获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses方法来进行类的重定义就会失败。那redefineClasses方法具体是怎么实现类的重定义的呢?它对运行时的JVM会造成什么样的影响呢?下面来分析redefineClasses的实现细节。
重定义类字节码的实现细节
上文中我们提到,libinstrument动态链接库中,JPLISAgent不仅实现了Agent入口代码执行的路由,而且还是Java代码与JVMTI之间的一道桥梁。我们在Java代码中调用Java Instrumentation API的redefineClasses,其实会调用libinstrument中的相关代码,我们来分析一下这条路径。
public void redefineClasses(ClassDefinition... var1) throws ClassNotFoundException {
if (!this.isRedefineClassesSupported()) {
throw new UnsupportedOperationException("redefineClasses is not supported in this environment");
} else if (var1 == null) {
throw new NullPointerException("null passed as 'definitions' in redefineClasses");
} else {
for(int var2 = 0; var2 < var1.length; ++var2) {
if (var1[var2] == null) {
throw new NullPointerException("element of 'definitions' is null in redefineClasses");
}
}
if (var1.length != 0) {
this.redefineClasses0(this.mNativeAgent, var1);
}
}
}
private native void redefineClasses0(long var1, ClassDefinition[] var3) throws ClassNotFoundException;
这是InstrumentationImpl中的redefineClasses实现,该方法的具体实现依赖一个Native方法redefineClasses(),我们可以在libinstrument中找到这个Native方法的实现:
JNIEXPORT void JNICALL Java_sun_instrument_InstrumentationImpl_redefineClasses0
(JNIEnv * jnienv, jobject implThis, jlong agent, jobjectArray classDefinitions) {
redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);
}
redefineClasses这个函数的实现比较复杂,代码很长。下面是一段关键的代码片段:
可以看到,其实是调用了JVMTI的RetransformClasses函数来完成类的重定义细节。Java-debug-tool
// class_count - pre-checked to be greater than or equal to 0
// class_definitions - pre-checked for NULL
jvmtiError JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
VMThread::execute(&op);
return (op.check_error());
} /* end RedefineClasses */
重定义类的请求会被JVM包装成一个VM_RedefineClasses类型的VM_Operation,VM_Operation是JVM内部的一些操作的基类,包括GC操作等。VM_Operation由VMThread来执行,新的VM_Operation操作会被添加到VMThread的运行队列中去,VMThread会不断从队列里面拉取VM_Operation并调用其doit等函数执行具体的操作。VM_RedefineClasses函数的流程较为复杂,下面是VM_RedefineClasses的大致流程:
- 加载新的字节码,合并常量池,并且对新的字节码进行校验工作
// Load the caller's new class definition(s) into _scratch_classes.
// Constant pool merging work is done here as needed. Also calls
// compare_and_normalize_class_versions() to verify the class
// definition(s).
jvmtiError load_new_class_versions(TRAPS);
- 清除方法上的断点
// Remove all breakpoints in methods of this class
JvmtiBreakpoints& jvmti_breakpoints = JvmtiCurrentBreakpoints::get_jvmti_breakpoints();
jvmti_breakpoints.clearall_in_class_at_safepoint(the_class());
- JIT逆优化
// Deoptimize all compiled code that depends on this class
flush_dependent_code(the_class, THREAD);
- 进行字节码替换工作,需要进行更新类itable/vtable等操作
- 进行类重定义通知
SystemDictionary::notice_modification();
VM_RedefineClasses实现比较复杂的,详细实现可以参考 RedefineClasses的实现。
Java-debug-tool设计与实现
Java-debug-tool是一个使用Java Instrument API来实现的动态调试工具,它通过在目标JVM上启动一个TcpServer来和调试客户端通信。调试客户端通过命令行来发送调试命令给TcpServer,TcpServer中有专门用来处理命令的handler,handler处理完命令之后会将结果发送回客户端,客户端通过处理将调试结果展示出来。下面将详细介绍Java-debug-tool的整体设计和实现。
整体架构
Java-debug-tool包括一个Java Agent和一个用于处理调试命令的核心API,核心API通过一个自定义的类加载器加载进来,以保证目标JVM的类不会被污染。整体上Java-debug-tool的设计是一个Client-Server的架构,命令客户端需要完整的完成一个命令之后才能继续执行下一个调试命令。Java-debug-tool支持多人同时进行调试,下面是整体架构图:
下面对每一层做简单介绍:
- 交互层:负责将程序员的输入转换成调试交互协议,并且将调试信息呈现出来。
- 连接管理层:负责管理客户端连接,从连接中读调试协议数据并解码,对调试结果编码并将其写到连接中去;同时将那些超时未活动的连接关闭。
- 业务逻辑层:实现调试命令处理,包括命令分发、数据收集、数据处理等过程。
- 基础实现层:Java-debug-tool实现的底层依赖,通过Java Instrumentation提供的API进行类查找、类重定义等能力,Java Instrumentation底层依赖JVMTI来完成具体的功能。
在Agent被挂载到目标JVM上之后,Java-debug-tool会安排一个Spy在目标JVM内活动,这个Spy负责将目标JVM内部的相关调试数据转移到命令处理模块,命令处理模块会处理这些数据,然后给客户端返回调试结果。命令处理模块会增强目标类的字节码来达到数据获取的目的,多个客户端可以共享一份增强过的字节码,无需重复增强。下面从Java-debug-tool的字节码增强方案、命令设计与实现等角度详细说明。
字节码增强方案
Java-debug-tool使用字节码增强来获取到方法运行时的信息,比如方法入参、出参等,可以在不同的字节码位置进行增强,这种行为可以称为“插桩”,每个“桩”用于获取数据并将他转储出去。Java-debug-tool具备强大的插桩能力,不同的桩负责获取不同类别的数据,下面是Java-debug-tool目前所支持的“桩”:
- 方法进入点:用于获取方法入参信息。
- Fields获取点1:在方法执行前获取到对象的字段信息。
- 变量存储点:获取局部变量信息。
- Fields获取点2:在方法退出前获取到对象的字段信息。
- 方法退出点:用于获取方法返回值。
- 抛出异常点:用于获取方法抛出的异常信息。
通过上面这些代码桩,Java-debug-tool可以收集到丰富的方法执行信息,经过处理可以返回更加可视化的调试结果。
字节码增强
Java-debug-tool在实现上使用了ASM工具来进行字节码增强,并且每个插桩点都可以进行配置,如果不想要什么信息,则没必要进行对应的插桩操作。这种可配置的设计是非常有必要的,因为有时候我们仅仅是想要知道方法的入参和出参,但Java-debug-tool却给我们返回了所有的调试信息,这样我们就得在众多的输出中找到我们所关注的内容。如果可以进行配置,则除了入参点和出参点外其他的桩都不插,那么就可以快速看到我们想要的调试数据,这种设计的本质是为了让调试者更加专注。下面是Java-debug-tool的字节码增强工作方式:
如图4-2-1所示,当调试者发出调试命令之后,Java-debug-tool会识别命令并判断是否需要进行字节码增强,如果命令需要增强字节码,则判断当前类+当前方法是否已经被增强过。上文已经提到,字节码替换是有一定损耗的,这种具有损耗的操作发生的次数越少越好,所以字节码替换操作会被记录起来,后续命令直接使用即可,不需要重复进行字节码增强,字节码增强还涉及多个调试客户端的协同工作问题,当一个客户端增强了一个类的字节码之后,这个客户端就锁定了该字节码,其他客户端变成只读,无法对该类进行字节码增强,只有当持有锁的客户端主动释放锁或者断开连接之后,其他客户端才能继续增强该类的字节码。
字节码增强模块收到字节码增强请求之后,会判断每个增强点是否需要插桩,这个判断的根据就是上文提到的插桩配置,之后字节码增强模块会生成新的字节码,Java-debug-tool将执行字节码替换操作,之后就可以进行调试数据收集了。
经过字节码增强之后,原来的方法中会插入收集运行时数据的代码,这些代码在方法被调用的时候执行,获取到诸如方法入参、局部变量等信息,这些信息将传递给数据收集装置进行处理。数据收集的工作通过Advice完成,每个客户端同一时间只能注册一个Advice到Java-debug-tool调试模块上,多个客户端可以同时注册自己的Advice到调试模块上。Advice负责收集数据并进行判断,如果当前数据符合调试命令的要求,Java-debug-tool就会卸载这个Advice,Advice的数据就会被转移到Java-debug-tool的命令结果处理模块进行处理,并将结果发送到客户端。
Advice 的工作方式
Advice是调试数据收集器,不同的调试策略会对应不同的Advice。Advice是工作在目标JVM的线程内部的,它需要轻量级和高效,意味着Advice不能做太过于复杂的事情,它的核心接口“match”用来判断本次收集到的调试数据是否满足调试需求。如果满足,那么Java-debug-tool就会将其卸载,否则会继续让他收集调试数据,这种“加载Advice” -> “卸载Advice”的工作模式具备很好的灵活性。
关于Advice,需要说明的另外一点就是线程安全,因为它加载之后会运行在目标JVM的线程中,目标JVM的方法极有可能是多线程访问的,这也就是说,Advice需要有能力处理多个线程同时访问方法的能力,如果Advice处理不当,则可能会收集到杂乱无章的调试数据。下面的图片展示了Advice和Java-debug-tool调试分析模块、目标方法执行以及调试客户端等模块的关系。
Advice的首次挂载由Java-debug-tool的命令处理器完成,当一次调试数据收集完成之后,调试数据处理模块会自动卸载Advice,然后进行判断,如果调试数据符合Advice的策略,则直接将数据交由数据处理模块进行处理,否则会清空调试数据,并再次将Advice挂载到目标方法上去,等待下一次调试数据。非首次挂载由调试数据处理模块进行,它借助Advice按需取数据,如果不符合需求,则继续挂载Advice来获取数据,否则对调试数据进行处理并返回给客户端。
命令设计与实现
命令执行
上文已经完整的描述了Java-debug-tool的设计以及核心技术方案,本小节将详细介绍Java-debug-tool的命令设计与实现。首先需要将一个调试命令的执行流程描述清楚,下面是一张用来表示命令请求处理流程的图片:
图4-3-1简单的描述了Java-debug-tool的命令处理方式,客户端连接到服务端之后,会进行一些协议解析、协议认证、协议填充等工作,之后将进行命令分发。服务端如果发现客户端的命令不合法,则会立即返回错误信息,否则再进行命令处理。命令处理属于典型的三段式处理,前置命令处理、命令处理以及后置命令处理,同时会对命令处理过程中的异常信息进行捕获处理,三段式处理的好处是命令处理被拆成了多个阶段,多个阶段负责不同的职责。前置命令处理用来做一些命令权限控制的工作,并填充一些类似命令处理开始时间戳等信息,命令处理就是通过字节码增强,挂载Advice进行数据收集,再经过数据处理来产生命令结果的过程,后置处理则用来处理一些连接关闭、字节码解锁等事项。
Java-debug-tool允许客户端设置一个命令执行超时时间,超过这个时间则认为命令没有结果,如果客户端没有设置自己的超时时间,就使用默认的超时时间进行超时控制。Java-debug-tool通过设计了两阶段的超时检测机制来实现命令执行超时功能:首先,第一阶段超时触发,则Java-debug-tool会友好的警告命令处理模块处理时间已经超时,需要立即停止命令执行,这允许命令自己做一些现场清理工作,当然需要命令执行线程自己感知到这种超时警告;当第二阶段超时触发,则Java-debug-tool认为命令必须结束执行,会强行打断命令执行线程。超时机制的目的是为了不让命令执行太长时间,命令如果长时间没有收集到调试数据,则应该停止执行,并思考是否调试了一个错误的方法。当然,超时机制还可以定期清理那些因为未知原因断开连接的客户端持有的调试资源,比如字节码锁。
获取方法执行视图
Java-debug-tool通过下面的信息来向调试者呈现出一次方法执行的视图:
- 正在调试的方法信息。
- 方法调用堆栈。
- 调试耗时,包括对目标JVM造成的STW时间。
- 方法入参,包括入参的类型及参数值。
- 方法的执行路径。
- 代码执行耗时。
- 局部变量信息。
- 方法返回结果。
- 方法抛出的异常。
- 对象字段值快照。
图4-3-2展示了Java-debug-tool获取到正在运行的方法的执行视图的信息。
与同类产品对比分析
Java-debug-tool的同类产品主要是greys,其他类似的工具大部分都是基于greys进行的二次开发,所以直接选择greys来和Java-debug-tool进行对比。
总结
本文详细剖析了Java动态调试关键技术的实现细节,并介绍了我们基于Java动态调试技术结合实际故障排查场景进行的一点探索实践;动态调试技术为研发人员进行线上问题排查提供了一种新的思路,我们基于动态调试技术解决了传统断点调试存在的问题,使得可以将断点调试这种技术应用在线上,以线下调试的思维来进行线上调试,提高问题排查效率。
参考文献
1.6.7 - Arthas
简介
Arthas
是Alibaba开源的Java诊断工具,深受开发者喜爱。
能解决什么问题
当你遇到以下类似问题而束手无策时,Arthas
可以帮助你解决:
- 这个类从哪个 jar 包加载的? 为什么会报各种类相关的 Exception?
- 我改的代码为什么没有执行到? 难道是我没 commit? 分支搞错了?
- 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
- 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
- 是否有一个全局视角来查看系统的运行状况?
- 有什么办法可以监控到JVM的实时运行状态?
Arthas
支持JDK 6+,支持Linux/Mac/Windows,采用命令行交互模式,同时提供丰富的 Tab
自动补全功能,进一步方便进行问题的定位和诊断。
Arthas资源推荐
技术基础
- greys-anatomy: Arthas代码基于Greys二次开发而来,非常感谢Greys之前所有的工作,以及Greys原作者对Arthas提出的意见和建议!
- termd: Arthas的命令行实现基于termd开发,是一款优秀的命令行程序开发框架,感谢termd提供了优秀的框架。
- crash: Arthas的文本渲染功能基于crash中的文本渲染功能开发,可以从这里看到源码,感谢crash在这方面所做的优秀工作。
- cli: Arthas的命令行界面基于vert.x提供的cli库进行开发,感谢vert.x在这方面做的优秀工作。
- compiler Arthas里的内存编绎器代码来源
- Apache Commons Net Arthas里的Telnet Client代码来源
JavaAgent
:运行在 main方法之前的拦截器,它内定的方法名叫 premain ,也就是说先执行 premain 方法然后再执行 main 方法ASM
:一个通用的Java字节码操作和分析框架。它可以用于修改现有的类或直接以二进制形式动态生成类。ASM提供了一些常见的字节码转换和分析算法,可以从它们构建定制的复杂转换和代码分析工具。ASM提供了与其他Java字节码框架类似的功能,但是主要关注性能。因为它被设计和实现得尽可能小和快,所以非常适合在动态系统中使用(当然也可以以静态方式使用,例如在编译器中)
同类工具
- BTrace
- 美团 Java-debug-tool
- 去哪儿Bistoury: 一个集成了Arthas的项目
- 一个使用MVEL脚本的fork
入门
安装
curl -O https://alibaba.github.io/arthas/arthas-boot.jar
java -jar arthas-boot.jar
示例
Dashboard
Thread
$ thread -n 3
"as-command-execute-daemon" Id=29 cpuUsage=75% RUNNABLE
at sun.management.ThreadImpl.dumpThreads0(Native Method)
at sun.management.ThreadImpl.getThreadInfo(ThreadImpl.java:440)
at com.taobao.arthas.core.command.monitor200.ThreadCommand$1.action(ThreadCommand.java:58)
at com.taobao.arthas.core.command.handler.AbstractCommandHandler.execute(AbstractCommandHandler.java:238)
at com.taobao.arthas.core.command.handler.DefaultCommandHandler.handleCommand(DefaultCommandHandler.java:67)
at com.taobao.arthas.core.server.ArthasServer$4.run(ArthasServer.java:276)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
at java.lang.Thread.run(Thread.java:745)
Number of locked synchronizers = 1
- java.util.concurrent.ThreadPoolExecutor$Worker@6cd0b6f8
"as-session-expire-daemon" Id=25 cpuUsage=24% TIMED_WAITING
at java.lang.Thread.sleep(Native Method)
at com.taobao.arthas.core.server.DefaultSessionManager$2.run(DefaultSessionManager.java:85)
"Reference Handler" Id=2 cpuUsage=0% WAITING on java.lang.ref.Reference$Lock@69ba0f27
at java.lang.Object.wait(Native Method)
- waiting on java.lang.ref.Reference$Lock@69ba0f27
at java.lang.Object.wait(Object.java:503)
at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:133)
jad 反编译
$ jad javax.servlet.Servlet
ClassLoader:
+-java.net.URLClassLoader@6108b2d7
+-sun.misc.Launcher$AppClassLoader@18b4aac2
+-sun.misc.Launcher$ExtClassLoader@1ddf84b8
Location:
/Users/xxx/work/test/lib/servlet-api.jar
/*
* Decompiled with CFR 0_122.
*/
package javax.servlet;
import java.io.IOException;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
public interface Servlet {
public void init(ServletConfig var1) throws ServletException;
public ServletConfig getServletConfig();
public void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;
public String getServletInfo();
public void destroy();
}
mc 编译 .class
mc /tmp/Test.java
redefine 加载外部 .class
修改已加载类
redefine /tmp/Test.class
redefine -c 327a647b /tmp/Test.class /tmp/Test\$Inner.class
sc 查找已加载类
$ sc -d org.springframework.web.context.support.XmlWebApplicationContext
class-info org.springframework.web.context.support.XmlWebApplicationContext
code-source /Users/xxx/work/test/WEB-INF/lib/spring-web-3.2.11.RELEASE.jar
name org.springframework.web.context.support.XmlWebApplicationContext
isInterface false
isAnnotation false
isEnum false
isAnonymousClass false
isArray false
isLocalClass false
isMemberClass false
isPrimitive false
isSynthetic false
simple-name XmlWebApplicationContext
modifier public
annotation
interfaces
super-class +-org.springframework.web.context.support.AbstractRefreshableWebApplicationContext
+-org.springframework.context.support.AbstractRefreshableConfigApplicationContext
+-org.springframework.context.support.AbstractRefreshableApplicationContext
+-org.springframework.context.support.AbstractApplicationContext
+-org.springframework.core.io.DefaultResourceLoader
+-java.lang.Object
class-loader +-org.apache.catalina.loader.ParallelWebappClassLoader
+-java.net.URLClassLoader@6108b2d7
+-sun.misc.Launcher$AppClassLoader@18b4aac2
+-sun.misc.Launcher$ExtClassLoader@1ddf84b8
classLoaderHash 25131501
stack 查看调用栈
$ stack test.arthas.TestStack doGet
Press Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 286 ms.
ts=2018-09-18 10:11:45;thread_name=http-bio-8080-exec-10;id=d9;is_daemon=true;priority=5;TCCL=org.apache.catalina.loader.ParallelWebappClassLoader@25131501
@test.arthas.TestStack.doGet()
at javax.servlet.http.HttpServlet.service(HttpServlet.java:624)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:731)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:303)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:220)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:110)
...
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:169)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:103)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:116)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:451)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1121)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:637)
at org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor.run(JIoEndpoint.java:316)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:745)
trace 追踪方法调用
watch 查看方法调用入参
$ watch test.arthas.TestWatch doGet {params[0], throwExp} -e
Press Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 65 ms.
ts=2018-09-18 10:26:28;result=@ArrayList[
@RequestFacade[org.apache.catalina.connector.RequestFacade@79f922b2],
@NullPointerException[java.lang.NullPointerException],
]
monitor 监控方法调用统计信息
$ monitor -c 5 org.apache.dubbo.demo.provider.DemoServiceImpl sayHello
Press Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 109 ms.
timestamp class method total success fail avg-rt(ms) fail-rate
----------------------------------------------------------------------------------------------------------------------------
2018-09-20 09:45:32 org.apache.dubbo.demo.provider.DemoServiceImpl sayHello 5 5 0 0.67 0.00%
timestamp class method total success fail avg-rt(ms) fail-rate
----------------------------------------------------------------------------------------------------------------------------
2018-09-20 09:45:37 org.apache.dubbo.demo.provider.DemoServiceImpl sayHello 5 5 0 1.00 0.00%
timestamp class method total success fail avg-rt(ms) fail-rate
----------------------------------------------------------------------------------------------------------------------------
2018-09-20 09:45:42 org.apache.dubbo.demo.provider.DemoServiceImpl sayHello 5 5 0 0.43 0.00%
time tunnel 记录调用现场以回放
$ tt -t org.apache.dubbo.demo.provider.DemoServiceImpl sayHello
Press Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 75 ms.
INDEX TIMESTAMP COST(ms) IS-RET IS-EXP OBJECT CLASS METHOD
-------------------------------------------------------------------------------------------------------------------------------------
1000 2018-09-20 09:54:10 1.971195 true false 0x55965cca DemoServiceImpl sayHello
1001 2018-09-20 09:54:11 0.215685 true false 0x55965cca DemoServiceImpl sayHello
1002 2018-09-20 09:54:12 0.236303 true false 0x55965cca DemoServiceImpl sayHello
1003 2018-09-20 09:54:13 0.159598 true false 0x55965cca DemoServiceImpl sayHello
1004 2018-09-20 09:54:14 0.201982 true false 0x55965cca DemoServiceImpl sayHello
1005 2018-09-20 09:54:15 0.214205 true false 0x55965cca DemoServiceImpl sayHello
1006 2018-09-20 09:54:16 0.241863 true false 0x55965cca DemoServiceImpl sayHello
1007 2018-09-20 09:54:17 0.305747 true false 0x55965cca DemoServiceImpl sayHello
1008 2018-09-20 09:54:18 0.18468 true false 0x55965cca DemoServiceImpl sayHello
classloader 查看类加载信息
$ classloader
name numberOfInstances loadedCountTotal
BootstrapClassLoader 1 3346
com.taobao.arthas.agent.ArthasClassloader 1 1262
java.net.URLClassLoader 2 1033
org.apache.catalina.loader.ParallelWebappClassLoader 1 628
sun.reflect.DelegatingClassLoader 166 166
sun.misc.Launcher$AppClassLoader 1 31
com.alibaba.fastjson.util.ASMClassLoader 6 15
sun.misc.Launcher$ExtClassLoader 1 7
org.jvnet.hk2.internal.DelegatingClassLoader 2 2
sun.reflect.misc.MethodUtil 1 1
web console 基于 web 的控制台
命令集
基础命令
- help——查看命令帮助信息
- cat——打印文件内容,和linux里的cat命令类似
- [grep]](https://github.com/alibaba/arthas/blob/master/site/src/site/sphinx/grep.md)——匹配查找,和linux里的grep命令类似
- pwd——返回当前的工作目录,和linux命令类似
- cls——清空当前屏幕区域
- session——查看当前会话的信息
- reset——重置增强类,将被 Arthas 增强过的类全部还原,Arthas 服务端关闭时会重置所有增强过的类
- version——输出当前目标 Java 进程所加载的 Arthas 版本号
- history——打印命令历史
- quit——退出当前 Arthas 客户端,其他 Arthas 客户端不受影响
- stop/shutdown——关闭 Arthas 服务端,所有 Arthas 客户端全部退出
- keymap——Arthas快捷键列表及自定义快捷键
JVM 命令
- dashboard——当前系统的实时数据面板
- thread——查看当前 JVM 的线程堆栈信息
- jvm——查看当前 JVM 的信息
- sysprop——查看和修改JVM的系统属性
- sysenv——查看JVM的环境变量
- vmoption——查看和修改JVM里诊断相关的option
- logger——查看和修改logger
- getstatic——查看类的静态属性
- ognl——执行ognl表达式
- mbean——查看 Mbean 的信息
- heapdump——dump java heap, 类似jmap命令的heap dump功能
class/classloader
- sc——查看JVM已加载的类信息
- sm——查看已加载类的方法信息
- jad——反编译指定已加载类的源码
- mc——内存编绎器,内存编绎
.java
文件为.class
文件 - redefine——加载外部的
.class
文件,redefine到JVM里 - dump——dump 已加载类的 byte code 到特定目录
- classloader——查看classloader的继承树,urls,类加载信息,使用classloader去getResource
monitor/watch/trace
这些命令,都通过字节码增强技术来实现的,会在指定类的方法中插入一些切面来实现数据统计和观测,因此在线上、预发使用时,请尽量明确需要观测的类、方法以及条件,诊断结束要执行
shutdown
或将增强过的类执行reset
命令。
- monitor——方法执行监控
- watch——方法执行数据观测
- trace——方法内部调用路径,并输出方法路径上的每个节点上耗时
- stack——输出当前方法被调用的调用路径
- tt——方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测
options
- options——查看或设置Arthas全局开关
管道
Arthas支持使用管道对上述命令的结果进行进一步的处理,如sm java.lang.String * | grep 'index'
- grep——搜索满足条件的结果
- plaintext——将命令的结果去除ANSI颜色
- wc——按行统计输出结果
后台异步任务
当线上出现偶发的问题,比如需要watch某个条件,而这个条件一天可能才会出现一次时,异步后台任务就派上用场了,详情请参考这里
- 使用 > 将结果重写向到日志文件,使用 & 指定命令是后台运行,session断开不影响任务执行(生命周期默认为1天)
- jobs——列出所有job
- kill——强制终止任务
- fg——将暂停的任务拉到前台执行
- bg——将暂停的任务放到后台执行
用户数据回报
在3.1.4
版本后,增加了用户数据回报功能,方便统一做安全或者历史数据统计。
在启动时,指定stat-url
,就会回报执行的每一行命令,比如: ./as.sh --stat-url 'http://192.168.10.11:8080/api/stat'
在tunnel server里有一个示例的回报代码,用户可以自己在服务器上实现。
其他特性
应用实例
查看最繁忙的线程,以及是否有阻塞情况发生?
场景:我想看下查看最繁忙的线程,以及是否有阻塞情况发生? 常规查看线程,一般我们可以通过 top 等系统命令进行查看,但是那毕竟要很多个步骤,很麻烦。
thread -n 3 # 查看最繁忙的三个线程栈信息
thread # 以直观的方式展现所有的线程情况
thread -b #找出当前阻塞其他线程的线程
确认某个类是否已被系统加载?
场景:我新写了一个类或者一个方法,我想知道新写的代码是否被部署了?
# 即可以找到需要的类全路径,如果存在的话
sc *MyServlet
# 查看这个某个类所有的方法
sm pdai.tech.servlet.TestMyServlet *
# 查看某个方法的信息,如果存在的话
sm pdai.tech.servlet.TestMyServlet testMethod
如何查看一个class类的源码信息?
场景:我新修改的内容在方法内部,而上一个步骤只能看到方法,这时候可以反编译看下源码
# 直接反编译出java 源代码,包含一此额外信息的
jad pdai.tech.servlet.TestMyServlet
重要:如何跟踪某个方法的返回值、入参…. ?
# 同时监控入参,返回值,及异常
watch pdai.tech.servlet.TestMyServlet testMethod "{params, returnObj, throwExp}" -e -x 2
如何看方法调用栈的信息?
stack pdai.tech.servlet.TestMyServlet testMethod
重要:找到最耗时的方法调用?
# 执行的时候每个子调用的运行时长,可以找到最耗时的子调用。
stack pdai.tech.servlet.TestMyServlet testMethod
重要:如何临时更改代码运行?
# 先反编译出class源码
jad --source-only com.example.demo.arthas.user.UserController > /tmp/UserController.java
# 然后使用外部工具编辑内容
mc /tmp/UserController.java -d /tmp # 再编译成class
# 最后,重新载入定义的类,就可以实时验证你的猜测了
redefine /tmp/com/example/demo/arthas/user/UserController.class
如上,是直接更改线上代码的方式,但是一般好像是编译不成功的。所以,最好是本地ide编译成 class文件后,再上传替换为好!
总之,已经完全不用重启和发布了!这个功能真的很方便,比起重启带来的代价,真的是不可比的。比如,重启时可能导致负载重分配,选主等等问题,就不是你能控制的了。
场景:我如何测试某个方法的性能问题?
monitor -c 5 demo.MathGame primeFactors
场景:Spring Context
通过请求处理器获取 Context
如果已知服务接口被调用的较为频繁,可以通过请求处理器获取上下文。
首先通过 tt 记录某次请求的上下文:
tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod
当请求出现时,会出现记录:
$ watch com.example.demo.Test * 'params[0]@sss'
$ tt -t org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter invokeHandlerMethod
Press Ctrl+C to abort.
Affect(class-cnt:1 , method-cnt:1) cost in 101 ms.
INDEX TIMESTAMP COST(ms IS-RE IS-EX OBJECT CLASS METHOD
) T P
------------------------------------------------------------------------------------------------------------------
1000 2019-01-27 16:31 3.66744 true false 0x4465cf70 RequestMappingHandlerAda invokeHandlerMethod
:54 pter
然后可以用tt
命令的-i
参数来指定index,并且用-w
参数来执行ognl表达式来获取spring context:
$ tt -i 1000 -w 'target.getApplicationContext()'
@AnnotationConfigEmbeddedWebApplicationContext[
reader=@AnnotatedBeanDefinitionReader[org.springframework.context.annotation.AnnotatedBeanDefinitionReader@35dc90ec],
scanner=@ClassPathBeanDefinitionScanner[org.springframework.context.annotation.ClassPathBeanDefinitionScanner@72078a14],
annotatedClasses=null,
basePackages=null,
]
Affect(row-cnt:1) cost in 7 ms.
然后从 context 中获取 bean:
$ tt -i 1000 -w 'target.getApplicationContext().getBean("helloWorldService").getHelloMessage()'
@String[Hello World]
Affect(row-cnt:1) cost in 5 ms.
通过静态方法获取 Context
在很多代码里都有static函数或者static holder类,可以获取很多其它的对象。比如在Dubbo里通过SpringExtensionFactory
获取spring context:
$ ognl '#context=@com.alibaba.dubbo.config.spring.extension.SpringExtensionFactory@contexts.iterator.next,
#context.getBean("userServiceImpl").findUser(1)'
@User[
id=@Integer[1],
name=@String[Deanna Borer],
]
实现原理
相关文章
1.6.8 - 本地调试
Intellij IDEA Debug
如下是在IDEA中启动Debug模式,进入断点后的界面,我这里是Windows,可能和Mac的图标等会有些不一样。就简单说下图中标注的8个地方:
- ① 以Debug模式启动服务,左边的一个按钮则是以Run模式启动。在开发中,我一般会直接启动Debug模式,方便随时调试代码。
- ② 断点:在左边行号栏单击左键,或者快捷键Ctrl+F8 打上/取消断点,断点行的颜色可自己去设置。
- ③ Debug窗口:访问请求到达第一个断点后,会自动激活Debug窗口。如果没有自动激活,可以去设置里设置,如图1.2。
- ④ 调试按钮:一共有8个按钮,调试的主要功能就对应着这几个按钮,鼠标悬停在按钮上可以查看对应的快捷键。在菜单栏Run里可以找到同样的对应的功能,如图1.4。
- ⑤ 服务按钮:可以在这里关闭/启动服务,设置断点等。
- ⑥ 方法调用栈:这里显示了该线程调试所经过的所有方法,勾选右上角的[Show All Frames]按钮,就不会显示其它类库的方法了,否则这里会有一大堆的方法。
- ⑦ Variables:在变量区可以查看当前断点之前的当前方法内的变量。
- ⑧ Watches:查看变量,可以将Variables区中的变量拖到Watches中查看
在设置里勾选Show debug window on breakpoint,则请求进入到断点后自动激活Debug窗口
基本应用
Show Execution Point
(Alt + F10):如果你的光标在其它行或其它页面,点击这个按钮可跳转到当前代码执行的行。
Step Over
(F8):步过,一行一行地往下走,如果这一行上有方法不会进入方法。
Step Into
(F7):步入,如果当前行有方法,可以进入方法内部,一般用于进入自定义方法内,不会进入官方类库的方法,如第25行的put方法。
Force Step Into
(Alt + Shift + F7):强制步入,能进入任何方法,查看底层源码的时候可以用这个进入官方类库的方法。
Step Out
(Shift + F8):步出,从步入的方法内退出到方法调用处,此时方法已执行完毕,只是还没有完成赋值。
Drop Frame
(默认无):回退断点,后面章节详细说明。
Run to Cursor
(Alt + F9):运行到光标处,你可以将光标定位到你需要查看的那一行,然后使用这个功能,代码会运行至光标行,而不需要打断点。
Evaluate Expression
(Alt + F8):计算表达式,后面章节详细说明。
2 - JVM 核心
2.1 - JVM Core
2.1.1 - CH01-JVM概览
JVM 结构
JVM 调试
2.1.2 - CH02-JVM字节码
多语言编译为 JVM 能够执行的字节码
计算机是不能直接运行java代码的,必须要先运行java虚拟机,再由java虚拟机运行编译后的java代码。这个编译后的java代码,就是本文要介绍的java字节码。
为什么jvm不能直接运行java代码呢,这是因为在cpu层面看来计算机中所有的操作都是一个个指令的运行汇集而成的,java是高级语言,只有人类才能理解其逻辑,计算机是无法识别的,所以java代码必须要先编译成字节码文件,jvm才能正确识别代码转换后的指令并将其运行。
Java代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的JVM虚拟机去读取执行,从而实现一次编写,到处运行的目的。
JVM 不仅支持Java,由此衍生出了许多基于JVM的编程语言,如Groovy, Scala, Koltin等等。
Java字节码文件
class文件本质上是一个以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件中。jvm根据其特定的规则解析该二进制数据,从而得到相关信息。
Class文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。
Class文件的结构属性
在理解之前先从整体看下java字节码文件包含了哪些类型的数据:
实例
下面以一个简单的例子来逐步讲解字节码。
//Main.java
public class Main {
private int m;
public int inc() {
return m + 1;
}
}
通过以下命令, 可以在当前所在路径下生成一个Main.class文件。
javac Main.java
以文本的形式打开生成的class文件,内容如下:
cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4d61 696e 2e6a 6176 610c
0007 0008 0c00 0500 0601 0010 636f 6d2f
7268 7974 686d 372f 4d61 696e 0100 106a
6176 612f 6c61 6e67 2f4f 626a 6563 7400
2100 0300 0400 0000 0100 0200 0500 0600
0000 0200 0100 0700 0800 0100 0900 0000
1d00 0100 0100 0000 052a b700 01b1 0000
0001 000a 0000 0006 0001 0000 0003 0001
000b 000c 0001 0009 0000 001f 0002 0001
0000 0007 2ab4 0002 0460 ac00 0000 0100
0a00 0000 0600 0100 0000 0800 0100 0d00
0000 0200 0e
文件开头的4个字节(“cafe babe”)称之为
魔数
,唯有以"cafe babe"开头的class文件方可被虚拟机所接受,这4个字节就是字节码文件的身份识别。0000是编译器jdk版本的次版本号0,0034转化为十进制是52,是主版本号,java的版本号从45开始,除1.0和1.1都是使用45.x外,以后每升一个大版本,版本号加一。也就是说,编译生成该class文件的jdk版本为1.8.0。
反编译字节码文件
输入命令javap -verbose -p Main.class
查看输出内容:
Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class
Last modified 2018-4-7; size 362 bytes
MD5 checksum 4aed8540b098992663b7ba08c65312de
Compiled from "Main.java"
public class com.rhythm7.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#4 = Class #21 // java/lang/Object
#5 = Utf8 m
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/rhythm7/Main;
#14 = Utf8 inc
#15 = Utf8 ()I
#16 = Utf8 SourceFile
#17 = Utf8 Main.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
#21 = Utf8 java/lang/Object
{
private int m;
descriptor: I
flags: ACC_PRIVATE
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/rhythm7/Main;
}
SourceFile: "Main.java"
字节码文件信息
开头的7行信息包括:Class文件当前所在位置,最后修改时间,文件大小,MD5值,编译自哪个文件,类的全限定名,jdk次版本号,主版本号。
然后紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER,访问标志的含义如下:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为Public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语义. |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
常量池
常量池可以理解成Class文件中的资源仓库。主要存放的是两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量类似于java中的常量概念,如文本字符串,final常量等,而符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符号(Descriptor)
- 方法的名称和描述符
不同于C/C++, JVM是在加载Class文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。 直接通过反编译文件来查看字节码内容:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#4 = Class #21 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#18 = NameAndType #7:#8 // "<init>":()V
#21 = Utf8 java/lang/Object
第一个常量是一个方法定义,指向了第4和第18个常量。以此类推查看第4和第18个常量。最后可以拼接成第一个常量右侧的注释内容:
java/lang/Object."<init>":()V
这段可以理解为该类的实例构造器的声明,由于Main类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了Main类的直接父类是Object。 该方法默认返回值是V, 也就是void,无返回值。
第二个常量同理可得:
#2 = Fieldref #3.#19 // com/rhythm7/Main.m:I
#3 = Class #20 // com/rhythm7/Main
#5 = Utf8 m
#6 = Utf8 I
#19 = NameAndType #5:#6 // m:I
#20 = Utf8 com/rhythm7/Main
此处声明了一个字段m,类型为I, I即是int类型。关于字节码的类型对应如下:
标识字符 | 含义 | |
---|---|---|
B | 基本类型byte | |
C | 基本类型char | |
D | 基本类型double | |
F | 基本类型float | |
I | 基本类型int | |
J | 基本类型long | |
S | 基本类型short | |
Z | 基本类型boolean | |
V | 特殊类型void | |
L | 对象类型,以分号结尾,如Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的16进制文件内容如何,我们直接看反编译后的内容。
private int m;
descriptor: I
flags: ACC_PRIVATE
此处声明了一个私有变量m,类型为int,返回值为int
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;
这里是构造方法:Main(),返回值为void, 公开方法。
code内的主要属性为:
stack: 最大操作数栈,JVM运行时会根据这个值来分配栈帧(Frame)中的操作栈深度,此处为1
locals: 局部变量所需的存储空间,单位为Slot, Slot是虚拟机为局部变量分配内存时所使用的最小单位,为4个字节大小。方法参数(包括实例方法中的隐藏参数this),显示异常处理器的参数(try catch中的catch块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
args_size: 方法参数的个数,这里是1,因为每个实例方法都会有一个隐藏参数this
attribute_info: 方法体内容,0,1,4为字节码"行号",该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的"java/lang/Object.““😦)V”, 然后执行返回语句,结束方法。
LineNumberTable: 该属性的作用是描述源码行号与字节码行号(字节码偏移量)之间的对应关系。可以使用 -g:none 或-g:lines选项来取消或要求生成这项信息,如果选择不生成LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是arg0, arg1这样的占位符。 start 表示该局部变量在哪一行开始可见,length表示可见行数,Slot代表所在帧栈位置,Name是变量名称,然后是类型签名。
同理可以分析Main类中的另一个方法"inc()”:
方法体内的内容是:将this入栈,获取字段#2并置于栈顶, 将int类型的1入栈,将栈内顶部的两个数值相加,返回一个int类型的值。
类名
SourceFile: "Main.java"
典型问题:try-catch-finally
public class TestCode {
public int foo() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
}
public int foo();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1 //int型1入栈 ->栈顶=1
1: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=1
2: iload_1 //将第二个int型局部变量推送至栈顶 ->栈顶=1
3: istore_2 //!!将栈顶int型数值存入第三个局部变量 ->局部3=1
4: iconst_3 //int型3入栈 ->栈顶=3
5: istore_1 //将栈顶的int型数值存入第二个局部变量 ->局部2=3
6: iload_2 //!!将第三个int型局部变量推送至栈顶 ->栈顶=1
7: ireturn //从当前方法返回栈顶int数值 ->1
8: astore_2 // ->局部3=Exception
9: iconst_2 // ->栈顶=2
10: istore_1 // ->局部2=2
11: iload_1 //->栈顶=2
12: istore_3 //!! ->局部4=2
13: iconst_3 // ->栈顶=3
14: istore_1 // ->局部1=3
15: iload_3 //!! ->栈顶=2
16: ireturn // -> 2
17: astore 4 //将栈顶引用型数值存入第五个局部变量=any
19: iconst_3 //将int型数值3入栈 -> 栈顶3
20: istore_1 //将栈顶第一个int数值存入第二个局部变量 -> 局部2=3
21: aload 4 //将局部第五个局部变量(引用型)推送至栈顶
23: athrow //将栈顶的异常抛出
Exception table:
from to target type
0 4 8 Class java/lang/Exception //0到4行对应的异常,对应#8中储存的异常
0 4 17 any //Exeption之外的其他异常
8 13 17 any
17 19 17 any
在字节码的4,5,以及13,14中执行的是同一个操作,就是将int型的3入操作数栈顶,并存入第二个局部变量。这正是我们源码在finally语句块中内容。也就是说,JVM在处理异常时,会在每个可能的分支都将finally语句重复执行一遍。
通过一步步分析字节码,可以得出最后的运行结果是:
- 不发生异常时: return 1
- 发生异常时: return 2
- 发生非Exception及其子类的异常,抛出异常,不返回值
2.1.3 - CH03-JVM类加载
类的生命周期
其中类加载的过程包括了加载
、验证
、准备
、解析
、初始化
五个阶段。在这五个阶段中,加载
、验证
、准备
和初始化
这四个阶段发生的顺序是确定的,而解析
阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
类的加载: 查找并加载类的二进制数据
加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有数据库中提取.class文件
- 将Java源文件动态编译为.class文件
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class
类的对象,这样便可以通过该对象访问方法区中的这些数据。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
连接
验证: 确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:
文件格式验证
: 验证字节流是否符合Class文件格式的规范;例如: 是否以0xCAFEBABE
开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。元数据验证
: 对字节码描述的信息进行语义分析(注意: 对比javac
编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如: 这个类是否有父类,除了java.lang.Object
之外。字节码验证
: 通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。符号引用验证
: 确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用
-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
准备: 为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(
static
),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、null
、false
等),而不是被在Java代码中被显式地赋予的值。假设一个类变量的定义为:
public static int value = 3
;那么变量value在准备阶段过后的初始值为0
,而不是3
,因为这时候尚未开始执行任何Java方法,而把value赋值为3的put static
指令是在程序编译后,存放于类构造器<clinit>()
方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
另外:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
- 对于同时被
static
和final
修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。 - 对于引用数据类型
reference
来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
。 - 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
- 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。我们可以理解为static final
常量在编译期就将其结果放入了调用它的类的常量池中
解析: 把类中的符号引用转换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类
或接口
、字段
、类方法
、接口方法
、方法类型
、方法句柄
和调用点
限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。
直接引用
就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:
- 声明类变量时指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机: 只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如Class.forName(“com.pdai.jvm.Test”))
- 初始化某个类的子类,则其父类也会被初始化
- Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
使用
类访问方法区内的数据结构的接口,对象是Heap区的数据。
卸载
Java虚拟机将结束生命周期的几种情况
- 执行了System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
类加载器
类加载器的层次
注意: 这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。
站在Java虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器: 它使用C++实现(这里仅限于
Hotspot
,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分; - 所有其他的类加载器: 这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类
java.lang.ClassLoader
,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类。
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类 :
启动类加载器
: Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器是无法被Java程序直接引用的。
扩展类加载器
: Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader
实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器
: Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader
来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
- 在执行非置信代码之前,自动验证数字签名。
- 动态地创建符合用户特定需要的定制化构建类。
- 从特定的场所取得java class,例如数据库中和网络中。
寻找类加载器
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
// output:
sun.misc.Launcher$AppClassLoader@64fef26a
sun.misc.Launcher$ExtClassLoader@1ddd40f3
null
从上面的结果可以看出,并没有获取到ExtClassLoader
的父Loader,原因是BootstrapLoader
(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null
。
类的加载
类加载有三种方式:
1、命令行启动应用时候由JVM初始化加载
2、通过Class.forName()方法动态加载
3、通过ClassLoader.loadClass()方法动态加载
Class.forName()和ClassLoader.loadClass()区别?
- Class.forName(): 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
- ClassLoader.loadClass(): 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
- Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。
JVM 类加载机制
全盘负责
,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入父类委托
,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类缓存机制
,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效双亲委派机制
, 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。- 系统类防止内存中出现多份同样的字节码
- 保证Java程序安全稳定运行
双亲委派机制
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先判断该类型是否已经被加载
Class c = findLoadedClass(name);
if (c == null) {
//如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
try {
if (parent != null) {
//如果存在父类加载器,就委派给父类加载器加载
c = parent.loadClass(name, false);
} else {
//如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
自定义类加载器
自定义类加载器一般都是继承自 ClassLoader 类,从上面对 loadClass 方法来分析来看,我们只需要重写 findClass 方法即可。下面我们通过一个示例来演示自定义类加载器的流程:
package com.pdai.jvm.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("D:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.pdai.jvm.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定义类加载器的核心在于对字节码文件的获取,如果是加密的字节码则需要在该类中对文件进行解密。由于这里只是演示,我并未对class文件进行加密,因此没有解密的过程。
这里有几点需要注意 :
1、这里传递的文件名需要是类的全限定性名称,即com.pdai.jvm.classloader.Test2
格式的,因为 defineClass 方法是按这种格式进行处理的。
2、最好不要重写loadClass方法,因为这样容易破坏双亲委托模式。
3、这类Test 类本身可以被 AppClassLoader 类加载,因此我们不能把com/pdai/jvm/classloader/Test2.class 放在类路径下。否则,由于双亲委托机制的存在,会直接导致该类由 AppClassLoader 加载,而不会通过我们自定义类加载器来加载。
2.1.4 - CH04-JVM内存结构
运行时数据区
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。
下图是 JVM 整体架构,中间部分就是 Java 虚拟机定义的各种运行时数据区域。
Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程私有:程序计数器、栈、本地栈
- 线程共享:堆、堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
程序计数器
程序计数寄存器(Program Counter Register),Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的线程信息,CPU 只有把数据装载到寄存器才能够运行。
这里,并非是广义上所指的物理寄存器,叫程序计数器(或PC计数器或指令计数器)会更加贴切,并且也不容易引起一些不必要的误会。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
作用
PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
进入class文件所在目录,执行 javap -v xx.class
反解析(或者通过 IDEA 插件 Jclasslib
直接查看,上图),可以看到当前类对应的Code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等信息。
概述
通过下面两个问题,理解下PC计数器:
使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?
- 因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
PC寄存器为什么会被设定为线程私有的?
- 多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。
总结:
它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域
虚拟机栈
概述
Java 虚拟机栈(Java Virtual Machine Stacks),早期也叫 Java 栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。
作用
主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM 直接对虚拟机栈的操作只有两个:每个方法执行,伴随着入栈(进栈/压栈),方法执行结束出栈
- 栈不存在垃圾回收问题
相关异常
Java 虚拟机规范允许 Java虚拟机栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过 Java 虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个OutOfMemoryError异常
可以通过参数-Xss
来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
存储单位
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在
- 在这个线程上正在执行的每个方法都各自有对应的一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
运行原理
- JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出
IDEA 在 debug 时候,可以在 debug 窗口看到 Frames 中各种方法的压栈和出栈情况:
内部结构
每个栈帧(Stack Frame)中存储着:
局部变量表(Local Variables)
操作数栈(Operand Stack)(或称为表达式栈)
动态链接(Dynamic Linking):指向运行时常量池的方法引用
方法返回地址(Return Address):方法正常退出或异常退出的地址
一些附加信息
局部变量表
局部变量表也被称为局部变量数组或者本地变量表
是一组变量值存储空间,主要用于存储方法参数和定义在方法体内的局部变量,包括编译器可知的各种 Java 虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表所需要的容量大小是编译期确定下来的,并保存在方法的 Code 属性的
maximum local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
参数值的存放总是在局部变量数组的 index0 开始,到数组长度 -1 的索引结束
槽 Slot:
局部变量表最基本的存储单元是 Slot(变量槽)
在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slot
- byte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 true
- long 和 double 则占据两个 Slot
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上
如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)
如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列(这里就引出一个问题:静态方法中为什么不可以引用 this,就是因为this 变量不存在于当前方法的局部变量表中)
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。(下图中,this、a、b、c 理论上应该有 4 个变量,c 复用了 b 的槽)
- 在栈帧中,与性能调优关系最为密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
操作数栈
每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)
操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作
相关解释:
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,此时这个方法的操作数栈是空的
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的
max_stack
数据项中栈中的任何一个元素都可以是任意的 Java 数据类型
- 32bit 的类型占用一个栈单位深度
- 64bit 的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
栈顶缓存—基于寄存器
HotSpot 的执行引擎采用的并非是基于寄存器的架构,但这并不代表 HotSpot VM 的实现并没有间接利用到寄存器资源。寄存器是物理 CPU 中的组成部分之一,它同时也是 CPU 中非常重要的高速存储资源。一般来说,寄存器的读/写速度非常迅速,甚至可以比内存的读/写速度快上几十倍不止,不过寄存器资源却非常有限,不同平台下的CPU 寄存器数量是不同和不规律的。寄存器主要用于缓存本地机器指令、数值和下一条需要被执行的指令地址等数据。
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接:指向运行时常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。
在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
JVM 是如何执行方法调用的
方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关:
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
- 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
- 早期绑定:早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
- 晚期绑定:如果被调用的方法在编译器无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式就被称为晚期绑定。
虚方法与非虚方法:
- 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,比如静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法
- 其他方法称为虚方法
虚方法表
在面向对象编程中,会频繁的使用到动态分派,如果每次动态分派都要重新在类的方法元数据中搜索合适的目标有可能会影响到执行效率。为了提高性能,JVM 采用在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找。非虚方法不会出现在表中。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
虚方法表会在类加载的连接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法表也初始化完毕。
方法返回地址
用来存放调用该方法的 PC 寄存器的值。一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
执行引擎遇到任意一个方法返回的字节码指令,会有返回值传递给上层的方法调用者,简称正常完成出口
一个方法的正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定
在字节码指令中,返回指令包含 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 以及 areturn,另外还有一个 return 指令供声明为 void 的方法、实例初始化方法、类和接口的初始化方法使用。
在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口
- 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。
本地方法栈
本地方法接口
简单的讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。
为什么需要本地方法(Native Method)?
Java 使用起来非常方便,然而有些层次的任务用 Java 实现起来也不容易,或者我们对程序的效率很在意时,问题就来了
- 与 Java 环境外交互:有时 Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
- 与操作系统交互:JVM 支持 Java 语言本身和运行时库,但是有时仍需要依赖一些底层系统的支持。通过本地方法,我们可以实现用 Java 与实现了 jre 的底层系统交互, JVM 的一些部分就是 C 语言写的。
- Sun’s Java:Sun的解释器就是C实现的,这使得它能像一些普通的C一样与外部交互。jre大部分都是用 Java 实现的,它也通过一些本地方法与外界交互。比如,类
java.lang.Thread
的setPriority()
的方法是用Java 实现的,但它实现调用的是该类的本地方法setPrioruty()
,该方法是C实现的,并被植入 JVM 内部。
本地方法栈(Native Method Stack)
Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用
本地方法栈也是线程私有的
允许线程固定或者可动态扩展的内存大小
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java虚拟机将会抛出一个
OutofMemoryError
异常
本地方法是使用 C 语言实现的
它的具体做法是
Mative Method Stack
中登记 native 方法,在Execution Engine
执行时加载本地方法库当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区,它甚至可以直接使用本地处理器中的寄存器,直接从本地内存的堆中分配任意数量的内存
并不是所有 JVM 都支持本地方法。因为 Java 虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果 JVM 产品不打算支持 native 方法,也可以无需实现本地方法栈
在 Hotspot JVM 中,直接将本地方栈和虚拟机栈合二为一
- 栈是运行时的单位,堆是存储的单位。
- 栈解决程序运行的问题,即程序如何执行,或者说如何处理数据。
- 堆解决的是数据存储的问题,即数据怎么放,放在那。
堆内存
内存划分
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存在逻辑上分为 3 个区域(分代的唯一原因就是优化 GC 性能):
- 新生代(年轻代):新对象和没达到一定年龄的对象都都在新生代。
- 老年代(老年区):被长时间使用的对象,老年代的内存空间应该要比新生代更大。
- 元空间(永久代):如一些方法中操作的临时对象,1.8 之前使用 JVM 内存,1.8 之后直接使用物理内存。
Java 虚拟机规范规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -Xmx
和 -Xms
控制),如果堆中没有完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError
异常。
新生代:Young Generation
- 新生代是存储所有新建对象的区域。
- 当填充(满)新生代时,执行垃圾收集。
- 这种垃圾收集称为 Minor GC。
- 新生代又被分为三个部分:
- 一个伊甸园(Eden Memory)和两个幸存区(Survivor Memory, from/to, s0/s1)
- 三个部分的比例是 8:1:1
- 大多数新建的对象都位于伊甸园空间内。
- 当伊甸园空间被对象填充(满)时,执行 Minor GC,并将所有幸存者对象移动到一个幸存区
- Minor 检查幸存者对象,并将它们移动到另一个幸存区,因此每次 Minor GC 之后会有一个空幸存区
- 经过多次 Minor GC 循环后仍然存活下来的对象被移动到老年代。
- 这是通过设置年轻代的年龄阈值来实现的,到达一定年龄后才有资格进入老年代。
老年代:Old Generation
- 旧的一代内存包含那些经过许多轮小型 GC 后仍然存活的对象。
- 通常,垃圾收集是在老年代内存满时触发执行。
- 老年代垃圾收集称为主 GC——Major GC,通常需要更长时间。
- 大型对象会直接被放入老年代,大型对象是指需要大量连续内存空间的的对象,以避免在新生代的伊甸园和幸存区之间出现大量的内存拷贝。
元空间/永久代
- 即 Java 虚拟机规范中方法区的实现。
- 虽然 Java 虚拟机规范将方法区描述为堆的一个逻辑部分,但是它却有一个别名——Non-Heap(非堆),目的是与 Java 堆区分开。
设置内存大小与 OOM
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了:
-Xmx
用来表示堆的起始内存,等价于-XX:InitialHeapSize
-Xms
用来表示堆的最大内存,等价于-XX:MaxHeapSize
如果堆的内存大小超过 -Xms
设定的最大内存, 就会抛出 OutOfMemoryError
异常。
我们通常会将 -Xmx
和 -Xms
两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。
- 默认情况下,初始堆内存大小为:电脑内存大小/64
- 默认情况下,最大堆内存大小为:电脑内存大小/4
我们可以通过代码获取启动时设置的值,当然也可以模拟 OOM:
//返回 JVM 堆大小
long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
//返回 JVM 堆的最大内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;
System.out.println("-Xms : "+initalMemory + "M");
System.out.println("-Xmx : "+maxMemory + "M");
System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
查看 JVM 堆内存分配
- 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小
- 默认情况下新生代和老年代的比例是 1:2,可以通过
–XX:NewRatio
来配置 - 新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过
-XX:SurvivorRatio
来配置 - 若在 JDK 7 中开启了
-XX:+UseAdaptiveSizePolicy
,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄- 此时
–XX:NewRatio
和-XX:SurvivorRatio
将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy
- 在 JDK 8中,不要随意关闭
-XX:+UseAdaptiveSizePolicy
,除非对堆内存的划分有明确的规划
- 此时
- 每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小
- 计算依据是GC过程中统计的GC时间、吞吐量、内存占用量
java -XX:+PrintFlagsFinal -version | grep HeapSize
uintx ErgoHeapSizeLimit = 0 {product}
uintx HeapSizePerGCThread = 87241520 {product}
uintx InitialHeapSize := 134217728 {product}
uintx LargePageHeapSizeThreshold = 134217728 {product}
uintx MaxHeapSize := 2147483648 {product}
java version "1.8.0_211"
Java(TM) SE Runtime Environment (build 1.8.0_211-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.211-b12, mixed mode)
jmap -heap 进程号
对象在堆中的生命周期
在 JVM 内存模型的堆中,堆被划分为新生代和老年代
- 新生代又被进一步划分为 Eden区 和 Survivor区
- Survivor 区由 From Survivor 和 To Survivor 组成
当创建一个对象时,对象会被优先分配到新生代的 Eden 区
- 此时 JVM 会给对象定义一个对象年轻计数器(
-XX:MaxTenuringThreshold
)
- 此时 JVM 会给对象定义一个对象年轻计数器(
当 Eden 空间不足时,JVM 将执行新生代的垃圾回收(Minor GC)
- JVM 会把存活的对象转移到 Survivor 中,并且对象年龄 +1
- 对象在 Survivor 中同样也会经历 Minor GC,每经历一次 Minor GC,对象年龄都会+1
如果分配的对象超过了
-XX:PetenureSizeThreshold
,对象会直接被分配到老年代
对象的分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。
- new 的对象先放在伊甸园区,此区有大小限制
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者 from 区
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 to 区,如果没有回收,就会放到幸存者 to 区
- 如果再次经历垃圾回收,此时会重新放回幸存者 from 区,接着再去幸存者 to 区
- 什么时候才会去老年代呢? 默认是 15 次回收标记
- 在养老区,相对悠闲。当养老区内存不足时,再次触发 Major GC,进行养老区的内存清理
- 若养老区执行了 Major GC 之后发现依然无法进行对象的保存,就会产生 OOM 异常
GC 垃圾回收简介
Minor、Major、Full
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代、方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)。
部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
TLAB:Thread Local Allocation Buffer
什么是 TLAB
从内存模型而不是垃圾回收的角度,对伊甸园继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在伊甸园内
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
OpenJDK 衍生出来的 JVM 大都提供了 TLAB 设计
为什么使用 TLAB
堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
在程序中,可以通过
-XX:UseTLAB
设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个伊甸园的 1%,我们可以通过
-XX:TLABWasteTargetPercent
设置 TLAB 空间所占用伊甸园的百分比大小。
一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在伊甸园中分配内存。
值能在堆中存储对象吗
随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
逃逸分析
逃逸分析(Escape Analysis)*是目前 Java 虚拟机中比较前沿的优化技术*。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
- 例如作为调用参数传递到其他地方中,称为方法逃逸。
参数设置
- 在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析
- 如果使用较早版本,可以通过
-XX"+DoEscapeAnalysis
显式开启
开发中使用局部变量,就不要在方法外定义。
使用逃逸分析,编译器可以对代码做优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递
代码优化:同步省略/锁消除
线程同步的代价是相当高的,同步的后果是降低并发性和性能
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。
这个取消同步的过程就叫做同步省略,也叫锁消除。
代码优化:标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。
在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。
通过 -XX:+EliminateAllocations
可以开启标量替换,-XX:+PrintEliminateAllocations
查看标量替换情况。
代码优化:栈上分配
我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
总结
关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。
方法区—元空间—永久代
- 方法区(Method Area)与 Java 堆一样,是所有线程共享的内存区域。
- 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
- Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。
- 运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的是
String.intern()
方法。受方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryErro
r 异常。
- 方法区大小的设定方式与堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误
- JVM 关闭后方法区即被释放
元空间、永久代、方法区?
- 方法区(method area)*只是 **JVM 规范**中定义的一个*概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)**是 **Hotspot** 虚拟机特有的概念, Java8 的时候又被**元空间**取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)
- Java7 中我们通过
-XX:PermSize
和-xx:MaxPermSize
来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
用来设置元空间参数 - 存储内容不同:元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中
- 如果方法区域中的内存不能用于满足分配请求,则 Java 虚拟机抛出
OutOfMemoryError
- JVM 规范说方法区在逻辑上是堆的一部分,但目前实际上是与 Java 堆分开的(Non-Heap)
所以对于方法区,Java8 之后的变化:
- 移除了永久代(PermGen),替换为元空间(Metaspace);
- 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机);
- 永久代中的 interned Strings 和 class static variables 转移到了 Java heap;
- 永久代参数 (PermSize MaxPermSize) -> 元空间参数(MetaspaceSize MaxMetaspaceSize)
设置空间大小
JDK8 及以后:
- 元数据区大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,替代上述原有的两个参数 - 默认值依赖于平台。Windows 下,
-XX:MetaspaceSize
是 21M,-XX:MaxMetaspacaSize
的值是 -1,即没有限制 - 与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据发生溢出,虚拟机一样会抛出异常
OutOfMemoryError:Metaspace
-XX:MetaspaceSize
:设置初始的元空间大小。对于一个 64 位的服务器端 JVM 来说,其默认的-XX:MetaspaceSize
的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize
时,适当提高该值。如果释放空间过多,则适当降低该值- 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。为了避免频繁 GC,建议将
-XX:MetaspaceSize
设置为一个相对较高的值。
内部结构
方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。
类型信息
对每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下类型信息
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于 interface或是 java.lang.Object,都没有父类)
- 这个类型的修饰符(public,abstract,final 的某个子集)
- 这个类型直接接口的一个有序列表
字段信息
JVM 必须在方法区中保存类型的所有域的相关信息以及域的声明顺序
域的相关信息包括:域名称、域类型、域修饰符(public、private、protected、static、final、volatile、transient 的某个子集)
方法信息
JVM 必须保存所有方法的:
- 方法名称
- 方法的返回类型
- 方法参数的数量和类型
- 方法的修饰符(public,private,protected,static,final,synchronized,native,abstract 的一个子集)
- 方法的字符码(bytecodes)、操作数栈、局部变量表及大小(abstract 和 native 方法除外)
- 异常表(abstract 和 native 方法除外)
- 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
方法区与堆、栈
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,理解运行时常量池的话,我们先来说说字节码文件(Class 文件)中的常量池(常量池表)。
字节码常量池
一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包含各种字面量和对类型、域和方法的符号引用。
为什么需要常量池
一个 Java 源文件中的类、接口,编译后产生一个字节码文件。而 Java 中的字节码需要数据支持,通常这种数据会很大以至于不能直接存到字节码里,换另一种方式,可以存到常量池,这个字节码包含了指向常量池的引用。在动态链接的时候用到的就是运行时常量池。
如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool
常量池可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等类型。
运行时常量池
在加载类和结构到虚拟机后,就会创建对应的运行时常量池
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存储编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
JVM 为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组项一样,是通过索引访问的
运行时常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定在编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
intern()
方法就是这样的
- 运行时常量池,相对于 Class 文件常量池的另一个重要特征是:动态性,Java 语言并不要求常量一定在编译期间才能产生,运行期间也可以将新的常量放入池中,String 类的
当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则 JVM 会抛出 OutOfMemoryError 异常。
方法区实现的演进
只有 HotSpot 才有永久代的说法:
jdk1.6及之前 | 有永久代,静态变量存放在永久代上 |
---|---|
jdk1.7 | 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中 |
jdk1.8及之后 | 取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中 |
移除永久代的原因
为永久代设置空间大小是很难确定的。
在某些场景下,如果动态加载类过多,容易产生 Perm 区的 OOM。如果某个实际 Web 工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现 OOM。
而元空间和永久代最大的区别在于,元空间不在虚拟机中,而是使用本地内存,所以默认情况下,元空间的大小仅受本地内存限制
对永久代进行调优较困难
方法区垃圾回收
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
HotSpot 虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。
判定一个类型是否属于“不再被使用的类”,需要同时满足三个条件:
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类及其任何派生子类的实例
加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP 的重加载等,否则通常很难达成
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
Java 虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc
参数进行控制,还可以使用 -verbose:class
以及 -XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
2.1.5 - CH05-JVM内存模型-1
内存结构 & 内存模型
堆栈
JVM 内部通过内存结构在线程栈和堆之间划分内存。 此图从逻辑角度说明了Java内存结构:
堆栈中的内容
线程堆栈还包含正在执行的每个方法的所有局部变量(调用堆栈上的所有方法)。 线程只能访问它自己的线程堆栈。 由线程创建的局部变量对于创建它的线程以外的所有其他线程是不可见的。 即使两个线程正在执行完全相同的代码,两个线程仍将在每个自己的线程堆栈中创建该代码的局部变量。 因此,每个线程都有自己的每个局部变量的版本。
基本类型的所有局部变量(boolean,byte,short,char,int,long,float,double)完全存储在线程堆栈中,因此对其他线程不可见。 一个线程可以将一个基本类型变量的副本传递给另一个线程,但它不能共享原始局部变量本身。
堆包含了在Java应用程序中创建的所有对象,无论创建该对象的线程是什么。 这包括基本类型的包装类(例如Byte,Integer,Long等)。 无论是创建对象并将其分配给局部变量,还是创建为另一个对象的成员变量,该对象仍然存储在堆上。
局部变量可以是基本类型,在这种情况下,它完全保留在线程堆栈上。
局部变量也可以是对象的引用。 在这种情况下,引用(局部变量)存储在线程堆栈中,但是对象本身存储在堆(Heap)上。
对象的成员变量与对象本身一起存储在堆上。 当成员变量是基本类型时,以及它是对象的引用时都是如此。
静态类变量也与类定义一起存储在堆上。
线程如何访问堆上的对象?
所有具有对象引用的线程都可以访问堆上的对象。 当一个线程有权访问一个对象时,它也可以访问该对象的成员变量。 如果两个线程同时在同一个对象上调用一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本。
两个线程有一组局部变量。 其中一个局部变量(局部变量2)指向堆上的共享对象(对象3)。 两个线程各自对同一对象具有不同的引用。 它们的引用是局部变量,因此存储在每个线程的线程堆栈中(在每个线程堆栈上)。 但是,这两个不同的引用指向堆上的同一个对象。
注意共享对象(对象3)如何将对象2和对象4作为成员变量引用(由对象3到对象2和对象4的箭头所示)。 通过对象3中的这些成员变量引用,两个线程可以访问对象2和对象4.
该图还显示了一个局部变量,该变量指向堆上的两个不同对象。 在这种情况下,引用指向两个不同的对象(对象1和对象5),而不是同一个对象。 理论上,如果两个线程都引用了两个对象,则两个线程都可以访问对象1和对象5。 但是在上图中,每个线程只引用了两个对象中的一个。
线程栈访问堆对象
那么,什么样的Java代码可以导致上面的内存图? 好吧,代码就像下面的代码一样简单:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member1 = 67890;
}
如果两个线程正在执行run()方法,则前面显示的图表将是结果。 run()方法调用methodOne(),methodOne()调用methodTwo()。
methodOne()声明一个局部基本类型变量(类型为int的localVariable1)和一个局部变量,它是一个对象引用(localVariable2)。
执行methodOne()的每个线程将在各自的线程堆栈上创建自己的localVariable1和localVariable2副本。 localVariable1变量将完全相互分离,只存在于每个线程的线程堆栈中。 一个线程无法看到另一个线程对其localVariable1副本所做的更改。
执行methodOne()的每个线程也将创建自己的localVariable2副本。 但是,localVariable2的两个不同副本最终都指向堆上的同一个对象。 代码将localVariable2设置为指向静态变量引用的对象。 静态变量只有一个副本,此副本存储在堆上。 因此,localVariable2的两个副本最终都指向静态变量指向的MySharedObject的同一个实例。 MySharedObject实例也存储在堆上。 它对应于上图中的对象3。
注意MySharedObject类还包含两个成员变量。 成员变量本身与对象一起存储在堆上。 两个成员变量指向另外两个Integer对象。 这些Integer对象对应于上图中的Object 2和Object 4。
另请注意methodTwo()如何创建名为localVariable1的局部变量。 此局部变量是对Integer对象的对象引用。 该方法将localVariable1引用设置为指向新的Integer实例。 localVariable1引用将存储在执行methodTwo()的每个线程的一个副本中。 实例化的两个Integer对象将存储在堆上,但由于该方法每次执行该方法时都会创建一个新的Integer对象,因此执行此方法的两个线程将创建单独的Integer实例。 在methodTwo()中创建的Integer对象对应于上图中的Object 1和Object 5。
另请注意类型为long的MySharedObject类中的两个成员变量,它们是基本类型。 由于这些变量是成员变量,因此它们仍与对象一起存储在堆上。 只有局部变量存储在线程堆栈中。
JMM 与硬件内存结构
硬件内存结构简介
现代硬件内存架构与内部Java内存模型略有不同。 了解硬件内存架构也很重要,以了解Java内存模型如何与其一起工作。 本节介绍了常见的硬件内存架构,后面的部分将介绍Java内存模型如何与其配合使用。
这是现代计算机硬件架构的简化图:
现代计算机通常有2个或更多CPU。 其中一些CPU也可能有多个内核。 关键是,在具有2个或更多CPU的现代计算机上,可以同时运行多个线程。 每个CPU都能够在任何给定时间运行一个线程。 这意味着如果您的Java应用程序是多线程的,线程真的在可能同时运行.
每个CPU基本上都包含一组在CPU内存中的寄存器。 CPU可以在这些寄存器上执行的操作比在主存储器中对变量执行的操作快得多。 这是因为CPU可以比访问主存储器更快地访问这些寄存器。
每个CPU还可以具有CPU高速缓存存储器层。 事实上,大多数现代CPU都有一些大小的缓存存储层。 CPU可以比主存储器更快地访问其高速缓存存储器,但通常不会像访问其内部寄存器那样快。 因此,CPU高速缓存存储器介于内部寄存器和主存储器的速度之间。 某些CPU可能有多个缓存层(级别1和级别2),但要了解Java内存模型如何与内存交互,这一点并不重要。 重要的是要知道CPU可以有某种缓存存储层。
计算机还包含主存储区(RAM)。 所有CPU都可以访问主内存。 主存储区通常比CPU的高速缓存存储器大得多。同时访问速度也就较慢.
通常,当CPU需要访问主存储器时,它会将部分主存储器读入其CPU缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当CPU需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器。
JMM 与硬件内存连接
如前所述,Java内存模型和硬件内存架构是不同的。 硬件内存架构不区分线程堆栈和堆。 在硬件上,线程堆栈和堆都位于主存储器中。 线程堆栈和堆的一部分有时可能存在于CPU高速缓存和内部CPU寄存器中。 这在图中说明:
当对象和变量可以存储在计算机的各种不同存储区域中时,可能会出现某些问题。 两个主要问题是:
- Visibility of thread updates (writes) to shared variables.
- Race conditions when reading, checking and writing shared variables. 以下各节将解释这两个问题。
JMM与硬件内存连接 - 对象共享后的可见性
如果两个或多个线程共享一个对象,而没有正确使用volatile声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
想象一下,共享对象最初存储在主存储器中。 然后,在CPU上运行的线程将共享对象读入其CPU缓存中。 它在那里对共享对象进行了更改。 只要CPU缓存尚未刷新回主内存,共享对象的更改版本对于在其他CPU上运行的线程是不可见的。 这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的CPU缓存中。
下图描绘了该情况。 在左CPU上运行的一个线程将共享对象复制到其CPU缓存中,并将其count变量更改为2.对于在右边的CPU上运行的其他线程,此更改不可见,因为计数更新尚未刷新回主内存中.
要解决此问题,您可以使用Java的volatile关键字。 volatile关键字可以确保直接从主内存读取给定变量,并在更新时始终写回主内存。
JMM与硬件内存连接 - 竞态条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能会出现竞态。
想象一下,如果线程A将共享对象的变量计数读入其CPU缓存中。 想象一下,线程B也做同样的事情,但是进入不同的CPU缓存。 现在,线程A将一个添加到count,而线程B执行相同的操作。 现在var1已经增加了两次,每个CPU缓存一次。
如果这些增量是按先后顺序执行的,则变量计数将增加两次并将原始值+ 2写回主存储器。
但是,两个增量同时执行而没有适当的同步。 无论线程A和B中哪一个将其更新后的计数版本写回主存储器,更新的值将仅比原始值高1,尽管有两个增量。
该图说明了如上所述的竞争条件问题的发生:
要解决此问题,您可以使用Java synchronized块。 同步块保证在任何给定时间只有一个线程可以进入代码的给定关键部分。 同步块还保证在同步块内访问的所有变量都将从主存储器中读入,当线程退出同步块时,所有更新的变量将再次刷新回主存储器,无论变量是不是声明为volatile。
2.1.6 - CH06-JVM内存模型-2
基础
并发编程模型
在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 的并发采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
Java 内存模型抽象
在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:
从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
- 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
- 对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
处理器重排序与内存屏障指令
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!为了具体说明,请看下面示例:
// Processor A
a = 1; //A1
x = b; //A2
// Processor B
b = 2; //B1
y = a; //B2
// 初始状态:a = b = 0;处理器允许执行后得到结果:x = y = 0
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0 的结果。具体的原因如下图所示:
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样,这里就不赘述了)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写 - 读操做重排序。
下面是常见处理器允许的重排序类型的列表:
Load-Load | Load-Store | Store-Store | Store-Load | 数据依赖 |
---|---|---|---|---|
sparc-TSO | N | N | N | Y |
x86 | N | N | N | Y |
ia64 | Y | Y | Y | Y |
PowerPC | Y | Y | Y | Y |
上表单元格中的“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。
- ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
- ※注 2:上表中的 x86 包括 x64 及 AMD64。
- ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
- ※注 4:数据依赖性后文会专门说明。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,先于 Load2 及所有后续装载指令的装载。 |
StoreStore | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),先于 Store2 及所有后续存储指令的存储。 |
LoadStore | Load1; LoadStore; Store2 | 确保 Load1 数据装载,先于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),先于 Load2 及所有后续装载指令的装载。 |
- StoreLoad 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
- StoreLoad 是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
happens-before
从 JDK5 开始,java 使用新的 JSR -133 内存模型。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。
重排序
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a = 1;b = a; | 写一个变量之后,再读这个位置。 |
写后写 | a = 1;a = 2; | 写一个变量之后,再写这个变量。 |
读后写 | a = b;b = 1; | 读一个变量之后,再写这个变量。 |
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。
前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial 语义
as-if-serial 语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三个操作的数据依赖关系如下图所示:
如上图所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。下图是该程序的两种执行顺序:
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
程序顺序规则
根据 happens- before 的程序顺序规则,上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
这里的第 3 个 happens- before 关系,是根据 happens- before 的传递性推导出来的。
这里 A happens- before B,但实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果 A happens- before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens- before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序并不非法(not illegal),JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从 happens- before 的定义我们可以看出,JMM 同样遵从这一目标。
重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer() 方法,随后 B 线程接着执行 reader() 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入?
答案是:不一定能看到。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果? 请看下面的程序执行时序图:
如上图所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
※注:本文统一用红色的虚箭线表示错误的读操作,用绿色的虚箭线表示正确的读操作。
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序的执行时序图:
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
顺序一致性
数据竞争与顺序一致性保证
当程序未正确同步时,就会存在数据竞争。java 内存模型规范对数据竞争的定义如下:
- 在一个线程中写一个变量,
- 在另一个线程读同一个变量,
- 而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
- 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)– 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。 +(不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。 顺序一致性内存模型为程序员提供的视图如下:
在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读 / 写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读 / 写操作串行化。
为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程 A 和 B 并发执行。其中 A 线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B 线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
假设这两个线程使用监视器来正确同步:A 线程的三个操作执行后释放监视器,随后 B 线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。
同步程序的顺序一致性效果
下面我们对前面的示例程序 ReorderExample 用监视器来同步,看看正确同步的程序如何具有顺序一致性。
请看下面的示例代码:
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}
上面示例代码中,假设 A 线程执行 writer() 方法后,B 线程执行 reader() 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:
在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到 JMM 在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。
未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM 保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。
和顺序一致性模型一样,未同步程序在 JMM 中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
- JMM 不保证对 64 位的 long 型和 double 型变量的读 / 写操作具有原子性,而顺序一致性模型保证对所有的内存读 / 写操作都具有原子性。
第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读 / 写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和 I/O 设备执行内存的读 / 写。下面让我们通过一个示意图来说明总线的工作机制:
如上图所示,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其它两个处理器则要等待处理器 A 的总线事务完成后才能开始再次执行内存访问。假设在处理器 A 执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器 D 向总线发起了总线事务,此时处理器 D 的这个请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读 / 写操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的读 / 写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读 / 写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。这两个 32 位的读 / 写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读 / 写将不具有原子性。
当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的无效值。
总结
处理器内存模型
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会把顺序一致性内存模型作为参照。JMM 和处理器内存模型在设计时会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型读 / 写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分为下面几种类型:
- 放松程序中写 - 读操作的顺序,由此产生了 total store ordering 内存模型(简称为 TSO)。
- 在前面 1 的基础上,继续放松程序中写 - 写操作的顺序,由此产生了 partial store order 内存模型(简称为 PSO)。
- 在前面 1 和 2 的基础上,继续放松程序中读 - 写和读 - 读操作的顺序,由此产生了 relaxed memory order 内存模型(简称为 RMO)和 PowerPC 内存模型。
注意,这里处理器对读 / 写操作的放松,是以两个操作之间不存在数据依赖性为前提的(因为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序)。
下面的表格展示了常见处理器内存模型的细节特征:
内存模型名称 | 对应的处理器 | Store-Load 重排序 | Store-Store 重排序 | Load-Load 和 Load-Store 重排序 | 可以更早读取到其它处理器的写 | 可以更早读取到当前处理器的写 |
---|---|---|---|---|---|---|
TSO | sparc-TSO X64 | Y | Y | |||
PSO | sparc-PSO | Y | Y | Y | ||
RMO | ia64 | Y | Y | Y | Y | |
PowerPC | PowerPC | Y | Y | Y | Y | Y |
在这个表格中,我们可以看到所有处理器内存模型都允许写 - 读重排序,原因在第一章以说明过:它们都使用了写缓存区,写缓存区可能导致写 - 读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区:由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己的写缓存区中的写。
上面表格中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计的会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比 JMM 要弱,java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱并不相同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。下图展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图:
如上图所示,JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 java 程序员呈现了一个一致的内存模型。
JMM,处理器内存模型与顺序一致性内存模型之间的关系
JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存模型是一个理论参考模型。下面是语言内存模型,处理器内存模型和顺序一致性内存模型的强弱对比示意图:
从上图我们可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计的会越弱。
JMM 的设计
从 JMM 设计者的角度来说,在设计 JMM 时,需要考虑两个关键因素:
- 程序员对内存模型的使用。程序员希望内存模型易于理解,易于编程。程序员希望基于一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的放松。下面让我们看看 JSR-133 是如何实现这一目标的。
为了具体说明,请看前面提到过的计算圆面积的示例代码
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面计算圆的面积的示例代码存在三个 happens- before 关系:
- A happens- before B;
- B happens- before C;
- A happens- before C;
由于 A happens- before B,happens- before 的定义会要求:A 操作执行的结果要对 B 可见,且 A 操作的执行顺序排在 B 操作之前。 但是从程序语义的角度来说,对 A 和 B 做重排序即不会改变程序的执行结果,也还能提高程序的执行性能(允许这种重排序减少了对编译器和处理器优化的束缚)。也就是说,上面这 3 个 happens- before 关系中,虽然 2 和 3 是必需要的,但 1 是不必要的。因此,JMM 把 happens- before 要求禁止的重排序分为了下面两类:
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM 对这两种不同性质的重排序,采取了不同的策略:
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不作要求(JMM 允许这种重排序)。
下面是 JMM 的设计示意图:
从上图可以看出两点:
- JMM 向程序员提供的 happens- before 规则能满足程序员的需求。JMM 的 happens- before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens- before B)。
- JMM 对编译器和处理器的束缚已经尽可能的少。从上面的分析我们可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。比如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再比如,如果编译器经过细致的分析后,认定一个 volatile 变量仅仅只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些优化既不会改变程序的执行结果,又能提高程序的执行效率。
JMM 的内存可见性保证
Java 程序的内存可见性保证按程序类型可以分为下列三类:
- 单线程程序。单线程程序不会出现内存可见性问题。编译器,runtime 和处理器会共同确保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步 / 未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
下图展示了这三类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同:
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与该程序在顺序一致性内存模型中的执行结果一致。
JSR-133 对旧内存模型的修补
JSR-133 对 JDK5 之前的旧内存模型的修补主要有两个:
- 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写 - 读和锁的释放 - 获取具有相同的内存语义。
- 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。现在,final 具有了初始化安全性。
2.1.7 - CH07-JVM垃圾回收
判断对象是否可被回收
引用计数法
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
可达性分析
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
方法区回收
因为方法区主要存放永久代(元空间)对象,而永久代对象的回收率比新生代低很多,因此在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:
- 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
finalize
finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会调用 finalize() 方法。
引用类型
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java 具有四种强度不同的引用类型。
强引用
被强引用关联的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
软引用
被软引用关联的对象只有在内存不够的情况下才会被回收。
使用 SoftReference 类来创建软引用。
弱引用
被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
使用 WeakReference 类来实现弱引用。
虚引用
又称为幽灵引用或者幻影引用。
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
使用 PhantomReference 来实现虚引用。
回收算法
标记-清除
将存活的对象进行标记,然后清理掉未被标记的对象。
不足:
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
标记-整理
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
复制
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
主要不足是只使用了内存的一半。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用: 复制算法
- 老年代使用: 标记 - 清除 或者 标记 - 整理 算法
垃圾收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
- 串行与并行:
- 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;
- 并形指的是垃圾收集器和用户程序同时执行。
- 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
Serial 收集器
Serial 翻译为串行,也就是说它以串行的方式执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。
Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
ParNew 收集器
它是 Serial 收集器的多线程版本。
是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
Parallel Scavenge 收集器
与 ParNew 一样是多线程收集器。
其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
可以通过一个开关参数打卡 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 。
Serial Old 收集器
是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
分为以下四个流程:
- 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除: 不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
具有以下缺点:
- 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
G1 收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记
- 并发标记
- 最终标记: 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- 筛选回收: 首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
具备如下特点:
- 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
内存分配与回收策略
Minor GC 与 Full GC
Minor GC: 发生在新生代上,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
Full GC: 发生在老年代上,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
1. 对象优先在 Eden 分配
- 大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
2. 大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
3. 长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
4. 动态对象年龄判定
- 虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5. 空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。
Full GC 触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
1. 调用 System.gc()
- 只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
2. 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。
除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。
还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
3. 空间分配担保失败
- 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
4. JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。
5. Concurrent Mode Failure
- 执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
2.1.8 - CH08-JVM-G1
G1垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方在ZGC还没有出现时也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。
概述
G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低停顿时间(pause time),同时兼顾良好的吞吐量。
G1回收器和CMS比起来,有以下不同:
- G1垃圾回收器是 compacting 的,因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间,更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式,而可以直接采用bump-the-pointer的方式;
- G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region,每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;
- G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
G1 内存模型
分区概念
分区 Region
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
卡片 Card
在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
堆 Heap
G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。
分代模型
分代垃圾收集
分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但G1依然使用了分代的思想。与其他垃圾收集器类似,G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。
整个年轻代内存会在初始空间-XX:G1NewSizePercent
(默认整堆5%)与最大空间(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis
(默认200ms)、需要扩缩容的大小以-XX:G1MaxNewSizePercent
及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。
本地分配缓冲 Local allocation buffer (Lab)
值得注意的是,由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。
- 其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;
- 而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;
- 对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
分区模型
G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。
巨形对象 Humongous Region
一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受TLab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
已记忆集合Remember Set (RSet)
在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet),内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。
Per Region Table (PRT)
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
- 稀少:直接记录引用对象的卡片索引
- 细粒度:记录引用对象的分区索引
- 粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
收集集合 (CSet)
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
年轻代收集集合 CSet of Young Collection
应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。
同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
混合收集集合 CSet of Mixed Collection
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。
并发标记算法(三色标记法)
CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。
GC 开始前所有对象都是白色,GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色。然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色。当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾。
漏标问题
在remark过程中,黑色指向了白色,如果不对黑色重新扫描,则会漏标。会把白色D对象当作没有新引用指向从而回收掉。
并发标记过程中,Mutator删除了所有从灰色到白色的引用,会产生漏标。此时白色对象应该被回收
产生漏标问题的条件有两个:
- 黑色对象指向了白色对象
- 灰色对象指向白色对象的引用消失
所以要解决漏标问题,打破两个条件之一即可:
- 跟踪黑指向白的增加 incremental update:增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS采用该方法。
- 记录灰指向白的消失 SATB snapshot at the beginning:关注引用的删除,当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被GC扫描到。G1采用该方法。
为什么G1采用SATB而不用incremental update?
因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。
也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。
G1 的活动周期
G1 垃圾收集活动汇总
RSet 的维护
由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)。
栅栏Barrier
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
起始快照算法Snapshot at the beginning (SATB)
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
并发优化线程Concurrence Refinement Threads
G1中使用基于Urs Hölzle的快速写栅栏,将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。
当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。
并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads
(默认等于-XX:ParellelGCThreads
)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone
,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
并发标记周期 Concurrent Marking Cycle
并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。
当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent
(老年代占整堆比,默认45%)时,便会触发并发标记周期。整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。
并发标记线程 Concurrent Marking Threads
要标记存活的对象,每个分区都需要创建位图(Bitmap)信息来存储标记数据,来确定标记周期内被分配的对象。G1采用了两个位图Previous Bitmap、Next Bitmap,来存储标记数据,Previous位图存储上次的标记数据,Next位图在标记周期内不断变化更新,同时Previous位图的标记数据也越来越过时,当标记周期结束后Next位图便替换Previous位图,成为上次标记的位图。同时,每个分区通过顶部开始标记(TAMS),来记录已标记过的内存范围。同样的,G1使用了两个顶部开始标记Previous TAMS(PTAMS)、Next TAMS(NTAMS),记录已标记的范围。
在并发标记阶段,G1会根据参数-XX:ConcGCThreads
(默认GC线程数的1/4,即-XX:ParallelGCThreads/4
),分配并发标记线程(Concurrent Marking Threads),进行标记活动。每个并发线程一次只扫描一个分区,并通过"手指"指针的方式优化获取分区。并发标记线程是爆发式的,在给定的时间段拼命干活,然后休息一段时间,再拼命干活。
每个并发标记周期,在初始标记STW的最后,G1会分配一个空的Next位图和一个指向分区顶部(Top)的NTAMS标记。Previous位图记录的上次标记数据,上次的标记位置,即PTAMS,在PTAMS与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在PTAMS与Top之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next位图吸收了Previous位图的标记数据,同时每个分区都会有新的对象分配,则Top与NTAMS分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出NTAMS与PTAMS之间的所有存活对象,将标记数据存储在Next位图中。同时,在NTAMS与Top之间的对象即成为已标记对象。如此不断地更新Next位图信息,并在清除阶段与Previous位图交换角色。
初始标记 Initial Mark
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads
(默认GC线程数的1/4,即-XX:ParallelGCThreads/4
)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark
会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
存活数据计算 Live Data Accounting
存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。
重新标记 Remark
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
清除 Cleanup
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
- RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数
-XX:+PrintAdaptiveSizePolicy
可以开启打印启发式算法决策细节; - 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
- 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。
年轻代收集/混合收集周期
年轻代收集和混合收集周期,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent
(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。
GC工作线程数
GC工作线程数 -XX:ParallelGCThreads
JVM可以通过参数-XX:ParallelGCThreads
进行指定GC工作的线程数量。参数-XX:ParallelGCThreads
默认值并不是固定的,而是根据当前的CPU资源进行计算。如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;当然也可以人工指定与CPU核数相等。
年轻代收集 Young Collection
每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。
并行活动
外部根分区扫描 Ext Root Scanning
:此活动对堆外的根(JVM系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合CSet中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间。更新已记忆集合 Update RS
:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新RSet的时间,可以设置暂停占用百分比-XX:G1RSetUpdatingPauseTimePercent(默认10%,即-XX:MaxGCPauseMills/10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少RSet的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对Java应用线程资源的争夺。RSet扫描 Scan RS
:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。如果RSet发生粗化,则会增加RSet的扫描时间。开启诊断模式-XX:UnlockDiagnosticVMOptions后,通过参数-XX:+G1SummarizeRSetStats可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为RSet粗化总数提供窗口。参数-XX:G1SummarizeRSetStatsPeriod=n可设置RSet的统计周期,即经历多少此GC后进行一次统计代码根扫描 Code Root Scanning
:对代码根集合进行扫描,扫描JVM编译后代码Native Method的引用信息(nmethod扫描),进行RSet扫描。事实上,只有CSet分区中的RSet有强代码根时,才会做nmethod扫描,查找对CSet的引用。转移和回收 Object Copy
:通过选定的CSet以及CSet分区完整的引用集,将执行暂停时间的主要部分:CSet分区存活对象的转移、CSet分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个GC线程分配缓冲区GCLAB。G1会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸。终止 Termination
:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止。GC外部的并行活动 GC Worker Other
:该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。
串行活动
代码根更新 Code Root Fixup
:根据转移对象更新代码根。代码根清理 Code Root Purge
:清理代码根集合表。清除全局卡片标记 Clear CT
:在任意收集周期会扫描CSet与RSet记录的PRT,扫描时会在全局卡片表中进行标记,防止重复扫描。在收集周期的最后将会清除全局卡片表中的已扫描标志。选择下次收集集合 Choose CSet
:该部分主要用于并发标记周期后的年轻代收集、以及混合收集中,在这些收集过程中,由于有老年代候选分区的加入,往往需要对下次收集的范围做出界定;但单纯的年轻代收集中,所有收集的分区都会被收集,不存在选择。引用处理 Ref Proc
:主要针对软引用、弱引用、虚引用、final引用、JNI引用。当Ref Proc占用时间过多时,可选择使用参数-XX:ParallelRefProcEnabled
激活多线程引用处理。G1希望应用能小心使用软引用,因为软引用会一直占据内存空间直到空间耗尽时被Full GC回收掉;即使未发生Full GC,软引用对内存的占用,也会导致GC次数的增加。引用排队 Ref Enq
:此项活动可能会导致RSet的更新,此时会通过记录日志,将关联的卡片标记为脏卡片。卡片重新脏化 Redirty Cards
:重新脏化卡片。回收空闲巨型分区 Humongous Reclaim
:G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。释放分区 Free CSet
:回收CSet分区的所有空间,并加入到空闲分区中。其他活动 Other
:GC中可能还会经历其他耗时很小的活动,如修复JNI句柄等。
并发标记周期后的年轻代收集 Young Collection Following Concurrent Marking Cycle
当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。
混合收集周期 Mixed Collection Cycle
单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
转移失败的担保机制 Full GC
转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
- 从老年代分区转移存活对象时,无法找到可用的空闲分区
- 分配巨型对象时在老年代无法找到足够的连续分区
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。
总结
G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。
虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。
- G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
- G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);
- G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;
- G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
2.1.9 - CH09-JVM-ZGC
2.1.10 - CH10-JVM调优参数
JVM 参数
-Xms
:堆的最小值-Xmx
:堆的最大值- 通常与
-Xms
相同以避免空间不足时动态扩容带来的影响
- 通常与
-Xmn
:新生代大小-Xss
:每个线程的堆栈大小- JDK 5 以上每个每个线程堆栈为 1M,之前是 256KB
- 相同物理内存下,减少该值可以增加线程数量,但仍受 OS 限制
-XX:NewRatio
:新生代与老年代比值- 4 表示新生代比老年代为
1:4
,新生代占堆的 1/5,与-Xmn
作用类似
- 4 表示新生代比老年代为
-XX:Persize
:元空间初始值,默认是物理空间的 1/64-XX:MaxPermSize
:元空间最大值,默认是物理空间 1/4-XX:MaxTenuringThreshold
:新生代对象年龄,默认 15- 若对象在伊甸园,经过 Minor GC 还活着,则年龄 +1,移动到幸存区
- 如果在幸存区,每次 Minor GC 还活着,年龄+1
- 到达 15 或移动到老年代
-XX:SurvivorRatio
:伊甸园与幸存区的比值,默认为 8- 两个幸存区与一个伊甸园的比值为
2:8
- 每个幸存区占新生代的 1/10
- 两个幸存区与一个伊甸园的比值为
-XX:+UseFastAccessorMethods
:原始类型快速优化-XX:+AggressiveOpts
:编译速度加快-XX:PretenureSizeThreshold
:大对象直接分配在老年代的阈值
实践
- 整个堆大小的计算公式: JVM 堆大小 = 年轻代大小+年老代大小+元空间大小。
- 增大新生代大小就会减少对应的年老代大小,设置-Xmn值对系统性能影响较大,所以如果设置新生代大小的调整,则需要严格的测试调整。而新生代是用来存放新创建的对象,大小是随着堆大小增大和减少而有相应的变化,默认值是保持堆大小的十五分之一,-Xmn参数就是设置新生代的大小,也可以通过-XX:NewRatio来设置新生代与年老代的比例,java 官方推荐配置为3:8。
- 新生代的特点就是内存中的对象更新速度快,在短时间内容易产生大量的无用对象,如果在这个参数时就需要考虑垃圾回收器设置参数也需要调整。
- 推荐使用: 复制清除算法和并行收集器进行垃圾回收,而新生代的垃圾回收叫做初级回收。
- StackOverflowError和OutOfMemoryException。
- 当线程中的请求的栈的深度大于最大可用深度,就会抛出前者;
- 若内存空间不够,无法创建新的线程,则会抛出后者。
- 栈的大小直接决定了函数的调用最大深度,栈越大,函数嵌套可调用次数就越多。
经验值
- Xmn用于设置新生代的大小。过小会增加Minor GC频率,过大会减小老年代的大小。一般设为整个堆空间的1/4或1/3.
- XX:SurvivorRatio用于设置新生代中survivor空间(from/to)和eden空间的大小比例; XX:TargetSurvivorRatio表示,当经历Minor GC后,survivor空间占有量(百分比)超过它的时候,就会压缩进入老年代(当然,如果survivor空间不够,则直接进入老年代)。默认值为50%。
- 为了性能考虑,一开始尽量将新生代对象留在新生代,避免新生的大对象直接进入老年代。因为新生对象大部分都是短期的,这就造成了老年代的内存浪费,并且回收代价也高(Full GC发生在老年代和方法区Perm).
- 当Xms=Xmx,可以使得堆相对稳定,避免不停震荡
- 一般来说,MaxPermSize设为64MB可以满足绝大多数的应用了。若依然出现方法区溢出,则可以设为128MB。若128MB还不能满足需求,那么就应该考虑程序优化了,减少动态类的产生。
垃圾回收
垃圾回收算法 :
- 引用计数法: 会有循环引用的问题,古老的方法;
- Mark-Sweep: 标记清除。根可达判断,最大的问题是空间碎片(清除垃圾之后剩下不连续的内存空间);
- Copying: 复制算法。对于短命对象来说有用,否则需要复制大量的对象,效率低。如Java的新生代堆空间中就是使用了它(survivor空间的from和to区);
- Mark-Compact: 标记整理。对于老年对象来说有用,无需复制,不会产生内存碎片
GC考虑的指标
- 吞吐量: 应用耗时和实际耗时的比值;
- 停顿时间: 垃圾回收的时候,由于Stop the World,应用程序的所有线程会挂起,造成应用停顿。
- 吞吐量和停顿时间是互斥的。
- 对于后端服务(比如后台计算任务),吞吐量优先考虑(并行垃圾回收);
- 对于前端应用,RT响应时间优先考虑,减少垃圾收集时的停顿时间,适用场景是Web系统(并发垃圾回收)
垃圾收集器参数
-XX:+UseSerialGC
:串行垃圾回收,现在基本很少使用。-XX:+UseParNewGC
:新生代使用并行,老年代使用串行;-XX:+UseConcMarkSweepGC
:新生代使用并行,老年代使用CMS- CMS是Concurrent Mark Sweep的缩写,并发标记清除,一看就是老年代的算法,所以,它可以作为老年代的垃圾回收器。
- CMS不是独占式的,它关注停顿时间
-XX:ParallelGCThreads
:指定并行的垃圾回收线程的数量,最好等于CPU数量-XX:+DisableExplicitGC
:禁用System.gc(),因为它会触发Full GC,这是很浪费性能的,JVM会在需要GC的时候自己触发GC。-XX:CMSFullGCsBeforeCompaction
:在多少次GC后进行内存压缩,这个是因为并行收集器不对内存空间进行压缩的,所以运行一段时间后会产生很多碎片,使得运行效率降低。-XX:+CMSParallelRemarkEnabled
:降低标记停顿-XX:+UseCMSCompactAtFullCollection
:在每一次Full GC时对老年代区域碎片整理,因为CMS是不会移动内存的,因此会非常容易出现碎片导致内存不够用的-XX:+UseCmsInitiatingOccupancyOnly
:使用手动触发或者自定义触发cms 收集,同时也会禁止hostspot 自行触发CMS GC-XX:CMSInitiatingOccupancyFraction
:使用CMS作为垃圾回收,使用70%后开始CMS收集-XX:CMSInitiatingPermOccupancyFraction
:设置perm gen使用达到多少%比时触发垃圾回收,默认是92%-XX:+CMSIncrementalMode
:设置为增量模式-XX:+CmsClassUnloadingEnabled
:CMS是不会默认对永久代进行垃圾回收的,设置此参数则是开启-XX:+PrintGCDetails
:开启详细GC日志模式,日志的格式是和所使用的算法有关-XX:+PrintGCDateStamps
将时间和日期也加入到GC日志中
JDK 默认收集器
java -XX:+PrintCommandLineFlags -version
查看当前 Java 的启动参数
- Java 7 - Parallel GC
- Java 8 - Parallel GC
- 年轻代:Parallel Scavenge
- 老年代:Parellel Old
- Java 9 - G1 GC
- Java 10 - G1 GC
- Java 11 - G1 GC
2.1.11 - CH11-JVM-OOM
堆内存溢出
在 Java 堆中只要不断的创建对象,并且
GC-Roots
到对象之间存在引用链,这样JVM
就不会回收对象。只要将
-Xms(最小堆)
,-Xmx(最大堆)
设置为一样禁止自动扩展堆内存。当使用一个
while(true)
循环来不断创建对象就会发生OutOfMemory
,还可以使用-XX:+HeapDumpOutofMemoryErorr
当发生 OOM 时会自动 dump 堆栈到文件中。当出现 OOM 时可以通过工具(如 JProfiler)来分析
GC-Roots
,查看对象和GC-Roots
是如何进行关联的,是否存在对象的生命周期过长,或者是这些对象确实改存在的,那就要考虑将堆内存调大了。
元空间溢出
JDK8
中将永久代移除,使用MetaSpace
来保存类加载之后的类信息,字符串常量池也被移动到 Java 堆。
- JDK 8 中将类信息移到到了本地堆内存(Native Heap)中,将原有的永久代移动到了本地堆中成为
MetaSpace
,如果不指定该区域的大小,JVM 将会动态的调整。 - 可以使用
-XX:MaxMetaspaceSize=10M
来限制最大元数据。这样当不停的创建类时将会占满该区域并出现OOM
。
2.1.12 - CH12-JVM线程Dump
概览
什么是Thread Dump
每一个Java虚拟机都有及时生成所有线程在某一点状态的thread-dump的能力,虽然各个 Java虚拟机打印的thread dump略有不同,但是 大多都提供了当前活动线程的快照,及JVM中所有Java线程的堆栈跟踪信息,堆栈信息一般包含完整的类名及所执行的方法,如果可能的话还有源代码的行数。
Thread Dump特点
- 能在各种操作系统下使用;
- 能在各种Java应用服务器下使用;
- 能在生产环境下使用而不影响系统的性能;
- 能将问题直接定位到应用程序的代码行上;
Thread Dump 抓取
一般当服务器挂起,崩溃或者性能低下时,就需要抓取服务器的线程堆栈(Thread Dump)用于后续的分析。在实际运行中,往往一次 dump的信息,还不足以确认问题。为了反映线程状态的动态变化,需要接连多次做thread dump,每次间隔10-20s,建议至少产生三次 dump信息,如果每次 dump都指向同一个问题,我们才确定问题的典型性。
- 获取 Java 进程 PID:
jps -l
- 获取 Java 进程 Thread Dump:
jstack [-l] <pid> | tee -a jstack.log
Thread Dump 分析
信息结构
- 头部信息:时间,JVM信息
2011-11-02 19:05:06
Full thread dump Java HotSpot(TM) Server VM (16.3-b01 mixed mode):
- 线程INFO信息块:
1. "Timer-0" daemon prio=10 tid=0xac190c00 nid=0xaef in Object.wait() [0xae77d000]
# 线程名称:Timer-0;线程类型:daemon;优先级: 10,默认是5;
# JVM线程id:tid=0xac190c00,JVM内部线程的唯一标识(通过java.lang.Thread.getId()获取,通常用自增方式实现)。
# 对应系统线程id(NativeThread ID):nid=0xaef,和top命令查看的线程pid对应,不过一个是10进制,一个是16进制。(通过命令:top -H -p pid,可以查看该进程的所有线程信息)
# 线程状态:in Object.wait();
# 起始栈地址:[0xae77d000],对象的内存地址,通过JVM内存查看工具,能够看出线程是在哪儿个对象上等待;
2. java.lang.Thread.State: TIMED_WAITING (on object monitor)
3. at java.lang.Object.wait(Native Method)
4. -waiting on <0xb3885f60> (a java.util.TaskQueue) # 继续wait
5. at java.util.TimerThread.mainLoop(Timer.java:509)
6. -locked <0xb3885f60> (a java.util.TaskQueue) # 已经locked
7. at java.util.TimerThread.run(Timer.java:462)
Java thread statck trace:是上面2-7行的信息。到目前为止这是最重要的数据,Java stack trace提供了大部分信息来精确定位问题根源。
堆栈信息应该逆向解读:程序先执行的是第7行,然后是第6行,依次类推。
- locked <0xb3885f60> (a java.util.ArrayList)
- waiting on <0xb3885f60> (a java.util.ArrayList)
也就是说对象先上锁,锁住对象0xb3885f60,然后释放该对象锁,进入waiting状态。为啥会出现这样的情况呢?看看下面的java代码示例,就会明白:
synchronized(obj) {
.........
obj.wait();
.........
}
如上,线程的执行过程,先用 synchronized
获得了这个对象的 Monitor(对应于 locked <0xb3885f60>
)。当执行到 obj.wait()
,线程即放弃了 Monitor的所有权,进入 “wait set”队列(对应于 waiting on <0xb3885f60>
)。
在堆栈的第一行信息中,进一步标明了线程在代码级的状态,例如:
java.lang.Thread.State: TIMED_WAITING (parking)
参考线程的状态说明:
|blocked|
> This thread tried to enter asynchronized block, but the lock was taken by another thread. This thread isblocked until the lock gets released.
|blocked (on thin lock)|
> This is the same state asblocked, but the lock in question is a thin lock.
|waiting|
> This thread calledObject.wait() on an object. The thread will remain there until some otherthread sends a notification to that object.
|sleeping|
> This thread calledjava.lang.Thread.sleep().
|parked|
> This thread calledjava.util.concurrent.locks.LockSupport.park().
|suspended|
> The thread's execution wassuspended by java.lang.Thread.suspend() or a JVMTI agent call.
Thread 状态分析
线程的状态是一个很重要的东西,因此thread dump中会显示这些状态,通过对这些状态的分析,能够得出线程的运行状况,进而发现可能存在的问题。线程的状态在Thread.State这个枚举类型中定义:
public enum State
{
/**
* Thread state for a thread which has not yet started.
*/
NEW,
/**
* Thread state for a runnable thread. A thread in the runnable
* state is executing in the Java virtual machine but it may
* be waiting for other resources from the operating system
* such as processor.
*/
RUNNABLE,
/**
* Thread state for a thread blocked waiting for a monitor lock.
* A thread in the blocked state is waiting for a monitor lock
* to enter a synchronized block/method or
* reenter a synchronized block/method after calling
* {@link Object#wait() Object.wait}.
*/
BLOCKED,
/**
* Thread state for a waiting thread.
* A thread is in the waiting state due to calling one of the
* following methods:
* <ul>
* <li>{@link Object#wait() Object.wait} with no timeout</li>
* <li>{@link #join() Thread.join} with no timeout</li>
* <li>{@link LockSupport#park() LockSupport.park}</li>
* </ul>
*
* <p>A thread in the waiting state is waiting for another thread to
* perform a particular action.
*
* For example, a thread that has called <tt>Object.wait()</tt>
* on an object is waiting for another thread to call
* <tt>Object.notify()</tt> or <tt>Object.notifyAll()</tt> on
* that object. A thread that has called <tt>Thread.join()</tt>
* is waiting for a specified thread to terminate.
*/
WAITING,
/**
* Thread state for a waiting thread with a specified waiting time.
* A thread is in the timed waiting state due to calling one of
* the following methods with a specified positive waiting time:
* <ul>
* <li>{@link #sleep Thread.sleep}</li>
* <li>{@link Object#wait(long) Object.wait} with timeout</li>
* <li>{@link #join(long) Thread.join} with timeout</li>
* <li>{@link LockSupport#parkNanos LockSupport.parkNanos}</li>
* <li>{@link LockSupport#parkUntil LockSupport.parkUntil}</li>
* </ul>
*/
TIMED_WAITING,
/**
* Thread state for a terminated thread.
* The thread has completed execution.
*/
TERMINATED;
}
NEW:
每一个线程,在堆内存中都有一个对应的Thread对象。Thread t = new Thread();当刚刚在堆内存中创建Thread对象,还没有调用t.start()方法之前,线程就处在NEW状态。在这个状态上,线程与普通的java对象没有什么区别,就仅仅是一个堆内存中的对象。
RUNNABLE:
该状态表示线程具备所有运行条件,在运行队列中准备操作系统的调度,或者正在运行。 这个状态的线程比较正常,但如果线程长时间停留在在这个状态就不正常了,这说明线程运行的时间很长(存在性能问题),或者是线程一直得不得执行的机会(存在线程饥饿的问题)。
BLOCKED:
线程正在等待获取java对象的监视器(也叫内置锁),即线程正在等待进入由synchronized保护的方法或者代码块。synchronized用来保证原子性,任意时刻最多只能由一个线程进入该临界区域,其他线程只能排队等待。
WAITING:
处在该线程的状态,正在等待某个事件的发生,只有特定的条件满足,才能获得执行机会。而产生这个特定的事件,通常都是另一个线程。也就是说,如果不发生特定的事件,那么处在该状态的线程一直等待,不能获取执行的机会。比如:
A线程调用了obj对象的obj.wait()方法,如果没有线程调用obj.notify或obj.notifyAll,那么A线程就没有办法恢复运行; 如果A线程调用了LockSupport.park(),没有别的线程调用LockSupport.unpark(A),那么A没有办法恢复运行。 TIMED_WAITING:
J.U.C中很多与线程相关类,都提供了限时版本和不限时版本的API。TIMED_WAITING意味着线程调用了限时版本的API,正在等待时间流逝。当等待时间过去后,线程一样可以恢复运行。如果线程进入了WAITING状态,一定要特定的事件发生才能恢复运行;而处在TIMED_WAITING的线程,如果特定的事件发生或者是时间流逝完毕,都会恢复运行。
TERMINATED:
线程执行完毕,执行完run方法正常返回,或者抛出了运行时异常而结束,线程都会停留在这个状态。这个时候线程只剩下Thread对象了,没有什么用了。
关键状态分析
Wait on condition:The thread is either sleeping or waiting to be notified by another thread.
该状态说明它在等待另一个条件的发生,来把自己唤醒,或者干脆它是调用了 sleep(n)。
此时线程状态大致为以下几种:
- java.lang.Thread.State: WAITING (parking):一直等那个条件发生;
- java.lang.Thread.State: TIMED_WAITING (parking或sleeping):定时的,那个条件不到来,也将定时唤醒自己。
Waiting for Monitor Entry 和 in Object.wait()
:The thread is waiting to get the lock for an object (some other thread may be holding the lock). This happens if two or more threads try to execute synchronized code. Note that the lock is always for an object and not for individual methods.
在多线程的JAVA程序中,实现线程之间的同步,就要说说 Monitor。Monitor是Java中用以实现线程之间的互斥与协作的主要手段,它可以看成是对象或者Class的锁。每一个对象都有,也仅有一个 Monitor 。下面这个图,描述了线程和 Monitor之间关系,以及线程的状态转换图:
如上图,每个Monitor在某个时刻,只能被一个线程拥有,该线程就是 “ActiveThread”,而其它线程都是 “Waiting Thread”,分别在两个队列“Entry Set”和“Wait Set”里等候。在“Entry Set”中等待的线程状态是“Waiting for monitor entry”,而在“Wait Set”中等待的线程状态是“in Object.wait()”。
先看“Entry Set”里面的线程。我们称被 synchronized保护起来的代码段为临界区。当一个线程申请进入临界区时,它就进入了“Entry Set”队列。对应的 code就像:
synchronized(obj) {
.........
}
这时有两种可能性:
- 该 monitor不被其它线程拥有, Entry Set里面也没有其它等待线程。本线程即成为相应类或者对象的 Monitor的 Owner,执行临界区的代码。
- 该 monitor被其它线程拥有,本线程在 Entry Set队列中等待。
在第一种情况下,线程将处于 “Runnable”的状态,而第二种情况下,线程 DUMP会显示处于 “waiting for monitor entry”。如下:
"Thread-0" prio=10 tid=0x08222eb0 nid=0x9 waiting for monitor entry [0xf927b000..0xf927bdb8]
at testthread.WaitThread.run(WaitThread.java:39)
- waiting to lock <0xef63bf08> (a java.lang.Object)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
临界区的设置,是为了保证其内部的代码执行的原子性和完整性。但是因为临界区在任何时间只允许线程串行通过,这和我们多线程的程序的初衷是相反的。如果在多线程的程序中,大量使用 synchronized,或者不适当的使用了它,会造成大量线程在临界区的入口等待,造成系统的性能大幅下降。如果在线程 DUMP中发现了这个情况,应该审查源码,改进程序。
再看“Wait Set”里面的线程。当线程获得了 Monitor,进入了临界区之后,如果发现线程继续运行的条件没有满足,它则调用对象(一般就是被 synchronized 的对象)的 wait() 方法,放弃 Monitor,进入 “Wait Set”队列。只有当别的线程在该对象上调用了 notify() 或者 notifyAll(),“Wait Set”队列中线程才得到机会去竞争,但是只有一个线程获得对象的Monitor,恢复到运行态。在 “Wait Set”中的线程, DUMP中表现为: in Object.wait()。如下:
"Thread-1" prio=10 tid=0x08223250 nid=0xa in Object.wait() [0xef47a000..0xef47aa38]
at java.lang.Object.wait(Native Method)
- waiting on <0xef63beb8> (a java.util.ArrayList)
at java.lang.Object.wait(Object.java:474)
at testthread.MyWaitThread.run(MyWaitThread.java:40)
- locked <0xef63beb8> (a java.util.ArrayList)
at java.lang.Thread.run(Thread.java:595)
综上,一般CPU很忙时,则关注runnable的线程,CPU很闲时,则关注waiting for monitor entry的线程。
JDK 5.0 的 Lock
上面提到如果 synchronized和 monitor机制运用不当,可能会造成多线程程序的性能问题。在 JDK 5.0中,引入了 Lock机制,从而使开发者能更灵活的开发高性能的并发多线程程序,可以替代以往 JDK中的 synchronized和 Monitor的 机制。但是,要注意的是,因为 Lock类只是一个普通类,JVM无从得知 Lock对象的占用情况,所以在线程 DUMP中,也不会包含关于 Lock的信息, 关于死锁等问题,就不如用 synchronized的编程方式容易识别。
关键状态示例
显示BLOCKED状态
package jstack;
public class BlockedState
{
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
先获取object的线程会执行5分钟,这5分钟内会一直持有object的监视器,另一个线程无法执行处在BLOCKED状态:
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1314 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7a800 nid=0x1350 waiting for monitor entry [0x2833f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at jstack.BlockedState$1.run(BlockedState.java:17)
- waiting to lock <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d79400 nid=0x1338 runnable [0x282ef000]
java.lang.Thread.State: RUNNABLE
at jstack.BlockedState$1.run(BlockedState.java:22)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
通过thread dump可以看到:t2线程确实处在BLOCKED (on object monitor)。waiting for monitor entry 等待进入synchronized保护的区域。
显示WAITING状态
package jstack;
public class WaitingState
{
private static Object object = new Object();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
synchronized (object)
{
long begin = System.currentTimeMillis();
long end = System.currentTimeMillis();
// 让线程运行5分钟,会一直持有object的监视器
while ((end - begin) <= 5 * 60 * 1000)
{
try
{
// 进入等待的同时,会进入释放监视器
object.wait();
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x1734 waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7e000 nid=0x17f4 in Object.wait() [0x2833f000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x27d7d400 nid=0x17f0 in Object.wait() [0x282ef000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x1cfcdc00> (a java.lang.Object)
at java.lang.Object.wait(Object.java:485)
at jstack.WaitingState$1.run(WaitingState.java:26)
- locked <0x1cfcdc00> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:662)
可以发现t1和t2都处在WAITING (on object monitor),进入等待状态的原因是调用了in Object.wait()。通过J.U.C包下的锁和条件队列,也是这个效果,大家可以自己实践下。
显示TIMED_WAITING状态
package jstack;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TimedWaitingState
{
// java的显示锁,类似java对象内置的监视器
private static Lock lock = new ReentrantLock();
// 锁关联的条件队列(类似于object.wait)
private static Condition condition = lock.newCondition();
public static void main(String[] args)
{
Runnable task = new Runnable() {
@Override
public void run()
{
// 加锁,进入临界区
lock.lock();
try
{
condition.await(5, TimeUnit.MINUTES);
} catch (InterruptedException e)
{
e.printStackTrace();
}
// 解锁,退出临界区
lock.unlock();
}
};
new Thread(task, "t1").start();
new Thread(task, "t2").start();
}
}
Full thread dump Java HotSpot(TM) Server VM (20.12-b01 mixed mode):
"DestroyJavaVM" prio=6 tid=0x00856c00 nid=0x169c waiting on condition [0x00000000]
java.lang.Thread.State: RUNNABLE
"t2" prio=6 tid=0x27d7d800 nid=0xc30 waiting on condition [0x2833f000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
"t1" prio=6 tid=0x280d0c00 nid=0x16e0 waiting on condition [0x282ef000]
java.lang.Thread.State: TIMED_WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x1cfce5b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:196)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2116)
at jstack.TimedWaitingState$1.run(TimedWaitingState.java:28)
at java.lang.Thread.run(Thread.java:662)
可以看到t1和t2线程都处在java.lang.Thread.State: TIMED_WAITING (parking),这个parking代表是调用的JUC下的工具类,而不是java默认的监视器。
案例分析
CPU飙高,load高,响应很慢
- 一个请求过程中多次dump;
- 对比多次dump文件的runnable线程,如果执行的方法有比较大变化,说明比较正常。如果在执行同一个方法,就有一些问题了;
查找占用CPU最多的线程
用命令:top -H -p pid(pid为被测系统的进程号),找到导致CPU高的线程ID,对应thread dump信息中线程的nid,只不过一个是十进制,一个是十六进制;
在thread dump中,根据top命令查找的线程id,查找对应的线程堆栈信息;
CPU使用率不高但是响应很慢
进行dump,查看是否有很多thread struck在了i/o、数据库等地方,定位瓶颈原因;
请求无法响应
多次dump,对比是否所有的runnable线程都一直在执行相同的方法,是则有可能出现了死锁。
死锁
死锁经常表现为程序的停顿,或者不再响应用户的请求。从操作系统上观察,对应进程的CPU占用率为零,很快会从top或prstat的输出中消失。
比如在下面这个示例中,是个较为典型的死锁情况:
"Thread-1" prio=5 tid=0x00acc490 nid=0xe50 waiting for monitor entry [0x02d3f000
..0x02d3fd68]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f18> (a java.lang.Object)
- locked <0x22c19f20> (a java.lang.Object)
"Thread-0" prio=5 tid=0x00accdb0 nid=0xdec waiting for monitor entry [0x02cff000
..0x02cff9e8]
at deadlockthreads.TestThread.run(TestThread.java:31)
- waiting to lock <0x22c19f20> (a java.lang.Object)
- locked <0x22c19f18> (a java.lang.Object)
在 JAVA 5中加强了对死锁的检测。线程 Dump中可以直接报告出 Java级别的死锁,如下所示:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x0003f334 (object 0x22c19f18, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x0003f314 (object 0x22c19f20, a java.lang.Object),
which is held by "Thread-1"
热锁
热锁,也往往是导致系统性能瓶颈的主要因素。其表现特征为:由于多个线程对临界区,或者锁的竞争,可能出现:
- 频繁的线程的上下文切换:从操作系统对线程的调度来看,当线程在等待资源而阻塞的时候,操作系统会将之切换出来,放到等待的队列,当线程获得资源之后,调度算法会将这个线程切换进去,放到执行队列中。
- 大量的系统调用:因为线程的上下文切换,以及热锁的竞争,或者临界区的频繁的进出,都可能导致大量的系统调用。
- 大部分CPU开销用在“系统态”:线程上下文切换,和系统调用,都会导致 CPU在 “系统态 ”运行,换而言之,虽然系统很忙碌,但是CPU用在 “用户态 ”的比例较小,应用程序得不到充分的 CPU资源。
- 随着CPU数目的增多,系统的性能反而下降。因为CPU数目多,同时运行的线程就越多,可能就会造成更频繁的线程上下文切换和系统态的CPU开销,从而导致更糟糕的性能。
上面的描述,都是一个 scalability(可扩展性)很差的系统的表现。从整体的性能指标看,由于线程热锁的存在,程序的响应时间会变长,吞吐量会降低。
那么,怎么去了解 “热锁 ”出现在什么地方呢?
一个重要的方法是 结合操作系统的各种工具观察系统资源使用状况,以及收集Java线程的DUMP信息,看线程都阻塞在什么方法上,了解原因,才能找到对应的解决方法。
JVM 重要线程
Attach Listener
Attach Listener 线程是负责接收到外部的命令,而对该命令进行执行的并把结果返回给发送者。通常我们会用一些命令去要求JVM给我们一些反馈信息,如:java -version、jmap、jstack等等。 如果该线程在JVM启动的时候没有初始化,那么,则会在用户第一次执行JVM命令时,得到启动。
Signal Dispatcher
前面提到Attach Listener线程的职责是接收外部JVM命令,当命令接收成功后,会交给signal dispather线程去进行分发到各个不同的模块处理命令,并且返回处理结果。signal dispather线程也是在第一次接收外部JVM命令时,进行初始化工作。
CompilerThread0
用来调用JITing,实时编译装卸class 。 通常,JVM会启动多个线程来处理这部分工作,线程名称后面的数字也会累加,例如:CompilerThread1。
Concurrent Mark-Sweep GC Thread
并发标记清除垃圾回收器(就是通常所说的CMS GC)线程, 该线程主要针对于老年代垃圾回收。ps:启用该垃圾回收器,需要在JVM启动参数中加上:-XX:+UseConcMarkSweepGC。
DestroyJavaVM
执行main()的线程,在main执行完后调用JNI中的 jni_DestroyJavaVM() 方法唤起DestroyJavaVM 线程,处于等待状态,等待其它线程(Java线程和Native线程)退出时通知它卸载JVM。每个线程退出时,都会判断自己当前是否是整个JVM中最后一个非deamon线程,如果是,则通知DestroyJavaVM 线程卸载JVM。
Finalizer Thread
这个线程也是在main线程之后创建的,其优先级为10,主要用于在垃圾收集前,调用对象的finalize()方法;关于Finalizer线程的几点:1) 只有当开始一轮垃圾收集时,才会开始调用finalize()方法;因此并不是所有对象的finalize()方法都会被执行;2) 该线程也是daemon线程,因此如果虚拟机中没有其他非daemon线程,不管该线程有没有执行完finalize()方法,JVM也会退出;3) JVM在垃圾收集时会将失去引用的对象包装成Finalizer对象(Reference的实现),并放入ReferenceQueue,由Finalizer线程来处理;最后将该Finalizer对象的引用置为null,由垃圾收集器来回收;4) JVM为什么要单独用一个线程来执行finalize()方法呢?如果JVM的垃圾收集线程自己来做,很有可能由于在finalize()方法中误操作导致GC线程停止或不可控,这对GC线程来说是一种灾难;
Low Memory Detector
这个线程是负责对可使用内存进行检测,如果发现可用内存低,分配新的内存空间。
Reference Handler
JVM在创建main线程后就创建Reference Handler线程,其优先级最高,为10,它主要用于处理引用对象本身(软引用、弱引用、虚引用)的垃圾回收问题 。
VM Thread
这个线程就比较牛b了,是JVM里面的线程母体,根据hotspot源码(vmThread.hpp)里面的注释,它是一个单个的对象(最原始的线程)会产生或触发所有其他的线程,这个单个的VM线程是会被其他线程所使用来做一些VM操作(如:清扫垃圾等)。
2.1.13 - CH13-JVM调试命令
文本操作
文本查找:grep
# 基本使用
grep yoursearchkeyword f.txt #文件查找
grep 'KeyWord otherKeyWord' f.txt cpf.txt #多文件查找, 含空格加引号
grep 'KeyWord' /home/admin -r -n #目录下查找所有符合关键字的文件
grep 'keyword' /home/admin -r -n -i # -i 忽略大小写
grep 'KeyWord' /home/admin -r -n --include *.{vm,java} #指定文件后缀
grep 'KeyWord' /home/admin -r -n --exclude *.{vm,java} #反匹配
# cat + grep
cat f.txt | grep -i keyword # 查找所有keyword且不分大小写
cat f.txt | grep -c 'KeyWord' # 统计Keyword次数
# seq + grep
seq 10 | grep 5 -A 3 #上匹配
seq 10 | grep 5 -B 3 #下匹配
seq 10 | grep 5 -C 3 #上下匹配,平时用这个就妥了
参数解释:
--color=auto:显示颜色;
-i, --ignore-case:忽略字符大小写;
-o, --only-matching:只显示匹配到的部分;
-n, --line-number:显示行号;
-v, --invert-match:反向显示,显示未匹配到的行;
-E, --extended-regexp:支持使用扩展的正则表达式;
-q, --quiet, --silent:静默模式,即不输出任何信息;
-w, --word-regexp:整行匹配整个单词;
-c, --count:统计匹配到的行数; print a count of matching lines;
-B, --before-context=NUM:print NUM lines of leading context 后#行
-A, --after-context=NUM:print NUM lines of trailing context 前#行
-C, --context=NUM:print NUM lines of output context 前后各#行
文本分析:awk
# 基本使用
awk '{print $4,$6}' f.txt
awk '{print NR,$0}' f.txt cpf.txt
awk '{print FNR,$0}' f.txt cpf.txt
awk '{print FNR,FILENAME,$0}' f.txt cpf.txt
awk '{print FILENAME,"NR="NR,"FNR="FNR,"$"NF"="$NF}' f.txt cpf.txt
echo 1:2:3:4 | awk -F: '{print $1,$2,$3,$4}'
# 匹配
awk '/ldb/ {print}' f.txt #匹配ldb
awk '!/ldb/ {print}' f.txt #不匹配ldb
awk '/ldb/ && /LISTEN/ {print}' f.txt #匹配ldb和LISTEN
awk '$5 ~ /ldb/ {print}' f.txt #第五列匹配ldb
内建变量:
NR
: NR表示从awk开始执行后,按照记录分隔符读取的数据次数,默认的记录分隔符为换行符,因此默认的就是读取的数据行数,NR可以理解为Number of Record的缩写。FNR
: 在awk处理多个输入文件的时候,在处理完第一个文件后,NR并不会从1开始,而是继续累加,因此就出现了FNR,每当处理一个新文件的时候,FNR就从1开始计数,FNR可以理解为File Number of Record。NF
: NF表示目前的记录被分割的字段的数目,NF可以理解为Number of Field。
文本处理:sed
# 文本打印
sed -n '3p' xxx.log #只打印第三行
sed -n '$p' xxx.log #只打印最后一行
sed -n '3,9p' xxx.log #只查看文件的第3行到第9行
sed -n -e '3,9p' -e '=' xxx.log #打印3-9行,并显示行号
sed -n '/root/p' xxx.log #显示包含root的行
sed -n '/hhh/,/omc/p' xxx.log # 显示包含"hhh"的行到包含"omc"的行之间的行
# 文本替换
sed -i 's/root/world/g' xxx.log # 用world 替换xxx.log文件中的root; s==search 查找并替换, g==global 全部替换, -i: implace
# 文本插入
sed '1,4i hahaha' xxx.log # 在文件第一行和第四行的每行下面添加hahaha
sed -e '1i happy' -e '$a new year' xxx.log #【界面显示】在文件第一行添加happy,文件结尾添加new year
sed -i -e '1i happy' -e '$a new year' xxx.log #【真实写入文件】在文件第一行添加happy,文件结尾添加new year
# 文本删除
sed '3,9d' xxx.log # 删除第3到第9行,只是不显示而已
sed '/hhh/,/omc/d' xxx.log # 删除包含"hhh"的行到包含"omc"的行之间的行
sed '/omc/,10d' xxx.log # 删除包含"omc"的行到第十行的内容
# 与find结合
find . -name "*.txt" |xargs sed -i 's/hhhh/\hHHh/g'
find . -name "*.txt" |xargs sed -i 's#hhhh#hHHh#g'
find . -name "*.txt" -exec sed -i 's/hhhh/\hHHh/g' {} \;
find . -name "*.txt" |xargs cat
文件操作
文件监听:tail
# 基本使用
tail -f xxx.log # 循环监听文件
tail -300f xxx.log #倒数300行并追踪文件
tail +20 xxx.log #从第 20 行至文件末尾显示文件内容
# tailf使用
tailf xxx.log #等同于tail -f -n 10 打印最后10行,然后追踪文件
tail -f 与 tail F 与 tailf 三者区别:
tail -f
等于–follow=descriptor,根据文件描述进行追踪,当文件改名或删除后,停止追踪。tail -F
等于 –follow=name ==retry,根据文件名字进行追踪,当文件改名或删除后,保持重试,当有新的文件和他同名时,继续追踪tailf
等于tail -f -n 10(tail -f或-F默认也是打印最后10行,然后追踪文件),与tail -f不同的是,如果文件不增长,它不会去访问磁盘文件,所以tailf特别适合那些便携机上跟踪日志文件,因为它减少了磁盘访问,可以省电。
参数解释:
-f 循环读取
-q 不显示处理信息
-v 显示详细的处理信息
-c<数目> 显示的字节数
-n<行数> 显示文件的尾部 n 行内容
--pid=PID 与-f合用,表示在进程ID,PID死掉之后结束
-q, --quiet, --silent 从不输出给出文件名的首部
-s, --sleep-interval=S 与-f合用,表示在每次反复的间隔休眠S秒
文件查找:find
sudo -u admin find /home/admin /tmp /usr -name \*.log(多个目录去找)
find . -iname \*.txt(大小写都匹配)
find . -type d(当前目录下的所有子目录)
find /usr -type l(当前目录下所有的符号链接)
find /usr -type l -name "z*" -ls(符号链接的详细信息 eg:inode,目录)
find /home/admin -size +250000k(超过250000k的文件,当然+改成-就是小于了)
find /home/admin f -perm 777 -exec ls -l {} \; (按照权限查询文件)
find /home/admin -atime -1 1天内访问过的文件
find /home/admin -ctime -1 1天内状态改变过的文件
find /home/admin -mtime -1 1天内修改过的文件
find /home/admin -amin -1 1分钟内访问过的文件
find /home/admin -cmin -1 1分钟内状态改变过的文件
find /home/admin -mmin -1 1分钟内修改过的文件
pgm
批量查询vm-shopbase满足条件的日志:
pgm -A -f vm-shopbase 'cat /home/admin/shopbase/logs/shopbase.log.2017-01-17|grep 2069861630'
网络
查看所有网络接口的属性
ifconfig
查看防火墙设置
iptables -L
查看路由表
route -n
netstat
- 查看所有监听端口:
netstat -lntp
- 查看所有已经建立的连接:
netstat -antp
- 查看当前连接:
netstat -nat|awk '{print $6}'|sort|uniq -c|sort -rn
- 查看网络统计信息进程:
netstat -s
进程
查看所有进程
ps -ef | grep java
top
top -H -p pid
存储
内存用量
free -m
磁盘用量
df -h
目录大小
du -sh
内存总量
grep MemTotal /proc/meminfo
内存空闲
grep MemFree /proc/meminfo
系统负载
cat /proc/loadavg
挂载分区状态
mount | column -t
所有分区
fdisk -l
所有交换分区
swapon -s
硬盘大小
fdisk -l | grep Disk
用户
活动用户
w
指定用户
id
登录日志
last
所有用户
cut -d: -f1 /etc/passwd
所有用户组
cut -d: f1 /etc/group
查看服务
# 查看当前用户的计划任务服务
crontab -l
# 列出所有系统服务
chkconfig –list
# 列出所有启动的系统服务程序
chkconfig –list | grep on
# 查看所有安装的软件包
rpm -qa
# 列出加载的内核模块
lsmod
查看系统
# 常用
env # 查看环境变量资源
uptime # 查看系统运行时间、用户数、负载
lsusb -tv # 列出所有USB设备的linux系统信息命令
lspci -tv # 列出所有PCI设备
head -n 1 /etc/issue # 查看操作系统版本,是数字1不是字母L
uname -a # 查看内核/操作系统/CPU信息的linux系统信息命令
# /proc/
cat /proc/cpuinfo :查看CPU相关参数的linux系统命令
cat /proc/partitions :查看linux硬盘和分区信息的系统信息命令
cat /proc/meminfo :查看linux系统内存信息的linux系统命令
cat /proc/version :查看版本,类似uname -r
cat /proc/ioports :查看设备io端口
cat /proc/interrupts :查看中断
cat /proc/pci :查看pci设备的信息
cat /proc/swaps :查看所有swap分区的信息
cat /proc/cpuinfo |grep "model name" && cat /proc/cpuinfo |grep "physical id"
tsar
tsar是淘宝开源的的采集工具。很好用, 将历史收集到的数据持久化在磁盘上,所以我们快速来查询历史的系统数据。当然实时的应用情况也是可以查询的啦。大部分机器上都有安装。
tsar ##可以查看最近一天的各项指标
tsar --live ##可以查看实时指标,默认五秒一刷
tsar -d 20161218 ##指定查看某天的数据,貌似最多只能看四个月的数据
tsar --mem
tsar --load
tsar --cpu ##当然这个也可以和-d参数配合来查询某天的单个指标的情况
2.1.14 - CH14-JVM调试工具
基本工具
jps
jps是jdk提供的一个查看当前java进程的小工具, 可以看做是JavaVirtual Machine Process Status Tool的缩写。
常用命令:
jps # 显示进程的ID 和 类的名称
jps –l # 输出输出完全的包名,应用主类名,jar的完全路径名
jps –v # 输出jvm参数
jps –q # 显示java进程号
jps -m # main 方法
jps -l xxx.xxx.xx.xx # 远程查看
参数解释:
-q:仅输出VM标识符,不包括classname,jar name,arguments in main method
-m:输出main method的参数
-l:输出完全的包名,应用主类名,jar的完全路径名
-v:输出jvm参数
-V:输出通过flag文件传递到JVM中的参数(.hotspotrc文件或-XX:Flags=所指定的文件
-Joption:传递参数到vm,例如:-J-Xms512m
实现原理:
java程序在启动以后,会在java.io.tmpdir指定的目录下,就是临时文件夹里,生成一个类似于hsperfdata_User的文件夹,这个文件夹里(在Linux中为/tmp/hsperfdata_{userName}/),有几个文件,名字就是java进程的pid,因此列出当前运行的java进程,只是把这个目录里的文件名列一下而已。 至于系统的参数什么,就可以解析这几个文件获得。
jstack
jstack是jdk自带的线程堆栈分析工具,使用该命令可以查看或导出 Java 应用程序中线程堆栈信息。
jstack常用命令:
# 基本
jstack 2815
# java和native c/c++框架的所有栈信息
jstack -m 2815
# 额外的锁信息列表,查看是否死锁
jstack -l 2815
jstack参数:
-l 长列表. 打印关于锁的附加信息,例如属于java.util.concurrent 的 ownable synchronizers列表.
-F 当’jstack [-l] pid’没有相应的时候强制打印栈信息
-m 打印java和native c/c++框架的所有栈信息.
-h | -help 打印帮助信息
jinfo
jinfo 是 JDK 自带的命令,可以用来查看正在运行的 java 应用程序的扩展参数,包括Java System属性和JVM命令行参数;也可以动态的修改正在运行的 JVM 一些参数。当系统崩溃时,jinfo可以从core文件里面知道崩溃的Java应用程序的配置信息。
jinfo常用命令:
# 输出当前 jvm 进程的全部参数和系统属性
jinfo 2815
# 输出所有的参数
jinfo -flags 2815
# 查看指定的 jvm 参数的值
jinfo -flag PrintGC 2815
# 开启/关闭指定的JVM参数
jinfo -flag +PrintGC 2815
# 设置flag的参数
jinfo -flag name=value 2815
# 输出当前 jvm 进行的全部的系统属性
jinfo -sysprops 2815
jinfo参数:
no option 输出全部的参数和系统属性
-flag name 输出对应名称的参数
-flag [+|-]name 开启或者关闭对应名称的参数
-flag name=value 设定对应名称的参数
-flags 输出全部的参数
-sysprops 输出系统属性
jmap
命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。
两个用途
# 查看堆的情况
jmap -heap 2815
# dump
jmap -dump:live,format=b,file=/tmp/heap2.bin 2815
jmap -dump:format=b,file=/tmp/heap3.bin 2815
# 查看堆的占用
jmap -histo 2815 | head -10
jmap参数
no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
heap: 显示Java堆详细信息
histo[:live]: 显示堆中对象的统计信息
clstats:打印类加载器信息
finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
dump:<dump-options>:生成堆转储快照
F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
help:打印帮助信息
J<flag>:指定传递给运行jmap的JVM的参数
jstat
jstat -gcutil 2815 1000
jdb
jdb可以用来预发debug,假设你预发的java_home是/opt/java/,远程调试端口是8000.那么
jdb -attach 8000
出现以上代表jdb启动成功。后续可以进行设置断点进行调试。
CHLSDB
实现 jstack和jmap的基础。
java -classpath /opt/taobao/java/lib/sa-jdi.jar sun.jvm.hotspot.CLHSDB
进阶工具
- btrace
- greys
- arthas
- javOSize
- JProfiler
- dmesg
- IDEA remote debugger
2.1.15 - CH15-JVM动态调试
动态调试要解决的问题
断点调试是我们最常使用的调试手段,它可以获取到方法执行过程中的变量信息,并可以观察到方法的执行路径。但断点调试会在断点位置停顿,使得整个应用停止响应。在线上停顿应用是致命的,动态调试技术给了我们创造新的调试模式的想象空间。本文将研究Java语言中的动态调试技术,首先概括Java动态调试所涉及的技术基础,接着介绍我们在Java动态调试领域的思考及实践,通过结合实际业务场景,设计并实现了一种具备动态性的断点调试工具Java-debug-tool,显著提高了故障排查效率。
Java Agent技术
JVMTI (JVM Tool Interface)是Java虚拟机对外提供的Native编程接口,通过JVMTI,外部进程可以获取到运行时JVM的诸多信息,比如线程、GC等。Agent是一个运行在目标JVM的特定程序,它的职责是负责从目标JVM中获取数据,然后将数据传递给外部进程。加载Agent的时机可以是目标JVM启动之时,也可以是在目标JVM运行时进行加载,而在目标JVM运行时进行Agent加载具备动态性,对于时机未知的Debug场景来说非常实用。下面将详细分析Java Agent技术的实现细节。
Agent的实现模式
JVMTI是一套Native接口,在Java SE 5之前,要实现一个Agent只能通过编写Native代码来实现。从Java SE 5开始,可以使用Java的Instrumentation接口(java.lang.instrument)来编写Agent。无论是通过Native的方式还是通过Java Instrumentation接口的方式来编写Agent,它们的工作都是借助JVMTI来进行完成,下面介绍通过Java Instrumentation接口编写Agent的方法。
通过Java Instrumentation API
- 实现Agent启动方法
Java Agent支持目标JVM启动时加载,也支持在目标JVM运行时加载,这两种不同的加载模式会使用不同的入口函数,如果需要在目标JVM启动的同时加载Agent,那么可以选择实现下面的方法:
[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);
JVM将首先寻找 1,如果没有发现 1,再寻找 2。如果希望在目标JVM运行时加载Agent,则需要实现下面的方法:
[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);
这两组方法的第一个参数AgentArgs是随同 “– javaagent”一起传入的程序参数,如果这个字符串代表了多个参数,就需要自己解析这些参数。inst是Instrumentation类型的对象,是JVM自动传入的,我们可以拿这个参数进行类增强等操作。
- 指定Main-Class
Agent需要打包成一个jar包,在ManiFest属性中指定“Premain-Class”或者“Agent-Class”:
Premain-Class: class
Agent-Class: class
- 挂载到目标JVM
将编写的Agent打成jar包后,就可以挂载到目标JVM上去了。如果选择在目标JVM启动时加载Agent,则可以使用 “-javaagent:[=
com.sun.tools.attach.VirtualMachine 这个类代表一个JVM抽象,可以通过这个类找到目标JVM,并且将Agent挂载到目标JVM上。下面是使用com.sun.tools.attach.VirtualMachine进行动态挂载Agent的一般实现:
private void attachAgentToTargetJVM() throws Exception {
List<VirtualMachineDescriptor> virtualMachineDescriptors = VirtualMachine.list();
VirtualMachineDescriptor targetVM = null;
for (VirtualMachineDescriptor descriptor : virtualMachineDescriptors) {
if (descriptor.id().equals(configure.getPid())) {
targetVM = descriptor;
break;
}
}
if (targetVM == null) {
throw new IllegalArgumentException("could not find the target jvm by process id:" + configure.getPid());
}
VirtualMachine virtualMachine = null;
try {
virtualMachine = VirtualMachine.attach(targetVM);
virtualMachine.loadAgent("{agent}", "{params}");
} catch (Exception e) {
if (virtualMachine != null) {
virtualMachine.detach();
}
}
}
首先通过指定的进程ID找到目标JVM,然后通过Attach挂载到目标JVM上,执行加载Agent操作。VirtualMachine的Attach方法就是用来将Agent挂载到目标JVM上去的,而Detach则是将Agent从目标JVM卸载。关于Agent是如何挂载到目标JVM上的具体技术细节,将在下文中进行分析。
启动时加载Agent
参数解析
创建JVM时,JVM会进行参数解析,即解析那些用来配置JVM启动的参数,比如堆大小、GC等;本文主要关注解析的参数为-agentlib、 -agentpath、 -javaagent,这几个参数用来指定Agent,JVM会根据这几个参数加载Agent。下面来分析一下JVM是如何解析这几个参数的。
// -agentlib and -agentpath
if (match_option(option, "-agentlib:", &tail) ||
(is_absolute_path = match_option(option, "-agentpath:", &tail))) {
if(tail != NULL) {
const char* pos = strchr(tail, '=');
size_t len = (pos == NULL) ? strlen(tail) : pos - tail;
char* name = strncpy(NEW_C_HEAP_ARRAY(char, len + 1, mtArguments), tail, len);
name[len] = '\0';
char *options = NULL;
if(pos != NULL) {
options = os::strdup_check_oom(pos + 1, mtArguments);
}
#if !INCLUDE_JVMTI
if (valid_jdwp_agent(name, is_absolute_path)) {
jio_fprintf(defaultStream::error_stream(),
"Debugging agents are not supported in this VM\n");
return JNI_ERR;
}
#endif // !INCLUDE_JVMTI
add_init_agent(name, options, is_absolute_path);
}
// -javaagent
} else if (match_option(option, "-javaagent:", &tail)) {
#if !INCLUDE_JVMTI
jio_fprintf(defaultStream::error_stream(),
"Instrumentation agents are not supported in this VM\n");
return JNI_ERR;
#else
if (tail != NULL) {
size_t length = strlen(tail) + 1;
char *options = NEW_C_HEAP_ARRAY(char, length, mtArguments);
jio_snprintf(options, length, "%s", tail);
add_init_agent("instrument", options, false);
// java agents need module java.instrument
if (!create_numbered_property("jdk.module.addmods", "java.instrument", addmods_count++)) {
return JNI_ENOMEM;
}
}
#endif // !INCLUDE_JVMTI
}
上面的代码片段截取自hotspot/src/share/vm/runtime/arguments.cpp中的 Arguments::parse_each_vm_init_arg(const JavaVMInitArgs* args, bool* patch_mod_javabase, Flag::Flags origin) 函数,该函数用来解析一个具体的JVM参数。这段代码的主要功能是解析出需要加载的Agent路径,然后调用add_init_agent函数进行解析结果的存储。下面先看一下add_init_agent函数的具体实现:
// -agentlib and -agentpath arguments
static AgentLibraryList _agentList;
static void add_init_agent(const char* name, char* options, bool absolute_path)
{ _agentList.add(new AgentLibrary(name, options, absolute_path, NULL)); }
AgentLibraryList是一个简单的链表结构,add_init_agent函数将解析好的、需要加载的Agent添加到这个链表中,等待后续的处理。
这里需要注意,解析-javaagent参数有一些特别之处,这个参数用来指定一个我们通过Java Instrumentation API来编写的Agent,Java Instrumentation API底层依赖的是JVMTI,对-JavaAgent的处理也说明了这一点,在调用add_init_agent函数时第一个参数是“instrument”,关于加载Agent这个问题在下一小节进行展开。到此,我们知道在启动JVM时指定的Agent已经被JVM解析完存放在了一个链表结构中。下面来分析一下JVM是如何加载这些Agent的。
执行加载操作
在创建JVM进程的函数中,解析完JVM参数之后,下面的这段代码和加载Agent相关:
// Launch -agentlib/-agentpath and converted -Xrun agents
if (Arguments::init_agents_at_startup()) {
create_vm_init_agents();
}
static bool init_agents_at_startup() {
return !_agentList.is_empty();
}
当JVM判断出上一小节中解析出来的Agent不为空的时候,就要去调用函数create_vm_init_agents来加载Agent,下面来分析一下create_vm_init_agents函数是如何加载Agent的。
void Threads::create_vm_init_agents() {
AgentLibrary* agent;
for (agent = Arguments::agents(); agent != NULL; agent = agent->next()) {
OnLoadEntry_t on_load_entry = lookup_agent_on_load(agent);
if (on_load_entry != NULL) {
// Invoke the Agent_OnLoad function
jint err = (*on_load_entry)(&main_vm, agent->options(), NULL);
}
}
}
create_vm_init_agents这个函数通过遍历Agent链表来逐个加载Agent。通过这段代码可以看出,首先通过lookup_agent_on_load来加载Agent并且找到Agent_OnLoad函数,这个函数是Agent的入口函数。如果没找到这个函数,则认为是加载了一个不合法的Agent,则什么也不做,否则调用这个函数,这样Agent的代码就开始执行起来了。对于使用Java Instrumentation API来编写Agent的方式来说,在解析阶段观察到在add_init_agent函数里面传递进去的是一个叫做”instrument”的字符串,其实这是一个动态链接库。在Linux里面,这个库叫做libinstrument.so,在BSD系统中叫做libinstrument.dylib,该动态链接库在{JAVA_HOME}/jre/lib/目录下。
instrument动态链接库
libinstrument用来支持使用Java Instrumentation API来编写Agent,在libinstrument中有一个非常重要的类称为:JPLISAgent(Java Programming Language Instrumentation Services Agent),它的作用是初始化所有通过Java Instrumentation API编写的Agent,并且也承担着通过JVMTI实现Java Instrumentation中暴露API的责任。
我们已经知道,在JVM启动的时候,JVM会通过-javaagent参数加载Agent。最开始加载的是libinstrument动态链接库,然后在动态链接库里面找到JVMTI的入口方法:Agent_OnLoad。下面就来分析一下在libinstrument动态链接库中,Agent_OnLoad函数是怎么实现的。
JNIEXPORT jint JNICALL
DEF_Agent_OnLoad(JavaVM *vm, char *tail, void * reserved) {
initerror = createNewJPLISAgent(vm, &agent);
if ( initerror == JPLIS_INIT_ERROR_NONE ) {
if (parseArgumentTail(tail, &jarfile, &options) != 0) {
fprintf(stderr, "-javaagent: memory allocation failure.\n");
return JNI_ERR;
}
attributes = readAttributes(jarfile);
premainClass = getAttribute(attributes, "Premain-Class");
/* Save the jarfile name */
agent->mJarfile = jarfile;
/*
* Convert JAR attributes into agent capabilities
*/
convertCapabilityAttributes(attributes, agent);
/*
* Track (record) the agent class name and options data
*/
initerror = recordCommandLineData(agent, premainClass, options);
}
return result;
}
上述代码片段是经过精简的libinstrument中Agent_OnLoad实现的,大概的流程就是:先创建一个JPLISAgent,然后将ManiFest中设定的一些参数解析出来, 比如(Premain-Class)等。创建了JPLISAgent之后,调用initializeJPLISAgent对这个Agent进行初始化操作。跟进initializeJPLISAgent看一下是如何初始化的:
JPLISInitializationError initializeJPLISAgent(JPLISAgent *agent, JavaVM *vm, jvmtiEnv *jvmtienv) {
/* check what capabilities are available */
checkCapabilities(agent);
/* check phase - if live phase then we don't need the VMInit event */
jvmtierror = (*jvmtienv)->GetPhase(jvmtienv, &phase);
/* now turn on the VMInit event */
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtiEventCallbacks callbacks;
memset(&callbacks, 0, sizeof(callbacks));
callbacks.VMInit = &eventHandlerVMInit;
jvmtierror = (*jvmtienv)->SetEventCallbacks(jvmtienv,&callbacks,sizeof(callbacks));
}
if ( jvmtierror == JVMTI_ERROR_NONE ) {
jvmtierror = (*jvmtienv)->SetEventNotificationMode(jvmtienv,JVMTI_ENABLE,JVMTI_EVENT_VM_INIT,NULL);
}
return (jvmtierror == JVMTI_ERROR_NONE)? JPLIS_INIT_ERROR_NONE : JPLIS_INIT_ERROR_FAILURE;
}
这里,我们关注callbacks.VMInit = &eventHandlerVMInit;这行代码,这里设置了一个VMInit事件的回调函数,表示在JVM初始化的时候会回调eventHandlerVMInit函数。下面来看一下这个函数的实现细节,猜测就是在这里调用了Premain方法:
void JNICALL eventHandlerVMInit( jvmtiEnv *jvmtienv,JNIEnv *jnienv,jthread thread) {
// ...
success = processJavaStart( environment->mAgent, jnienv);
// ...
}
jboolean processJavaStart(JPLISAgent *agent,JNIEnv *jnienv) {
result = createInstrumentationImpl(jnienv, agent);
/*
* Load the Java agent, and call the premain.
*/
if ( result ) {
result = startJavaAgent(agent, jnienv, agent->mAgentClassName, agent->mOptionsString, agent->mPremainCaller);
}
return result;
}
jboolean startJavaAgent( JPLISAgent *agent,JNIEnv *jnienv,const char *classname,const char *optionsString,jmethodID agentMainMethod) {
// ...
invokeJavaAgentMainMethod(jnienv,agent->mInstrumentationImpl,agentMainMethod, classNameObject,optionsStringObject);
// ...
}
看到这里,Instrument已经实例化,invokeJavaAgentMainMethod这个方法将我们的premain方法执行起来了。接着,我们就可以根据Instrument实例来做我们想要做的事情了。
运行时加载Agent
比起JVM启动时加载Agent,运行时加载Agent就比较有诱惑力了,因为运行时加载Agent的能力给我们提供了很强的动态性,我们可以在需要的时候加载Agent来进行一些工作。因为是动态的,我们可以按照需求来加载所需要的Agent,下面来分析一下动态加载Agent的相关技术细节。
AttachListener
Attach机制通过Attach Listener线程来进行相关事务的处理,下面来看一下Attach Listener线程是如何初始化的。
// Starts the Attach Listener thread
void AttachListener::init() {
// 创建线程相关部分代码被去掉了
const char thread_name[] = "Attach Listener";
Handle string = java_lang_String::create_from_str(thread_name, THREAD);
{ MutexLocker mu(Threads_lock);
JavaThread* listener_thread = new JavaThread(&attach_listener_thread_entry);
// ...
}
}
我们知道,一个线程启动之后都需要指定一个入口来执行代码,Attach Listener线程的入口是attach_listener_thread_entry,下面看一下这个函数的具体实现:
static void attach_listener_thread_entry(JavaThread* thread, TRAPS) {
AttachListener::set_initialized();
for (;;) {
AttachOperation* op = AttachListener::dequeue();
// find the function to dispatch too
AttachOperationFunctionInfo* info = NULL;
for (int i=0; funcs[i].name != NULL; i++) {
const char* name = funcs[i].name;
if (strcmp(op->name(), name) == 0) {
info = &(funcs[i]); break;
}}
// dispatch to the function that implements this operation
res = (info->func)(op, &st);
//...
}
}
整个函数执行逻辑,大概是这样的:
- 拉取一个需要执行的任务:AttachListener::dequeue。
- 查询匹配的命令处理函数。
- 执行匹配到的命令执行函数。
其中第二步里面存在一个命令函数表,整个表如下:
static AttachOperationFunctionInfo funcs[] = {
{ "agentProperties", get_agent_properties },
{ "datadump", data_dump },
{ "dumpheap", dump_heap },
{ "load", load_agent },
{ "properties", get_system_properties },
{ "threaddump", thread_dump },
{ "inspectheap", heap_inspection },
{ "setflag", set_flag },
{ "printflag", print_flag },
{ "jcmd", jcmd },
{ NULL, NULL }
};
对于加载Agent来说,命令就是“load”。现在,我们知道了Attach Listener大概的工作模式,但是还是不太清楚任务从哪来,这个秘密就藏在AttachListener::dequeue这行代码里面,接下来我们来分析一下dequeue这个函数:
LinuxAttachOperation* LinuxAttachListener::dequeue() {
for (;;) {
// wait for client to connect
struct sockaddr addr;
socklen_t len = sizeof(addr);
RESTARTABLE(::accept(listener(), &addr, &len), s);
// get the credentials of the peer and check the effective uid/guid
// - check with jeff on this.
struct ucred cred_info;
socklen_t optlen = sizeof(cred_info);
if (::getsockopt(s, SOL_SOCKET, SO_PEERCRED, (void*)&cred_info, &optlen) == -1) {
::close(s);
continue;
}
// peer credential look okay so we read the request
LinuxAttachOperation* op = read_request(s);
return op;
}
}
这是Linux上的实现,不同的操作系统实现方式不太一样。上面的代码表面,Attach Listener在某个端口监听着,通过accept来接收一个连接,然后从这个连接里面将请求读取出来,然后将请求包装成一个AttachOperation类型的对象,之后就会从表里查询对应的处理函数,然后进行处理。
Attach Listener使用一种被称为“懒加载”的策略进行初始化,也就是说,JVM启动的时候Attach Listener并不一定会启动起来。下面我们来分析一下这种“懒加载”策略的具体实现方案。
// Start Attach Listener if +StartAttachListener or it can't be started lazily
if (!DisableAttachMechanism) {
AttachListener::vm_start();
if (StartAttachListener || AttachListener::init_at_startup()) {
AttachListener::init();
}
}
// Attach Listener is started lazily except in the case when
// +ReduseSignalUsage is used
bool AttachListener::init_at_startup() {
if (ReduceSignalUsage) {
return true;
} else {
return false;
}
}
上面的代码截取自create_vm函数,DisableAttachMechanism、StartAttachListener和ReduceSignalUsage这三个变量默认都是false,所以AttachListener::init();这行代码不会在create_vm的时候执行,而vm_start会执行。下面来看一下这个函数的实现细节:
void AttachListener::vm_start() {
char fn[UNIX_PATH_MAX];
struct stat64 st;
int ret;
int n = snprintf(fn, UNIX_PATH_MAX, "%s/.java_pid%d",
os::get_temp_directory(), os::current_process_id());
assert(n < (int)UNIX_PATH_MAX, "java_pid file name buffer overflow");
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == 0) {
ret = ::unlink(fn);
if (ret == -1) {
log_debug(attach)("Failed to remove stale attach pid file at %s", fn);
}
}
}
这是在Linux上的实现,是将/tmp/目录下的.java_pid{pid}文件删除,后面在创建Attach Listener线程的时候会创建出来这个文件。上面说到,AttachListener::init()这行代码不会在create_vm的时候执行,这行代码的实现已经在上文中分析了,就是创建Attach Listener线程,并监听其他JVM的命令请求。现在来分析一下这行代码是什么时候被调用的,也就是“懒加载”到底是怎么加载起来的。
// Signal Dispatcher needs to be started before VMInit event is posted os::signal_init(); 这是create_vm中的一段代码,看起来跟信号相关,其实Attach机制就是使用信号来实现“懒加载“的。下面我们来仔细地分析一下这个过程。
void os::signal_init() {
if (!ReduceSignalUsage) {
// Setup JavaThread for processing signals
EXCEPTION_MARK;
Klass* k = SystemDictionary::resolve_or_fail(vmSymbols::java_lang_Thread(), true, CHECK);
instanceKlassHandle klass (THREAD, k);
instanceHandle thread_oop = klass->allocate_instance_handle(CHECK);
const char thread_name[] = "Signal Dispatcher";
Handle string = java_lang_String::create_from_str(thread_name, CHECK);
// Initialize thread_oop to put it into the system threadGroup
Handle thread_group (THREAD, Universe::system_thread_group());
JavaValue result(T_VOID);
JavaCalls::call_special(&result, thread_oop,klass,vmSymbols::object_initializer_name(),vmSymbols::threadgroup_string_void_signature(),
thread_group,string,CHECK);
KlassHandle group(THREAD, SystemDictionary::ThreadGroup_klass());
JavaCalls::call_special(&result,thread_group,group,vmSymbols::add_method_name(),vmSymbols::thread_void_signature(),thread_oop,CHECK);
os::signal_init_pd();
{ MutexLocker mu(Threads_lock);
JavaThread* signal_thread = new JavaThread(&signal_thread_entry);
// ...
}
// Handle ^BREAK
os::signal(SIGBREAK, os::user_handler());
}
}
JVM创建了一个新的进程来实现信号处理,这个线程叫“Signal Dispatcher”,一个线程创建之后需要有一个入口,“Signal Dispatcher”的入口是signal_thread_entry:
这段代码截取自signal_thread_entry函数,截取中的内容是和Attach机制信号处理相关的代码。这段代码的意思是,当接收到“SIGBREAK”信号,就执行接下来的代码,这个信号是需要Attach到JVM上的信号发出来,这个后面会再分析。我们先来看一句关键的代码:AttachListener::is_init_trigger():
bool AttachListener::is_init_trigger() {
if (init_at_startup() || is_initialized()) {
return false; // initialized at startup or already initialized
}
char fn[PATH_MAX+1];
sprintf(fn, ".attach_pid%d", os::current_process_id());
int ret;
struct stat64 st;
RESTARTABLE(::stat64(fn, &st), ret);
if (ret == -1) {
log_trace(attach)("Failed to find attach file: %s, trying alternate", fn);
snprintf(fn, sizeof(fn), "%s/.attach_pid%d", os::get_temp_directory(), os::current_process_id());
RESTARTABLE(::stat64(fn, &st), ret);
}
if (ret == 0) {
// simple check to avoid starting the attach mechanism when
// a bogus user creates the file
if (st.st_uid == geteuid()) {
init();
return true;
}
}
return false;
}
首先检查了一下是否在JVM启动时启动了Attach Listener,或者是否已经启动过。如果没有,才继续执行,在/tmp目录下创建一个叫做.attach_pid%d的文件,然后执行AttachListener的init函数,这个函数就是用来创建Attach Listener线程的函数,上面已经提到多次并进行了分析。到此,我们知道Attach机制的奥秘所在,也就是Attach Listener线程的创建依靠Signal Dispatcher线程,Signal Dispatcher是用来处理信号的线程,当Signal Dispatcher线程接收到“SIGBREAK”信号之后,就会执行初始化Attach Listener的工作。
运行时加载Agent的实现
我们继续分析,到底是如何将一个Agent挂载到运行着的目标JVM上,在上文中提到了一段代码,用来进行运行时挂载Agent,可以参考上文中展示的关于“attachAgentToTargetJvm”方法的代码。这个方法里面的关键是调用VirtualMachine的attach方法进行Agent挂载的功能。下面我们就来分析一下VirtualMachine的attach方法具体是怎么实现的。
public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException {
if (var0 == null) {
throw new NullPointerException("id cannot be null");
} else {
List var1 = AttachProvider.providers();
if (var1.size() == 0) {
throw new AttachNotSupportedException("no providers installed");
} else {
AttachNotSupportedException var2 = null;
Iterator var3 = var1.iterator();
while(var3.hasNext()) {
AttachProvider var4 = (AttachProvider)var3.next();
try {
return var4.attachVirtualMachine(var0);
} catch (AttachNotSupportedException var6) {
var2 = var6;
}
}
throw var2;
}
}
}
这个方法通过attachVirtualMachine方法进行attach操作,在MacOS系统中,AttachProvider的实现类是BsdAttachProvider。我们来看一下BsdAttachProvider的attachVirtualMachine方法是如何实现的:
public VirtualMachine attachVirtualMachine(String var1) throws AttachNotSupportedException, IOException {
this.checkAttachPermission();
this.testAttachable(var1);
return new BsdVirtualMachine(this, var1);
}
BsdVirtualMachine(AttachProvider var1, String var2) throws AttachNotSupportedException, IOException {
int var3 = Integer.parseInt(var2);
this.path = this.findSocketFile(var3);
if (this.path == null) {
File var4 = new File(tmpdir, ".attach_pid" + var3);
createAttachFile(var4.getPath());
try {
sendQuitTo(var3);
int var5 = 0;
long var6 = 200L;
int var8 = (int)(this.attachTimeout() / var6);
do {
try {
Thread.sleep(var6);
} catch (InterruptedException var21) {
;
}
this.path = this.findSocketFile(var3);
++var5;
} while(var5 <= var8 && this.path == null);
} finally {
var4.delete();
}
}
int var24 = socket();
connect(var24, this.path);
}
private String findSocketFile(int var1) {
String var2 = ".java_pid" + var1;
File var3 = new File(tmpdir, var2);
return var3.exists() ? var3.getPath() : null;
}
findSocketFile方法用来查询目标JVM上是否已经启动了Attach Listener,它通过检查”tmp/“目录下是否存在java_pid{pid}来进行实现。如果已经存在了,则说明Attach机制已经准备就绪,可以接受客户端的命令了,这个时候客户端就可以通过connect连接到目标JVM进行命令的发送,比如可以发送“load”命令来加载Agent。如果java_pid{pid}文件还不存在,则需要通过sendQuitTo方法向目标JVM发送一个“SIGBREAK”信号,让它初始化Attach Listener线程并准备接受客户端连接。可以看到,发送了信号之后客户端会循环等待java_pid{pid}这个文件,之后再通过connect连接到目标JVM上。
load命令的实现
下面来分析一下,“load”命令在JVM层面的实现:
static jint load_agent(AttachOperation* op, outputStream* out) {
// get agent name and options
const char* agent = op->arg(0);
const char* absParam = op->arg(1);
const char* options = op->arg(2);
// If loading a java agent then need to ensure that the java.instrument module is loaded
if (strcmp(agent, "instrument") == 0) {
Thread* THREAD = Thread::current();
ResourceMark rm(THREAD);
HandleMark hm(THREAD);
JavaValue result(T_OBJECT);
Handle h_module_name = java_lang_String::create_from_str("java.instrument", THREAD);
JavaCalls::call_static(&result,SystemDictionary::module_Modules_klass(),vmSymbols::loadModule_name(),
vmSymbols::loadModule_signature(),h_module_name,THREAD);
}
return JvmtiExport::load_agent_library(agent, absParam, options, out);
}
这个函数先确保加载了java.instrument模块,之后真正执行Agent加载的函数是 load_agent_library ,这个函数的套路就是加载Agent动态链接库,如果是通过Java instrument API实现的Agent,则加载的是libinstrument动态链接库,然后通过libinstrument里面的代码实现运行agentmain方法的逻辑,这一部分内容和libinstrument实现premain方法运行的逻辑其实差不多,这里不再做分析。至此,我们对Java Agent技术已经有了一个全面而细致的了解。
动态替换类字节码技术
动态字节码修改的限制
上文中已经详细分析了Agent技术的实现,我们使用Java Instrumentation API来完成动态类修改的功能,在Instrumentation接口中,通过addTransformer方法来增加一个类转换器,类转换器由类ClassFileTransformer接口实现。ClassFileTransformer接口中唯一的方法transform用于实现类转换,当类被加载的时候,就会调用transform方法,进行类转换。在运行时,我们可以通过Instrumentation的redefineClasses方法进行类重定义,在方法上有一段注释需要特别注意:
* The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.
这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过ASM获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses方法来进行类的重定义就会失败。那redefineClasses方法具体是怎么实现类的重定义的呢?它对运行时的JVM会造成什么样的影响呢?下面来分析redefineClasses的实现细节。
重定义类字节码的实现细节
上文中我们提到,libinstrument动态链接库中,JPLISAgent不仅实现了Agent入口代码执行的路由,而且还是Java代码与JVMTI之间的一道桥梁。我们在Java代码中调用Java Instrumentation API的redefineClasses,其实会调用libinstrument中的相关代码,我们来分析一下这条路径。
public void redefineClasses(ClassDefinition... var1) throws ClassNotFoundException {
if (!this.isRedefineClassesSupported()) {
throw new UnsupportedOperationException("redefineClasses is not supported in this environment");
} else if (var1 == null) {
throw new NullPointerException("null passed as 'definitions' in redefineClasses");
} else {
for(int var2 = 0; var2 < var1.length; ++var2) {
if (var1[var2] == null) {
throw new NullPointerException("element of 'definitions' is null in redefineClasses");
}
}
if (var1.length != 0) {
this.redefineClasses0(this.mNativeAgent, var1);
}
}
}
private native void redefineClasses0(long var1, ClassDefinition[] var3) throws ClassNotFoundException;
这是InstrumentationImpl中的redefineClasses实现,该方法的具体实现依赖一个Native方法redefineClasses(),我们可以在libinstrument中找到这个Native方法的实现:
JNIEXPORT void JNICALL Java_sun_instrument_InstrumentationImpl_redefineClasses0
(JNIEnv * jnienv, jobject implThis, jlong agent, jobjectArray classDefinitions) {
redefineClasses(jnienv, (JPLISAgent*)(intptr_t)agent, classDefinitions);
}
redefineClasses这个函数的实现比较复杂,代码很长。下面是一段关键的代码片段:
可以看到,其实是调用了JVMTI的RetransformClasses函数来完成类的重定义细节。
// class_count - pre-checked to be greater than or equal to 0
// class_definitions - pre-checked for NULL
jvmtiError JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
VMThread::execute(&op);
return (op.check_error());
} /* end RedefineClasses */
重定义类的请求会被JVM包装成一个VM_RedefineClasses类型的VM_Operation,VM_Operation是JVM内部的一些操作的基类,包括GC操作等。VM_Operation由VMThread来执行,新的VM_Operation操作会被添加到VMThread的运行队列中去,VMThread会不断从队列里面拉取VM_Operation并调用其doit等函数执行具体的操作。VM_RedefineClasses函数的流程较为复杂,下面是VM_RedefineClasses的大致流程:
- 加载新的字节码,合并常量池,并且对新的字节码进行校验工作
// Load the caller's new class definition(s) into _scratch_classes.
// Constant pool merging work is done here as needed. Also calls
// compare_and_normalize_class_versions() to verify the class
// definition(s).
jvmtiError load_new_class_versions(TRAPS);
- 清除方法上的断点
// Remove all breakpoints in methods of this class
JvmtiBreakpoints& jvmti_breakpoints = JvmtiCurrentBreakpoints::get_jvmti_breakpoints();
jvmti_breakpoints.clearall_in_class_at_safepoint(the_class());
- JIT逆优化
// Deoptimize all compiled code that depends on this class
flush_dependent_code(the_class, THREAD);
- 进行字节码替换工作,需要进行更新类itable/vtable等操作
- 进行类重定义通知
SystemDictionary::notice_modification();
VM_RedefineClasses实现比较复杂的,详细实现可以参考 RedefineClasses的实现。
Java-debug-tool设计与实现
Java-debug-tool是一个使用Java Instrument API来实现的动态调试工具,它通过在目标JVM上启动一个TcpServer来和调试客户端通信。调试客户端通过命令行来发送调试命令给TcpServer,TcpServer中有专门用来处理命令的handler,handler处理完命令之后会将结果发送回客户端,客户端通过处理将调试结果展示出来。下面将详细介绍Java-debug-tool的整体设计和实现。
Java-debug-tool整体架构
Java-debug-tool包括一个Java Agent和一个用于处理调试命令的核心API,核心API通过一个自定义的类加载器加载进来,以保证目标JVM的类不会被污染。整体上Java-debug-tool的设计是一个Client-Server的架构,命令客户端需要完整的完成一个命令之后才能继续执行下一个调试命令。Java-debug-tool支持多人同时进行调试,下面是整体架构图:
下面对每一层做简单介绍:
- 交互层:负责将程序员的输入转换成调试交互协议,并且将调试信息呈现出来。
- 连接管理层:负责管理客户端连接,从连接中读调试协议数据并解码,对调试结果编码并将其写到连接中去;同时将那些超时未活动的连接关闭。
- 业务逻辑层:实现调试命令处理,包括命令分发、数据收集、数据处理等过程。
- 基础实现层:Java-debug-tool实现的底层依赖,通过Java Instrumentation提供的API进行类查找、类重定义等能力,Java Instrumentation底层依赖JVMTI来完成具体的功能。
在Agent被挂载到目标JVM上之后,Java-debug-tool会安排一个Spy在目标JVM内活动,这个Spy负责将目标JVM内部的相关调试数据转移到命令处理模块,命令处理模块会处理这些数据,然后给客户端返回调试结果。命令处理模块会增强目标类的字节码来达到数据获取的目的,多个客户端可以共享一份增强过的字节码,无需重复增强。下面从Java-debug-tool的字节码增强方案、命令设计与实现等角度详细说明。
Java-debug-tool的字节码增强方案
Java-debug-tool使用字节码增强来获取到方法运行时的信息,比如方法入参、出参等,可以在不同的字节码位置进行增强,这种行为可以称为“插桩”,每个“桩”用于获取数据并将他转储出去。Java-debug-tool具备强大的插桩能力,不同的桩负责获取不同类别的数据,下面是Java-debug-tool目前所支持的“桩”:
- 方法进入点:用于获取方法入参信息。
- Fields获取点1:在方法执行前获取到对象的字段信息。
- 变量存储点:获取局部变量信息。
- Fields获取点2:在方法退出前获取到对象的字段信息。
- 方法退出点:用于获取方法返回值。
- 抛出异常点:用于获取方法抛出的异常信息。
通过上面这些代码桩,Java-debug-tool可以收集到丰富的方法执行信息,经过处理可以返回更加可视化的调试结果。
字节码增强
Java-debug-tool在实现上使用了ASM工具来进行字节码增强,并且每个插桩点都可以进行配置,如果不想要什么信息,则没必要进行对应的插桩操作。这种可配置的设计是非常有必要的,因为有时候我们仅仅是想要知道方法的入参和出参,但Java-debug-tool却给我们返回了所有的调试信息,这样我们就得在众多的输出中找到我们所关注的内容。如果可以进行配置,则除了入参点和出参点外其他的桩都不插,那么就可以快速看到我们想要的调试数据,这种设计的本质是为了让调试者更加专注。下面是Java-debug-tool的字节码增强工作方式:
如图4-2-1所示,当调试者发出调试命令之后,Java-debug-tool会识别命令并判断是否需要进行字节码增强,如果命令需要增强字节码,则判断当前类+当前方法是否已经被增强过。上文已经提到,字节码替换是有一定损耗的,这种具有损耗的操作发生的次数越少越好,所以字节码替换操作会被记录起来,后续命令直接使用即可,不需要重复进行字节码增强,字节码增强还涉及多个调试客户端的协同工作问题,当一个客户端增强了一个类的字节码之后,这个客户端就锁定了该字节码,其他客户端变成只读,无法对该类进行字节码增强,只有当持有锁的客户端主动释放锁或者断开连接之后,其他客户端才能继续增强该类的字节码。
字节码增强模块收到字节码增强请求之后,会判断每个增强点是否需要插桩,这个判断的根据就是上文提到的插桩配置,之后字节码增强模块会生成新的字节码,Java-debug-tool将执行字节码替换操作,之后就可以进行调试数据收集了。
经过字节码增强之后,原来的方法中会插入收集运行时数据的代码,这些代码在方法被调用的时候执行,获取到诸如方法入参、局部变量等信息,这些信息将传递给数据收集装置进行处理。数据收集的工作通过Advice完成,每个客户端同一时间只能注册一个Advice到Java-debug-tool调试模块上,多个客户端可以同时注册自己的Advice到调试模块上。Advice负责收集数据并进行判断,如果当前数据符合调试命令的要求,Java-debug-tool就会卸载这个Advice,Advice的数据就会被转移到Java-debug-tool的命令结果处理模块进行处理,并将结果发送到客户端。
Advice的工作方式
Advice是调试数据收集器,不同的调试策略会对应不同的Advice。Advice是工作在目标JVM的线程内部的,它需要轻量级和高效,意味着Advice不能做太过于复杂的事情,它的核心接口“match”用来判断本次收集到的调试数据是否满足调试需求。如果满足,那么Java-debug-tool就会将其卸载,否则会继续让他收集调试数据,这种“加载Advice” -> “卸载Advice”的工作模式具备很好的灵活性。
关于Advice,需要说明的另外一点就是线程安全,因为它加载之后会运行在目标JVM的线程中,目标JVM的方法极有可能是多线程访问的,这也就是说,Advice需要有能力处理多个线程同时访问方法的能力,如果Advice处理不当,则可能会收集到杂乱无章的调试数据。下面的图片展示了Advice和Java-debug-tool调试分析模块、目标方法执行以及调试客户端等模块的关系。
Advice的首次挂载由Java-debug-tool的命令处理器完成,当一次调试数据收集完成之后,调试数据处理模块会自动卸载Advice,然后进行判断,如果调试数据符合Advice的策略,则直接将数据交由数据处理模块进行处理,否则会清空调试数据,并再次将Advice挂载到目标方法上去,等待下一次调试数据。非首次挂载由调试数据处理模块进行,它借助Advice按需取数据,如果不符合需求,则继续挂载Advice来获取数据,否则对调试数据进行处理并返回给客户端。
Java-debug-tool的命令设计与实现
命令执行
上文已经完整的描述了Java-debug-tool的设计以及核心技术方案,本小节将详细介绍Java-debug-tool的命令设计与实现。首先需要将一个调试命令的执行流程描述清楚,下面是一张用来表示命令请求处理流程的图片:
图4-3-1简单的描述了Java-debug-tool的命令处理方式,客户端连接到服务端之后,会进行一些协议解析、协议认证、协议填充等工作,之后将进行命令分发。服务端如果发现客户端的命令不合法,则会立即返回错误信息,否则再进行命令处理。命令处理属于典型的三段式处理,前置命令处理、命令处理以及后置命令处理,同时会对命令处理过程中的异常信息进行捕获处理,三段式处理的好处是命令处理被拆成了多个阶段,多个阶段负责不同的职责。前置命令处理用来做一些命令权限控制的工作,并填充一些类似命令处理开始时间戳等信息,命令处理就是通过字节码增强,挂载Advice进行数据收集,再经过数据处理来产生命令结果的过程,后置处理则用来处理一些连接关闭、字节码解锁等事项。
Java-debug-tool允许客户端设置一个命令执行超时时间,超过这个时间则认为命令没有结果,如果客户端没有设置自己的超时时间,就使用默认的超时时间进行超时控制。Java-debug-tool通过设计了两阶段的超时检测机制来实现命令执行超时功能:首先,第一阶段超时触发,则Java-debug-tool会友好的警告命令处理模块处理时间已经超时,需要立即停止命令执行,这允许命令自己做一些现场清理工作,当然需要命令执行线程自己感知到这种超时警告;当第二阶段超时触发,则Java-debug-tool认为命令必须结束执行,会强行打断命令执行线程。超时机制的目的是为了不让命令执行太长时间,命令如果长时间没有收集到调试数据,则应该停止执行,并思考是否调试了一个错误的方法。当然,超时机制还可以定期清理那些因为未知原因断开连接的客户端持有的调试资源,比如字节码锁。
获取方法执行视图
Java-debug-tool通过下面的信息来向调试者呈现出一次方法执行的视图:
- 正在调试的方法信息。
- 方法调用堆栈。
- 调试耗时,包括对目标JVM造成的STW时间。
- 方法入参,包括入参的类型及参数值。
- 方法的执行路径。
- 代码执行耗时。
- 局部变量信息。
- 方法返回结果。
- 方法抛出的异常。
- 对象字段值快照。
图4-3-2展示了Java-debug-tool获取到正在运行的方法的执行视图的信息。
Java-debug-tool与同类产品对比分析
Java-debug-tool的同类产品主要是greys,其他类似的工具大部分都是基于greys进行的二次开发,所以直接选择greys来和Java-debug-tool进行对比。
总结
本文详细剖析了Java动态调试关键技术的实现细节,并介绍了我们基于Java动态调试技术结合实际故障排查场景进行的一点探索实践;动态调试技术为研发人员进行线上问题排查提供了一种新的思路,我们基于动态调试技术解决了传统断点调试存在的问题,使得可以将断点调试这种技术应用在线上,以线下调试的思维来进行线上调试,提高问题排查效率。
参考资料
2.2 - JSR 133
2.2.1 - CH01-指令重排
对于编译器的编写者来说,JMM 主要是由“禁止指令重排的规则”所组成的,其中包括了字段(包括数组中的元素)的存取指令和监视器(锁)的控制指令。
Volatile 与监视器
JMM 中关于 volatile 和监视器的主要规则可以被看做是一个矩阵。这个矩阵中的单元格表示当存在一些特定的后续关联指令的情况下,指令不能被重排。下面的表格并非 JMM 所包含的内容,而是一个用来观察 JMM 对编译器和运行系统所造成的影响的工具。
能否重排 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|
第一个操作 | Nomal Load/Nornal Store | Volatile Load/Moitor Enter | Volatile Store/Monitor Exit |
Nomal Load/Nornal Store | NO | ||
Volatile Load/Moitor Enter | NO | NO | NO |
Volatile Store/Monitor Exit | NO | NO |
术语说明:
- Normal Load 指令包括:对非 volatile 字段的读取,getField/getStatic/arrayLoad。
- Normal Store 指令包括:对非 volatile 字段的存储,putFiled/putStatic/arrayStore。
- Volatile Load 指令包括:对多线程环境的 volatile 变量的读取,getField/getStatic。
- Volatile Store 指令包括:对多线程环境的 volatile 变量的存储,putField/putStatic。
- Monitor Enter(包括进入同步块 synchronized 方法)是用于多线程环境的锁对象。
- Monitor Exist(包括离开同步块 synchronized 方法)是用于多线程环境的锁对象。
在 JMM 中,Normal Load 指令与 Nornal Store 指令的规则是一致的,类似的还有 Volatile Load 指令与 Monitor Enter 指令,以及 Volatile Store 指令和 Monitor Exit 指令,因此这几对指令的单元格在上面的表格中都被合并在了一起(但是在后续的表格中,会在必要的时候将其展开)。在这个小节中,我们仅仅考虑那些被当做原子单元的可读写的变量,也就是说那些没有位域(bit fields)、非对齐访问(unaligned acesses)、或者超过平台最大字长(word size)的访问。
任意数量的指令操作都可被表示成这个表格中的“第一个操作”或“下一个操作”。例如在单元格 “Normal Store, Volatile Store” 中,有一个 NO,就表示任何非 volatile 字段的 store 指令操作不能与后面任何一个 vaolatile store 指令重排,如果出现任何这样的重排就会使得多线程程序的行为发生变化。
JSR 133 规范规定上述关于 volatile 和监视器的规则仅仅适用于可能会被多线程访问的变量或对象。因此,如果一个编译器可以最终证明(这往往需要很大的努力)一个锁仅被单线程访问,那么这个锁就可以被移除。与之类似,一个 volatile 变量只被单线程访问也可以被当做是普通的变量。还有进一步耕细粒度的分析与优化,例如:那些被证明在一段时间内对多线程不可访问的字段。
在上表中,空白的单元格代表在不违反 Java 的基本语义下重排是允许的(详细可参考 JLS 中的说明)。例如,即使上表中没有说明,但是也不能对同一内存地址上的 load 指令和之后紧跟着的 store 指令进行重排。但是你可以对两个不同内存地址上的 load 和 store 指令进行重排,而且往往还在很多编译器转换和优化中会这么做。这往往就包括了一些不被认为是指令重排的例子,如:重用一个基于已加载的字段的计算后的值,而不是像第一次指令重排那样去重新加载并重新计算。然而,JMM 规范允许编译器经过一些转换后消除这些可以避免的依赖,使其可以支持指令重排。
在任何情况下,即使是开发者错误的使用了同步读取,指令重排的结果也必须达到最基本的 Java 安全要求,所有的显式字段都必须要么被设定成 0 或 null 这样的与构造值,要么被其他线程设置值。这通常必须把所有存储在堆内存里的对象在其被构造函数使用前进行归零操作,并且从来不对归零 store 指令进行重排。一种比较好的方式是在垃圾回收中对回收的内存进行归零操作。可以参考 JSR 133 规范中其他情况下的一些关于安全保证的规则。
这里描述的规则和属性都是适用于读取 Java 环境的字段。在实际的应用中,这些都可能会另外与读取内部的一些记账字段和数据交互,例如对象头,GC 表和动态生成的代码。
final 字段
Final 字段的 load 和 store 指令相对于有锁的或者 volatile 字段来说,就跟 Normal load 和 Normal store 的存取是一样的,但是需要加入两条附加的指令重排规则:
- 如果在构造函数中有一条 fianl 字段的 store 指令,同时这个字段是一个引用,那么它将不能与构造函数外后续可以让持有这个 final 字段的对象被其他线程访问的指令重排。例如,你不能重排下列语句:
x.finalField = v;
...;
sharedRef = x;
这条规则会在下列情况下生效,例如当你内联一个构造函数时,正如“…”的部分表示的该构造函数的逻辑边界那样。你不能把这个构造函数中的对这个 final 字段的 store 紫菱移动到构造函数外的一条 store 指令之后,因为这可能会使这个对象对其他线程可见。(正如你将在下面看到的,这样的操作还需要声明一个内存屏障)。类似的,你不能把下面的前两条指令与第三条指令进行重排:
x.afield = 1;
x.finalField = v;
...;
sharedRef = x;
- 一个 final 字段的初始化 load 指令不能与包含该字段的对象的初始化 load 指令进行重排。在下面的情况中,这条规则就会生效:
x = shareRef;...;x=x.finalField
。由于这两条指令是依赖的,编译器不能对这样的指令进行重排。但是,这条规则会对某些处理器有影响。
上述规则,要求对于带有 fianl 字段的对象的 load 本身是 synchronized、volatile、final 或来自类似的 load 指令,从而确保 Java 开发者对于 fianl 字段的正确使用,并最终使构造函数中初始化的 store 指令和构造函数外的 store 指令排序。
2.2.2 - CH02-内存屏障
编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要做什么处理就可以保证正确的执行顺序。但是在多核处理器上通常需要使用内存屏障指令来确保这种一致性。即使编译器优化掉了一个字段访问(比如因为一个读入的值未被使用),这种情况下还是需要产生内存屏障,就好像该访问仍然需要被保护一样。
内存屏障仅仅与内存模型中“获取”、“释放”这些高层次概念有间接的关系。内存屏障并非“同步屏障”,内存屏障也与在一些垃圾回收机制中的“写屏障(write barriers)”概念无关。内存屏障指令仅仅直接控制 CPU 与缓存之间,CPU 与其准备将数据写入主存或写入等待读取、预测指令执行的缓冲中的写缓冲之间的相互操作。这些操作可能导致缓冲、主存和其他处理器执行进一步的交互。但在 Java 内存模型规范中,没有强制处理之间的交互方式,只要数据最终变为全局可用,即在所有处理器中均可见,并当这些数据可见时可以获得它们。
内存屏障的种类
几乎所有处理器都至少支持一种粗粒度的屏障指令,通常被称为“栅栏(fence)”,它保证栅栏前初始化的 load 和 store 指令,能够严格有序的在栅栏后的 load 和 store 指令之前执行。无论在何种处理器上,这几乎是最耗时的操作之一(与原子指令差不多、甚至更加消耗资源),所以大部分处理器支持更细粒度的屏障指令。
内存屏障的一个特性是将它们运用于内存之间的访问。尽管在一些处理器上有一些名为屏障的指令,但是正确的、最好的屏障使用取决于内存访问的类型。下面是一些屏障指令的通用分类,它们正好能够对应上常用处理器上的特定指令(有时这些指令会导致空操作)。
LoadLoad 屏障
Load1, LoadLoad, Load2
确保 Load1 所要读入的数据能够在被 Load2 和后续的 load 指令访问前读入。通常执行预加载指令或/和支持乱序处理的处理器中需要显式声明该 LoadLoad 屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器,设置该屏障相当于空操作。
StoreStore 屏障
Store1, StoreStore, Store2
确保 Store1 的数据在 Store2 及后续 store 指令操作相关数据之前对其处理器可见(如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其他处理器和主存中按顺序刷新数据,那么就需要使用 StoreStore 屏障。
LoadStore 屏障
Load1, LoadStore, Store2
确保 Load1 的数据在 Store2 和后续 store 指令被刷新之前读取。在等待 store 指令可以越过 load 指令的乱序处理器上需要使用 LoadStore 屏障。
StoreLoad 屏障
Store1, LoadStore, Load2
确保 Store1 的数据在被 Load2 及后续的 load 指令读取之前对其他处理器可见。StoreLoad 屏障可以放置一个后续的 load 指令不正确的使用 Store1 的数据,而不是另一个处理器在相同内存位置写入一个新数据。真因为如此,所以下面所讨论的处理器为了在屏障前读取同样内存位置存过的数据时,必须使用一个 StoreLoad 屏障将存储指令和后续的加载指令分开。StoreLoad 屏障在几乎所有的现代处理器中都需要使用,但通常它的开销也是最昂贵的。它们昂贵的部分原因是它们必须关闭通常的“略过缓存直接从写缓冲区读取数据”的机制。这可能通过让一个缓冲区进行充分刷新,以及它的延迟的方式来实现。
在下面讨论的所有处理器中,执行 StoreLoad 指令也会同时获得其他三种屏障效果。所以 StoreLoad 可以作为最通用(但通常也是最耗性能)的一种 fence。(这是基于经验得出的结论,并非必然)。反之则不成立,为了达到 StoreLoad 的效果而组合使用其他屏障的情况并不多见。
排序规则
下表显示了这些屏障如何符合 JSR 133 的排序规则:
需要的屏障 | 下个操作 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|---|
第一个操作 | Nomal Load | Nornal Store | Volatile Load Moitor Enter | Volatile Store Monitor Exit |
Nomal Load | LoadStore | |||
Nornal Store | StoreStore | |||
Volatile Load Moitor Enter | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile Store Monitor Exit | StoreLoad | StoreStore |
另外,特殊的 final 字段规则在下列代码中需要一个 StoreStore 屏障:
x.finalField = v;
StoreStore;
sharedRef = x;
下面的例子解释了如何放置屏障:
Class X {
int a, b;
volatil int v, u;
void f() {
int i, j;
i = a;// load a
j = b;// load b
i = v;// load v
// LoadLoad
j = u;// load u
// LoadStore
a = i;// store a
b = j;// store b
// StoreStore
v = i;// store v
// StoreStore
u = j;// store u
// StoreLoad
i = u;// load u
// LoadLoad
// LoadStore
j = b;// load b
a = i;// store a
}
}
数据依赖与屏障
一些处理器为了保证依赖指令的交互次序需要使用 LoadLoad 和 LoadStore 屏障。在一些(大部分)处理器中,一个 load 指令或者一个依赖于之前加载值的 store 指令被处理器排序,并不需要一个显式的屏障。这通常发生于两种情况:
- 间接取值(indirection):
Load x; Load x.field
- 条件控制(control):
Load x; if(predicate(x)) Load or Store y;
但特别的是不遵循间接排序的处理器,需要为 final 字段设置屏障,使它能通过共享引用来访问最初的引用。
x = sharedRef;
...;
LoadLoad;
i = x.finalField;
相反的,如下讨论,确定遵循数据依赖的处理器,提供了几种优化掉 LoadLoad 和 LoadStore 屏障指令的机会。(尽管如此,在任何处理器上,对于 StoreLoad 屏障不会自动清除依赖关系)
与原子指令交互
屏障在不同处理器上还需要与 MonitorEnter 和 MonitorExit 实现交互。加解锁通常必须使用原子条件更新操作 CampareAndSwap(CAS) 指令或 LoadLinked/StoreConditional(LL/SC),就如执行一个 volatile store 之后紧跟 volatile load 的语义一样。CAS 或者 LL/SC 能够满足最小功能,一些处理器还需要提供其他的原子操作(如,一个无条件交换),这在某些时候它可以替代或者与原子条件更新操作结合使用。
在所有处理器中,原子操作可以避免在正被读取/更新的内存位置进行“写后读(read-after-write)”。(否则标准的循环直到成功的结构体(loop-until-success)无法正常工作)。但处理器是否在为原子操作提供比隐式的 StoreLoad 更一般的屏障特性上表现不同。一些处理器上这些指令可以为 MonitorEnter/Exit 原生的生成屏障;其他处理器中一部分或全部屏障必须显式的指定。
为了分清这些影响,我们必须把 volatile 和 monitor 分开:
需要的屏障 | 下个操作 | 下个操作 | 下个操作 | 下个操作 | 下个操作 | 下个操作 |
---|---|---|---|---|---|---|
第一个操作 | Nomal Load | Nornal Store | Volatile Load | Volatile Store | Moitor Enter | Monitor Exit |
Nomal Load | LoadStore | LoadStore | ||||
Nornal Store | StoreStore | StoreExit | ||||
Volatile Load | LoadLoad | LoadStore | LoadLoad | LoadStore | LoadEnter | LoadExit |
Volatile Store | StoreLoad | StoreStore | StoreEnter | StoreExit | ||
Moitor Enter | EnterLoad | EnterStore | EnterLoad | EnterStore | EnterEnter | EnterExit |
Monitor Exit | ExitLoad | ExitStore | ExitEnter | ExitExit |
同样,特殊的 final 字段规则需要一个 StoreLoad 屏障:
x.finalField = v;
StoreStore;
sharedRef = x;
在该表中,“Enter” 与 “Load” 相同,“Exit” 与 “Store” 相同,除非被原子性指令的使用和特性覆盖。特别是:
- EnterLoad 在进入任何需要执行 Load 指令的同步块/方法时都需要。这与 LoadLoad 相同,除非在 MonitorEnter 时候使用了原子指令并且它本身提供一个至少有 LoadLoad 属性的屏障。如果是这种情况,相当于空操作。
- StoreExit 在退出任何执行 Store 指令的同步方法块时都需要。这与 StoreStore 一致,除非 MonitorExit 使用原子操作,并且提供了一个至少拥有 StoreStore 属性的屏障,如果是这种情况,相当于空操作。
- ExitEnter 和 StoreLoad 一样,除非 MonitorExit 使用了原子指令,并且/或者 MonitorEnter 至少提供一种屏障,该屏障具有 StoreLoad 的属性,如果是这种情况,相当于没有操作。
在编译时不起作用或者导致处理器上不产生操作的指令比较特殊。例如,当没有交替的 laod 和 store 指令时,EnterEnter 用于分离嵌套的 MonitorEnter。下面的例子说明了如何使用这些指令类型:
class X {
int a;
volatile int v;
void f() {
int i;
synchronized (this) { // enter EnterLoad EnterStore
i = a;// load a
a = i;// store a
}// LoadExit StoreExit exit ExitEnter
synchronized (this) {// enter ExitEnter
synchronized (this) {// enter
}// EnterExit exit
}// ExitExit exit ExitEnter ExitLoad
i = v;// load v
synchronized (this) {// LoadEnter enter
} // exit ExitEnter ExitStore
v = i; // store v
synchronized (this) { // StoreEnter enter
} // EnterExit exit
}
}
Java 层次的对原子条件更新的操作将在 JDK 1.5 中发布(JSR 166),因此编译器需要发布相应的代码,综合使用上表中对 MonitorEnter 和 MonitorExist 的方式,从语义上说,有时在实践中,这些 Java 中的原子更新操作,就如同他们被锁所包围一样。
2.2.3 - CH03-多处理器
本文总结了在多处理器(MPs)中常用的的处理器列表。这不是一个完全详细的列表,但已经包括了我所知道的在当前或者将来Java 实现中所使用的多核处理器。
略。
2.2.4 - CH04-开发指南
单处理器
如果能够保证要生成的代码仅会运行在单个处理器上,那就可以跳过本节的其余部分。因为单处理器保持着明确的顺序一致性,除非对象内存以某种方式与可异步访问的 IO 内存共享,否则永远都不需要插入屏障指令。采用了特殊映射的 java.nio buffers 可能会出现这种情况,但也许只会影响内部的 JVM 支持代码,而不会影响 Java 代码。而且,可以想象,如果上下文切换时不要求充分的同步,那就需要使用一些特殊的屏障了。
插入屏障
当程序执行时遇到了不同类型的存取,那就需要屏障指令。几乎无法找到一个“最理想”的位置,能将屏障执行总次数讲到最小。编译器不知道指定的 load 或 store 指令是先于还是后于需要一个屏障指令的另一个 load 或 store 指令,如:当 volatile store 后面是一个 return 时。最简单保守的策略是为任何一个给定的 load、store、lock 或 unlock 生成代码时,都假设该类型的存取需要“最重量级”的屏障:
- 在每条 volatile store 指令之前插入一个 StoreStore 屏障。
- 如果一个类包含 final 字段,在该类每个构造器的全部 store 指令之后、return 指令之前插入一个 StoreStore 屏障。
- 在每条 volatile store 指令之后插入一条 StoreStore 屏障。注意,虽然也可以在每条 volatile load 指令之前插入一个 StoreStore 屏障,但对于使用 volatile 的典型程序来说则会更慢,因为读操作会大大超过写操作。或者如果可以的话,将 volatile store 实现成一条原子指令,就可以省略掉屏障操作。如果原子指令比 StoreLoad 屏障成本低,这种方式就会更加高效。
- 在每条 volatile load 指令之后插入 LoadLoad 和 LoadStore 屏障。在持有数据依赖的处理器上,如果下一条存取指令依赖于 volatile load 出来的值,就不需要插入屏障。特别是,在 load 一个 volatile 引用之后,如果后续指令是 null 检查或 load 此引用所指对象中的某个字段,此时就无需屏障。
- 在每条 MonitorEnter 指令之前或在每条 MonitorExit 指令之后插入一个 ExitEnter 屏障。(根据上面的讨论,如果 MonitorExit 或 MonitorEnter 使用了相当于 StoreLoad 屏障的原子指令,ExitEnter 可以是个空操作(no-op)。其余步骤中,其他涉及 Enter 和 Eixt 的屏障也是如此。)
- 在每条 MonitorEnter 指令之后插入 EnterLoad 和 EnterStore 屏障。
- 在每条 MonitorExit 指令之后插入 StoreExit 和 LoadExit 屏障。
- 如果在未内置直接间接 load 顺序的处理器上,可以在 final 字段的每条 load 指令之前插入一个 LoadLoad 屏障。
这些屏障中的有一些通常会简化成空操作。实际上,大部分都会简化成空操作,只不过是在不同处理器的锁模式下使用了不同的方式。最简单的例子,在 x86 或 sparc-TSO 平台上使用 CAS 实现锁,仅相当于在 volatile store 后面放了一个 StoreLoad 屏障。
移除屏障
上面的保守策略对有些程序来说也许还可以接受。volatile 的主要性能问题出在 store 指令相关的 StoreLoad 屏障上。这些应当是相对罕见的——将 volatile 主要用于避免并发程序里读操作中锁的使用,仅当读操作大大超过写操作才会有问题。但是至少能在以下几个方面改进这种策略:
- 移除冗余的屏障。可以根据前面章节的表格来消除屏障:
Original | => | Transformed | ||||
---|---|---|---|---|---|---|
1st | ops | 2nd | => | 1st | ops | 2nd |
LoadLoad | no loads | LoadLoad | => | no loads | LoadLoad | |
LoadLoad | no loads | StoreLoad | => | no loads | StoreLoad | |
StoreStore | no stores | StoreStore | => | no stores | StoreStore | |
StoreStore | no stores | StoreLoad | => | no stores | StoreLoad | |
StoreLoad | no loads | LoadLoad | => | StoreLoad | no loads | |
StoreLoad | no stores | StoreStore | => | StoreLoad | no loads | |
StoreLoad | no volatile loads | StoreLoad | => | no volatile loads | StoreLoad |
类似的屏障消除也可以用于锁的交互,但要依赖于锁的实现方式。使用循环、调用及分支来实现这一切的工作就作为读者练习吧。
- 重排代码(在允许的范围内)以进一步移除 LoadLoad 和 LoadStore 屏障,这些屏障因处理器维持着数据依赖顺序而不再需要。
- 移动指令流中屏障的位置以提高调度效率,只要在该屏障被需要的时间内最终仍会在某处执行即可。
- 移除那些没有多线程依赖因此不再需要的屏障,例如,某个 volatile 变量被证实只会对单个线程可见。而且,如果能证明线程仅能对某些特定字段执行 store 指令或仅能执行 load 指令,则可以移除这里面使用的屏障。但是所有这些通常都需要进行大量的分析。
杂记
JSR 133 也讨论了在更为特殊的情况下可能需要屏障的其他几个问题:
- Thread.start 需要屏障来确保该已启动的线程能够看到在调用时刻对调用者可见的所有 store 的内容。相反,Thread.join 需要屏障来确保调用者能看到正在终止的线程所 store 的内容。实现 Thread.start 和 Thread.join 时需要同步,这些屏障通常是通过这些同步来产生的。
- static final 初始化需要 StoreStore 屏障,遵守 Java 类加载和初始化规则的那些机制需要这些屏障。
- 确保默认的 0/null 初始字段值时通常需要屏障、同步或/和垃圾收集器里的底层缓存控制。
- 在构造器之外或静态初始化器之外设置 System.in、System.out、System.err 的 JVM 私有例程需要特别注意,因为它们是 JMM final 字段规则的遗留例外情况。
- 类似的,JVM 内部反序列化设置 final 字段的代码通常需要一个 StoreStore 屏障。
- 终结方法可能需要屏障(垃圾收集器里)来确保 Object.finalize 中的代码能够看到某个对象不再被引用之前 store 到该对象所有字段的值。这通常是通过同步来确保的,这些同步用于在 reference 队列中添加和删除 reference。
- 调用 JNI 例程以及从 JNI 例程中返回可能需要屏障,尽管看起来是实现方面的问题。
- 大多数处理器都设计有其他专用于 IO 或 OS 操作的同步指令。他们不会直接影响 JMM 的这些问题,但是有可能与 IO、类加载及动态代码生成紧密相关。
2.3 - JMM 规范
2.3.1 - CH01-JMM规范
The Java ® Language Specification SE 8 - Chapter 17 Threads and Locks
Java 内存模型定义了线程之间如何通过内存进行交互,是深入学习 Java 并发编程的必要前提。
Java 虚拟机可以支持多线程同时执行。这些线程可以独立的对驻留在内存中的值和对象执行操作代码。对这些线程的支持,可以是多硬件处理器,也可以是单硬件处理器的时间片机制,或者是多硬件处理器的时间片机制。
线程由 Thread 类表示。对用户来说,创建线程的唯一方式就是创建该类的对象,每个线程都和一个这样的对象关联。在 Thread 类的对象上调用 start 方法将会启动对应的线程。
在进行不正确的同步操作时,可能会引起线程行为的混淆与反常。本章描述的是多线程的语义,其中包含以下规则:若主存(内存)是由多个线程更新的,那么对主存的读操作可以看到哪些值。 因为这些规范与针对不同硬件架构的“内存模型”类似,因此也被称为“Java 编程语言内存模型”。当不会产生任何混淆时,我们将直接称这些规则为“内存模型”。
这些语义并未规定多线程程序应该如何执行,它们描述的是多线程程序允许展示出来的行为。
同步
Java 语言为线程间通信提供了多种机制,这些方法中最简单的就是“同步(synchronization)”,它是使用“监视器(monitor)”实现的。Java 中每个对象都与一个可以被线程锁定或解锁的监视器相关联。在任何时刻,只有一个线程可以持有某个监视器上的锁。任何其他试图锁定该监视器的线程都将被阻塞,直至它们可以获得该监视器上的锁。一个线程可以多次锁定某个指定的监视器,每个解锁操作都会抵消前一次锁定。
synchronized
语句计算的是对象的引用,然后试图锁定该对象的监视器,并且在锁定动作完成之前,不会执行下一个动作。在锁定动作执行之后,synchronized
语句体被执行。如果该语句体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。
synchronized
方法在被调用时会自动执行锁定动作,它的方法体在该锁定动作完成之前是不会被执行的。如果该方法是实例方法,那么会锁定调用该实例方法的对象所关联的监视器。如果该方法是静态方法,那么它会锁定定义该方法的类的 Class 对象相关联的监视器。如果该方法体执行结束,无论是正常结束还是猝然结束,都会在之前锁定的监视器上执行解锁操作。
Java 编程语言既不阻止也不要求对死锁情况的探测。线程(间接或直接)持有多个对象上的锁的的程序应该使用避免死锁的惯用技术,如果必要的话,可以创建更高级别的不会产生死锁的锁定原语。
其他机制,如 volatile
变量的读写和对 JUC 包中的类的使用,都提供了可替代的同步方式。
等待集合通知
每个对象除了拥有关联的监视器,还拥有关联的“等待集”,即一个线程集。
当对象最先被创建时,它的等待集为空。向等待集中添加或移除线程的基础动作都是原子性的。等待集只能通过 Object.wait
、Object.notify
、Object.notifyAll
方法进行操作。
等待集的操作还会受到线程的中断和 Thread 类中用于处理中断的方法的影响。另外,Thread 类中用于睡眠和连接其他线程的方法也具有从等待和通知动作中导出的属性。
等待
调用 wait()
时,或者调用具有定时机制的 wait(long millisecs)
和 wait(long millisecs, int nanosecs)
时会发生“等待动作”。
向带有定时机制的 wait 方法传入参数值 0 等同于调用没有定时机制的 wait 方法。
如果线程返回时没有抛出 InterruptedException 异常,那么该线程就是正常返回的。
设线程 t 在对象 m 上执行 wait 方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
- 如果 n 是 0(即线程 t 还没有锁定目标 m),那么会抛出 IllegalMonitorStateException。
- 如果是定时的等待,并且 nanosecs 的范围不在 0~999999 范围内,或者 millisecs 是负数,那么将会抛出 IllegalArgumentException。
- 如果线程 t 被中断,那么会抛出 InterruptedException,并且 t 的中断状态会被设为 false。
- 除此之外,会执行下面的序列:
- 线程 t 被添加到对象 m 的等待集,并且在 m 上执行 n 个解锁动作。
- 线程 t 不执行任何更进一步的指令,直到它从 m 的等待集中移除。线程 t 可以因下列任何一个动作而从等待集中移除,并在之后某个时刻继续执行:
- 在 m 上执行 notify 动作,在该动作中 t 被选中并从等待集中移除。
- 在 m 上执行 notifyAll 动作。
- 在 t 上执行 interrupt 动作。
- 如果是定时等待,那么在从该等到动作开始至少 millisecs 毫秒加上 nanosecs 纳秒的时间流逝之后,一个内部动作将 t 从 m 的等待集中移除。
- 内部动作由 Java 编程语言的实现执行。我们允许但不鼓励 Java 编程语言的实现执行的是“欺骗性唤醒”,即将线程从等待集中移除,由此无需显式指令就可以使得线程能够继续执行。
- 线程 t 在 m 上执行 n 个锁定操作。
- 如果线程 t 在第 2 步因为竞争而从 m 的等待集中被移除,那么 t 的中断状态会被设置为 false,并且 wait 方法会抛出中断异常。
@@@ note
第 2 条款迫使开发者必须循序以下 Java 编码习惯:对于在线程等待某个逻辑条件满足时才会终止的循环,才适合使用 wait。
@@@
每个线程必须确定可以引发从等待集中被移除的事件顺序。该顺序不必与其他排序方式一致,但是线程的行为必须是看起来就像这些事件是按照这个顺序发生的一样。
例如,如果线程 t 在 m 的等待集中,并且 t 的中断和 m 的通知都发生了,那么这些事件必然有一个顺序。如果中断被认为首先发生,那么 t 最终会抛出中断异常而从 wait 返回,并且位于 m 的等待集的另一个线程(如果在发通知时存在的话)必须收到这个通知;如果通知被认为首先发生,那么 t 最终会从 wait 中正常返回,而中断将被悬挂。
通知
调用 notify 或 notifyAll 时发生通知动作。
设线程 t 在对象 m 上执行这两个方法,n 是线程 t 在对象 m 上尚未解锁的锁定动作的数量,那么将会发生下列动作之一:
- 如果 n = 0,将会抛出非法监视器状态异常。这种情况表示线程 t 还没有处理目标 m 的锁。
- 如果 n > 0,并且是 notify 动作,那么如果 m 的等待集不为空,那么作为 m 的当前等待集中的成员线程 u 将被选中并从等待集中移除。不能保证等待集中哪个线程会被选中。从等待集中移除使得 u 可以在等待动作中继续。但是,需要注意 u 在继续执行时的加锁动作只有在 t 完全解锁 m 的监视器之后的某个时刻才能成功。
- 如果 n > 0,并且是 notifyAll 动作,那么所有线程都会从 m 的等待集中移除,因此也就都可以继续执行。
但是需要注意,其中每次仅有一个线程可以在等待过程中锁定所需的监视器。
中断
调用 Thread.interrupt 或 ThreadGoup.interrupt 方法时,发生中断动作。
设 t 是调用 u.interrupt 的线程,其中 u 是某个线程,t 和 u 可以相同。该调用动作会使得 u 的中断状态被设置为 true。
另外,如果存在某个对象 m,其等待集合包含 u,那么 u 会从 m 的等待集合中移除。这使得 u 从等待动作中恢复,在这种情况下,这个等待在重新锁定 m 的监视器之后,会抛出中断异常。
调用 Thread.isInterrupted 可以确定线程的中断状态。静态方法 Thread.interrupted 可以被线程调用以观察和清除自身的中断状态。
等待、通知、中断之间的交互
如果线程在等待时被通知了然后又被中断,那么它可以:
- 从 wait 中正常返回,尽管仍然具有悬挂的中断。
- 从 wait 中抛出中断异常。
线程不可以重置它的中断状态并从对 wait 的调用中返回。类似的,通知不能因中断而丢失。假设线程集 s 在对象 m 的等待集中,另一个线程在 m 上执行另一个 notify,那么:
- s 中至少有一个线程必须从 wait 中正常返回。
- s 中所有线程都必须抛出中断异常并退出 wait。
@@@ note
如果一个线程被 notify 中断和唤醒,并且该线程以抛出中断异常的方式从 wait 返回,那么在等待集中的其他线程必须必通知。
@@@
睡眠与让步
Thread.sleep 会导致当前运行的线程睡眠(暂时中止执行)指定的一段时间,具体时间取决于系统定时器和调度器的精确度。睡眠的线程不会丧失对任何监视器的所有权,而继续执行的时机则依赖于执行该线程的处理器的调度时机和可用性。
注意到这一点很重要:无论是 Thread.sleep 还是 Thread.yield 都没有任何同步语义。特别是,编译器不必在调用 Thread.sleep 或 Thread.yield 之前将寄存器中缓存的写操作冲刷到共享内存中,也不必在调用 Thread.sleep 或 Thread.yield 之后重新加载寄存器中缓存的值。
例如在下面的代码中,假设 this.done 是非 volatile 的 boolean 域:
while(!this.done)
Thread.sleep(1000);
编译器可以只读取 this.done 一次,并且在循环中的每次迭代中重用缓存的值。这意味着即使另一个线程修改了 this.done 的值,该循环永远也不会停止。
内存模型
给定一个程序和该程序的执行轨迹,“内存模型”可以描述该执行轨迹是否是该程序的一次合法执行。Java 编程语言的内存模型是通过以下方式实现的:查验执行轨迹中的每个读操作,并依据特定的规则检查该读操作所观察到的写操作是否有效。
内存模型描述了程序的潜在行为。Java 语言的实现可以按照其喜好来产生任何代码,只要程序的执行过程都会产生内存模型可以预测的结果。
这为 Java 语言的实现者提供了很大的自由度去执行大量的代码转换,包括重排序动作和移除不必要的同步。
示例:不正确的同步程序会展示惊人的行为
Java 编程语言的语义允许编译器和微处理器执行优化,与未正确同步的代码进行交互,而这种交互方式可能会产生看起来很荒谬的行为。下面的几个示例展示了未正确同步的程序可能会展示出惊人的行为。
例如,考虑下表中展示的样例程序轨迹。该程序使用了局部变量 r1 和 r2、共享变量 A 和 B。最初 A==B==0:
Thread 1 | Thread 2 |
---|---|
1: r2 = A; | 3: r1 = B; |
2: B =1; | 4: A = 2; |
看起来好像不可能产生 r2==2 和 r1==1 这样的结果。直觉上,在某次执行中,要么是指令 1,要么是指令 3 先到。如果是指令 1 先到,那么它应该看不到指令 4 的写操作。如果指令 3 先到,那么它应该看不到指令 2 的写操作。
如果某次执行确实展示了这种行为,即产生 r2==2 和 r1==1 这样的结果,那么我们就知道指令的顺序是 4、1、2、3,这表面上看起来很荒谬。
但是,编译器可以对其中一个线程的指令进行重排序,只要重排序不会影响该线程单独执行时的效果即可。如果指令 1、2 进行重排序,就像下表中展示的轨迹顺序,那么就很容易看到 r2==2 和 r1==1 这样的结果。
Thread 1 | Thread 2 |
---|---|
1: B = A; | 3: r1 = B; |
2: r2 =A; | 4: A = 2; |
对有些开发者而言,这种行为看起来像是“受损了”。但是应该注意到,这种代码实际上只是没有进行正确的同步:
- 在一个线程中存在对一个变量的写操作。
- 在另一个线程中存在对同一个变量的读操作。
- 而写操作和读操作并未通过同步进行排序。
这种情况是“数据竞争”的一个实例。当代码包含数据竞争时,经常会产生有悖直觉的结果。
很多机制都可以产生上述重排序。Java 虚拟机实现中的即时编译器(JIT)可以重新安排代码或处理器。另外,对于 Java 虚拟机的实现而言,其架构的内存层次结构使得代码看起来就像是被重排序过一样。在本章中,我们将任何可以重排序代码的事物都归类为“编译器”。
另一个会出现惊人结果的例子可以在下表中看到。最初,p==q 且 p.x==0。该程序也未进行正确的同步,它对其中的写操作没有进行任何强制排序就向共享内存执行了写操作。
Thread 1 | Thread 2 |
---|---|
1: r1 = P; | 1: r6 = p; |
2: r2 = r1.x; | 2: r6.x = |
3: r3 = q; | |
4: r4 = r3.x; | |
4: r5 = r1.x; |
一项常见的编译器优化是,在对 r5 执行读操作时,复用了对 r2 执行读操作后所获得的值,因为它们都是在没有任何具有干扰效果的写操作时对 r1.x 的读操作。
现在请考虑这样的情况:在 Thread 2 中对 r6.x 的赋值发生在 Thread 1 中对 r1.x 的第一次读操作和对 r3.x 的读操作之间。如果编译器决定对 r5 重用 r2 的值,那么 r2 和 r5 的值就都是 0,而 r4 的值将是 3。从开发者的角度看,在 p.x 中存储的值从 0 变成了 3,之后又变回去了。
Thread 1 | Thread 2 |
---|---|
1: r1 = P; | 1: r6 = p; |
2: r2 = r1.x; | 2: r6.x = 3; |
3: r3 = q; | |
4: r4 = r3.x; | |
4: r5 = r1.x; |
线程内语义
内存模型可以确定程序中的每个点可以读取什么值。每个线程单独的动作必须由该线程的语义来管制其行为,但是每个读取操作看到的值是由内存模型决定的。当我们提到这一点时,就称程序遵循“线程内语义”。线程内语义是用于单线程程序的语义,并且允许对线程行为进行完整的预测,而该行为是基于该线程内的读动作所能看到的值的。为了确定在程序执行中线程 t 的动作是否是合法的,我们可以直接计算线程 t 的实现,因为它将在单线程上下文中执行,就像在本规范其他部分中定义的那样。
每当线程 t 的计算会生成线程间动作时,它必须匹配为在程序顺序中紧接着到来的 t 的线程间动作 a。如果 a 是读操作,那么 t 的进一步计算将使用由内存模型确定的 a 所看到的值。
本节将提供 Java 编程语言内存模型的规范,但是不包括处理 final 域的话题,它们将在下一节进行描述。
这里描述的内存模型不基于 Java 编程语言的面向对象特性。为了保持例子的简洁性和简单性,我们经常展示的是没有类或方法定义或显式引用的代码片段。大多数例子都包括两个或多个线程,它们包含对对象的局部变量、共享全局变量、实例域的访问语句。典型情况是,我们将使用 r1 和 r2 这样的变量名来表示方法或线程的局部变量。这种变量对其他线程是不可访问的。
共享变量
可以在线程间共享的内存被称为“共享内存”或“堆内存”。所有实例域、静态域、数组元素都存储在堆内存。在本章,我们将使用“变量”来指代这些域或数组元素。
局部变量、形式方法参数、异常处理参数永远都不会在线程间共享,因此也就不受内存模型的影响。
如果至少有一个访问是写操作,两个对相同变量的访问(读或写)被称为是“冲突的”。
动作
“线程间动作”是指一个线程执行的动作可以被另一个线程探测到或者直接受另一个线程影响。程序可以执行的线程间动作有如下几种:
- 读(正常读或对非 volatile 的读):对变量的读。
- 写(正常写或对非 volatile 的写):对变量的写。
- 同步动作,包括:
- volatile 读:对变量的 volatile 读。
- volatile 写:对变量的 volatile 写。
- 锁定:锁定监视器。
- 解锁:解锁监视器。
- 线程(合成)的第一个和最后一个动作。
- 启动线程或探测线程是否已被终止的动作。
- 外部动作:可以从“执行外部”观察到的动作,其结果基于“执行外部”的环境。
- 线程分岔动作:它只能由在不执行任何内存、同步或外部动作的无限循环中的线程执行。如果一个线程执行了线程分岔动作,那么它后续会跟随无限数量的线程分岔动作。
- 引入线程分岔动作是为了对一个线程可能会如何导致所有其他线程停顿或不能有所进展的情况进行建模。
本规范只关注线程间的动作,我们不需要关心线程内的工作(如将两个局部变量加起来存储到第三个局部变量中)。如前所述,所有线程都需要遵守正确的 Java 程序线程内语义。我们通常将线程间动作更简洁的称为“动作”。
一个动作 a 由元组 <t, k, v, u> 构成,其中:
- t 是执行动作的线程。
- k 是动作的种类。
- v 是动作涉及的变量和监视器。
- 对于锁定动作,v 是被锁定的监视器;对于解锁动作,v 是被解锁的监视器。
- 如果动作是(volatile 或非 volatile)的读,那么 v 就是被读取的变量。
- 如果动作是(volatile 或非 volatile)的写,那么 v 就是被写入的变量。
- u 是该动作的任意唯一的标识符。
外部动作元组还包含额外的组成部分,它包含执行该动作的线程可以感知到的该外部动作的结果,可以是表示该动作成功或失败的信息,以及该动作读取的值。
外部动作的参数(如哪些字节要写到哪些 socket)不是外部动作元组的组成部分。这些参数由线程内的其他动作设置,并且可以通过检查线程内语义而确定。它们在内存模型中没有专门的讨论。
在不终止的执行中,不是所有的外部动作都是可观察的。
程序与程序顺序
在每个线程 t 执行的所有线程动作中,t 的程序顺序是一种全序,反映了这些动作按照 t 的线程内语义执行的顺序。
动作集是连续一致的,如果其所有动作都按照与程序顺序一致的全序(执行顺序)发生,并且每个对变量 v 的读操作 r 都可以看到由对 v 的写操作 w 写入的值,使得:
- w 在执行顺序中在 r 之前到来。
- 没有任何其他写操作 w’ 使得在执行顺序中 w 在 w’ 之前到来、而且 w’ 在 r 之前到来。
连续一致性是对程序执行中的可见性和排序做出的非常强的保障。在连续一致的执行中,在所有单独的动作(如读操作和写操作)之上存在全序(total order),它与程序的顺序一致,并且每个单独的动作都是原子性的,且对每个线程都是立即可见的。
如果程序中没有任何数据竞争,那么程序的所有执行看起来都是连续一致的。
对于由“需要被原子性的感知”和“不需要被原子性的感知”的操作构成的组,连续一致性和不存在的数据竞争仍旧不能保证这样的组中不会产生错误。
@@@ note
如果我们要使用连续一致性作为我们的内存模型,那么我们讨论过的编译器和处理器的很多优化都是非法的。
@@@
同步顺序
每次执行都有一个同步顺序。同步顺序是执行中所有同步动作之上的全序。对于每个线程 t,t 中的同步动作的同步顺序与 t 的程序顺序是一致的。
同步动作可以归纳出动作上的“被同步”关系,定义如下:
- 在监视器 m 上的解锁动作会同步所有后续的在 m 上的锁定动作(其中“后续”是按照同步顺序定义的)。
- 对于 volatile 变量 v 的写操作会同步所有后续由任何线程执行的对 v 的读操作(其中“后续”是按照同步顺序定义的)。
- 启动线程的动作会同步它所启动的线程中的第一个动作。
- 对每个变量写入缺省值(0、false、null)的操作会同步每个线程中的第一个动作。
- 尽管看起来有点怪,在给包含变量的对象分配内存之前就对该变量写入了缺省值,但是在概念上,每个对象是在程序开始时使用缺省的初始化值创建的。
- 线程 T1 中的最后一个动作会与探测到 T1 已经终止的另一个线程 T2 中的任何动作同步。
- T2 可以通过调用 T1.isAlive 或 T1.join 来实现这种探测。
- 如果线程 T1 中断了线程 T2,那么对于代码中的任何点,只要其他任何线程(包含 T2)能够确定 T2 已经被中断(可以通过抛出中断异常、调用 Thread.interrupted、Thread.isInterrupted 方法来实现),那么 T1 执行的中断动作就会在这些点进行同步。
在表示同步关系的边中,源头称为释放,目的地称为获取。
Happens-Before 顺序
两个动作可以通过 “Happens-Before” 关系进行排序。如果一个动作在另一个动作之前发生,那么第一个动作对第二个动作就是可见的,并且排在第二个动作之前。
如果我们有两个动作 x 和 y,那么我们写作 hb(x,y) 来表示 x 在 y 之前发生。
- 如果 x 和 y 是同一个线程的动作,并且按照程序顺序 x 在 y 之前到来,那么 hb(x,y)。
- 从对象的构造器的末尾到该对象的终结器的开头,存在一条表示 “Happens-Before” 的边。
- 如果动作 x 会同步接下来的动作 y,那么我们也可以得出 hb(x,y)。
- 如果 hb(x,y) 且 hb(y,z),那么 hb(x,z)。
Object 类的 wait 方法与其相关联的锁定和解锁动作,它们之间的 “Happens-Before” 关系是由这些关联的动作定义的。
@@@ note
应该注意,两个动作之间存在 “Happens-Before” 关系并不意味着在代码实现中它们必须按照该顺序发生。如果重排序产生的结果与合法的执行是一致的,那么它就并不是非法的。
@@@
例如,对由某线程构造的对象的每个域写入其缺省值的操作,就不必非要在该线程的开始之前发生,只要没有任何读操作可以观察到这个事实即可。
更具体的说,如果两个动作共享 “Happens-Before” 关系,那么对于不和它们共享 “Happens-Before” 关系的任何代码来说,它们不必看起来非要是以该顺序发生的。例如,如果在一个线程中的写操作会与在另一个线程中的读操作产生数据竞争,那么这些写操作对那些读操作来说可以看起来像是乱序发生的。
在数据竞争发生时,需要定义 “Happens-Before” 关系。
由同步的边组成集合 S 是充分的,如果他是一个最小集,使得带有程序顺序的 S 的传递闭包可以确定在执行中的所有 “Happens-Before” 的边。这个集是唯一的。
由上面的定义可以得出下面的内容:
- 在监视器上的解锁动作在每个后续在该监视器上的锁定操作之前发生。
- 对 volatile 域的写操作在每个后续对该域的读操作之前发生。
- 在线程上对 start() 的调用在被启动线程的所有动作之前发生。
- 一个线程中的所有动作在任何其线程成功的从该线程上的 join 发生之前发生。
- 任何对象的缺省值初始化在程序中的其他任何动作(除了缺省的写操作)之前发生。
如果一个程序包含了两个互相冲突且没有 “Happens-Before” 排序关系的访问操作,那么就称该程序包含“数据竞争”。
对于不是线程间动作的操作,例如对数组长度的读操作、受检强制类型转换的执行和对虚拟方法的调用,其语义不受数据竞争的直接影响。
- 因此,数据竞争不能引发不正确的行为,例如返回错误的数组长度。
- 当且仅当所有连续一致的执行都没有数据竞争,程序则是正确同步的。
- 如果程序是正确同步的,那么该程序的所有执行看起来都是连续一致的。
这对开发者来说是很好的保障。开发者不需要推断重排序方式以确定他们的代码是否包含数据竞争,因此也就不需要在确定他们的代码是否被正确的同步时推断重排序方式。一旦确定了代码是正确同步的,开发者就不需要担心重排序是否会影响他们的代码。
程序必须被正确同步以避免各种在代码重排序时会被观察到的反常行为。正确的同步并不能确保程序的整体行为是正确的,但是它使得开发者可以以简单的方式推断程序可能的行为。对于正确同步的程序,其行为对可能的重排序形成的依赖要少的多。没有正确的同步,就可能会产生非常奇怪的、混乱和反常的行为。
我们称变量 v 的读操作 r 允许观察对 v 的写操作 w,如果在执行轨迹的 “Happens-Before” 的偏序(partial-order)关系中:
- r 的排序不在 w 之前,即非 hb(r,w)。
- 中间没有介入任何对 v 的写操作 w’,即没有任何对 v 的写操作 w’ 使得 hb(w,w’) 和 hb(w’,r) 同时成立。
非正式的讲,读操作 r 允许看到写操作 w 结果,如果没有任何 “Happens-Before” 排序会阻止该操作。
如果对该动作集 A 中的每个读操作 r,用 w(r) 表示 r 可以看到的写操作,都不满足 hb(r, w(r)),或 A 中不存在些操作 w 使得 w.v = r.v、hb(w(r), w) 和 hb(w, r) 同时成立,那么动作集 A 具有 “Happens-Before” 一致性。
在具有 “Happens-Before 一致性” 的工作集中,每个读操作看到的写操作都是 “Happens-Before” 排序机制允许看到的写操作。
示例: Happens-Before 一致性
对于下图中的轨迹,初始时 A==B==0。该轨迹可以观察到 r2==0 和 r1==0,并且在 “Happens-Before” 上仍旧保持一致性,因为执行顺序允许每个读操作看到恰当的写操作。
Thread 1 | Thread 2 |
---|---|
1: B = 1; | 1: A = 2; |
2: r2 = A; | 2: r1 = B; |
因为没有任何同步,所有每个读操作都可以看到写入初始值的写操作或由另一线程执行的写操作。下面的执行顺序展示了这种行为:
1: B=1;
2: A=2;
3: r2=A; // seen initial write of 0
4: r1=B; // seen initial write of 0
另一种具有 “Happens-Before 一致性” 的执行顺序为:
1: r2=A; // seen write of A=2
2: r1=B; // seen write of B=1
3: B=1;
4: A=2;
在该执行中,读操作看到的是在执行顺序之后发生的写操作。这看起来很反常,但是是 “Happens-Before一致性” 所允许的。允许读操作看到之后发生的写操作有时可能会产生不可接受的行为。
执行
执行 E 可以用元组 <P, A, po, so, W, V, sw, hb> 表示,其构成为:
- P:程序。
- A:动作集。
- po:程序顺序,对于每个线程 t,是在 A 中由 t 执行的所有动作上的全序。
- so:同步顺序,即 A 中所有同步动作上的全序。
- W:“被看到的写动作”函数,对 A 中的每个读操作 r,会给出 W(r),即在 E 中 r 看到的写动作。
- V:“被写入的值”函数,对 A 中的每个写操作 w,会给出 V(w),即在 E 中 w 写入的值。
- sw:与….同步,即同步关系上的偏序。
- hb:之前发生,即动作上的偏序。
@@@ note
“与….同步”和“之前发生”元素是由执行中的其他组成部分以及有关良构执行的规则唯一确定的。
@@@
执行具有 “Happens-Before 一致性”,如果它的工作集具有“Happens-Before 一致性”。
良构执行
我们只考虑良构(Well-Formed)的执行。如果下面的条件都为 true,则执行 E = <P, A, po, so, W, V, sw, hb> 是良构的:
- 每个读操作看到的都是在该执行中对同一个变量的写操作。
- 所有对 volatile 变量的读操作和写操作都是 volatile 动作。对于 A 中的所有读操作,其 W(r) 都在 A 内,且 W(r).v = r.v。变量 r.v 是 volatile 的,当前仅当 r 是 volatile 读操作;w.v 是 volatile 的,当且仅当 W 是 volatile 的。
- “之前发生”顺序是偏序。
- “之前发生”顺序是由“与….同步”边和程序顺序的传递闭包给出的。它必须是有效的偏序:自反的、传递的且反对称的。
- 该执行遵守线程内一致性。
- 对每个线程 t,在 A 中由 t 执行的动作与该线程在单独执行时的程序顺序中生成的动作相同,如果每个读操作 r 看到的值都是 v(w(r)),那么每个写操作都会写入 v(w)。每个读操作看到的值是由内存模型确定的。所给出的程序顺序必须反映按照 P 的线程内语义执行动作的程序顺序。
- 该执行是 “Happens-Before 一致性”。
- 该执行遵循同步顺序一致性。
- 对所有 A 中的 volatile 读操作,即不存在 so(r, W(t)),也不存在 A 中的写操作 W 使得 w.v = r.v、so(w(r), w) 和 so(w,r)同时成立。
执行和因果关系要求
我们使用 Fd 表示这样的函数:将 F 的域限定为 d。对于所有在 d 中的 x,Fd(x) = F(x),并且对于所有不在 d 中的 x,Fd(x) 无定义。
我们使用 Pd 表示将偏序 P 限定为 d 中的元素。对于所有在 d 中的 x 和 y,P(x,y) 成立当且仅当 Pd(x,y)。如果 x 和 y 不在 d 中,那么就不存在 Pd(x,y)。
良构 E = <P, A, po, so, W, V, sw, hb> 是由 A 中的“提交动作”所验证的。如果 A 中的所有动作都能够提交,那么该执行就满足 Java 编程语言内存模型有关因果关系的要求。
从空集 C0 开始,我们执行一系列步骤,将动作从动作集 A 中取出,并将其添加到提交动作集 Ci 中,以得到新的提交集 C i+1。为了证明这种方式的合理性,对于每个 Ci,我们需要证明包含 Ci 的执行 E 满足特定的条件。
形式化的将,执行 E 满足 Java 编程语言内存模型的因果关系要求当且仅当存在:
- 动作集 C0、Ci、….,使得:
- C0 是空集。
- Ci 是 C i+1 的真子集。
- A = U(C0, C1, …)。
- 如果 A 是有限集,那么 C0、C1、….Cn 序列是有限的,以 Cn=A 结尾。
- 如果 A 是无限集,那么 C0、C1、….Cn、… 序列也可能是无限的,并且必须满足这个无限序列中的所有元素的并集等于 A。
- 良构的执行 E0、…、Ei、…,其中 Ei = <P, Ai, poi, soi, Wi, Vi, swi, hbi>
- 给定这些动作集 C0、… 和执行 Ei、…,每个在 Ci 中的动作必须是 Ei 的动作之一。所有在 Ci 中的动作必须共享与 Ei 和 E 中相同的相对的之前发生顺序和同步顺序。形式化的讲:
- Ci 是 Ai 的子集。
- hbi|ci = hb|ci
- soi|ci = so|ci
- 由 Ci 中的写操作写入的值必须与在 Ei 和 E 中写入的值相同。只有在 Ci-1 中的读操作才要求在 Ei 中看到的写操作与在 E 中看到的写操作相同。形式化的讲:
- Vi|ci = V|ci
- Wi|ci-1 = W|ci-1
- 所有在 Ei 中但不在 Ci-1 中的读操作必须看到在它们知己去哪发生的写操作。每个在 Ci - Ci-1 中的读操作 r 都必须在 Ei 和 E 中看到在 Ci-1 中的写操作,但是在 Ei 中看到的写操作可以在 E 中看到的写操作不同。形式化的讲:
- 对于任何在 Ai - Ci 中的读操作 r,都有 hbi(Wi(r), r)。
- 对于任何在 (Ci - Ci-1) 中的读操作 r,都有 Wi(r) 在 Ci-1 中并且 W(r) 在 Ci-1 中。
- 给定 Ei 的充分的“与…同步”边的集合,如果有一个“释放-获取”对在你正在提交的动作之前发生,那么该操作对必须在所有的 Ej 中都存在,其中 j >= i。形式化的讲:
- 设 SSWi 是在 hbi 的传递归约中但不在 po 中的 SWi 的边。我们称是 SSWi 为 “Ei 的充分的与…同步的边”。如果 SSWi(x,y) 和 hbi(y,z),且 z 在 Ci 中,那么对所有的 j>=i,都有 SWj(x,y)。
- 如果动作 y 被提交,那么所有在 y 之前发生的所有外部动作也都会被提交。
- 如果 y 在 Ci 中,x 是外部动作,且有 hbi(x,y),那么 x 在 Ci 中。
- 给定这些动作集 C0、… 和执行 Ei、…,每个在 Ci 中的动作必须是 Ei 的动作之一。所有在 Ci 中的动作必须共享与 Ei 和 E 中相同的相对的之前发生顺序和同步顺序。形式化的讲:
示例:“Happens-Before 一致性” 是不充分的
“Happens-Before 一致性” 是必要的但不是充分的约束集。仅仅强制实现 “Happens-Before 一致性” 仍旧会允许不可接受的行为发生,这些行为会违反我们已经为程序实现的需求。例如,“Happens-Before 一致性”使得值看起来像是“无中生有”的。通过对下表的轨迹进行详细检查就会看到这一点。
Thread 1 | Thread 2 |
---|---|
1: r1 = x; | 1: r2 = y |
2: if(r1 != 0) y = 1; | 2: if(r2 != 0) x = 1; |
上表中展示的代码是正确同步的。这看起来很令人惊讶,因为它没有执行任何同步动作。但是请记住,当且仅当在它以连续一致的方式执行时程序是正确同步的,不会有任何数据竞争。如果这段代码是以连续一致的方式执行的,每个动作都按照程序顺序发生,每个写操作都不会发生。因为不会发生任何写操作,所以不会有任何数据竞争,因此:该程序是正确同步的。
既然这个程序是正确同步的,那么我们唯一允许其产生的行为只能是连续一致的行为。但是,这个程序存在这样一种执行:它是“Happens-Before 一致”的,但不是连续一致的:
r1 = x; // sees write of x = 1
y = 1;
r2 = y; // sees write of y = 1
x = 1;
这个结果是“Happens-Before 一致”的:没有任何“Happens-Before”关系会阻止它发生。但是很明显,这个结果不可接受:没有任何连续一致性的执行会产生这种行为。因此,由于我们允许读操作看到在执行顺序中之后到来的写操作,所以有时就会产生这种不可接受的行为。
尽管允许“读操作看到在执行顺序中之后到来的写操作”有时并不是我们想要的,但是它有时又是必须的。就像我们在上一个示例的表中看到的,其中的轨迹就要求某些读操作要看到在执行顺序中之后到来的写操作。由于在每个线程中,读操作总是先到,所以在执行顺序中第一个动作必然是读操作。如果该读操作不能看到之后发生的写操作,那么它就看不到它所读取的变量初始值之外的任何其他值。很明显,这将无法反映所有的行为。
我们将“读操作何时可以看到将来的写操作”的问题称为“因果关系”,因为这些问题与上表中所展示的情况类似。在那种情况中,读操作导致写操作发生,而写操作又导致读操作发生。对于这些动作而言,没有“首因”。因此,我们在内存模型中需要一种一致的方式,以确定哪些读操作可以提前看到写操作。
诸如上个示例的表中证明了本规范在描述读操作是否可以看到执行中之后发生的写操作时,必须格外小心(一定要记住,如果一个读操作可以看到执行中之后发生的读操作,那么就表示该写操作实际上是之前就执行过的)。
内存模型将给定的执行和程序作为输入,确定该执行是否是该程序的和合法执行。它是通过渐进的构建“提交”动作集来实现这一目的的,该动作集反映了程序执行了哪些动作。 通常,下一个被提交的动作将表现为在连续一致执行中可以执行的下一个动作。但是,为了表现需要看到之后发生的写操作的读操作,我们允许某些动作的提交时机早于在它们之前发生的工作的提交时机。
很明显,某些动作可以被提早提交,但是某些动作则不行。如本例的表中的某个写操作是在对该变量的读操作之前提交的,那么读操作将看到写操作,因为会产生“无中生有”的结果。非形式化的讲,我们允许某个动作早提交,前提是我们知道该动作的发生不会导致任何数据竞争。在上表中,两个写操作都不能提早执行,因为除非读操作可以看到数据竞争的结果,否则这些写操作就不能发生。
可观察的行为和不终止的执行
对于总是会在某个边界的有限时间内终止的程序,它们的行为可以直接根据它们允许的执行而被(以非形式化的方式)理解。对于不会在有限时间段内终止的程序,会产生更多微妙的问题。
程序的可观察的行为是用该程序可以执行的外部动作的有限集来定义的。例如,只是打印“Hello”的程序可以用这样的行为集描述:对于任何非负整数 i,该行为集包含打印 “Hello” i 次的行为。
“终止”不会被显式的建模为行为,但是程序可以很容易的扩展为生成额外的外部动作 executionTermination,该动作在所有线程被终止时发生。
我们还定义了一个特殊的悬挂(hand)动作。如果行为是用包含悬挂动作的外部动作集描述的,那么它表示的行为是:在外部动作被观察到之后,程序可以在时间上无界的运行,而不需要执行任何额外的外部动作或不需要终止。程序可以悬挂,如果所有线程都被阻塞,或者该程序可以执行在数量上无界的动作,而不需要执行任何外部动作。
线程可以在各种各样的环境中被阻塞,例如当它视图获取锁或者执行依赖于外部数据的外部动作(诸如读操作)时。
执行可以导致某个线程被无限阻塞,并且该执行不会终止。在这种情况下,被阻塞线程所产生的动作必须由该线程到被阻塞时为止所产生的所有动作构成,包括导致该线程被阻塞的动作,并且不包含在导致阻塞的动作之后该线程所产生的动作。
为了推断可观察到的行为,我们需要谈谈可观察动作集。
如果 o 是执行 E 的可以观察动作集,那么 o 必须是 E 的动作集 A 的子集,并且必须包含有限数量的动作,即使 A 包含无限数量的动作也是如此。并且,如果 y 是在 o 中的动作,并且有 hb(x,y) 或 so(x,y),那么 x 在 o 中。
@@@ note
可观察动作集并没有被限制为仅能包含外部动作,但是只有在动作集中的外部动作才会被当做可观察的外部动作。
@@@
行为 B 是程序 P 允许的行为,当且仅当 B 是有限外部动作集,并且:
- 存在 P 的执行 E 和 E 的可观察动作集 O,使得 B 是 O 中的外部动作集(如果 E 中的任何线程都归于阻塞状态,并且 O 包含 E 中的所有动作,那么 B 也可以包含悬挂动作)。
- 存在动作集 O,使得 B 由悬挂动作和所有 O 中的外部动作构成,并且对于所有 k >= |O|,都存在带有动作集 的 P 的执行 E,以及动作集 O’,使得:
- O 和 O’ 都是 A 的子集,且它们满足可观察动作集的要求。
- O <= O’ <= A。
- |O’| >= k。
- O’ - O 不包含任何外部动作。
@@@ note
行为 B 没有描述 B 中的外部动作被观察到的顺序,但是其他外部动作应该如何被生成和执行的(内部)约束条件可以被施加这种限制。
@@@
final 域的语义
声明为 final 的域只会被初始化一次,但是在正常情况下永远都不会再变更。final 域的详细语义与普通域的语义有些不同。特别是,编译器在同步栅栏和对任意或未知方法的调用之间可以有很大的自由度去移动对 final 域必须被重载的场景中,也不会从内存从载它。
final 域还使得开发者无需同步而实现线程安全的不可变对象。线程安全的不可变对象可以被所有线程看做是不可变的,即使数据竞争被用来在线程间传递不可变对象的引用,也是如此。这可以提供安全保障,以防止通过不正确或有恶意的代码误用不可变类。final 域必须被正确使用,以提供不可变性的保障。
对象在其构造器执行完成时被认为是完全初始化的。对于只能在对象完全初始化之后才能看到对该对象的引用的线程,可以保证它看到该对象的 fianl 域是被正确初始化的值。
final 域的使用模型非常简单:在对象的构造器中设置 fianl 域,并且不要在另一个线程可以在该对象的构造器质性完成之前看到它的地方,对该对象的引用执行写操作。如果遵循了这一点,那么当该对象被另一个线程看到时,这个线程就总是会看到该对象的 final 域的正确构造版本,并且对于任何被这些 fianl 域引用的对象或数组,这个线程也会看到它们至少与这些 final 域同样新的版本。
示例:Java 内存模型中的 fianl 域
下面的程序展示了 final 域与普通域的比较:
class FinalFiledExample {
final int x;
int y;
static final FinalFieldExample f;
punlic FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if(f != null) {
int i = f.x; // guranteed to see 3
int j = f.y; // cound see 0
}
}
}
一个线程可能会执行该类的 wirter 方法,而另一个线程可能会执行其 reader 方法。
因为 writer 方法在该对象构造器执行完成之后会写入 f,因此可以保证 reader 方法能正确看到 f.x 的初始化值:3。但是,f.y 不是 final 的,因此不能保证 reader 方法会看到它的值为 4。
示例:用于安全目的的 final 域
final 域被设计用来保证必要的安全性。请考虑下面的程序,其中一个线程(称为线程 1)会执行:
Global.s = "/tmp/user".substring(4);
而另一个线程(2)执行:
Strin myS = Global.s;
if(myS.equals("/tmp")) System.out.println(myS);
String 对象被设计为不可变的,并且字符串操作也不执行同步。尽管 String 的实现没有任何数据竞争,但是其他涉及使用 String 对象的代码也许会有数据竞争,并且内存模型对具有数据竞争的程序只提供弱保证。特别是,如果 String 类的域不是 final 的,那么它就有可能出现这样的情况(尽管不大可能):线程 2 最初会看到字符串对象的偏移量为缺省值 0,使得可以将它与 “/tmp” 进行比较看它们是否相等。而稍后在 String 对象上的操作可能会看到正确的偏移量 4,使得该 String 对象可以被感知到是 “/usr”。Java 编程语言的很多安全特性都依赖于 String 对象被感知为真的不可变,即使恶意代码可以利用数据竞争在线程间传递 String 引用也是如此。
final 域的语义
设 o 是对象,c 是 o 的构造器,在 c 中 final 域 f 会被写入。在 o 的 final 域上的“冻结”动作会在 c 退出时发生,无论是正常退出还是猝然退出。
注意,如果一个构造器调用了另一个构造器,并且被调用的构造器设置了 final 域,那么该 final 域的冻结就会在被调用的构造器的结尾处发生。
对于每次执行,读操作的行为会受到两个额外的偏序关系影响,即解引用链 dereferences() 和内存链 mc(),它们被认为是执行的一部分(因此被认为对任何特定执行都是不变的)。这些偏序必须满足下面的约束条件(不必有唯一解决方案):
- 解引用链:如果线程 t 没有初始化对象 o,但是动作 a 是由线程 t 执行的对对象 o 的某个域或元素的读操作或写操作,那么必然存在某个由线程 t 执行的可以看到 o 的地址的读操作 r,使得 r dereferences(r,a) 成立。
- 内存链:在内存链排序上有若干约束条件。
- 如果 r 是可以看到写操作 w 的读操作,那么必然有 mc(w,r)。
- 如果 r 和 a 是使得 dereferences(r,a) 成立的动作,那么必然有 mc(r,a)。
- 如果线程 t 没有初始化对象 o,但是 w 是由线程 t 执行的对对象 o 的地址的写操作,那么必然存在某个线程 t 执行的可以看到 o 的地址读操作 r,使得 r mc(r,w) 成立。
假设没有写操作 w、冻结动作 f、动作 a(不是对 final 域的读操作)、对由 f 冻结的 final 域的读操作 r1,以及读操作 r2,使得 hb(w,f)、hb(f,a)、mc(a,r1) 和 dereferences(r1,r2) 成立,那么在确定哪些值可以被 r2 看到时,我们认为 hb(w,r2)。(这个“之前发生”排序与其他的“之前发生”排序没有构成产地闭包)
注意:dereferences 顺序是自反的,并且 r1 可以和 r2 相同。
对于对 final 域的读操作,只有被认为在这个对 final 域的读操作之前到来的写操作才是可以通过 final 域语义导出的操作。
在构造阶段读 final 域
如果某个对象位于构造它的线程中,那么对这个对象的 final 域的读操作是根据通常的 “Happens-Before” 规则,针对该域的初始化而排序的。如果该读操作出现在该域在构造器中被设置之后,那么它就会看到该 final 域已经赋过的值,否则,它会看到缺省值。
对 final 域的后续修改
在某些情况下,例如反序列化,系统需要在对象构造之后修改其 final 域。final 域可以通过反射和其他依赖于 Java 具体实现的方式被修改。唯一能够是这种修改具有合理语义的模式,就是允许先构造对象,然后再修改对象的 fianl 域的模式。这种对象不应该对其其他线程是可见的,而 final 域也不应该被读取,直至所有该对象的 final 域的更新都结束。final 域的冻结可以发生在设置该 fianl 域的构造器的末尾,或者在紧挨每个通过反射或其他特殊机制修改该 final 域的操作之后。
即使如此,还存在大量的复杂性。如果 final 域被初始化为域声明中的编译时常量表达式,那么对该 final 域的修改可能不会被观察到,因为对该 final 域的使用在编译器时就已经替换成了该常量表达式。
另一个问题是本规范允许对 final 域进行积极优化。在线程中,允许重排序对 final 域的读操作和对不再构造器中发生的对该域的修改操作。
示例:对 final 域的积极优化
class A {
final int x;
A() {
x = 1;
}
inf f() {
return d(this,this);
}
int d(A a1, A a2) {
int i = a1.x;
g(a1);
int i = a2.x;
return j - i;
}
static void g(A A) {
// use reflection to change a.x to 2
}
}
在方法 d 中,编译器可以任意对 x 的读操作和对 g 的调用进行重排序。因此,new A().f() 可能会返回 -1、0、1。
Java 编程语言的实现可以提供一种方式,用来在 final 域安全的上下文中执行代码块。如果某个对象是在 final 域安全的上下文中出现的对该 final 域的修改操作进行重排序。
final 域安全的上下文具有额外的保护措施。如果一个线程已经看到了未正确发布的对某个对象的引用,该线程通过该引用可以看到 final 域的缺省值,并且之后在 final 域安全的上下文中读取了正确发布的对该对象的引用,那么可以保证该线程可以看到该 final 域的正确的值。在形式上,在 final 域安全的上下文中执行的代码会被当做单独的线程处理(这种处理仅仅只针对 final 域的语义)。
在 Java 编程语言的实现中,编译器不应该将对 final 域的访问操作移入或移出 final 域安全的上下文(尽管它可以围绕着这种上下文的执行而移动,只要该对象不是在该上下文中构造的)。
有一种场景适合使用 fianl 域安全的上下文,即在执行器或线程池中。通过执行在彼此分离的 final 域安全的上下文中的每一个 Runnable 对象,执行器可以保证某个 Runnable 对象 o 的不正确访问将不会影响对由同一个执行器处理的其他 Runnable 做出的对 final 域的保证。
写受保护的域
正常情况下,是 final 且是 static 的域不能被修改。
但是,因历史遗留问题,System.in、System.out 和 System.err 虽然是 static final 域,但是它们必须通过 System.setIn、System.setOut、System.setErr 方法进行修改。我们将这些域称为“写受保护”的域,以便与普通 final 域区分。
编译器需要将这些域与其他 final 域区别对待。例如,对普通 final 域的读操作对同步是“免疫的”:涉及锁或 volatile 读的屏障不会影响从 final 域中读出的值。但是,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,由于我们可以看到对写受保护的域的值所做的变更,所以同步事件应该对它们有影响。因此,其语义要求这些域应该被当做不能由用户代码修改的普通域进行处理,除非用户代码在 System 类中。
字撕裂
对 Java 虚拟机的实现有一种考虑,即每个域和数组元素都被认为是有区别的,对一个域或元素的更新不必与其他域或元素的读或更新操作交互。特别是,分别更新字节数组中毗邻元素的两个线程必定不会互相干涉或交互,因此也就不需要同步以确保连续的一致性。
某些处理器并不提供对单个字节进行写操作的能力。在这种处理器上通过直接读取整个字、更新恰当的字节,然后将整个字写回内存的方式来实现字节数组的更新是非法的。这个问题有时被称为字撕裂,在不能很容易的单独更新单个字节的处理器上,需要其他的实现方式。
示例:探测字撕裂
public class WordTearing extends Thread {
static final int LENGTH = 8;
static final int ITERS = 1000000;
static byte[] counts = new byte[LENGTH];
static Thread[] threads = new Thread[LENGTH];
final int id;
WordTearing(int i) {
id = i;
}
public void run() {
byte b = 0;
for(int i=0; i < ITERS; i++) {
byte v2 = counts[id];
if(v != v2) {
System.err.pringln("Word-Tearing foung: " +
"counts[" + id + "] =" + v2 +
", shoube be " + v);
return;
}
v++;
counts[id] = v;
}
}
}
这里的关键是字节必须不能被写操作覆盖为毗邻的字节。
double 和 long 的非原子化处理
考虑到 Java 编程语言的内存模型,对非 volatile 的 long 或 double 的单个写操作会当做两个分离的写操作处理:每个操作处理 32 位。这会导致一种情况:一个线程会看到由某个写操作写入的 64 位值的头 32 位、由另一个写操作写入的后 32 位。
对 volatile 的 long 或 double 值的读操作和写操作总是原子性的。
对引用的读操作和写操作总是原子性的,无论它们被实现为 32 位还是 64 位的值。
某些实现会发现将单个对 64 位的 long 或 double 值的写动作分成两个毗邻的 32 位值的写动作会更方便。由于效率的原因,这种行为是实现相关的,Java 虚拟机的实现可以自由选择对 long 或 double 值的写操作是原子性的还是分成两部分。
我们鼓励 Java 虚拟机的实现应该避免将 64 位值分开,并鼓励开发者将共享的 64 位值声明为 volatile 的,或者正确的同步使用它们的程序以避免可能出现的复杂性。
2.3.2 - CH02-JMM Explain
多任务和高并发的内存交互
多任务和高并发是衡量一台计算机的处理能力的重要指标之一。一般衡量一个服务器性能的高低好坏,使用每条事务处理数(Transcations Per Second, TPS),该指标比较能够说明问题,它代表着一秒内服务器平均能够响应的请求数,而 TPS 值与程序的并发能力有着非常密切的关系。“物理机”的并发问题与“虚拟机”中的情况有很多相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
由于计算机的存储设备与处理器的运算能力之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的“高速缓存(cache)”来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能够快速运行;当运算结束后再将数据从缓存同步会内存之中,这样一来处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是引入了一个新的问题:“缓存一致性(Cache Coherence)”。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主存,如下图所示:多个处理器运算任务都涉及同一块主存,需要一种协议可以保障数据的一致性(及多个处理器看到相同的数据),这类协议有 MSI/MESI/MOSI/Dragon Protocl 等。
除此之外,为了使得处理器内部的运算单元能尽可能被充分利用,处理器可能会对输入代码进行“乱序执行(Out-Of-Order Execution)”优化,处理器会在计算之后将对乱序执行的代码进行结果重组,以保证结果的准确性。与处理器的乱序执行优化类似,Java 虚拟机的即时编译器(JIT)中也有类似的“指令重排序(Instruction Recorder)”优化。
Java 内存模型
内存模型可以理解为在特定的操作协议下,对特定的内存或告诉缓存进行读写访问的过程抽象,不同架构下的物理机拥有不同的内存模型,Java 虚拟机也有自己的内存模型,即“Java 内存模型(JMM)”。在 C/C++ 语言中则是直接使用物理硬件和操作系统的内存模型,导致不同平台下并发访问出错,需要进行多平台的兼容。而 JMM 的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,使得 Java 程序能够“一次编写,到处运行”。
主内存与工作内存
Java 内存模型的目的主要是“定义程序中各个变量的访问规则”,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与进行 Java 编程时所说的变量不同,包括了实例字段、静态字段、数组元素,但是不包括局部变量与方法参数,因为后者是线程私有的,永远不会被共享。
Java 内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面所说的处理器的高速缓存类比),线程的工作内存中使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存和工作内存之间的交互关系如下图:
@@@ note
这里的主内存、工作内存与 Java 内存区域的 Java 堆、栈、方法区不是同一层次的内存划分,两者之间没有关系。
@@@
内存交互操作
由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下 8 种操作来完成:
- lock:作用于主内存的变量,把一个变量标识为由一条线程独占的状态。
- unlock:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
- load:作用于工作内存变量,把 read 操作从主内存得到的变量值放入工作内存的变量副本中。
- use:作用于工作内存变量,把工作内存变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行该操作。
- assign:作用于工作内存变量,它把一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行该操作。
- store:作用于工作内存变量,把工作内存变量值传送给主内存,以便随后的 write 操作。
- write:作用于主内存的变量,它把 store 操作从工作内存中得到的变量值传送到主内存的变量中。
如果要把一个变量从主内存复制到工作内存,就需要按序执行 read 和 load 操作;如果把变量从工作内存中同步到主内存中,就需要按序执行 store 和 write 操作。JMM 只要求上述两个操作“必须按序执行,而没有保证必须是连续执行”。也就是说在 read 和 load 之间、store 和 write 之间可以插入其他指令,如对主内存中的变量 a、b 进行访问时,可能顺序是 read a、read b、load b、load a。JMM 还规定了在执行上述 8 种基本操作时,必须满足如下规则:
- 不允许 read 和 load、store 和 write 操作之一单独出现。
- 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须同步回主内存中。
- 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load、assign)的变量。即在对一个变量实施 use 和 store 操作之前,必须已经对该变量执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,lock 和 unlock 必须成对出现。
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作来初始化该变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对其执行 unlock 从操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把该变量同步到主内存中(执行 store 和 write操作)。
这 8 中内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens before)原则来确定一个内存访问在并发环境下是否安全。
volatile 变量
关键字 volatile 是 JVM 中最轻量级的同步机制。volatile 变量具有两种特性:
- 保证变量的可见性。对一个 volatile 变量的读,总是能看到(任意线程)该 volatile 变量最后的写入,这个新值对于其他线程来说是立即可见的。
- 屏蔽指令重排序。指令重排序是编译器和处理器为了执行效率而对程序执行的优化手段,后文有详细分析。
volatile 语义并不能保证变量的原子性。对任意单个 volatile 变量的读/写具有原子性,但类似自增、自减这种复合操作不具有原子性,因为自增运算包括取值、加 1、重新赋值这 3 步操作,并不具备原子性。
由于 volatile 只能保证变量的可见性和屏蔽指令重排,只有满足以下两条规则时,才能使用 volatile 来保证并发安全,否则就需要加锁(使用 synchronized、lock、JUC 中的 Atomic 原子类)来保证并发中的原子性:
- 运算结果不存在数据依赖(重排序的数据依赖性),或者仅有单一的线程修改变量的值(重排序的 as-if-serial 语义)。
- 变量不需要与其他的状态变量共同参与不变约束。
因为需要在本地代码中插入许多内存屏障指令来屏蔽特定条件下的重排序,volatile 变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。
long/double 非原子协定
JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 个操作都必须具有原子性,但对于 64 位的数据类型 long 和 double,具有非原子协定:允许虚拟机经没有被修饰为 volatile 的 64 位数据的读写操作划分为两次 32 位的操作进行。(于此类似的是,在栈帧结构的局部变量表中,long 和 double 类型的局部变量可以使用 2 个能存储 32 位变量的变量槽来存储,详见“深入理解 Java 虚拟机” 第 8 章)
如果多个线程共享一个没有声明为 volatile 的 long 或 double 变量,并且同时执行存取操作,某些线程可能会读到一个即非原值、又非其他线程修改了的代表了“半个变量”的数值。不过这种情况十分罕见。因为非原子协定换句话说,同样允许 long 和 double 的读写操作实现为原子操作,并且目前绝大多数虚拟机都是这样做的。
原子性、可见性、有序性
原子性
JMM 保证的原子性变量操作包括 read、load、assign、use、store、write,而 long 和 double 非原子协定导致的非原子性操作基本可以忽略。如果需要对更大范围的代码实行原子性操作,则需要使用 JMM 提供的 lock、unlock、synchronized。
可见性
前面分析 volatile 语义时已经提到,可见性是指当一个线程修改了变量的值,其他线程能够立即得知这个修改。JMM 在变量被修改后将新值重新同步回主内存,依赖主内存作为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。普通变量和 volatile 变量都是如此,只不过 volatile 的特殊规则保证了这种可见性是立即得到的,而普通变量并不具备这样严格的可见性。除了 volatile 外,synchronized 和 final 也能保证可见性。
有序性
JMM 的有序性表现为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行语义(as-if=serial)”,后半句指“指令重排序”和变量的“工作内存与主内存的同步延迟”现象。
重排序
在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从硬件架构上来说,指令重排序是指 CPU 采用了允许将多条指令不再按照程序规定的顺序,分开发送给相应电路单元处理器,而不是将指令任意重排。重排序分为 3 种类型:
- 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排程序的执行顺序。
- 指令级并行重排序。先来处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
JMM 重排序屏障
从 Java 源代码到最终实际执行的指令序列,会经过 3 种重排序。但是为了保证内存的可见性,Java 编译器会在生成的指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM 会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM 会插入特定类型的内存屏障,通过内存的屏障指令来禁止特定类型的处理器重排序。这里讨论 JMM 对处理器的重排序,为了更深刻理解 JMM 对处理器重排序的处理,先来认识一下常见处理器的重排序规则:
其中的 N 表示处理器不允许两个操作进行重排序,Y 表示允许。可以看出:常见处理器你对 StoreLoad 都是允许重排的,并且常见处理器都不允许对存在数据依赖的操作进行重排序。另外,对应数据转换这一列都为 N,所以处理器均不允许这种重排序。
那么这个结论有什么用呢?比如第一点:处理器允许 StoreLoad 操作的重排序,那么在并发编程中读线程可能读到一个未被初始化或 null 值,出现不可预知的错误,基于这一点,JMM 会在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。
内存屏障指令共有 4 类:LoadLoad、StoreStore、LoadStore、StoreLoad。详细释义参考 JMM 规范。
数据依赖性
根据上面的表格,处理器不会对存在数据依赖性的操作进行重排序。这里数据依赖性的准确定义是:如果两个操作访问同一个变量,其中一个操作是写,此时两个操作就构成了数据依赖性。常见的具有这种特点的操作有自增、自减。如果改变了具有数据依赖性的两个操作的执行顺序,那么最后的执行结果就会被改变。这也就是不能进行重排序的原因。
- 写后读:
a = 1; b = a;
- 写后写:
a = 1; a = 2;
- 读后写:
a = b; b = 1;
重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行属性怒。但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
as-if-serial 语义
as-if-serial 语义指的是:“无论怎么重排序,(单线程)程序的执行结果不能被改变”。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器,runtime 和处理器共同为编写单线程程序的开发者穿件了一个幻觉:单线程程序是按程序编写的顺序来执行的。as-if-serial 语义使单线程开发者无需单行重排会干扰他们,也无需担心内存可见性问题。
重排序对多线程的影响
如果代码中存在控制依赖,会影响指令序列执行的并行度。因此,编译器和处理器会采用猜测(speculation)执行来克服控制的相关性。所以重排序破坏了程序的顺序规则(该规则是说指令顺序与实际代码的执行顺序是一致的,但是处理器和编译器会进行重排序,只要最后的结果不变,重排序就是合理的)。
先行发生原则(happens-before)
前面所说的内存交互原则都必须满足一定的规则,而 happens-before 就是定义这些规则时的一个等效判断的原则。happens-before 是 JMM 定义的、两个操作之间的偏序关系:如操作 A 线程发生于操作 B,则 A 产生的影响能够被 B 观察到,“影响”包括了修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足 happens-before 原则,那么就不需要进行同步操作,JVM 能够保证操作具有顺序性,此时不能随意进行重排序。否则,无法保证顺序性,就能进行指令的重排序。
happens-before 原则主要包括:
- 程序次序原则
- 管理锁定原则
- volatile 变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
详细释义参见 JMM 规范。
2.3.3 - CH03-JMM原则
Reference
3.1 Java内存模型的基础
3.1.1 并发编程模型的两个关键问题
在并发编程中,需要处理两个关键问题:线程之间的通信与同步。(这里所说的线程是指并发执行的活动实体)。通信是指线程之间如何交换信息。在命命令式编程中,线程之间的通信机制有两种:共享内存与消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
同步是指程序中用于控制不同线程间操作所发生的相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送操作一定是在消息的接收操作之前,因此同步是隐式进行的。
Java 的并发实现采用的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。
3.1.2 Java 内存模型的抽象结构
在 Java 中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”来代指实例域、静态域和数组元素)。局部变量、方法参数、异常处理器参数不会在线程之间共享(会被保存在对应执行线程的栈上),因此不存在内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都自己的私有本地内存,本地内存中存储了该线程用以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。 JMM 的抽象示意如图所示。
从上图来看,如果线程 A 与线程 B 之间要通信的话,必须要经历下面 2 个步骤:
- 线程 A 将本地内存 A 中被更新过的共享变量刷新到主内存中。
- 线程 B 到主内存中去读取由线程 A 之前更新过的共享变量。
下面通过示意图来说明这两个步骤。
如上图所示,本地内存 A 和本地内存 B 持有主内存中共享变量 x 的副本。假设初始时,这 3 个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。
3.1.3 从源代码到指令序列的重排序
为了在执行程序时提高性能,编译器和处理器常常会对指令做重排序。重排序分 3 种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句 的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上 去可能是在乱序执行。
从 Java 源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
上述第 1 步属于编译器重排序,2~3 步属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(并非所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
3.1.4 并发编程模型的分类
现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,从而减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所属的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面的表。
假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终可能得到 x=y=0 的结果。具体的原因如图所示。
这里处理器 A 和处理器 B 可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到 x=y=0 的结果。
从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器 A 执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是 A2→A1。此时,处理器 A 的内存操作顺序被重排序了(处理器 B 的情况和处理器 A 一样)。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此 现代的处理器都会允许对写-读操作进行重排序。
下表是常见处理器允许的重排序类型的列表。
元格中的 “N” 表示处理器不允许两个操作重排序,“Y” 表示允许重排序。
从上表我们可以看出:常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作应用重排序。sparc-TSO 和 X86 拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。
为了保证内存可见性,Java 编译器在所生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为4类,如表所示。
StoreLoad Barriers 是一个“全能型”的屏障,它同时具有其他 3 个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障的开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
3.1.5 happens-before 简介
从 JDK 5 开始,Java 使用新的 JSR-133 内存模型。JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
注意:两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens-before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如图所示。
如图所示,一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。对于 Java 程序员来说,happens-before 规则简单易懂,它避免 Java 程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法。
3.2 重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
3.2.1 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列 3 种类型,如表 3-4 所示。
上面 3 种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
前面提到过,编译器和处理器可能会对操作应用重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。
这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。
3.2.2 as-if-serial 语义
as-if-serial 语义是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、运行时和处理器都必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
上面 3 个操作的数据依赖关系如图 3-6 所示。
如图 3-6 所示,A 和 C 之间存在数据依赖关系,同时 B 和 C 之间也存在数据依赖关系。因此在最终执行的指令序列中,C 不能被重排序到 A 和 B 的前面(C 排到 A 和 B 的前面,程序的结果将会被改变)。但 A 和 B 之间没有数据依赖关系,编译器和处理器可以重排序 A 和 B 之间的执行顺序。 图 3-7 是该程序的两种执行顺序。
as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、运行时和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial 语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
3.2.3 程序顺序规则
根据 happens-before 的程序顺序规则,上面计算圆的面积的示例代码存在 3 个 happens-before 关系:
- A happens-before B。
- B happens-before C。
- A happens-before C。
这里的第 3 个 happens-before 关系,是根据 happens-before 的传递性推导出来的。
这里 A happens-before B,但在实际执行时 B 却可以排在 A 之前执行(看上面的重排序后的执行顺序)。如果 A happens-before B,JMM 并不要求 A 一定要在 B 之前执行。JMM 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作 A 的执行结果不需要对操作 B 可见;而且重排序操作 A 和操作 B 后的执行结果,与操作 A 和操作 B 按 happens-before 顺序执行的结果一致。在这种情况下,JMM 会认为这种重排序合法,因此 JMM 允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从 happens-before 的定义我们可以看出,JMM 同样遵从这一目标。
3.2.4 重排序对多线程的影响
现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码。
class RecordExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if(flag) { // 3
int i = a * a; // 4
}
}
}
flag 变量是个标记,用来标识变量 a 是否已被写入。这里假设有两个线程 A 和 B,A 首先执行 writer 方法,随后 B 线程接着执行 reader 方法。线程 B 在执行操作 4 时,能否看到线程 A 在操作 1 对共享变量 a 的写入呢?答案是不一定。
由于操作 1 和操作 2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作 3 和操作 4 没有数据依赖关系(有控制依赖关系),编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作 1 和操作 2 重排序时,可能会产生什么效果?请看下面的程序执行时序图,如图 38 所示。
如图 3-8 所示,操作 1 和操作 2 做了重排序。程序执行时,线程 A 首先写标记变量 flag,随后线程 B 读这个变量。由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还没有被线程 A 写入,在这里多线程程序的语义被重排序破坏了!
下面再让我们看看,当操作 3 和操作 4 重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作 3 和操作 4 重排序后,程序执行的时序图,如图 3-9 所示。
在程序中,操作 3 和操作 4 存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程 B 的处理器可以提前读取并计算 a * a
,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作 3 的条件判断为真时,就把该计算结果写入变量 i 中。
从图 3-9 中我们可以看出,猜测执行实质上对操作 3 和 4 做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是 as-if-serial 语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
3.3 顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
3.3.1 数据竞争与顺序一致性
当程序未正确同步时,就可能会存在数据竞争。Java内存模型规范对数据竞争的定义下:
- 在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。
当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。
JMM 对正确同步的多线程程序的内存一致性做了如下保证:
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。马上我们就会看到,这对于程序员来说是一个极强的保证。这里的同步是指广义上的同步,包括对常用同步原语(synchronized、volatile、final)的正确使用。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内 存模型中,每个操作都必须原子执行且立刻对所有线程可见。
顺序一致性内存模型为程序员提供的视图如图 3-10 所示。
在概念上,顺序一致性模型有一个单一的全局内存,该内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序来执行内存读/写操作。从上 面的示意图可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化(即在顺序一致性模型中,所有操作之间具有全序关系)。
为了更好进行理解,下面通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程 A 和 B 并发执行。其中A线程有 3 个操作,它们在程序中的顺序是: A1→A2→A3。B线程也有3个操作,它们在程序中的顺序是:B1→B2→B3。
假设这两个线程使用监视器锁来正确同步:A 线程的 3 个操作执行后释放监视器锁,随后 B 线程获取同一个监视器锁。那么程序在顺序一致性模型中的执行效果将如图 3-11 所示。
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图,如图 3-12 所示。
未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程 A 和 B 看到的执行顺序都是: B1→A1→A2→B2→A3→B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操 作必须立即对任意线程可见。
但是,在 JMM 中就没有这个保证。未同步程序在 JMM 中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
3.3.3 同步程序的顺序一致性效果
下面,对前面的示例程序 ReorderExample 用锁来同步,看看正确同步的程序如何具有顺序一致性。
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() {
a = 1;
flag = true;
}
public synchronized void reader() {
if(flag) {
int i = a;
...
}
}
}
在上面示例代码中,假设 A 线程执行 writer 方法后,B 线程执行 reader 方法。这是一个正确同步的多线程程序。根据 JMM 规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图,如图 3-13 所示。
顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM 会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程 A 在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程 B 根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到,JMM 在具体实现上的基本方针为:在不改变(正确同步的)程序执 行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
3.3.4 未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM 只提供最小安全性:线程执行时读取到的 值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM 保证线程读操作读取到的值不会无中生有的冒出来。为了实现最小安全性,JVM 在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM 内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。
JMM 不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为如果想要保证执行结果一致,JMM 需要禁止大量的处理器和编译器的优化,这对程序的执行 性能会产生很大的影响。而且未同步程序在顺序一致性模型中执行时,整体是无序的,其执行结果往往无法预知。而且,保证未同步程序在这两个模型中的执行结果一致没什么意义。
未同步程序在 JMM 中的执行时,整体上是无序的,其执行结果无法预知。未同步程序在两个模型中的执行特性有如下几个差异:
- 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而 JMM 不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。
- 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而 JMM 不保证所有线程能看到一致的操作执行顺序。
- 字撕裂。JMM 不保证对 64 位的 long/double 型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。
第 3 个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(Bus Transaction)。总线事务包括读事务和写事务。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线事务。在一个处理器执行总线事务期间,总线会禁止其他的处理器和 I/O 设备执行内存的读/写。下面,让我们通过一个示意图来说明总线的工作机制,如图3-14所示。
由图可知,假设处理器 A,B 和 C 同时向总线发起总线事务,这时总线仲裁会对竞争做出裁决,这里假设总线在仲裁后判定处理器 A 在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器 A 继续它的总线事务,而其他两个处理器则要等待 处理器 A 的总线事务完成后才能再次执行内存访问。假设在处理器 A 执行总线事务期间(无论读写),处理器 D 向总线发起了总线事务,此时处理器 D 的请求会被总线禁止。
总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写 操作具有原子性。
在一些 32 位的处理器上,如果要求对 64 位数据的写操作具有原子性,会有比较大的开销。为了照顾这种处理器,Java语 言规范鼓励但不强求 JVM 对 64 位的 long/double 型变量的写操作具有原子性。当 JVM 在这种处理器上运行时,可能会把一个 64 位 long/double 型变量的写操作拆分为两个 32 位的写操作来执行。这两个 32 位的写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的写操作将不具有原子性。
当单个内存操作不具有原子性时,可能会产生意想不到后果。请看示意图,如图 3-15 所示。
如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时,处理器 B 中 64 位的读操作被分配到单个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A “写了一半”的无效值。
注意,在 JSR-133 之前的旧内存模型中,一个 64 位 long/double 型变量的读/写操作可以被拆分为两个 32 位的读/写操作来执行。从 JSR-133 内存模型开始(JDK5),仅仅只允许把一个 64 位l ong/double 型变量的写操作拆分为两个 32 位的写操作来执行,任意的读操作在 JSR133 中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
3.4 volatile 的内存语义
当声明共享变量为 volatile 后,对这个变量的读/写将会很特别。为了揭开 volatile 的神秘面纱,下面将介绍 volatile 的内存语义及其实现。
3.4.1 volatile 的特性
理解 volatile 特性的一个好方法是把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下。
假设有多个线程分别调用上面程序的 3 个方法,这个程序在语义上和下面程序等价。
如上面示例程序所示,一个 volatile 变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。
锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入值。
锁的语义决定了临界区代码的执行具有原子性。这意味着,即使是 64 位的 long 型和double 型变量,只要它是 volatile 变量,对该变量的读/写就具有原子性。如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile 变量自身具有下列特性:
- 可见性。对一个 volatile 变量的读,总是能看到任意线程对这个 volatile 变量最后的写入值。
- 原子性。对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
3.4.2 volatile 写-读建立的 happens-before 关系
上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。
从 JSR-133 开始,volatile 变量的写-读可以实现线程之间的通信。
从内存语义的角度来说,volatile 的写-读与锁的释放-获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义。
请看下面使用 volatile 变量的示例代码。
假设线程 A 执行 writer 方法之后,线程 B 执行 reader 方法。根据 happens-before 规则,这个过程建立的 happens-before 关系可以分为 3 类:
- 根据程序次序规则,1 happens-before 2,3 happens-before 4。
- 根据 volatile 规则,2 happens-before 3。
- 根据 happens-before 的传递性规则,1 happens-before 4。
上述 happens-before 关系的图形化表现形式如下。
在上图中,每一个箭头链接的两个节点,代表了一个 happens-before 关系。黑色箭头表示 序顺序规则;橙色箭头表示 volatile 规则;蓝色箭头表示组合这些规则后提供的 happens-before 保证。
这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。
volatile 写-读的内存语义
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer 方法,随后线程 B 执行 reader 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。图 3-17 是线程 A 执行 volatile 写后,共享变量的状态示意图。
如图 3-17 所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。
volatile 读的内存语义如下。
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
图 3-18 为线程 B 读同一个 volatile 变量后,共享变量的状态示意图。
如图所示,在读 flag 变量后,本地内存 B 包含的值已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值变成一致。
如果我们把 volatile 写和 volatile 读两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。
下面对 volatile 写和 volatile 读的内存语义做个总结。
- 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
- 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程B发送消息。
3.4.4 volatile 内存语义的实现
前文提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。表 3-5 是 JMM 针对编译器制定的 volatile 重排序规则表。
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。
从表 3-5 我们可以看出。
- 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
- 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
- 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。
下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图,如图 3-19 所示。
图 3-19 中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。
这里比较有意思的是,volatile 写后面的 StoreLoad 屏障。此屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面是否需要插入一个 StoreLoad 屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现 volatile 的内存语义,JMM 在采取了保守策略:在每个 volatile 写的后面,或者在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 最终选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图,如图 3-20 所示。
图 3-20 中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。
上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。
针对 readAndWrite 方法,编译器在生成字节码时可以做如下的优化。
注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 X86 处理器为例,图 3-21 中除最后的 StoreLoad 屏障外,其他的屏障都会被省略。
前面保守策略下的 volatile 读和写,在 X86 处理器平台可以优化成如图 3-22 所示。
前文提到过,X86 处理器仅会对写-读操作做重排序。X86 不会对读-读、读-写和写-写操作做重排序,因此在 X86 处理器中会省略掉这 3 种操作类型对应的内存屏障。在 X86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写-读的内存语义。这意味着在 X86 处理器中,volatile 写的开销比volatile 读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133 为什么要增强 volatile 的内存语义
在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行,如图 3-23 所示。
在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。
因此,在旧的内存模型中,volatile 的写-读没有锁的释放-获所具有的内存语义。为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替锁,请一定谨慎,具体详情请参 阅 Brian Goetz 的文章。
3.5 锁的内存语义
众所周知,锁可以让临界区互斥执行。这里将介绍锁的另一个同样重要,但常常被忽视的 功能:锁的内存语义。
3.5.1 锁的释放-获取建立的 happens-before 关系
锁是 Java 并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
下面是锁释放-获取的示例代码。
假设线程 A 执行 writer 方法,随后线程 B 执行 reader 方法。根据 happens-before 规则,这个过程包含的 happens-before 关系可以分为 3 类。
- 根据程序次序规则,1 happens-before 2, 2 happens-before 3, 4 happens-before 5, 5 happens-before 6。
- 根据监视器锁规则,3 happens-before 4。
- 根据 happens-before 的传递性,2 happens-before 5。
上述 happens-before 关系的图形化表现形式如图 3-24 所示。
在图 3-24 中,每一个箭头链接的两个节点,代表了一个 happens-before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens-before 保证。
图 3-24 表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens-before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。
3.5.2 锁的释放和获取的内存语义
当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。以上 面的 MonitorExample 程序为例,A 线程释放锁后,共享数据的状态示意图如图 3-25 所示。
当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量。图 3-26 是锁获取的状态示意图。
对比锁释放-获取的内存语义与 volatile 写-读的内存语义可以看出:锁释放与 volatile 写有着相同的内存语义;锁获取与 volatile 读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结。
- 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息。
- 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息。
- 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。
3.5.3 锁内存语义的实现
本文将借助 ReentrantLock 的源代码,来分析锁内存语义的具体实现机制。请看下面的示例代码。
在 ReentrantLock 中,调用 lock 方法获取锁;调用 unlock 方法释放锁。
ReentrantLock 的实现依赖于 Java 同步器框架AbstractQueuedSynchronizer。AQS 使用一个整型的 volatile 变量(命名为 state)来维护同步状态,马上我们会看到,这个 volatile 变量是 ReentrantLock 内存语义实现的关键。
图 3-27 是 ReentrantLock 的类图(仅画出与本文相关的部分)。
ReentrantLock 分为公平锁和非公平锁,我们首先分析公平锁。使用公平锁时,加锁方法 lock 调用轨迹如下。
- ReentrantLock:lock()。
- FairSync:lock()。
- AbstractQueuedSynchronizer:acquire(int arg)。
- ReentrantLock:tryAcquire(int acquires)。
在第 4 步真正开始加锁,下面是该方法的源代码。
从上面源代码中我们可以看出,加锁方法首先读 volatile 变量 state。在使用公平锁时,解锁方法 unlock 调用轨迹如下。
- ReentrantLock:unlock()。
- AbstractQueuedSynchronizer:release(int arg)。
- Sync:tryRelease(int releases)。
在第 3 步真正开始释放锁,下面是该方法的源代码。
从上面的源代码可以看出,在释放锁的最后写 volatile 变量 state。
公平锁在释放锁的最后写 volatile 变量 state,在获取锁时首先读这个 volatile 变量。根据 volatile 的 happens-before 规则,释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变得对获取锁的线程可见。
现在我们来分析非公平锁的内存语义的实现。非公平锁的释放和公平锁完全一样,所以这里仅仅分析非公平锁的获取。使用非公平锁时,加锁方法 lock 调用轨迹如下。
- ReentrantLock:lock()。
- NonfairSync:lock()。
- AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)。
在第 3 步真正开始加锁,下面是该方法的源代码。
该方法以原子操作的方式更新 state 变量,本文把 Java 的 compareAndSet 方法调用简称为 CAS。JDK 文档对该方法的说明如下:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。此操作具有 volatile 读和写的内存语义。
这里我们分别从编译器和处理器的角度来分析,CAS 如何同时具有 volatile 读和 volatile 写的内存语义。
前文我们提到过,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。组合这两个条件,意味着为了同时实现 volatile 读和 volatile 写的内存语义,编译器不能对 CAS 与 CAS 前面和后面的任意内存操作重排序。
下面我们来分析在常见的 intel X86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。
下面是 sun.misc.Unsafe 类的 compareAndSwapInt() 方法的源代码。
public final native boolean compareAndSwapInt(Object o, long offset)
可以看到,这是一个本地方法调用。
如上面源代码所示,程序会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略 lock 前缀(单处理器自身会维护单处理器内的顺序一致性,不需要 lock 前缀提供的内存屏障效果)。
intel 的手册对 lock 前缀的说明如下。
- 确保对内存的读-改-写操作原子执行。在 Pentium 及 Pentium 之前的处理器中,带有 lock 前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从 Pentium 4、Intel Xeon 及 P6 处理器开始,Intel 使用缓存锁定(Cache Locking)来保证指令执行的原子性。缓存锁定将大大降低 lock 前缀指令的执行开销。
- 禁止该指令,与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读写的内存语义。
经过上面的分析,现在我们终于能明白为什么 JDK 文档说 CAS 同时具有 volatile 读写的内存语义了。
现在对公平锁和非公平锁的内存语义做个总结。
- 公平锁和非公平锁释放时,最后都要写一个 volatile 变量 state。
- 公平锁获取时,首先会去读 volatile 变量。
- 非公平锁获取时,首先会用 CAS 更新 volatile 变量,这个操作同时具有 volatile 读写的内存语义。
从本文对 ReentrantLock 的分析可以看出,锁释放-获取的内存语义的实现至少有下面两种方式。
- 利用 volatile 变量的写-读所具有的内存语义。
- 利用 CAS 所附带的 volatile 读写的内存语义。
3.5.4 JUC 包的实现
由于 Java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面 4 种方式。
- A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
- A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
- A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。
Java 的 CAS 会使用现代处理器上提供的高效机器级别的原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信。把这些特性整合在一起,就形成了整个 JUC 包得以实现的基石。如果我们仔细分析 JUC 包的源代码实现,会发现一个通用化的实现模式。
- 首先,声明共享变量为 volatile。
- 然后,使用 CAS 的原子条件更新来实现线程之间的同步。
- 同时,配合以 volatile 的读/写和 CAS 所具有的 volatile 读和写的内存语义来实现线程之间的通信。
AQS,非阻塞数据结构和原子变量类,这些 JUC 包中的基础类都是使用这种模式来实现的,而 JUC 包中的高层类又是依赖于这些基础类来实现的。从整体来看,JUC 包的实现示意图如 3-28 所示。
3.6 final域的内存语义
与前面介绍的锁和 volatile 相比,对 final 域的读和写更像是普通的变量访问。下面将介绍 final 域的内存语义。
3.6.1 final 域的重排序规则
对于 final 域,编译器和处理器要遵守两个重排序规则。
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
下面通过一些示例性的代码来分别说明这两个规则。
这里假设一个线程 A 执行 writer 方法,随后另一个线程 B 执行 reader 方法。下面我们通过这两个线程的交互来说明这两个规则。
3.6.2 写 final 域的重排序规则
写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面。
- JMM 禁止编译器把 final 域的写重排序到构造函数之外。
- 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。
现在让我们分析 writer 方法。writer 方法只包含一行代码:finalExample=new FinalExample()
。这行代码包含两个步骤,如下。
- 构造一个 FinalExample 类型的对象。
- 把这个对象的引用赋值给引用变量 obj。
假设线程 B 读对象引用与读对象的成员域之间没有重排序,图 3-29 是一种可能的执行时序。
在图 3-29 中,写普通域的操作被编译器重排序到了构造函数之外,读线程 B 错误地读取了普通变量 i 初始化之前的值。而写 final 域的操作,被写 final 域的重排序规则“限定”在了构造函数之内,读线程 B 正确地读取了 final 变量初始化之后的值。
写 final 域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化过了,而普通域不具有这个保障。以上图为例,在读线程 B “看到”对象引用 obj 时,很可能 obj 对象还没有构造完成(对普通域i的写操作被重排序到构造函数外,此时初始值 1 还 没有写入普通域 i)。
3.6.3 读 final 域的重排序规则
读 final 域的重排序规则是,在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器的。
reader 方法包含 3 个操作。
- 初次读引用变量 obj。
- 初次读引用变量 obj 指向对象的普通域 j。
- 初次读引用变量 obj 指向对象的 final 域 i。
现在假设写线程 A 没有发生任何重排序,同时程序在不遵守间接依赖的处理器上执行,图 3-30 所示是一种可能的执行时序。
在图 3-30 中,读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该 域还没有被写线程 A 写入,这是一个错误的读取操作。而读 final 域的重排序规则会把读对象 final 域的操作“限定”在读对象引用之后,此时该 final 域已经被 A 线程初始化过了,这是一个正确的读取操作。
读 final 域的重排序规则可以确保:在读一个对象的 final 域之前,一定会先读包含这个 final 域的对象的引用。在这个示例程序中,如果该引用不为 null,那么引用对象的 final 域一定已经被 A 线程初始化过了。
3.6.4 final域为引用类型
上面我们看到的 final 域是基础数据类型,如果 final 域是引用类型,将会有什么效果?请看下列示例代码。
本例 final 域为一个引用类型,它引用一个 int 型的数组对象。对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
对上面的示例程序,假设首先线程 A 执行 writerOne() 方法,执行完后线程 B 执行 writerTwo() 方法,执行完后线程 C 执行 reader() 方法。图 3-31 是一种可能的线程执行时序。
在图 3-31 中,1 是对 final 域的写入,2 是对这个 final 域引用的对象的成员域的写入,3 是把被构造的对象的引用赋值给某个引用变量。这里除了前面提到的 1 不能和 3 重排序外,2 和 3 也不能重排序。
JMM 可以确保读线程 C 至少能看到写线程 A 在构造函数中对 final 引用对象的成员域的写入。即 C 至少能看到数组下标 0 的值为 1。而写线程 B 对数组元素的写入,读线程 C 可能看得到,也可能看不到。JMM 不保证线程 B 的写入对读线程 C 可见,因为写线程 B 和读线程 C 之间存在数据竞争,此时的执行结果不可预知。
如果想要确保读线程 C 看到写线程B对数组元素的写入,写线程B和读线程 C 之间需要使用同步原语(lock/volatile)来确保内存可见性。
3.6.5 为什么 final 引用不能从构造函数内“溢出”
前面我们提到过,写 final 域的重排序规则可以确保:在引用变量为任意线程可见之前,该引用变量指向的对象的 final 域已经在构造函数中被正确初始化过了。其实,要得到这个效果,还需要一个保证:在构造函数内部,不能让这个被构造对象的引用为其他线程所见,也就是对象引用不能在构造函数中“逸出”。为了说明问题,让我们来看下面的示例代码。
假设一个线程 A 执行 writer() 方法,另一个线程 B 执行 reader() 方法。这里的操作 2 使得对象还未完成构造前就为线程 B 可见。即使这里的操作 2 是构造函数的最后一步,且在程序中操作 2 排在操作 1 后面,执行 read() 方法的线程仍然可能无法看到 final 域被初始化后的值,因为这里的操作 1 和操作 2 之间可能被重排序。实际的执行时序可能如图 3-32 所示。
从图 3-32 可以看出:在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的 final 域可能还没有被初始化。在构造函数返回后,任意线程都将保证能看到 final 域正确初始化之后的值。
3.6.6 final 语义在处理器中的实现
现在我们以 X86 处理器为例,说明 final 语义在处理器中的具体实现。
上面我们提到,写 final 域的重排序规则会要求编译器在 final 域的写之后,构造函数 return 之前插入一个 StoreStore 障屏。读 final 域的重排序规则要求编译器在读 final 域的操作前面插入一个 LoadLoad 屏障。
由于 X86 处理器不会对写-写操作做重排序,所以在 X86 处理器中,写 final 域需要的 StoreStore 障屏会被省略掉。同样,由于 X86 处理器不会对存在间接依赖关系的操作做重排序,所以在 X86 处理器中,读 final 域需要的 LoadLoad 屏障也会被省略掉。也就是说,在 X86 处理器中,final 域的读/写不会插入任何内存屏障!
3.6.7 JSR-133 为什么要增强 final 的语义
在旧的 Java 内存模型中,一个最严重的缺陷就是线程可能看到 final 域的值会改变。比如,一个线程当前看到一个整型 final 域的值为 0(还未初始化之前的默认值),过一段时间之后这个线程再去读这个 final 域的值时,却发现值变为 1(被某个线程初始化之后的值)。最常见的例子就是在旧的 Java 内存模型中,String 的值可能会改变。
为了修补这个漏洞,JSR-133 专家组增强了 final 的语义。通过为 final 域增加写和读重排序规则,可以为 Java 程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有“逸出”),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保证任意线程都能看到这个 final 域在构造函数中被初始化之后的值。
3.7 happens-before
happens-before 是 JMM 最核心的概念。对应 Java 程序员来说,理解 happens-before 是理解 JMM 的关键。
3.7.1 JMM 的设计
首先,让我们来看 JMM 的设计意图。从 JMM 设计者的角度,在设计 JMM 时,需要考虑两个关键因素。
- 程序员对内存模型的使用。程序员希望内存模型易于理解、易于编程。程序员希望基于 一个强内存模型来编写代码。
- 编译器和处理器对内存模型的实现。编译器和处理器希望内存模型对它们的束缚越少越 好,这样它们就可以做尽可能多的优化来提高性能。编译器和处理器希望实现一个弱内存模型。
由于这两个因素互相矛盾,所以 JSR-133 专家组在设计 JMM 时的核心目标就是找到一个好的平衡点:一方面,要为程序员提供足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地放松。下面让我们来看 JSR-133 是如何实现这一目标的。
上面计算圆的面积的示例代码存在 3 个 happens-before 关系,如下。
- A happens-before B。
- B happens-before C。
- A happens-before C。
在 3 个 happens-before 关系中,2 和 3 是必需的,但 1 是不必要的。因此,JMM 把 happens-before 要求禁止的重排序分为了下面两类。
- 会改变程序执行结果的重排序。
- 不会改变程序执行结果的重排序。
JMM对这两种不同性质的重排序,采取了不同的策略,如下。
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
- 对于不会改变程序执行结果的重排序,JMM 对编译器和处理器不做要求(JMM 允许这种 重排序)。
图 3-33 是 JMM 的设计示意图。
从图 3-33 可以看出两点,如下。
- JMM 向程序员提供的 happens-before 规则能满足程序员的需求。JMM 的happens-before 规则不但简单易懂,而且也向程序员提供了足够强的内存可见性保证(有些内存可见性保证其实并不一定真实存在,比如上面的 A happens-before B)。
- JMM 对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个 volatile 变量只会被单个线程访问,那么编译器可以把这个 volatile 变量当作一个普通变量来对待。这些 优化既不会改变程序的执行结果,又能提高程序的执行效率。
3.7.2 happens-before 的定义
happens-before 的概念最初由 Leslie Lamport 在其一篇影响深远的论文(Time,Clocks and the Ordering of Events in a Distributed System)中提出。Leslie Lamport 使用 happens-before 来定义分布式系统中事件之间的偏序关系(partial ordering)。Leslie Lamport 在这篇论文中给出了一个分布式算法,该算法可以将该偏序关系扩展为某种全序关系。
JSR-133 使用 happens-before 的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。因此,JMM 可以通过 happens-before 关系向程序员提供跨线程的内存可见性保证(如果 A 线程的写操作 a 与 B 线程的读操作 b 之间存在 happens-before 关系,尽管 a 操作和 b 操作在不同的线程中执行,但 JMM 向程序员保证 a 操作将对 b 操作可见)。
JSR-133 对 happens-before 关系的定义如下:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
上面的第一点是 JMM 对程序员的承诺。从程序员的角度来说,可以这样理解 happens-before 关系:如果 A happens-before B,那么 Java 内存模型将向程序员保证—— A 操作的结果将对 B 可见, 且A的执行顺序排在 B 之前。注意,这只是 Java 内存模型向程序员做出的保证!
上面的第二点是 JMM 对编译器和处理器重排序的约束原则。正如前面所言,JMM 其实是在遵循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM 这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before 关系本质上和 as-if-serial 语义是一回事。
- as-if-serial 语义保证单线程内程序的执行结果不被改变,happens-before 关系保证正确同步的多线程程序的执行结果不被改变。
- as-if-serial 语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before 关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按 happens-before 指定的顺序来执行的。
as-if-serial 语义和 happens-before 这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
3.7.3 happens-before 规则
JSR-133 定义了如下 happens-before 规则。
- 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start() 规则:如果线程A执行操作 ThreadB.start()(启动线程B),那么 A 线程的 ThreadB.start() 操作 happens-before 于线程B中的任意操作。
- join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回。
这里的规则 1~4 前面都讲到过,这里再做个总结。由于 2 和 3 情况类似,这里只以1、3 和 4 为例来说明。图 3-34 是 volatile 写-读建立的 happens-before 关系图。
结合图 3-34,我们做以下分析。
- 1 happens-before 2 和 3 happens-before 4 由程序顺序规则产生。由于编译器和处理器都要遵守 as-if-serial 语义,也就是说,as-if-serial 语义保证了程序顺序规则。因此,可以把程序顺序规则看成是对 as-if-serial 语义的“封装”。
- 2 happens-before 3 是由 volatile 规则产生。前面提到过,对一个 volatile 变量的读,总是能看到“任意线程”之前对这个 volatile 变量最后的写入。因此,volatile 的这个特性可以保证实现 volatile 规则。
- 1 happens-before 4 是由传递性规则产生的。这里的传递性是由 volatile 的内存屏障插入策略和 volatile 的编译器重排序规则共同来保证的。
下面我们来看 start() 规则。假设线程 A 在执行的过程中,通过执行 ThreadB.start() 来启动线程 B;同时,假设线程 A 在执行 ThreadB.start() 之前修改了一些共享变量,线程 B 在开始执行后会 读这些共享变量。图 3-35 是该程序对应的 happens-before 关系图。
在图 3-35 中,1 happens-before 2 由程序顺序规则产生。2 happens-before 4 由 start() 规则产 生。根据传递性,将有 1 happens-before 4。这实意味着,线程 A 在执行 ThreadB.start() 之前对共享变量所做的修改,接下来在线程 B 开始执行后都将确保对线程B可见。
下面我们来看 join() 规则。假设线程A在执行的过程中,通过执行 ThreadB.join() 来等待线程 B 终止;同时,假设线程 B 在终止之前修改了一些共享变量,线程 A 从 ThreadB.join() 返回后会 读这些共享变量。图 3-36 是该程序对应的 happens-before 关系图。
在图 3-36 中,2 happens-before 4 由 join() 规则产生;4 happens-before 5 由程序顺序规则产生。根据传递性规则,将有 2 happens-before 5。这意味着,线程 A 执行操作 ThreadB.join() 并成功返回后,线程 B 中的任意操作都将对线程 A 可见。
3.8 双重检查锁定与延迟初始化
在 Java 多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。本文将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。
3.8.1 双重检查锁定的由来
在 Java 程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码。
在 UnsafeLazyInitialization 类中,假设A线程执行代码 1 的同时,B 线程执行代码 2。此时,线程 A 可能会看到 instance 引用的对象还没有完成初始化。
对于 UnsafeLazyInitialization 类,我们可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化。示例代码如下。
由于对 getInstance() 方法做了同步处理,synchronized 将导致性能开销。如果 getInstance() 方法被多个线程频繁的调用,将会导致程序执行性能的下降。反之,如果 getInstance() 方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的性能。
在早期的 JVM 中,synchronized(甚至是无竞争的 synchronized)存在巨大的性能开销。因此,人们想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。
如上面代码所示,如果第一次检查 instance 不为 null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低 synchronized 带来的性能开销。上面代码表面上看起来,似乎两全其美。
- 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
- 在对象创建好之后,执行 getInstance() 方法将不需要获取锁,直接返回已创建好的对象。
双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第 4 行,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。
3.8.2 问题的根源
前面的双重检查锁定示例代码的第 7 行创建了一个对象。这一行代码可以分解为如下的 3 行伪代码。
上面 3 行伪代码中的 2 和 3 之间,可能会被重排序(在一些 JIT 编译器上,这种重排序是真实发生的,详情见参考文献 1 的“Out-of-order writes”部分)。2 和 3 之间重排序之后的执行时序如下。
根据 JLS,所有线程在执行 Java 程序时必须要遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics 允许那些在单线程内、不会改变单线程程序执行结果的重排序。上面 3 行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。
为了更好地理解 intra-thread semantics,请看如图 3-37 所示的示意图(假设一个线程 A 在构造对象后,立即访问这个对象)。
如图 3-37 所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。
下面,再让我们查看多线程并发执行的情况。如图 3-38 所示。
由于单线程内要遵守 intra-thread semantics,从而能保证 A 线程的执行结果不会被改变。但是,当线程 A 和 B 按图 3-38 的时序执行时,B 线程将看到一个还没有被初始化的对象。
回到本文的主题,DoubleCheckedLocking 示例代码的第 7 行如果发生重排序,另一个并发执行的线程B就有可能在第 4 行判断 instance 不为 null。线程 B 接下来将 访问 instance 所引用的对象,但此时这个对象可能还没有被 A 线程初始化!表 3-6 是这个场景的具体执行时序。
这里 A2 和 A3 虽然重排序了,但 Java 内存模型的 intra-thread semantics 将确保 A2 一定会排在 A4 前面执行。因此,线程 A 的 intra-thread semantics 没有改变,但 A2 和 A3 的重排序,将导致线程 B 在 B1 处判断出 instance 不为空,线程 B 接下来将访问 instance 引用的对象。此时,线程 B 将会访问到一个还未初始化的对象。
在知晓了问题发生的根源之后,我们可以想出两个办法来实现线程安全的延迟初始化。
- 不允许 2 和 3 重排序。
- 允许 2 和 3 重排序,但不允许其他线程“看到”这个重排序。
后文介绍的两个解决方案,分别对应于上面这两点。
3.8.3 基于volatile的解决方案
对于前面的基于双重检查锁定来实现延迟初始化的方案,只需要做一点小的修改(把 instance 声明为 volatile 型),就可以实现线程安全的延迟初始化。请看下面的示例代码。
当声明对象的引用为 volatile 后,3.8.2 节中的 3 行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行,如图 3-39 所示。
这个方案本质上是通过禁止图 3-39 中的 2 和 3 之间的重排序,来保证线程安全的延迟初始化。
3.8.4 基于类初始化的解决方案
JVM 在类的初始化阶段(即在 Class 被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为 Initialization On Demand Holder idiom)。
假设两个线程并发执行 getInstance() 方法,下面是执行的示意图,如图 3-40 所示。
这个方案的实质是:允许 3.8.2 节中的 3 行伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到”这个重排序。
初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型 T 将被立即初始化。
- T 是一个类,而且一个T类型的实例被创建。
- T 是一个类,且 T 中声明的一个静态方法被调用。
- T 中声明的一个静态字段被赋值。
- T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
- T 是一个顶级类(Top Level Class),而且一个断言语句嵌套在 T 内部被执行。
在 InstanceFactory 示例代码中,首次执行 getInstance() 方法的线程将导致 InstanceHolder 类被初始化(符合情况 4)。
由于 Java 语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用 getInstance() 方法来初始化 InstanceHolder 类)。因此,在Java 中初始化一个类或者接口时,需要做细致的同步处理。
Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java 语言规范允许 JVM 的具体实现在这里做一些优化,见后文的说明)。
对于类或接口的初始化,Java 语言规范制定了精巧而复杂的类初始化处理过程。Java 初始化一个类或接口的处理过程如下。这里对类初始化处理过程的说明,省略了与本文无关的部分;同时为了更好的说明类初始化过程中的同步处理机制,笔者人为的把类初始化的处理过程分为了 5 个阶段。
第1阶段:通过在 Class 对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
假设 Class 对象当前还没有被初始化(初始化状态 state,此时被标记为 state=noInitialization),且有两个线程 A 和 B 试图同时初始化这个 Class 对象。图 3-41 是对应的示意图。
表 3-7 是这个示意图的说明。
第 2 阶段:线程 A 执行类的初始化,同时线程 B 在初始化锁对应的 condition 上等待。
表 3-8 是这个示意图的说明。
第 3 阶段:线程 A 设置 state=initialized,然后唤醒在 condition 中等待的所有线程。
表 3-9 是这个示意图的说明。
第 4 阶段:线程B结束类的初始化处理。
线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放初始化锁;线程 B 在第 4 阶段的 B1 获取同一个初始化锁,并在第 4 阶段的 B4 之后才开始访问这个类。根据 Java 内存模型规范的锁规则,这里将存在如下的 happens-before 关系。
这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作(执行类的静态初始化和初始化类中声明的静态字段),线程 B 一定能看到。
第 5 阶段:线程 C 执行类的初始化的处理。
表 3-11 是这个示意图的说明。
在第 3 阶段之后,类已经完成了初始化。因此线程 C 在第 5 阶段的类初始化处理过程相对简单一些(前面的线程 A 和 B 的类初始化处理过程都经历了两次锁获取-锁释放,而线程 C 的类初始化处理只需要经历一次锁获取-锁释放)。
线程 A 在第 2 阶段的 A1 执行类的初始化,并在第 3 阶段的 A4 释放锁;线程 C 在第 5 阶段的 C1 获取同一个锁,并在在第 5 阶段的 C4 之后才开始访问这个类。根据 Java 内存模型规范的锁规则,将存在如下的 happens-before 关系。
这个 happens-before 关系将保证:线程 A 执行类的初始化时的写入操作,线程 C 一定能看到。
通过对比基于 volatile 的双重检查锁定的方案和基于类初始化的方案,我们会发现基于类初始化的方案的实现代码更简洁。但基于 volatile 的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用上面介绍的基于 volatile 的延迟初始化的方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
3.9 Java 内存模型综述
前面对 Java 内存模型的基础知识和内存模型的具体实现进行了说明。下面对 Java 内存模型的相关知识做一个总结。
3.9.1 处理器的内存模型
顺序一致性内存模型是一个理论参考模型,JMM 和处理器内存模型在设计时通常会以顺 序一致性内存模型为参照。在设计时,JMM 和处理器内存模型会对顺序一致性模型做一些放松,因为如果完全按照顺序一致性模型来实现处理器和 JMM,那么很多的处理器和编译器优化都要被禁止,这对执行性能将会有很大的影响。
根据对不同类型的读/写操作组合的执行顺序的放松,可以把常见处理器的内存模型划分 为如下几种类型。
- 放松程序中写-读操作的顺序,由此产生了 Total Store Ordering 内存模型,简称为TSO。
- 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了 Partial Store Order 内存模型,简称为PSO。
- 在前面两条的基础上,继续放松程序中读-写和读-读操作的顺序,由此产生了 Relaxed Memory Order 内存模型(简称为 RMO)和 PowerPC 内存模型。
注意,这里处理器对读/写操作的放松,是以两个操作之间不存在数据依赖性为前提的。因 为处理器要遵守 as-if-serial 语义,处理器不会对存在数据依赖性的两个内存操作做重排序。
表 3-12 展示了常见处理器内存模型的细节特征如下。
从表 3-12 中可以看到,所有处理器内存模型都允许写-读重排序,原因在第 1 章已经说明过:它们都使用了写缓存区。写缓存区可能导致写-读操作重排序。同时,我们可以看到这些处理器内存模型都允许更早读到当前处理器的写,原因同样是因为写缓存区。由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。
表 3-12 中的各种处理器内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计得会越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。
由于常见的处理器内存模型比JMM要弱,Java 编译器在生成字节码时,会在执行指令序 列的适当位置插入内存屏障来限制处理器的重排序。同时,由于各种处理器内存模型的强弱不同,为了在不同的处理器平台向程序员展示一个一致的内存模型,JMM 在不同的处理器中需要插入的内存屏障的数量和种类也不相同。图 3-48 展示了 JMM 在不同处理器内存模型中需要插入的内存屏障的示意图。
JMM 屏蔽了不同处理器内存模型的差异,它在不同的处理器平台之上为 Java 程序员呈现了一个一致的内存模型。
3.9.2 各种内存模型之间的关系
JMM 是一个语言级的内存模型,处理器内存模型是硬件级的内存模型,顺序一致性内存 模型是一个理论参考模型。下面是语言内存模型、处理器内存模型和顺序一致性内存模型的 强弱对比示意图,如图 3-49 所示。
从图中可以看出:常见的 4 种处理器内存模型比常用的 3 中语言内存模型要弱,处理器内存模型和语言内存模型都比顺序一致性内存模型要弱。同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。
3.9.3 JMM 的内存可见性保证
按程序类型,Java 程序的内存可见性保证可以分为下列 3 类。
- 单线程程序。单线程程序不会出现内存可见性问题。编译器、运行时和处理器会共同确 保单线程程序的执行结果与该程序在顺序一致性模型中的执行结果相同。
- 正确同步的多线程程序。正确同步的多线程程序的执行将具有顺序一致性(程序的执行 结果与该程序在顺序一致性内存模型中的执行结果相同)。这是 JMM 关注的重点,JMM 通过限制编译器和处理器的重排序来为程序员提供内存可见性保证。
- 未同步/未正确同步的多线程程序。JMM 为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
注意,最小安全性保障与 64 位数据的非原子性写并不矛盾。它们是两个不同的概念,它 们“发生”的时间点也不同。最小安全性保证对象默认初始化之后(设置成员域为0、null或 false),才会被任意线程使用。最小安全性“发生”在对象被任意线程使用之前。64 位数据的非原子性写“发生”在对象被多个线程使用的过程中(写共享变量)。当发生问题时(处理器 B 看到仅仅被处理器 A “写了一半”的无效值),这里虽然处理器 B 读取到一个被写了一半的无效值,但这个值仍然是处理器 A 写入的,只不过是处理器 A 还没有写完而已。最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。但最小安全性并不保证 线程读取到的值,一定是某个线程写完后的值。最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。
图 3-50 展示了这 3 类程序在 JMM 中与在顺序一致性内存模型中的执行结果的异同。
只要多线程程序是正确同步的,JMM 保证该程序在任意的处理器平台上的执行结果,与 该程序在顺序一致性内存模型中的执行结果一致。
3.9.4 JSR-133 对旧内存模型的修补
JSR-133 对 JDK 5 之前的旧内存模型的修补主要有两个。
- 增强 volatile 的内存语义。旧内存模型允许 volatile 变量与普通变量重排序。JSR-133 严格限制 volatile 变量与普通变量的重排序,使 volatile 的写-读和锁的释放-获取具有相同的内存语义。
- 增强 final 的内存语义。在旧内存模型中,多次读取同一个 final 变量的值可能会不相同。为此,JSR-133 为 final 增加了两个重排序规则。在保证 final 引用不会从构造函数内逸出的情况下,final 具有了初始化安全性。
2.3.4 - CH04-JSR133-FAQ
什么是内存模型
在多核系统中,处理器一般拥有一层或多层缓存,这些缓存通过加速数据访问(因为数据距离处理器更近)和减少共享内存在总线上的通讯(因为通过本地缓存来满足许多内存操作)来提高 CPU 性能。缓存能够大大提升性能,但同时也带来了很多挑战。例如,当两个 CPU 同时检查相同的内存地址时会发生什么呢?在什么样的条件下它们能够看到相同的值?
在处理器层面,内存模型定义了一个充要条件,“让当前的处理器可以看到其他处理器写入到内存的数据”,以及“其他处理器可以看到当前处理器写入到内存的数据”。有些处理器拥有很强的内存模型(strong memory model),能够让所有处理器在任何时候从任何指定的内存地址上看到完全相同的值。而另外一些处理器则有较弱的内存模型(weaker memory model),在这种处理器中,必须使用内存屏障(一种特殊的指令)来刷新本地处理器缓存并使本地处理器缓存无效,目的是为了让当前处理器能够看到其他处理器的写操作,或者让其他处理器看到当前处理器的写操作。这些内存屏障通常在 lock 和 unlock 操作的时候完成。内存屏障在高级语言中对开发者是不可见的。
在强内存模型下,编写程序有时会很容易,因为减少了对内存屏障的依赖。但是即使在一些最强的内存模型下,内存屏障仍然是必须的。设置内存屏障往往与我们的直觉并不一致。近来处理器设计的趋势更偏向于弱的内存模型,因为弱内存模型削弱了缓存一致性,所以在多处理器平台和更大容量的内存下可以实现更好的可伸缩性。
“一个线程的写操作对其他线程可见”,该问题是因为编译器对代码进行重排序导致的。例如,只要代码的移动不会改变程序的语义,当编译器认为向下移动一个写操作会更有效的时候,编译器就会对代码执行移动。如果编译器推迟执行一个操作,其他线程可以能在这个操作执行完成之前都不会看到该操作的结果,这反映了缓存的影响。
此外,写入内存的操作能够被移动到程序中更靠前的位置。这种情况下,其他线程在程序中可能看到一个比实际发生更早的写操作。所有这些灵活性的设计都是为了通过给编译器、运行时或硬件提供灵活性以使其能够在最佳顺序的情况下来执行操作。在内存模型的限定下,我们能够获得更高的性能。
考虑如下示例:
ClassReordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
假设在两个并发线程中执行这段代码,读取变量 Y 将会得到 2。因为这个写入比写入到变量 X 的操作更晚一些,程序员可能认为读取变量 X 将一定会得到 1。但是,写入操作可能被重排序。如果发生了重排序,那么,就能发生对变量 Y 的写入操作,读取两个变量的操作紧随其后,而且写入到变量 X 的操作能够发生。程序的结果可能是 r1==2, r2==0。
Java 内存模型描述了在多线程中哪些行为是合法的,以及线程如果通过内存进行交互。它描述了“程序中的变量”与“从内存或寄存器获取或存储它们的底层细节”之间的关系。Java 内存模型通过使用各种硬件和编译器优化来正确实现以上能力。
Java 包含了几个语言级别的关键字,包括:volatile、final、synchronized,目的是为了帮助开发者向编译器描述一个程序的并发需求。Java 内存模型定义了 volatile 和 synchronized 的行为,更重要的是,保证了通过的 Java 程序在所有测处理器架构下都能正确运行。
其他语言有内存模型吗
大部分其他的语言,如 C/C++,都没有被设计成直接支持多线程。这些语言对于发生在编译器和处理器平台架构的重排序行为的保护机制会严重依赖于程序中使用的线程库(如 pthreads)、编译器,以及代码所运行的硬件平台所提供的保障,尤其是 CPU 架构。
JSR 133 是什么
从 1997 年以来,人们不断发现 Java 语言规范的第 17 章定义的 Java 内存模型中存在一些严重的缺陷。这些缺陷会导致一些使人迷惑的行为(如 final 字段会被观察到值的变化)和破坏编译器常见的优化功能。
Java 内存模型是一个雄心勃勃的计划,它是编程语言规第一次尝试合并一个能够在各种处理器架构中为并发提供一致语义的内存模型。不过,定义一个既一致又直观的内存模型远比想象的要难。JSR 133 和 Java 语言定义了一个新的内存模型,它修复了早期内存模型中的缺陷。为了实现 JSR133,final 和 volatile 的语义需要被重新定义。
完整的定义见文档,但是正式的语义不是小心翼翼的,它是令人惊讶的清醒的,目的是让人们意识到一些看似简单的概念(如同步)其实有多复杂。幸运的是,你不需要懂得这些正式定义的细节——JSR 133 的目的是创建一组正式语义,这些语义提供了描述 volatile、synchronized、final 如何正确工作的直观框架。
JSR 133 的目标包括:
- 保留已经存在的安全保证(如类型安全)并强化其他的安全保证。例如,变量值不能凭空创建:线程观察到的每个变量的值必须是被其他线程合理创建的。
- 正确同步的程序的语义应该尽量简单和直观。
- 应该定义未完成或为正确同步的程序的语义,主要是为了把潜在的安全危害降到最低。
- 开发者应该能够自信的推断多线程如何与内存进行交互。
- 能够在现在许多流行的硬件架构中设计正确且高性能的 JVM 实现。
- 应该能够提供“安全初始化”保证。如果一个对象正确的进行了构建(指它的引用没有在构建时逸出),那么所有能够看到该对象的引用的线程,在不进行同步的情况下,也将能够看到在构造方法中设置的 fianl 字段的值。
- 应该尽量不影响现有的代码。
重排序意味着什么
在很多情况下,访问一个程序变量(对象实例字段、类静态字段、数组元素)可能会使用不同的执行顺序,而不是程序语义所指定的顺序执行。编译器能够自由的以优化的名义去改变指令顺序。在特定环境下,处理器可能会次序颠倒的执行指令。数据可能在寄存器、缓冲区或主存中以不同的次序移动,而不是按照程序指定的顺序。
例如,如果一个线程写入值到字段 a、然后写入值到字段 b,并且 b 的值不依赖于 a 的值,那么,处理器能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新 b 的值到主内存。有许多潜在的重排序来源,例如编译器、JIT 及缓冲区。
编译器、运行时和硬件被期望一起协力创建看似是按顺序执行的语义的假象,这意味着在单线程程序中,程序应该不能观察到重排序的影响。但是,重排序在没有正确同步的多线程程序中会产生作用,在这些多线程程序中,一个线程能够观察到其他线程的影响,也可能检测到其他线程将会以一种不同于程序语义所规定的执行顺序来访问变量。
大部分情况下,一个线程不会关注到其他线程在做什么,但是当它需要关注的时候,就需要使用(正确的)同步了。
旧内存模型的缺陷有哪些?
旧的内存模型中有几个严重的问题。这些问题很难理解,因此被广泛的违背。例如,旧的存储模型在许多情况下,不允许 JVM 发生各种重排序行为。旧的内存模型中让人产生困惑的因素造就了 JSR 133 的诞生。
例如,一个被广泛认可的概念就是:如果使用 final 字段,那么就没有必要在多个线程间使用同步来保证其他线程能够看到这个字段的值。尽管这是一个合理的假设和明显的行为,也是我们所期待的结果。实际上,在旧的内存模型中,我们想让程序正确运行起来却是不行的。在旧的内存模型中,final 字段并没有同其他字段进行区别对待——这意味着同步是保证所有线程看到一个在构造方法中初始化为 final 字段的唯一方法。结果——如果没有正确进行同步,对一个线程来说,它可能看到一个(final)字段的默认值,然后在稍后的时间里,又能看到构造方法中设置的值。这意味着,一些不可变的对象,例如 String,能够改变它们的值——这是在是让人很郁闷。
旧的内存模型允许 volatile 变量的写操作和非 volatile 变量的读写操作一起进行重排序,这和大多数的开发人员对于 volatile 变量的直观感受是不一致的,因此会造成迷惑。
最后,我们将看到的是,开发者对于“程序没有被正确同步的情况下将会发生什么”的直观感受通常是错误的。JSR 133 的目的之一就是要引起这方面的注意。
没有正确同步的含义是什么
没有正确同步的代码对于不同的人来说可能会有不同的理解。在 Java 内存模型这个语义环境下,我们谈到“没有正确同步”时指的是:
- 一个线程中存在对一个变量的写操作。
- 另外一个线程存在对该变量的读操作。
- 而且写操作和读操作之间没有通过同步来保证顺序。
当违反这些规则时,我们就说在这个变量上有一个“数据竞争”。一个拥有数据竞争的程序就是一个没有正确同步的程序。
同步会做些什么
同步有几个方面的作用。最广为人知的就是“互斥”——一次只有一个线程能够获得一个监视器,因此,在一个监视器上同步意味着一旦一个线程进入到该监视器保护的同步块中,其他的线程都不能进入到同一个监视器保护的块中间,除非第一个线程退出了同步块。
但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或之中的一个内存写入操作以可预知的方式对其他拥有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,该监视器拥有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器拥有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其他线程对共享变量的修改对当前线程来说就变得可见了。
基于缓存来讨论同步,可能听起来这些观点仅仅会影响到多处理器的系统。但是,重排序效果能够在单一处理器上很容易的观察到。对于编译器来说,在获取之前或者释放之后移动你的代码是不可能的。当我们谈到在缓冲区上面进行的获取和释放操作时,我们使用了简述的方式来描述大量可能的影响。
新的内存模型语义在内存操作(读写字段、加解锁)以及其他线程的操作(start/join)中创建了一个部分(partial,相对于整个程序的语义顺序)排序,在这些操作中,一些操作被称为 happens before 其他操作。当一个操作在另一个操作之前发生,第一个操作保证能够被排到前面并且对第二个操作可见。这些排序的规则如下:
- 线程中的每个操作 happenns before 该线程中在程序顺序上后续的每个操作。
- 解锁一个监视器的操作 happens before 随后对相同监视器进行的解锁操作。
- 对 volatile 字段的写操作 happens before 后续对相同 volatile 字段的读取操作。
- 在线程上调用 start 方法 happens before 这个线程启动后的任何操作。
- 一个线程中的所有操作都 happens before 从这个线程的 join 方法成功返回的任何其他线程。(即其他线程等待一个线程的 join 方法完成,那么,这个线程中的所有操作 happens before 其他线程中的所有操作)
这意味着:对于任何内存操作,该内存操作在退出一个同步块之前对一个线程是可见的,对任何线程在它进入一个被相同的监视器保护的同步块之后都是可见的,因为所有内存操作 happens before 释放监视器、(当前线程)释放监视器 happens before (其他线程)获取监视器。
其他类似如下模式的实现,被一些人用来强迫实现一个内存屏障,不会生效:
synchronized (new Object()) {}
这段代码其实不会执行任何操作,编译器会将其完全移除,因为编译器知道没有其他线程会使用相同监视器进行同步。要看到其他线程的结果,你必须为一个线程建立 happens before 关系。
@@@ note
对两个线程来说,为了正确建立 happens before 关系而在相同监视器上面进行同步是非常重要的。以下观点是错误的:当线程 A 在对象 X 上面同步的时候,所有东西对线程 A 可见,线程 B 在对象 Y 上面进行同步的时候,所有东西对线程 B 也是可见的。释放监视器和获取监视器必须匹配(即要在相同监视器上面完成这两个操作),否则,代码就会存在数据竞争。
@@@
final 字段是如何改变其值的
我们可以通过分析 String 类的实现细节来展示一个 final 变量是如何可以改变的。
String 对象包含 3 个字段:一个 character 数组、一个数据的 offset、一个 length。实现 String 类的基本原理是:它不仅仅拥有 character 数组,而且为了避免多余的对象分配和拷贝,多个 String 和 StringBuffer 对象都会共享相同的 character 数组。因此,String.substring 方法能够通过改变 length 和 offset,通过共享原始的 character 数组来创建一个新的 String 对象。对于一个 String 来说,这些字段都是 final 型的字段。
String s1 = "/usr/tmp";
String s2 = s1.substring(4);
s2 的 offset 值为 4,length 的值为 4。但是,在旧的内存模型下,对于其他线程来说,有机会看到 offset 拥有默认的值 0,而且,在稍后一点时间会看到正确的值 4,好像字符串的值从 “/usr” 变成了 “/tmp” 一样。
旧的 Java 内存模型允许这些行为,部分 JVM 已经展现出这样的行为了。而在新的 Java 内存模型中,这是非法的。
在新的 JMM 下 final 是如何工作的
一个对象的 final 字段值是在对象的构造方法中设置的。假设对象被正确的构造了,一旦对象被构造,在构造方法里面设置 final 字段的值在没有同步的情况下会对所有其他线程可见。另外,引用这些 final 字段的对象或数组都会看到其最新值。
对于一个对象来说,被正确构造又是什么意思?简单来说,它意味着这个正在构造的对象的引用在构造期间没有被允许逸出(参见安全构造技术)。换句话说,不要让其他线程在其他地方能够看见一个处于构造期的对象引用。不要指派给一个静态字段,不要作为一个 listener 注册给其他对象等。这些操作应该在构造方法完成之后进行,而不是构造方法中进行。
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
上面的类展示了 fianl 字段应该如何使用。一个正在执行 reader 方法的线程保证看到 f.x 的值为 3,因为它是 final 字段。它不保证看到 f.y 的值为 4,因为 f.y 不是 final 字段。如果 FinalFieldExample 的构造方法是如下这样:
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}
那么从 global.obj 中读取 this 引用线程不会保证读取到的 x 的值 3。
能够看到字段的正确构造的值固然不错,但是,如果字段本身就是一个引用,那么,你还是希望你的代码能够看到引用所指向的这个对象(或数组)的最新值。如果你的字段是 final 字段,那就就是能够保证的。因此,当一个 final 指针指向一个数组,你无需担心线程能够看到引用的最新值却看不到引用所指向的数组的最新值。重复一下,这里的“正确的”是指“对象构造方法结尾的最新的值”而不是“最新可用的值”。
现在,在讲了如上的片段之后,如果在一个线程构造了一个不可变对象之后(对象仅包含 final 字段),你希望保证该对象被其他线程正确的查看,你仍然需要使用同步才行。例如,没有其他的方式可以保证不可变对象的引用将被第二个线程看到。使用 final 字段的程序应该仔细调试,这需要深入而且仔细的理解并发在你的代码中是如被被管理的。
如果你使用 JNI 来改变你的 final 字段,这方面的行为是没有定义的。
volatile 的作用
Volatile 字段是用于线程间通讯的特殊字段。每次读 volatile 字段都会看到其他线程写入该字段的最新值;实际上,开发者之所以要定义 volatile 字段是因为在某些情况下由于缓存和重排序所看到的陈旧的变量值是不可接受的,编译器和运行时禁止在寄存器里分配它们。它们还必须保证,在它们被写好之后,它们被从缓冲区刷新到主存中,因此,它们能够对其他线程立即可见。同样,在读取一个 volatile 字段之前,缓冲区必须失效,因为值是存在于主存中而不是本地处理器的缓冲区。在重排序访问 volatile 变量的时候还有其他的限制。
在旧的内存模型下,访问 volatile 变量不能被重排序,但是,它们可能和访问非 volatile 变量一起被重排序。这破坏了 volatile 字段从一个线程到另一个线程作为一个信号条件的手段。
下新的内存模型下,volatile 变量仍然不能彼此重排序。和旧模型不同的是,volatile 周围的普通字段也不再能随便的重排序了。写入一个 volatile 字段和释放监视器拥有相同的内存影响,而且读取 volatile 字段和获取监视器也有相同的内存影响。事实上,因为新的内存模型在重排序 volatile 字段访问上面和其他字段(volatile 或非 volatile)访问上面拥有更加严格的约束。当线程 A 写入一个 volatile 字段 f 的时候,如果这时线程 B 读取 f 的值,那么对线程 A 可见的任何东西都变得对线程 B 可见了。
下面的例子展示了应该如何使用 volatile 字段:
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}
假设一个名为 “writer” 的线程和一个名为 “reader” 的线程。对变量 v 的写操作会等到变量 x 被写入到内存之后,然后读线程才能看到 v 的值。因此,如果 reader 线程看到了 v 的值为 true,那么,这也保证能够看到之前发生的“向 x 写入 42”这个操作。而在旧的内存模型中未必如此。如果 v 不是 volatile 变量,那么,编译器可以在 writer 线程中重排序写入操作,然后 reader 线程中读取 x 变量的操作可能会看到其值为 0。
实际上,volatile 的语义已经被加强了,已经块达到同步的级别了。为了可见性的原因,每次读取和写入一个 volatile 字段已经像是半个同步操作了。
@@@ note
对于两个线程来说,为了正确的设置 happens before 关系,访问相同的 volatile 变量是很重要的。以下结论是不正确的:当线程 A 写 volatile 字段 f 的时候,线程 A 可见的所有东西,在线程 B 读取字段 g 的时候,都变得对线程 B 可见了。释放操作和获取操作必须匹配(也就是在同一个 volatile 字段上完成)。
@@@
新的 JMM 是否修复了双重锁检查问题
臭名昭著的双重锁检查(也称多线程单例模式)是一个骗人的把戏,它用来支持 lazy 初始化,同时避免过度使用同步。在非常早的 JVM 中,同步非常慢,开发人员非常系统删掉它。双重锁检查代码如下:
// double-checked-locking - don't do this!
private static Something instance = null;
public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}
这看起来好像非常聪明——在公用代码中避免了同步。这段代码只有一个问题——它不能正常工作。为什么呢?最明显的原因是,初始化实例的写入操作和实例字段的写入操作能够被编译器或缓冲区重排序,重排序可能会导致返回部分构造的一些东西。就是我们会读取到一个没有被初始化的对象。这段代码还有很多其他的错误,以及为什么对这段代码的算法修正是错误的。在旧的内存模型下无法将其修复。更多深入的信息参见:Double-checkedlocking: Clever but broken 和 The “Double-Checked Locking is Broken” Declaration。
很多人认为使用 volatile 关键字能够消除双重锁检查模式的问题。在 1.5 的 JVM 之前,volatile 并不能保证这段代码能够正常工作(因环境而定)。在新的内存模型中,实例字段使用 volatile 可以解决双重锁检查问题,因为在构造线程来初始化一些东西和读取线程返回它的值之间有 happens before 关系。
然后,对于喜欢使用双重锁检查的人来说(我们真的希望没有人喜欢这么用),仍然不是好消息。双重锁检查的重点是为了避免过度使用同步导致性能问题。从 Java 1.0 开始,同步不仅会有昂贵的性能开销,而在新的内存模型下,使用 volatile 的性能开销也有所上升,几乎达到了和同步一样的性能开销。因此,使用双重锁检查来实现单例模式仍然不是一个好的选择(注意这里需要修正,在大多数平台下,volatile 的性能开销还是比较低的)。
使用 IODH 来实现多线程模式下的单例会更加易读:
private static class LazySomethingHolder {
public static Something something = new Something();
}
public static Something getInstance() {
return LazySomethingHolder.something;
}
这段代码是正确的,因为初始化由 static 字段来保证。如果一个子弹设置在 static 舒适化中,对其他访问这个类的线程来说是能够正确的保证其可见性的。
如何实现一个 VM
参考 The JSR-133 Cookbook for Compiler Writers。
为什么要关注 JMM
为什么你需要关注java内存模型?并发程序的bug非常难找。它们经常不会在测试中发生,而是直到你的程序运行在高负荷的情况下才发生,非常难于重现和跟踪。你需要花费更多的努力提前保证你的程序是正确同步的。这不容易,但是它比调试一个没有正确同步的程序要容易的多。
Reference
2.3.5 - CH05-JSR133-Cook
从最初的探索至今已经有十年了。在此期间,很多关于处理器和语言的内存模型的规范和问题变得更清楚,更容易理解,但还有一些没有研究清楚。本指南一直在修订、完善来保证它的准确性,然而本指南部分内容展开的细节还不是很完整。想更全面的了解, 可以特别关注下 Peter Sewell 和 Cambridge Relaxed Memory Concurrency Group 的研究工作。
这是一篇用于说明在 JSR 133 中制定的新 Java 内存模型(JMM) 的非官方指南。这篇指南提供了在最简单的背景下各种规则存在的原因,而不是这些规则在指令重排、多核处理器屏障指令和原子操作等方面对编译器和 JVM 所造成的影响。它还包括了一系列如何遵守 JSR 133 的指南。本指南是“非官方”的文档,因为它还包括特定处理器性能和规范的解释,我们不能保证所有的解释都是正确的。此外,处理器的规范和实现也可能会随时改变。
Reference
2.4 - JVM 深入理解 V2
2.4.1 - CH01-走近 Java
Java 技术体系
Java 技术体系包括以下几个组成部分:
- Java程序设计语言
- 各种硬件平台上的Java虚拟机
- Class文件格式
- Java API类库
- 来自商业机构和开源社区的第三方Java类库
我们可以把Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境,在后面的内容中,为了讲解方便,有一些地方会以JDK来代替整个Java技术体系。另外,可以把Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),JRE是支持Java程序运行的标准环境。图1-2展示了Java技术体系所包含的内容,以及JDK和JRE所涵盖的范围。
以上是根据各个组成部分的功能来进行划分的,如果按照技术所服务的领域来划分,或者说按照Java技术关注的重点业务领域来划分,Java技术体系可以分为4个平台,分别为:
- Java Card:支持一些Java小程序(Applets)运行在小内存设备(如智能卡)上的平台。
- Java ME(Micro Edition):支持Java程序运行在移动终端(手机、PDA)上的平台,对Java API有所精简,并加入了针对移动终端的支持,这个版本以前称为J2ME。
- Java SE(Standard Edition):支持面向桌面级应用(如Windows下的应用程序)的Java平台,提供了完整的Java核心API,这个版本以前称为J2SE。
- Java EE(Enterprise Edition):支持使用多层架构的企业应用(如ERP、CRM应用)的Java平台,除了提供Java SE API外,还对其做了大量的扩充并提供了相关的部署支持,这个版本以前称为J2EE。
虚拟机发展史
- Sun Classic/Exact VM,只能使用纯解释方式来执行 Java 代码,如果需要使用 JIT 编译期,就需要进行外挂。一旦外挂 JIT,解释器便被完全接管。
- Exact VM,已具备现代高性能虚拟机的雏形,具备两级即时编译期、编译期与解释器混合工作模式,使用精准式内存管理。
- HotSpot,名称指的是热点代码探测技术,准确式 GC。
- Sun Mobile-Embedded VM/Meta-Circular VM
- JRockit VM,专注于服务器端应用,它可以不太关注程序启动速度,因此内部不包含解析器实现,全部代码都靠即时编译器编译后执行。除此之外,J垃圾收集器和MissionControl服务套件等部分的实现,在众多Java虚拟机中也一直处于领先水平。
- IBM J9 VM,它是一款设计上从服务器端到桌面应用再到嵌入式都全面考虑的多用途虚拟机,J9的开发目的是作为IBM公司各种Java产品的执行平台,它的主要市场是和IBM产品(如IBM WebSphere等)搭配以及在IBM AIX和z/OS这些平台上部署Java应用。
- Azul VM,基于 HotSpot 改进,用于专有硬件Vega系统上的 Java 虚拟机,每个 Azul 实例可与管理至少数十个 CPU 和数百GB内存的硬件资源,并提供在巨大内存范围内实现可供 GC 时间的垃圾回收器,为专有硬件优化的线程调度。
- BEA Liquid VM,即现在的 JRockit VE,不需要操作系统的支持,自身实现了一个专用操作系统的必要功能。由虚拟机越过通用操作系统直接控制硬件可以获得很多好处,如在线程调度时不需要进程内核态/用户态的切换等,以最大限度的发挥硬件的能力。
- Apache Harmony,很多优秀代码被吸纳进 JDK 7 和 Google Android SDK 中,尤其对 Android 的发展起到了很大的推动作用。
- Google Android Dalvik VM,Andriod 平台的核心组件,不遵循 JVM 规范,不能直接执行 Class 文件,使用的是寄存器架构而非 JVM 中常见的架构。其执行文件可以通过 Class 文件转化而来,可以使用 Java 来编写应用并调用大部分的 Java API。随着 Android 的迅猛发展得以快速发展。
- Microsoft JVM,Windows 平台特定 JVM,最终因侵权终止。
- GraalVM,支持多种编程语言的通用虚拟机,低负载、高性能。
热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。 如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。
未来展望
- 模块化。解决应用系统与技术平台越来越复杂、越来越庞大的一个重要功能。
- 混合语言。当单一的Java开发已经无法满足当前软件的复杂需求时,越来越多基于Java虚拟机的语言开发被应用到软件项目中,Java平台上的多语言混合编程正成为主流,每种语言都可以针对自己擅长的方面更好地解决问题。
- 多核并行。ForkJoin 和 Lambda 帮助 Java 顺利过渡到多核,对多核的深入支持有助于稳定 Java 的引领低位。
- 丰富语法。
- 64 位虚拟机。由于指针膨胀和各种数据类型对齐补白的原因,运行于64位系统上的Java应用需要消耗更多的内存,通常要比32位系统额外增加10%~30%的内存消耗;其次,多个机构的测试结果显示,64位虚拟机的运行速度在各个测试项中几乎全面落后于32位虚拟机,两者大约有15%左右的性能差距。
源码结构
如何阅读 HotSpot 源码
什么时候不读 HotSpot 源码
- 不同 JVM 具体实现相当复杂。
- 基础概念不扎实时。
- 硬读实现复杂的源码对理解基础概念的帮助不大。
- 繁琐的实现细节反而会掩盖一些抽象概念。
- 已有现成的阅读资料时。
- 读资料比读源码更容易吸收自己需要的信息。
- 因人而异。
不明就里读源码的坏处
- 加深误解
- 浪费时间/精力
- 读了但全然无法理解,还不如先不读
- 有些细节知道了也没用
在读源码之前
- 仅为理解 Java 程序的行为?
- 是否已经了解 Java 语言层面的规定?
- Java 语言规范
- 是否已经了解 JVM 的抽象概念?
- JVM 规范
- 已确定需要关注的行为是特定与某个实现?
- 回到上面两点
- 是否有关于该实现的内部细节的文字描述?
- 优先选择阅读文字描述。
- 是否已经了解 Java 语言层面的规定?
- 仍然想深入学习 VM 的内部知识?
- 阅读相关背景知识的书、论文、博客等。
- 能够在阅读源码之前事先了解术语。
- 知道术语便于找到更多资料。
- 阅读一些更加简单的 VM 实现的源码。
- 循序渐进。
- 自己动手编写简单的编译器、VM
- 实践是检验真理最有效途径。
- 最后,如果真的有空才去读 HotSpot 源码。
- 阅读相关背景知识的书、论文、博客等。
- 工作的内容就是开发 HotSpot VM?
- 需要非常仔细的阅读。
- 优先选择动态调试。
- 上手顺序:文档——读代码——做实验——调试。
其他 VM
- KVM:简单小巧的 JVM 实现。
- 优点:
- 包含 JVM 的最核心组件
- 实现方式与 JVM 规范所描述的抽象比较接近
- 缺点:
- 是 Java ME CLDC VM,而不是 Java SE VM
- 未实现反射、浮点计算等功能
- 优点:
- Maxine VM:纯 Java 实现的 JVM。
- 可在 IDE 中开发调试
- 二进制兼容性,可使用 Oracle JDK/OpenJDK 的类库,兼容主流的 Java 应用。
- VMKit/J3:使用线程组件搭建的 JVM。
其他资源
2.4.2 - CH02-内存区域与溢出
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。
2.1 概述
在 C/C++ 中,开发人员即拥有每一个对象的“所有权”,又负担着每个对象生命开始到终结的维护责任。
在 Java 中,在虚拟机自动内存管理机制的帮助下,开发人员不需要为每个 new 操作去编写与之配对的 delete/free 代码,不容易出现内存泄露和内存溢出问题。
2.2 运行时数据区域
JVM 在执行 Java 程序时会将它所管理的内存划分为不若干个不同的数据区域。这些区域都有各自的用途、创建和销毁实现,有的区域随着虚拟机进程的启动而存在,有些则依赖于用户线程的启动和结束而创建和销毁。
2.2.1 程序计数器
程序计数器是一块较小的内存空间,可以被看作是当前线程所执行字节码的“行号指示器”。在虚拟机的概念模型中,字节码解释器工作时就是通过改变该计数器的值来选取下一条要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于该计数器来完成。
由于 JVM 的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说指一个核)都只会执行一个条线程中的指令。因此,为了线程切换后恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间的程序计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,该计数器记录的是正在执行的虚拟机字节码指令的地址:如果正在执行的是 Native 方法,该计数器值则为空(undefined)。此内存区域是唯一一个在 JVM 规范中没有规定任何 OOM 情况的区域。
2.2.2 JVM 栈
与程序计数器一样,JVM 栈(stack)也是线程私有的,其声明周期与线程相同。它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用直至执行完成的过程,就对应这一个栈帧在 JVM 栈中入栈到出栈的过程。
经常有人把 Java 内存区域分为堆内存和栈内存,这种分类方式比较粗糙,Java 内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的区域是这两块。其中所指的“栈”就是这里所讲的 JVM 栈,或者说是局部变量表部分。
局部变量表存放了编译期可知的各种数据类型(8 种基本类型)、对象引用(引用类型,不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与该对象相关的位置)、returnAddress 类型(指向一条字节码指令的地址)。
其中 64 位长度的 long 和 double 型数据会占用 2 个局部两两空间(slot),其余的数据类型仅占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,该方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在 JVM 规范中,对该区域规定了两种异常情况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈溢出异常。
- 如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,将抛出内存溢出异常。
当前大部分虚拟机实现都可以动态扩展,只不过规范中也允许固定长度的虚拟机栈。
2.2.3 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈相似,区别在于:
- 虚拟机栈用于虚拟机执行 Java 方法,为字节码服务。
- 本地方法栈用于为虚拟机本身用到的 Native 方法服务。
虚拟机规范中没有强制规定本地方法栈中方法需要使用的语言、使用方式、数据结构,因此具体的虚拟机可以自由实现。甚至有虚拟机把直接把本地方法栈和虚拟机栈合二为一,如 Sun HotSpot 虚拟机。
可能会抛出栈溢出或内存溢出异常。
Java 堆
Java Heap 是 JVM 所管理内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建,用于存放对象实例,几乎所有的对象实例都在这里分配内存。
规范中的描述为:所有的对象实例和数组都要在堆上分配。但是随着 JIT 编译器的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换等优化技术将会带来一些微秒的变化,所有对象都在堆上分配也慢慢变得不那么“绝对”了。
堆是 GC 管理的主要区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以堆可以被细分为新生代和老年代,再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。从内存分配的角度来看,线程共享的堆中可能划分出多个线程私有的分配缓冲区。不过无论如何划分,都与存放的内容无关,无论哪个区域,存储依然都是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存。
根据虚拟机规范的规定,堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,这类似于磁盘空间。在实现时,既可以实现为固定大小的、也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展的方式来实现的(通过 -Xmx
和 -Xms
控制)。如果在堆中没有内存完成实例分配,并且堆也无法再进行扩展时,将抛出内存溢出异常。
2.2.5 方法区
方法区与堆一样,被各个线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。虽然规范中将方法区描述为堆的一个逻辑部分,但是他却有一个别名叫做“非堆(Non-Heap)”,目的应该是为了与堆进行区分。
对于习惯于在 HotSpot 虚拟机实现上开发的程序员来说,很多人更愿意称方法区为“永久带”,本质上两者并不等价,仅仅是因为 HotSpot 的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久带来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理堆一样来管理这部分内存,这能够省去专门为方法区编写内存管理代码的工作。其他虚拟机(JRockit/J9)则不存在永久带的概念。原则上,如何实现方法区属于虚拟机的实现细节,不受规范的约束,但是用永久带来实现方法区,现在看来并非一个好主意,因为这样更容易遇到内存溢出问题,而且极少有方法会因为这个原因导致不同虚拟机下出现不同的表现。因此对于 HotSpot 来说,根据官方发布的路线图信息,目前也有放弃永久带并逐步改为采用 Native Memory 来实现方法区的规划了。在目前已经发布的 JDK 1.7 中,已经把原本放在永久带的字符串常量池移出。
规范对方法区的限制非常宽松,除了和堆一样不需要连续的内存、可以选择固定大小和可扩展之外,还可以选择实现垃圾收集机制。相对堆而言,垃圾收集行为在该区域较少出现,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。该区域的内存回收目标主要是针对常量池和类的卸载,一般来说,该区域的回收“成绩”比较难以令人满意,尤其是对类的卸载,条件相当苛刻,但是该区域的回收是确实必要的。
根据规范规定,当方法区无法满足内存分配需求时,将抛出内存溢出异常。
2.2.6 运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项就是常量池,用于存放在编译期生成的各种字面量和符合引用,该部分内容将在类加载后被存放到方法区的运行时常量池。
JVM 对 Class 文件的各个部分的格式都有严格的规定,每个字节用于存放哪种数据都必须符合规范中的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,规范中没有任何细节的要求,不同的供应商实现的虚拟机可以按照自己的需求来实现该内存区域。但一般来说,除了保存 Class 文件中描述的符号引用之外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 并不要求常量只能在编译期产生,也就是并非预置入 Class 文件中常量池的内存才能进入方法区运行时常量池,运行区间也可能将新的常量放入池中,这种特性被开发人员利用的比较多的便是 String 类的 intern()
方法。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池中无法申请到内存时将抛出内存溢出异常。
2.2.7 直接内存
直接内存并非虚拟机运行时数据的一部分,也不是 JVM 规范中定义的内存区域。但是这部分内存也被频繁的使用,而且可能导致内存溢出异常,因此有必要介绍。
在 JDK 1.4 中引入例如 NIO 类,引入了一种基于 Channel 和 Buffer 的 IO 方式,可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提升性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
显然,本机内存的分配不会受到 Java 堆大小的限制,但是会收到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx
等参数信息,但经常会忽略直接内存,使得各个内存区域的总和大于实际的物理内存限制,从而导致扩展时出现内存溢出异常。
2.3 HotSpot 对象探秘
2.3.1 对象的创建
虚拟机遇到一条 new 指令时,首先回去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且检查该符号引用代表的类是否已经被加载、解析且初始化。如果没有,必须首先执行对相关类的加载过程。
如果类加载检查通过,接下来虚拟机将为新生对象分配内存。对象所需要的内存大小在类加载时便可以完全确定,为对象分配空间的任务等同于把一块确定大小的内存从堆中划分出来。假设堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,所分配的内存就仅仅是把该指针向空闲空间一侧挪动一段与新生对象的大小相等的距离,这种分配方式成为“指针碰撞”。如果堆中的内存是不规整的,已用内存与空闲内存相互交错,虚拟机就需要维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间来分配给新生对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。堆是否规整来决定了使用哪种分配方式,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定。因此,在使用 Serial、ParNew 等带有 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 MarkSweep 算法的收集器时,通常会采用空闲列表方式。
除了如何划分可用空间之外,还有另外一个需要考虑的问题是,在虚拟机中对象创建是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决该问题有两种方案,一种是对内存分配动作进行同步——实际上虚拟机采用 CAS 加上失败重试的方式保证更新操作的原子性;另一种方式是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称之为本地线程分配缓冲(TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时,才需要同步。虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB
参数来设定。
内存分配完成后,虚拟机需要将分配的内存空间都初始化为零值(不包括对象头),如果使用 TLAB,这一过程也可以被提前至分配 TLAB 时进行。该操作保证了对象的实例字段在 Java 代码中可以不赋值初始值就能直接被使用,程序将访问到这些字段的类型所对应的零值。
接下来,虚拟机要对对象重新进行必要的设置,比如该对象是哪个类的实例、如何才能找到类的元信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁,对象头会有不同的设置方式。
在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的角度来看,对象创建才刚刚开始——init
方法还没有执行,所有的字段都还为零值。所以,一般来说(根据字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 init
方法,把对象安装程序员的意愿进行初始化,这样一个真正可用的对象才算创建完成。
下面的代码清单是 HotSpot 中 butecodeInterpreter.cpp
的片段:
// 确保常量池中存放的是已解释的类
if(!constants->tag_at(index).is_unresolved_kclass()) {
// 断言确保是 klassOop 和 instanceKlassOop
oop entry = (klassOop)*constants->obj_at_addr(index);
assert(entry->is_klass(), "Should be resolved klass");
klassOop k_entry=(klassOop)entry;
assert(k_entry->klass_part()->oop_is_instance(), "Should be instan
instanceKlass * ik=(instanceKlass*)k_entry->klass_part();
// 确保对象所属类型已经经过初始化阶段
if(ik->is_initialized()&&ik->can_be_fastpath_allocated()) {
//取对象长度
size_t obj_size=ik->size_helper();
oop result=NULL;
//记录是否需要将对象所有字段置零值
bool need_zero=!ZeroTLAB;
//是否在TLAB中分配对象
if(UseTLAB){
result=(oop)THREAD->tlab().allocate(obj_size);
}
if(result==NULL){
need_zero=true;
//直接在eden中分配对象
retry:
HeapWord * compare_to=*Universe:heap()->top_addr();
HeapWord * new_top=compare_to+obj_size;
/*cmpxchg是x86中的CAS指令, 这里是一个C++方法, 通过CAS方式分配空间, 如果并发失败, 转到retry中重试, 直至成功分配为止*/
if(new_top<=*Universe:heap()->end_addr()){
if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(), compare_to)!=compare_to){
goto retry;
}
result=(oop)compare_to;
}
}
if(result!=NULL){
//如果需要, 则为对象初始化零值
if(need_zero){
HeapWord * to_zero=(HeapWord*)result+sizeof(oopDesc)/oopSize;
obj_size-=sizeof(oopDesc)/oopSize;
if(obj_size>0){
memset(to_zero, 0, obj_size * HeapWordSize);
}
}
//根据是否启用偏向锁来设置对象头信息
if(UseBiasedLocking){
result->set_mark(ik->prototype_header());
}else{
result->set_mark(markOopDesc:prototype());
}
result->set_klass_gap(0);
result->set_klass(k_entry);
//将对象引用入栈, 继续执行下一条指令
SET_STACK_OBJECT(result, 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
}
}
}
2.3.2 对象的内存布局
在 HotSpot 中,对象在内存中存储的布局可以分为三个区域:
- 对象头
- 实例数据
- 对齐填充
对象头包括两部分。第一部分用于存储对象自身的运行时数据,如哈希值、GC 年龄分代、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 位和 64 位,官方称其为 “Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。如,在 32 位的 HotSpot 中,如果对象处于未被锁定的状态下,那么 MarkWord 的 32 位存储空间中的 25 位用于存储对象哈希值,4位用于存储对象年龄分代,2位用于存储标志位。1位固定为0,而在其他状态(轻量级锁、重量级锁、GC 标记、可偏向)下对象的存储内容见下表:
对象的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过该指针来确定这个对象的所属的类。并不是所有虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个数组,拿在对象头中还必须有一块用于记录数组长度的信息,因为虚拟机可以通过普通 Java 对象的元数据信息来确定 Java 的对象的大小,但是从数组的元数据中无法确定数组的大小。
代码清单 2-2 为 HotSpot 中 markOop.cpp
的代码片段,描述了 32 位下 MarkWord 的存储状态:
//Bit-format of an object header(most significant first,big endian layout below):
//32 bits:
//--------
//hash:25------------>|age:4 biased_lock:1 lock:2(normal object)
//JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2(biased object)
//size:32------------------------------------------>|(CMS free block)
//PromotedObject*:29---------->|promo_bits:3----->|(CMS promoted object)
接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承而来,而是在子类中定义的,都需要记录下来。这部分的存储顺序会收到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 代码中定义顺序的影响。HotSpot 默认的分配策略为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers)。从分配策略可看出,相同宽度的字段总是会被分配在一起。在满足该前提条件的情况下,在父类中定义的字段会出现在子类定义的字段之前。如果 CompactFields 参数为真(默认为真),那么子类中较窄的字段也可能会插入到父类字段的空隙中。
第三部分对齐填充并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。由于 HotSpot 的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,即对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节数的倍数,因此当对象实例数据部分没有对齐时,就需要对齐填充来补全。
2.3.3 对象的访问定位
Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在虚拟机规范中国之规定了一个指向对象的引用,并没有定义该引用应该通过哪种方式去定位、访问堆中对象的具体位置,所以对象访问的方式也取决于具体虚拟机的实现。目前主流的方向是使用句柄或直接指针。
如果使用句柄来访问,那么堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如下图:
如果使用直接指针访问,那么堆对象的布局就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。如下图:
这两种对象访问方式各有优势,句柄方式访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动(如垃圾收集时移动对象)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
直接指针的方式的最大好处就是速度快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的 HotSpot 而言,它使用直接指针的方式来访问对象,但从整体软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
2.4 实战内存溢出异常
2.4.1 堆溢出
堆用于存储对象实例,只要不断创建对象,并且保证 GC Roots 到对象之间的存在可达路径来避免 GC 清除这些对象,那么在对象数量达到堆的最大容量时就会产生堆内存溢出异常。
/**
*VM Args:-Xms20m-Xmx20m-XX:+HeapDumpOnOutOfMemoryError
*@author zzm
*/
public class HeapOOM{
static class OOMObject{
}
public static void main(String[]args){
List<OOMObject>list=new ArrayList<OOMObject>();
while(true){
list.add(new OOMObject());
}
}
}
要解决该区域的内存溢出异常,一般的手段是先通过内存映像分析工具(如 MAT)对 dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,即要分清楚到底是出现了内存泄露还是内存溢出。
如果是内存泄露,可以进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象是通过怎样的路径与 RC Roots 相关联并导致垃圾收集器无法将其回收。掌握了泄露对象的类型信息及其 GC Roots 引用链就能比较准确的定位出泄露代码的位置。
如果不存在泄露,就是内存中的对象确实都是需要存活的,那么就应当检查虚拟机的堆参数,与机器物理内存相比是否还能调大,从代码上检查是否存在某些对象的生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
2.4.2 虚拟机栈和本地方法栈溢出
由于在 HotSpot 中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说虽然 -Xoss
参数(用于设置本地方法栈的大小)存在,但实际上是无效的,栈容量仅能通过 -Xss
参数来设定。关于虚拟机栈和本地方法栈,JVM 规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出栈溢出异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出内存溢出异常。
这里把异常分为两种情况看似更加严谨,但却存在着一些互相重叠的地方:当占空间无法继续分配时,到底是内存太小、还是已使用的栈深度太大,其本质上只是对同一件事情的两种描述而已。
在笔者的实验中,将实验范围限定于单线程的操作中,尝试了下面两种方式均无法然虚拟机产生内存溢出异常,尝试的结果都是栈溢出异常:
- 使用
-Xss
参数减少栈内存容量,结果抛出栈溢出异常,异常出现时输出的栈深度相应缩小。 - 定义了大量的本地变量,以增大此方法帧中本地变量表的长度。结果抛出栈溢出异常时输出的栈深度相应缩小。
/**
*VM Args:-Xss128k
*@author zzm
*/
public class JavaVMStackSOF{
private int stackLength=1;
public void stackLeak(){
stackLength++;
stackLeak();
}
public static void main(String[]args)throws Throwable{
JavaVMStackSOF oom=new JavaVMStackSOF();
try{
oom.stackLeak();
}catch(Throwable e){
System.out.println("stack length:"+oom.stackLength);
throw e;
}
}
}
实验结果表明,在单个线程中,无论是由于栈帧太大还是虚拟机栈容量太小,当无法分配内存的时候,抛出的都是栈溢出异常。
如果测试时不限于单线程,通过不断创建线程的方式倒是可以产生内存溢出异常。但是这样产生的内存溢出异常与栈空间十分足够大并不存在任何联系,或者准确的说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。
具体原因不难理解,操作系统分配给每个进程的内存是有限制的,比如 32 位的 Windows 限制为 2GB。虚拟机提供了参数来控制 Java 堆和方法区的内存最大值。剩余的内存为 2GB 减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小可以忽略。如果虚拟机进程本身消耗的内存不算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以创建的线程数量就越少,创建新的线程时就越容易把剩下的内存耗尽。
这一点读者需要在开发多线程的应用时特别注意,出现栈溢出异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说在大多数情况下)达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。如果没有这方面的处理经验,这种通过“减少内存”的手段来解决内存溢出的方式会比较难以想到。
代码清单 2-5 创建线程导致内存溢出异常:
/**
*VM Args:-Xss2M(这时候不妨设置大些)
*@author zzm
*/
public class JavaVMStackOOM{
private void dontStop(){
while(true){
}
}
public void stackLeakByThread(){
while(true){
Thread thread=new Thread(new Runnable(){
@Override
public void run(){
dontStop();
}
});
thread.start();
}
}
public static void main(String[]args)throws Throwable{
JavaVMStackOOM oom=new JavaVMStackOOM();
oom.stackLeakByThread();
}
}
2.4.3 方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。前面提到JDK 1.7开始逐步“去永久代”的事情,在此就以测试代码观察一下这件事对程序的实际影响。
String.intern() 是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过 -XX:PermSize
和 -XX:MaxPermSize
限制方法区大小,从而间接限制其中常量池的容量,如代码清单 2-6 所示。
/**
*VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M
*@author zzm
*/
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
//使用List保持着常量池引用, 避免Full GC回收常量池行为
List<String>list=new ArrayList<String>();
//10MB的PermSize在integer范围内足够产生OOM了
int i=0;
while(true){
list.add(String.valueOf(i++).intern());
}
}
}
而使用 JDK 1.7 运行这段程序就不会得到相同的结果,while 循环将一直进行下去。关于这个字符串常量池的实现问题,还可以引申出一个更有意思的影响,如代码清单 2-7 所示。
public class RuntimeConstantPoolOOM{
public static void main(String[]args){
public static void main(String[]args){
String str1=new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern()==str1);
String str2=new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern()==str2);
}
}
}
这段代码在 JDK 1.6 中运行,会得到两个 false,而在 JDK 1.7 中运行,会得到一个 true 和一个 false。产生差异的原因是:在 JDK 1.6中,intern() 方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder 创建的字符串实例在 Java 堆上,所以必然不是同一个引用,将返回false。而 JDK 1.7(以及部分其他虚拟机,例如JRockit)的 intern() 实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此 intern() 返回的引用和由 StringBuilder 创建的那个字符串实例是同一个。对 str2 比较返回 false 是因为"java"这个字符串在执行 StringBuilder.toString() 之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串则是首次出现的,因此返回 true。
方法区用于存放 Class 的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这些区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API 也可以动态产生类(如反射时的 GeneratedConstructorAccessor 和动态代理等),但在本次实验中操作起来比较麻烦。在代码清单 2-8中,笔者借助 CGLib 直接操作字节码运行时生成了大量的动态类。
/**
*VM Args:-XX:PermSize=10M-XX:MaxPermSize=10M
*@author zzm
*/
public class JavaMethodAreaOOM{
public static void main(String[]args){
while(true){
Enhancer enhancer=new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor(){
public Object intercept(Object obj,Method method,Object[]args,MethodProxy proxy)throws Throwable{
return proxy.invokeSuper(obj,args);
}
});
enhancer.create();
}
}
static class OOMObject{
}
}
值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如 Spring、Hibernate,在对类进行增强时,都会使用到 CGLib 这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的 Class 可以加载入内存。另外,JVM 上的动态语言(例如Groovy等)通常都会持续创建类来实现语言的动态性,随着这类语言的流行,也越来越容易遇到与代码清单 2-8 相似的溢出场景。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量 Class 的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了 CGLib 字节码增强和动态语言之外,常见的还有:大量JSP 或动态产生 JSP 文件的应用(JSP第一次运行时需要编译为Java类)、基于 OSGi 的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
2.4.4 本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize
指定,如果不指定,则默认与 Java 堆最大值(-Xmx指定)一样,代码清单 2-9 越过了 DirectByteBuffer 类,直接通过反射获取 Unsafe 实例进行内存分配(Unsafe 类的 getUnsafe() 方法限制了只有引导类加载器才会返回实例,也就是设计者希望只有 rt.jar 中的类才能使用 Unsafe 的功能)。因为,虽然使用 DirectByteBuffer 分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是 unsafe.allocateMemory()
。
/**
*VM Args:-Xmx20M-XX:MaxDirectMemorySize=10M
*@author zzm
*/
public class DirectMemoryOOM{
private static final int_1MB=1024*1024;
public static void main(String[]args)throws Exception{
Field unsafeField=Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe=(Unsafe)unsafeField.get(null);
while(true){
unsafe.allocateMemory(_1MB);
}
}
}
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果读者发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,那就可以考虑检查一下是不是这方面的原因。
2.5 本章小结
通过本章的学习,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然 Java 有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因,第3章将详细讲解 Java 垃圾收集机制为了避免内存溢出异常的出现都做了哪些努力。
2.4.3 - CH03-垃圾收集与分配策略
3.1 概述
当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的(动态内存分配与内存回收)技术实施必要的监控和调节。
第 2 章介绍了 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由 JIT 编译器进行一些优化,但在本章基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存,本章后续讨论中的“内存”分配与回收也仅指这一部分内存。
3.2 对象已死?
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
引用技术法
很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。作者面试过很多的应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法(Reference Counting)的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的 COM(Component Object Model)技术、使用 ActionScript 3 的FlashPlayer、Python 语言和在游戏脚本领域被广泛应用的 Squirrel 中都使用了引用计数算法进行内存管理。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决 对象之间相互循环引用 的问题。
举个简单的例子,请看代码清单 3-1 中的 testGC() 方法:对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。
/**
*testGC()方法执行后, objA和objB会不会被GC呢?
*@author zzm
*/
public class ReferenceCountingGC{
public Object instance=null;
private static final int_1MB=1024*1024;
/**
*这个成员属性的唯一意义就是占点内存, 以便能在GC日志中看清楚是否被回收过
*/
private byte[]bigSize=new byte[2*_1MB];
public static void testGC(){
ReferenceCountingGC objA=new ReferenceCountingGC();
ReferenceCountingGC objB=new ReferenceCountingGC();
objA.instance=objB;
objB.instance=objA;
objA=null;
objB=null;
//假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}
从运行结果中可以清楚看到,GC 日志中包含"4603K->210K",意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。
3.2.2 可达性分析算法
在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。如图 3-1 所示,对象 object 5、object 6、object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会被判定为是可回收的对象。
在Java语言中,可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的Native方法)引用的对象。
3.2.3 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在 JDK 1.2 以前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。
在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这 4 种引用强度依次逐渐减弱。
强引用就是指在程序代码之中普遍存在的,类似"Object obj=new Object()“这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1. 2之后,提供了 WeakReference 类来实现弱引用。
虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了 PhantomReference 类来实现虚引用。
3.2.4 生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在 finalize() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从代码清单 3-2 中我们可以看到一个对象的 finalize() 被执行,但是它仍然可以存活。
/**
*此代码演示了两点:
*1.对象可以在被 GC 时自我拯救。
*2.这种自救的机会只有一次, 因为一个对象的 finalize() 方法最多只会被系统自动调用一次
*@author zzm
*/
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 mehtod executed!");
FinalizeEscapeGC.SAVE_HOOK=this;
}
public static void main(String[]args)throws Throwable{
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:(");
}
//下面这段代码与上面的完全相同, 但是这次自救却失败了
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:(");
}
}
}
从代码清单3-2的运行结果可以看出,SAVE_HOOK 对象的 finalize() 方法确实被 GC 收集器触发过,并且在被收集前成功逃脱了。
另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行,因此第二段代码的自救行动失败了。
需要特别说明的是,上面关于对象死亡时 finalize() 方法的描述可能带有悲情的艺术色彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是 C/C++ 中的析构函数,而是 Java 刚诞生时为了使 C/C++ 程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对这个方法用途的一种自我安慰。finalize() 能做的所有工作,使用 try-finally 或者其他方式都可以做得更好、更及时,所以笔者建议大家完全可以忘掉 Java 语言中有这个方法的存在。
3.2.5 回收方法区
很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java 虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做"abc"的,换句话说,就是没有任何 String 对象引用常量池中的"abc"常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot 虚拟机提供了-Xnoclassgc
参数进行控制,还可以使用-verbose:class
以及-XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息,其中-verbose:class
和-XX:+TraceClassLoading
可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading
参数需要 FastDebug 版的虚拟机支持。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
3.3 垃圾回收算法
3.3.1 标记-清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实在前一节讲述对象标记判定时已经介绍过了。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记—清除算法的执行过程如图 3-2 所示。
3.3.2 复制算法
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,代价未免太高了一点。复制算法的执行过程如图3-3所示。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉Eden和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivo r空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于对新生代进行分配担保的内容,在本章稍后在讲解垃圾收集器执行规则时还会再详细讲解。
3.3.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如图3-4所示。
3.3.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
3.4 HotSpot 算法实现
3.2 节和 3.3 节从理论上介绍了对象存活判定算法和垃圾收集算法,而在 HotSpot 虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
3.4.1 枚举根节点
从可达性分析中从 GC Roots 节点找引用链这个操作为例,可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程(Sun将这件事情称为"Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
由于目前的主流 Java 虚拟机使用的都是准确式 GC(这个概念在第 1 章介绍Exact VM对 Classic VM 的改进时讲过),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在 HotSpot 的实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完成的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC 在扫描时就可以直接得知这些信息了。下面的代码清单 3-3 是 HotSpot Client VM 生成的一段 String.hashCode() 方法的本地代码,可以看到在 0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为 16 的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从 call 指令开始直到 0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即 hlt 指令为止。
[Verified Entry Point]
0x026eb730:mov%eax, -0x8000(%esp)
……
;ImplicitNullCheckStub slow case
0x026eb7a9:call 0x026e83e0
;OopMap{ebx=Oop[16]=Oop off=142}
;*caload
;-java.lang.String:hashCode@48(line 1489)
;{runtime_call}
0x026eb7ae:push$0x83c5c18
;{external_word}
0x026eb7b3:call 0x026eb7b8
0x026eb7b8:pusha
0x026eb7b9:call 0x0822bec0;{runtime_call}
0x026eb7be:hlt
3.4.2 安全点
在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外空间,这样 GC 的空间成本将会变得很高。
实际上,HotSpot 也的确没有为每条指令都生成 OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。Safepoint 的选定既不能太少以致于让 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 Safepoint。
对于 Sefepoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。
而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。下面代码清单 3-4 中的 test 指令是 HotSpot 生成的轮询指令,当需要暂停线程时,虚拟机把 0x160100 的内存页设置为不可读,线程执行到 test 指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条汇编指令便完成安全点轮询和触发线程中断。
0x01b6d627:call 0x01b2b210;OopMap{[60]=Oop off=460}
;*invokeinterface size
;-Client1:main@113(line 23)
;{virtual_call}
0x01b6d62c:nop
;OopMap{[60]=Oop off=461}
;*if_icmplt
;-Client1:main@118(line 23)
0x01b6d62d:test%eax, 0x160100;{poll}
0x01b6d633:mov 0x50(%esp), %esi
0x01b6d637:cmp%eax, %esi
3.4.3 安全区域
使用 Safepoint 似乎已经完美地解决了如何进入 GC 的问题,但实际情况却并不一定。Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safepoint。
在线程执行到 Safe Region 中的代码时,首先标识自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起 GC 时,就不用管标识自己为 Safe Region 状态的线程了。在线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开 Safe Region 的信号为止。
到此,笔者简要地介绍了 HotSpot 虚拟机如何去发起内存回收的问题,但是虚拟机如何具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的 GC 收集器决定的,而通常虚拟机中往往不止有一种 GC 收集器。下面继续来看 HotSpot 中有哪些 GC 收集器。
3.5 垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于 JDK 1.7 Update 14 之后的 HotSpot 虚拟机(在这个版本中正式提供了商用的 G1 收集器,之前 G1 仍处于实验状态),这个虚拟机包含的所有收集器如图 3-5 所示。
图 3-5 展示了 7 种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。接下来笔者将逐一介绍这些收集器的特性、基本原理和使用场景,并重点分析 CMS 和 G1 这两款相对复杂的收集器,了解它们的部分运作细节。
在介绍这些收集器各自的特性之前,我们先来明确一个观点:虽然我们是在对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有最好的收集器出现,更加没有万能的收集器,所以我们选择的只是对具体应用最合适的收集器。这点不需要多加解释就能证明:如果有一种放之四海皆准、任何场景下都适用的完美收集器存在,那 HotSpot 虚拟机就没必要实现那么多不同的收集器了。
3.5.1 Serial 收集器
Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代收集的唯一选择。大家看名字就会知道,这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World"这个名字也许听起来很酷,但这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。读者不妨试想一下,要是你的计算机每运行一个小时就会暂停响应 5 分钟,你会有什么样的心情?图 3-6 示意了 Serial/Serial Old 收集器的运行过程。
对于"Stop The World"带给用户的不良体验,虚拟机的设计者们表示完全理解,但也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质的,但实际上肯定还要比打扫房间复杂得多啊!
从 JDK 1.3 开始,一直到现在最新的 JDK 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)乃至 GC 收集器的最前沿成果 Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括 RTSJ 中的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!
写到这里,笔者似乎已经把 Serial 收集器描述成一个“老而无用、食之无味弃之可惜”的鸡肋了,但实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。
3.5.2 ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio
、-XX:PretenureSizeThreshold
、-XX:HandlePromotionFailure
等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如图 3-7 所示。
ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器——CMS 收集器(Concurrent Mark Sweep,本节稍后将详细介绍这款收集器),这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,用前面那个例子的话来说,就是做到了在你的妈妈打扫房间的时候你还能一边往地上扔纸屑。
不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。ParNew 收集器也是使用 -XX:+UseConcMarkSweepGC
选项后的默认新生代收集器,也可以使用 -XX:+UseParNewGC
选项来强制指定它。
ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。当然,随着可以使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(譬如32个,现在 CPU 动辄就4核加超线程,服务器超过 32 个逻辑 CPU 的情况越来越多了)的环境下,可以使用 -XX:ParallelGCThreads
参数来限制垃圾收集的线程数。
注意 从 ParNew 收集器开始,后面还会接触到几款并发和并行的收集器。在大家可能产生疑惑之前,有必要先解释两个名词:并发和并行。这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。
- 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
3.5.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器……看上去和 ParNew 都一样,那它有什么特别之处呢?
Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis
参数以及直接设置吞吐量大小的 -XX:GCTimeRatio
参数。
MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不过大家不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。
GCTimeRatio 参数的值应当是一个大于 0 且小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大GC时间就占总时间的 5%(即1/(1+19)),默认值为 99,就是允许最大 1%(即1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge 收集器还有一个参数 -XX:+UseAdaptiveSizePolicy
值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象年龄(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作原来不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大堆),然后使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或 GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。
3.5.4 Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。这两点都将在后面的内容中详细讲解。Serial Old 收集器的工作过程如图 3-8 所示。
3.5.5 Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外别无选择(还记得上面说过 Parallel Scavenge 收集器无法与 CMS 收集器配合工作吗?)。由于老年代 Serial Old 收集器在服务端应用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。
直到 Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作过程如图3-9所示。
3.5.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
从名字(包含"Mark Sweep”)上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要"Stop The World"。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。通过图 3-10 可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。
CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,Sun 公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但是 CMS 还远达不到完美的程度,它有以下 3 个明显的缺点:
CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU数量+3)/4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(譬如2个)时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的 CMS 收集器变种,所做的事情和单 CPU 年代 PC 机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让 GC 线程、用户线程交替运行,尽量减少 GC 线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,也就是速度下降没有那么明显。实践证明,增量时的 CMS 收集器效果很一般,在目前版本中, i-CMS 已经被声明为 “deprecated”,即不再提倡用户使用。
CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现"Concurrent Mode Failure"失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。在 JDK 1.5 的默认设置下,CMS 收集器当老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数 -XX:CMSInitiatingOccupancyFraction
的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在 JDK 1.6 中,CMS 收集器的启动阈值已经提升至 92%。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX:CM SInitiatingOccupancyFraction
设置得太高很容易导致大量"Concurrent Mode Failure"失败,性能反而降低。
还有最后一个缺点,在本节开头说过,CMS 是一款基于“标记—清除”算法实现的收集器,如果读者对前面这种算法介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection
开关参数(默认就是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction
,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。
3.5.7 G1 收集器
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,早在 JDK 1.7 刚刚确立项目目标,Sun 公司给出的 JDK 1.7 RoadMap 里面,它就被视为 JDK 1.7 中 HotSpot 虚拟机的一个重要进化特征。从 JDK 6u14 中开始就有 Early Access 版本的 G1 收集器供开发人员实验、试用,由此开始 G1 收集器的"Experimental"状态持续了数年时间,直至 JDK 7u4,Sun 公司才认为它达到足够成熟的商用程度,移除了"Experimental"的标识。
G1 是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。与其他 GC 收集器相比,G1 具备如下特点。
并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
空间整合:与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
G1 把内存“化整为零”的思路,理解起来似乎很容易,但其中的实现细节却远远没有想象中那样简单,否则也不会从 2004 年 Sun 实验室发表第一篇 G1 的论文开始直到今天(将近10年时间)才开发出 G1 的商用版。笔者以一个细节为例:把 Java 堆分为多个 Region 后,垃圾收集是否就真的能以 Region 为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region 不可能是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其他对象引用,而是可以与整个 Java 堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个 Java 堆才能保证准确性?这个问题其实并非在 G1 中才有,只是在 G1 中更加突出而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象时也面临相同的问题,如果回收新生代时也不得不同时扫描老年代的话,那么 Minor GC 的效率可能下降不少。
在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
对 CMS 收集器运作过程熟悉的读者,一定已经发现 G1 的前几个步骤的运作过程和 CMS 有很多相似之处。初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,从 Sun 公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。通过图 3-11 可以比较清楚地看到 G1 收集器的运作步骤中并发和需要停顿的阶段。
由于目前 G1 成熟版本的发布时间还很短,G1 收集器几乎可以说还没有经过实际应用的考验,网络上关于 G1 收集器的性能测试也非常贫乏,到目前为止,笔者还没有搜索到有关的生产环境下的性能测试报告。强调“生产环境下的测试报告”是因为对于垃圾收集器来说,仅仅通过简单的 Java 代码写个 Microbenchmark 程序来创建、移除 Java 对象,再用 -XX:+PrintGCDetails
等参数来查看 GC 日志是很难做到准确衡量其性能的。因此,关于 G1 收集器的性能部分,笔者引用了 Sun 实验室的论文《Garbage-First Garbage Collection》中的一段测试数据。
此处略去对 G1 的性能度量部分。
3.5.8 理解 GC 日志
阅读 GC 日志是处理 Java 虚拟机内存问题的基础技能,它只是一些人为确定的规则,没有太多技术含量。在本书的第 1 版中没有专门讲解如何阅读分析 GC 日志,为此作者收到许多读者来信,反映对此感到困惑,因此专门增加本节内容来讲解如何理解 GC 日志。
每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的 GC 日志:
33.125:[GC[DefNew:3324K->152K(3712K), 0.0025925 secs]3324K->152K(11904K), 0.0031680 secs]
100.667:[Full GC[Tenured:0 K->210K(10240K), 0.0149142secs]4603K->210K(19456K), [Perm:2999K->2999K(21248K)], 0.0150007 secs][Times:user=0.01 sys=0.00, real=0.02 secs]
最前面的数字“33.125:”和“100.667:”代表了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来经过的秒数。
GC日志开头的 [GC
和 [Full GC
说明了这次垃圾收集的停顿类型,而不是用来区分新生代 GC 还是老年代 GC 的。如果有"Full",说明这次 GC 是发生了 Stop-The-World 的,例如下面这段新生代收集器 ParNew 的日志也会出现 [Full GC
(这一般是因为出现了分配担保失败之类的问题,所以才导致 STW)。如果是调用System.gc() 方法所触发的收集,那么在这里将显示 [Full GC(System)
。
[Full GC 283.736:[ParNew:261599K->261599K(261952K), 0.0000288 secs]
接下来的 [DefNew
、[Tenured
、[Perm
表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的,例如上面样例所使用的 Serial 收集器中的新生代名为"Default New Generation",所以显示的是 [DefNew
。如果是 ParNew 收集器,新生代名称就会变为 [ParNew
,意为"Parallel New Generation"。如果采用 Parallel Scavenge 收集器,那它配套的新生代称为"PSYoungGen",老年代和永久代同理,名称也是由收集器决定的。
后面方括号内部的"3324K->152K(3712K)“含义是“GC 前该内存区域已使用容量->GC 后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的"3324K->152K(11904K)“表示“ GC 前 Java 堆已使用容量->GC 后 Java 堆已使用容量(Java 堆总容量)”。
再往后,“0.0025925 secs"表示该内存区域 GC 所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如 [Times:user=0.01 sys=0.00,real=0.02 secs]
,这里面的 user、sys 和 real 与 Linux 的 time 命令所输出的时间含义一致,分别代表用户态消耗的 CPU 时间、内核态消耗的 CPU 时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU 时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘 I/O、等待线程阻塞,而 CPU 时间不包括这些耗时,但当系统有多 CPU 或者多核的话,多线程操作会叠加这些 CPU 时间,所以读者看到 user 或 sys 时间超过 real 时间是完全正常的。
3.5.9 垃圾收集器参数总结
JDK 1.7 中的各种垃圾收集器到此已全部介绍完毕,在描述过程中提到了很多虚拟机非稳定的运行参数,在表 3-2 中整理了这些参数供读者实践时参考。
3.6 内存分配与回收策略
Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们已经使用了大量篇幅去介绍虚拟机中的垃圾收集器体系以及运作原理,现在我们再一起来探讨一下给对象分配内存的那点事儿。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。本节下面的代码在测试时使用 Client 模式虚拟机运行,没有手工指定收集器组合,换句话说,验证的是在使用 Serial/Serial Old 收集器下(ParNew/Serial Old 收集器组合的规则也基本一致)的内存分配和回收的策略。读者不妨根据自己项目中使用的收集器写一些程序去验证一下使用其他几种收集器的内存分配策略。
3.6.1 对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC
。
虚拟机提供了 -XX:+PrintGCDetails
这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不多,直接阅读就能看得很清楚。
代码清单 3-5 的 testAllocation() 方法中,尝试分配 3 个 2MB 大小和 1 个 4MB 大小的对象,在运行时通过 -Xms20M、-Xmx20M、-Xmn10M 这 3 个参数限制了 Java 堆大小为 20MB,不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。-XX:SurvivorRatio=8
决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8:1,从输出的结果也可以清晰地看到"eden space 8192K、from space 1024K、to space 1024K"的信息,新生代总可用空间为 9216KB(Eden 区+1个 Survivor 区的总容量)。
执行 testAllocation() 中分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次 GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。
这次 GC 结束后,4MB 的 allocation4 对象顺利分配在 Eden 中,因此程序执行完的结果是 Eden 占用 4MB(被 allocation4 占用),Survivor 空闲,老年代被占用 6MB(被 allocation1、allocation2、allocation3 占用)。通过 GC 日志可以证实这一点。
注意:作者多次提到的 Minor GC 和 Full GC 有什么不一样吗?
- 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
- 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
代码清单3-5 新生代 Minor GC:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
-XX:SurvivorRatio=8
*/
public static void testAllocation(){
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
}
执行结果:
[GC[DefNew:6651K->148K(9216K), 0.0070106 secs]6651K->6292K(19456K),
0.0070426 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4326K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 14%used[0x032d0000, 0x032f5370, 0x033d0000)
to space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
tenured generation total 10240K,used 6144K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 60%used[0x033d0000, 0x039d0030, 0x039d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
3.6.2 大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的 byte[]
数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替 Java 虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个 -XX:PretenureSizeThreshold
参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。
执行代码清单 3-6 中的 testPretenureSizeThreshold() 方法后,我们看到 Eden 空间几乎没有被使用,而老年代的 10MB 空间被使用了 40%,也就是 4MB 的 allocation 对象直接就分配在老年代中,这是因为 PretenureSizeThreshold 被设置为 3MB(就是 3145728,这个参数不能像 -Xmx 之类的参数一样直接写 3MB),因此超过 3MB 的对象都会直接在老年代进行分配。注意 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。
代码清单 3-6 大对象直接进入老年代:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold(){
byte[]allocation;
allocation=new byte[4*_1MB];//直接分配在老年代中
}
运行结果:
Heap
def new generation total 9216K,used 671K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 8%used[0x029d0000, 0x02a77e98, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 40%used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2107K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fdefd0, 0x03fdf000, 0x049d0000)
No shared spaces configured.
3.6.3 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
设置。
读者可以试试分别以 -XX:MaxTenuringThreshold=1
和 -XX:MaxTenuringThreshold=15
两种设置来执行代码清单 3-7 中的 testTenuringThreshold() 方法,此方法中的 allocation1 对象需要 256KB 内存,Survivor 空间可以容纳。当 MaxTenuringThreshold=1 时,allocation1 对象在第二次 GC 发生时进入老年代,新生代已使用的内存 GC 后非常干净地变成 0KB。而 MaxTenuringThreshold=15 时,第二次 GC 发生后,allocation1 对象则还留在新生代 Survivor 空间,这时新生代仍然有 404KB 被占用。
代码清单3-7 长期存活的对象进入老年代:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=1
*-XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold(){
byte[]allocation1, allocation2, allocation3;
allocation1=new byte[_1MB/4];
//什么时候进入老年代取决于XX:MaxTenuringThreshold设置
allocation2=new byte[4*_1MB];
allocation3=new byte[4*_1MB];
allocation3=null;
allocation3=new byte[4*_1MB];
}
以 MaxTenuringThreshold=1 参数来运行的结果:
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 1(max 1) -age 1:414664 bytes, 414664 total :4859K->404K(9216K), 0.0065012 secs]4859K->4500K(19456K), 0.0065283 secs [Times:user=0.02 sys=0.00, real=0.02 secs]
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 1(max 1):4500K->0K(9216K), 0.0009253 secs]8596K->4500K(19456K),0.0009458 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000,0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4500K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 43%used[0x033d0000, 0x03835348, 0x03835400, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
以MaxTenuringThreshold=15参数来运行的结果:
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 15(max 15) -age 1:414664 bytes, 414664 total:4859K->404K(9216K), 0.0049637 secs]4859K->4500K(19456K), 0.0049932 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
[GC[DefNew Desired Survivor size 524288 bytes,new threshold 15(max 15) -age 2:414520 bytes, 414520 total :4500K->404K(9216K), 0.0008091 secs]8596K->4500K(19456K), 0.0008305 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4582K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 39%used[0x031d0000, 0x03235338, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 40%used[0x033d0000, 0x037d0010, 0x037d0200, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe0998, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
3.6.4 动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
执行代码清单 3-8 中的 testTenuringThreshold2() 方法,并设置 -XX:MaxTenuringThreshold=15
,会发现运行结果中 Survivor 的空间占用仍然为 0%,而老年代比预期增加了 6%,也就是说,allocation1、allocation2 对象都直接进入了老年代,而没有等到 15 岁的临界年龄。因为这两个对象加起来已经到达了 512KB,并且它们是同年的,满足同年对象达到 Survivor 空间的一半规则。我们只要注释掉其中一个对象 new 操作,就会发现另外一个就不会晋升到老年代中去了。
代码清单3-8 动态对象年龄判定:
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=15
*-XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2(){
byte[]allocation1, allocation2, allocation3, allocation4;
allocation1=new byte[_1MB/4];
//allocation1+allocation2大于survivo空间一半
allocation2=new byte[_1MB/4];
allocation3=new byte[4*_1MB];
allocation4=new byte[4*_1MB];
allocation4=null;
allocation4=new byte[4*_1MB];
}
运行结果:
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1(max 15)
-age 1:676824 bytes, 676824 total
:5115K->660K(9216K), 0.0050136 secs]5115K->4756K(19456K), 0.0050443 secs][Times:user=0.00 sys=0.01, real=0.01 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15(max 15)
:4756K->0K(9216K), 0.0010571 secs]8852K->4756K(19456K), 0.0011009 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000, 0x033d0000, 0x033d0000)
eden space 8192K, 51%used[0x029d0000, 0x02de4828, 0x031d0000)
from space 1024K, 0%used[0x031d0000, 0x031d0000, 0x032d0000)
to space 1024K, 0%used[0x032d0000, 0x032d0000, 0x033d0000)
tenured generation total 10240K,used 4756K[0x033d0000, 0x03dd0000, 0x03dd0000)
the space 10240K, 46%used[0x033d0000, 0x038753e8, 0x03875400, 0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000, 0x049d0000, 0x07dd0000)
the space 12288K, 17%used[0x03dd0000, 0x03fe09a0, 0x03fe0a00, 0x049d0000)
No shared spaces configured.
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure 开关打开,避免 Full GC 过于频繁,参见代码清单3-9,请读者在 JDK 6 Update 24 之前的版本中运行测试。
代码清单3-9 空间分配担保:
private static final int_1MB=1024*1024;
/**
*VM参数:-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:-HandlePromotionFailure
*/
@SuppressWarnings("unused")
public static void testHandlePromotion(){
byte[]allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
allocation1=new byte[2*_1MB];
allocation2=new byte[2*_1MB];
allocation3=new byte[2*_1MB];
allocation1=null;
allocation4=new byte[2*_1MB];
allocation5=new byte[2*_1MB];
allocation6=new byte[2*_1MB];
allocation4=null;
allocation5=null;
allocation6=null;
allocation7=new byte[2*_1MB];
}
以HandlePromotionFailure=false参数来运行的结果:
[GC[DefNew:6651K->148K(9216K), 0.0078936 secs]6651K->4244K(19456K), 0.0079192 secs][Times:user=0.00 sys=0.02, real=0.02 secs]
[GC[DefNew:6378K->6378K(9216K), 0.0000206secs][Tenured:4096K->4244K(10240K), 0.0042901 secs]10474K->4244K(19456K), [Perm:2104K->2104K(12288K)], 0.0043613 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
以HandlePromotionFailure=true参数来运行的结果:
[GC[DefNew:6651K->148K(9216K), 0.0054913 secs]6651K->4244K(19456K), 0.0055327 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
[GC[DefNew:6378K->148K(9216K), 0.0006584 secs]10474K->4244K(19456K), 0.0006857 secs][Times:user=0.00 sys=0.00, real=0.00 secs]
在 JDK 6 Update 24 之后,这个测试结果会有差异,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化(见代码清单3-10),虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。
代码清单3-10 HotSpot中空间分配检查的代码片段:
bool TenuredGeneration:promotion_attempt_is_safe(size_t
max_promotion_in_bytes)const{
//老年代最大可用的连续空间
size_t available=max_contiguous_available();
//每次晋升到老年代的平均大小
size_t av_promo=(size_t)gc_stats()->avg_promoted()->padded_average();
//老年代可用空间是否大于平均晋升大小, 或者老年代可用空间是否大于当此GC时新生代所有对象容量
bool res=(available>=av_promo)||(available>=
max_promotion_in_bytes);
return res;
}
3.7 本章小结
本章介绍了垃圾收集的算法、几款 JDK 1.7 中提供的垃圾收集器特点以及运作原理。通过代码实例验证了 Java 虚拟机中自动内存分配及回收的主要规则。
内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求、实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。因此,学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。在接下来的两章中,作者将会介绍内存分析的工具和调优的一些具体案例。
2.4.4 - CH04-性能监控与故障处理
4.1 概述
给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC 日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。经常使用适当的虚拟机监控和分析的工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应当意识到工具永远都是知识技能的一层包装,没有什么工具是“秘密武器”,不可能学会了就能包治百病。
4.2 JDK 命令行工具
Java 开发人员肯定都知道 JDK 的 bin 目录中有"java.exe"、“javac.exe"这两个命令行工具,但并非所有程序员都了解过JDK的bin目录之中其他命令行程序的作用。每逢JDK更新版本之时,bin 目录下命令行工具的数量和功能总会不知不觉地增加和增强。bin目录的内容如图 4-1 所示。
说起 JDK 的工具,比较细心的读者,可能会注意到这些工具的程序体积都异常小巧。假如以前没注意到,现在不妨再看看图 4-1 中的最后一列“大小”,几乎所有工具的体积基本上都稳定在 27KB 左右。并非 JDK 开发团队刻意把它们制作得如此精炼来炫耀编程水平,而是因为这些命令行工具大多数是 jdk/lib/tools.jar
类库的一层薄包装而已,它们主要的功能代码是在 tools 类库中实现的。读者把图 4-1 和图 4-2 两张图片对比一下就可以看得很清楚。
假如读者使用的是Linux版本的JDK,还会发现这些工具中很多甚至就是由Shell脚本直接写成的,可以用vim直接打开它们。
JDK开发团队选择采用Java代码来实现这些监控工具是有特别用意的:当应用程序部署到生产环境后,无论是直接接触物理服务器还是远程Telnet到服务器上都可能会受到限制。借助tools.jar类库里面的接口,我们可以直接在应用程序中实现功能强大的监控分析功能。
需要特别说明的是,本章介绍的工具全部基于Windows平台下的JDK 1.6 Update 21,如果JDK版本、操作系统不同,工具所支持的功能可能会有较大差别。大部分工具在JDK 1.5中就已经提供,但为了避免运行环境带来的差异和兼容性问题,建议读者使用JDK 1.6来验证本章介绍的内容,因为JDK 1.6的工具可以正常兼容运行于JDK 1.5的虚拟机之上的程序,反之则不一定。表4-1中说明了JDK主要命令行监控工具的用途。
注意 如果读者在工作中需要监控运行于JDK 1.5的虚拟机之上的程序,在程序启动时请添加参数”-Dcom.sun.management.jmxremote"开启JMX管理功能,否则由于部分工具都是基于JMX(包括4.3节介绍的可视化工具),它们都将会无法使用,如果被监控程序运行于JDK 1.6的虚拟机之上,那JMX管理默认是开启的,虚拟机启动时无须再添加任何参数。
- jps:JVM Process Status Tool,显示指定系统内所有的 HotSpot 进程。
- jstat:JVM Statiistics Monitoring Tool,用于收集 HotSpot 各方面的运行数据。
- jinfo:Configuration Info for Java,显示虚拟机配置信息。
- jmap:Memory Map for Java,生成虚拟机的内存转储快照。
- jhat:JVM Heap Dump Browser,用于分析 heandump 文件。
- jstack:Stack Trace for Java,显示虚拟机的线程快照。
4.2.1 jps:虚拟机进程状况工具
可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。虽然功能比较单一,但它是使用频率最高的JDK命令行工具,因为其他的JDK工具大多需要输入它查询到的LVMID来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(Process Identifier,PID)是一致的,使用Windows的任务管理器或者UNIX的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就只能依赖jps命令显示主类的功能才能区分了。
执行方式:
jps[options][hostid]
jps可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,hostid为RMI注册表中注册的主机名。jps的其他常用选项见表4-2。
4.2.2 jstat:虚拟机统计信息显示工具
jstat 是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。
命令格式为:
jstat[option vmid[interval[s|ms][count]]]
对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:][//]lvmid[@hostname[:port]/servername]
参数interval和count代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:
jstat-gc 2764 250 20
选项option代表着用户希望查询的虚拟机信息,主要分为3类:类装载、垃圾收集、运行期编译状况,具体选项及作用请参考表4-3中的描述。
4.2.3 jinfo:Java配置信息工具
作用是实时地查看和调整虚拟机各项参数。使用jps命令的-v参数可以查看虚拟机启动时显式指定的参数列表,但如果想知道未被显式指定的参数的系统默认值,除了去找资料外,就只能使用jinfo的-flag选项进行查询了(如果只限于JDK 1.6或以上版本的话,使用java-XX:+PrintFlagsFinal查看参数默认值也是一个很好的选择),jinfo还可以使用-sysprops选项把虚拟机进程的System.getProperties()的内容打印出来。这个命令在JDK 1.5时期已经随着Linux版的JDK发布,当时只提供了信息查询的功能,JDK 1.6之后,jinfo在Windows和Linux平台都有提供,并且加入了运行期修改参数的能力,可以使用-flag[+|-]name
或者-flag name=value
修改一部分运行期可写的虚拟机参数值。JDK 1.6中,jinfo对于Windows平台功能仍然有较大限制,只提供了最基本的-flag选项。
命令格式:
jinfo[option]pid
执行样例:查询CMSInitiatingOccupancyFraction参数值。
C:\>jinfo-flag CMSInitiatingOccupancyFraction 1444
-XX:CMSInitiatingOccupancyFraction=85
4.2.4 jmap:Java内存映像工具
用于生成堆转储快照(一般称为heapdump或dump文件)。如果不使用jmap命令,要想获取Java堆转储快照,还有一些比较“暴力”的手段:譬如在第2章中用过的-XX:+HeapDumpOnOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后自动生成dump文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用 Ctrl+Break 键让虚拟机生成dump文件,又或者在Linux系统下通过Kill-3命令发送进程退出信号“吓唬”一下虚拟机,也能拿到dump文件。
jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。
和jinfo命令一样,jmap有不少功能在Windows平台下都是受限的,除了生成dump文件的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统都提供之外,其余选项都只能在Linux/Solaris下使用。
命令格式:
jmap[option]vmid
option选项的合法值与具体含义见表4-4。
代码清单4-2是使用jmap生成一个正在运行的Eclipse的dump快照文件的例子,例子中的3500是通过jps命令查询到的LVMID。
C:\Users\IcyFenix>jmap-dump:format=b,file=eclipse.bin 3500
Dumping heap to C:\Users\IcyFenix\eclipse.bin……
Heap dump file created
4.2.5 jhat:虚拟机堆转储快照分析工具
Sun JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在浏览器中查看。不过实事求是地说,在实际工作中,除非笔者手上真的没有别的工具可用,否则一般都不会去直接使用jhat命令来分析dump文件,主要原因有二:一是一般不会在部署应用程序的服务器上直接分析dump文件,即使可以这样做,也会尽量将dump文件复制到其他机器上进行分析,因为分析工作是一个耗时而且消耗硬件资源的过程,既然都要在其他机器进行,就没有必要受到命令行工具的限制了;另一个原因是jhat的分析功能相对来说比较简陋,后文将会介绍到的VisualVM,以及专业用于分析dump文件的Eclipse Memory Analyzer、IBM HeapAnalyzer等工具,都能实现比jhat更强大更专业的分析功能。
命令格式:
jhat[heapdump.file]
4.2.6 jstack:Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做些什么事情,或者等待着什么资源。
命令格式:
jstack[option]vmid
option选项的合法值与具体含义见表4-5。
4.2.7 HSDIS:JIT生成代码反汇编
在Java虚拟机规范中,详细描述了虚拟机指令集中每条指令的执行过程、执行前后对操作数栈、局部变量表的影响等细节。这些细节描述与Sun的早期虚拟机(Sun Classic VM)高度吻合,但随着技术的发展,高性能虚拟机真正的细节实现方式已经渐渐与虚拟机规范所描述的内容产生了越来越大的差距,虚拟机规范中的描述逐渐成了虚拟机实现的“概念模型”——即实现只能保证规范描述等效。基于这个原因,我们分析程序的执行语义问题(虚拟机做了什么)时,在字节码层面上分析完全可行,但分析程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,需要通过其他方式解决。
分析程序如何执行,通过软件调试工具(GDB、Windbg等)来断点调试是最常见的手段,但是这样的调试方式在Java虚拟机中会遇到很大困难,因为大量执行代码是通过JIT编译器动态生成到CodeBuffer中的,没有很简单的手段来处理这种混合模式的调试(不过相信虚拟机开发团队内部肯定是有内部工具的)。因此,不得不通过一些特别的手段来解决问题,基于这种背景,本节的主角——HSDIS插件就正式登场了。
HSDIS是一个Sun官方推荐的HotSpot虚拟机JIT编译代码的反汇编插件,它包含在HotSpot虚拟机的源码之中,但没有提供编译后的程序。在Project Kenai的网站也可以下载到单独的源码。它的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把动态生成的本地代码还原为汇编代码输出,同时还生成了大量非常有价值的注释,这样我们就可以通过输出的代码来分析问题。读者可以根据自己的操作系统和CPU类型从Project Kenai的网站上下载编译好的插件,直接放到JDK_HOME/jre/bin/client和JDK_HOME/jre/bin/server目录中即可。如果没有找到所需操作系统(譬如Windows的就没有)的成品,那就得自己使用源码编译一下。
还需要注意的是,如果读者使用的是Debug或者FastDebug版的HotSpot,那可以直接通过-XX:+PrintAssembly指令使用插件;如果使用的是Product版的HotSpot,那还要额外加入一个-XX:+UnlockDiagnosticVMOptions参数。笔者以代码清单4-6中的简单测试代码为例演示一下这个插件的使用。
测试代码:
public class Bar{
int a=1;
static int b=2;
public int sum(int c){
return a+b+c;
}
public static void main(String[]args){
new Bar().sum(3);
}
}
编译这段代码,并使用以下命令执行。
java-XX:+PrintAssembly-Xcomp-XX:CompileCommand=dontinline, *Bar.sum-XX:Compi leCommand=compileonly, *Bar.sum test.Bar
其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样代码可以“偷懒”,不需要执行足够次数来预热就能触发JIT编译。两个 -XX:CompileCommand
意思是让编译器不要内联sum()并且只编译sum(),-XX:+PrintAssembly
就是输出反汇编内容。如果一切顺利的话,那么屏幕上会出现类似下面代码清单4-7所示的内容。
[Disassembling for mach='i386']
[Entry Point]
[Constants]
#{method}'sum''(I)I'in'test/Bar'
#this:ecx='test/Bar'
#parm0:edx=int
#[sp+0x20](sp of caller)
……
0x01cac407:cmp 0x4(%ecx), %eax
0x01cac40a:jne 0x01c6b050;{runtime_call}
[Verified Entry Point]
0x01cac410:mov%eax, -0x8000(%esp)
0x01cac417:push%ebp
0x01cac418:sub$0x18, %esp;*aload_0
;-test.Bar:sum@0(line 8)
;block B0[0, 10]
0x01cac41b:mov 0x8(%ecx), %eax;*getfield a
;-test.Bar:sum@1(line 8)
0x01cac41e:mov$0x3d2fad8, %esi;{oop(a
'java/lang/Class'='test/Bar')}
0x01cac423:mov 0x68(%esi), %esi;*getstatic b
;-test.Bar:sum@4(line 8)
0x01cac426:add%esi, %eax
0x01cac428:add%edx, %eax
0x01cac42a:add$0x18, %esp
0x01cac42d:pop%ebp
0x01cac42e:test%eax, 0x2b0100;{poll_return}
0x01cac434:ret
上段代码并不多,下面一句句进行说明。
- mov%eax,-0x8000(%esp):检查栈溢。
- push%ebp:保存上一栈帧基址。
- sub$0x18,%esp:给新帧分配空间。
- mov 0x8(%ecx),%eax:取实例变量a,这里0x8(%ecx)就是ecx+0x8的意思,前面"Constants"节中提示了"this:ecx=‘test/Bar’",即ecx寄存器中放的就是this对象的地址。偏移0x8是越过this对象的对象头,之后就是实例变量a的内存位置。这次是访问“Java堆”中的数据。
- mov$0x3d2fad8,%esi:取test.Bar在方法区的指针。
- mov 0x68(%esi),%esi:取类变量b,这次是访问“方法区”中的数据。
- add%esi,%eax和add%edx,%eax:做两次加法,求a+b+c的值,前面的代码把a放在eax中,把b放在esi中,而c在 Constants 中提示了,“parm0:edx=int”,说明c在edx中。
- add$0x18,%esp:撤销栈帧。
- pop%ebp:恢复上一栈帧。
- test%eax,0x2b0100:轮询方法返回处的SafePoint。
- ret:方法返回。
4.3 JDK的可视化工具
4.3.1 JConsole:Java监视与管理控制台
JConsole 是一种基于JMX的可视化监视、管理工具。它管理部分的功能是针对JMX MBean进行管理,由于MBean可以使用代码、中间件服务器的管理控制台或者所有符合JMX规范的软件进行访问,所以本节将会着重介绍JConsole监视部分的功能。
4.3.2 VisualVM:多合一故障处理工具
VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随JDK发布的功能最强大的运行监视和故障处理程序,并且可以预见在未来一段时间内都是官方主力发展的虚拟机故障处理工具。官方在VisualVM的软件说明中写上了"All-in-One"的描述字样,预示着它除了运行监视、故障处理外,还提供了很多其他方面的功能。如性能分析(Profiling),VisualVM的性能分析功能甚至比起JProfiler、YourKit等专业且收费的Profiling工具都不会逊色多少,而且VisualVM的还有一个很大的优点:不需要被监视的程序基于特殊Agent运行, 不需要被监视的程序基于特殊Agent运行,因此它对应用程序的实际性能的影响很小,使得它可以直接应用在生产环境中。这个优点是JProfiler、YourKit等工具无法与之媲美的。
4.4 本章小结
本章介绍了随JDK发布的6个命令行工具及两个可视化的故障处理工具,灵活使用这些工具可以给问题处理带来很大的便利。
除了JDK自带的工具之外,常用的故障处理工具还有很多,如果读者使用的是非Sun系列的JDK、非HotSpot的虚拟机,就需要使用对应的工具进行分析,如:
- IBM的Support Assistant、Heap Analyzer、Javacore Analyzer、Garbage Collector Analyzer适用于IBM J9 VM。
- HP的HPjmeter、HPjtune适用于HP-UX、SAP、HotSpot VM。
- Eclipse的Memory Analyzer Tool(MAT)适用于HP-UX、SAP、HotSpot VM,安装IBM DTFJ插件后可支持IBM J9 VM。
- BEA的JRockit Mission Control适用于JRockit VM。
2.4.5 - CH05-调优案例
5.1 概述
除了知识与工具外,经验同样是一个很重要的因素。
5.2 案例分析
5.2.1 高性能硬件上的程序部署策略
例如,一个 15 万PV/天左右的在线文档类型网站最近更换了硬件系统,新的硬件为 4 个 CPU、16GB 物理内存,操作系统为 64 位 CentOS 5.4,Resin 作为 Web 服务器。整个服务器暂时没有部署别的应用,所有硬件资源都可以提供给这访问量并不算太大的网站使用。管理员为了尽量利用硬件资源选用了 64 位的 JDK 1.5,并通过 -Xmx 和 -Xms 参数将 Java 堆固定在 12GB。使用一段时间后发现使用效果并不理想,网站经常不定期出现长时间失去响应的情况。
监控服务器运行状况后发现网站失去响应是由 GC 停顿导致的,虚拟机运行在 Server 模式,默认使用吞吐量优先收集器,回收 12GB 的堆,一次 Full GC 的停顿时间高达 14 秒。并且由于程序设计的关系,访问文档时要把文档从磁盘提取到内存中,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代,没有在 Minor GC 中清理掉。这种情况下即使有 12GB 的堆,内存也很快被消耗殆尽,由此导致每隔十几分钟出现十几秒的停顿,令网站开发人员和管理员感到很沮丧。
这里先不延伸讨论程序代码问题,程序部署上的主要问题显然是过大的堆内存进行回收时带来的长时间的停顿。硬件升级前使用 32 位系统 1.5GB 的堆,用户只感觉到使用网站比较缓慢,但不会发生十分明显的停顿,因此才考虑升级硬件以提升程序效能,如果重新缩小给 Java 堆分配的内存,那么硬件上的投资就显得很浪费。
在高性能硬件上部署程序,目前主要有两种方式:
- 通过 64 位 JDK 来使用大内存。
- 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。
此案例中的管理员采用了第一种部署方式。对于用户交互性强、对停顿时间敏感的系统,可以给 Java 虚拟机分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用,譬如十几个小时乃至一天才出现一次 Full GC,这样可以通过在深夜执行定时任务的方式触发 Full GC 甚至自动重启应用服务器来保持内存可用空间在一个稳定的水平。
控制 Full GC 频率的关键是看应用中绝大多数对象能否符合“朝生夕灭”的原则,即大多数对象的生存时间不应太长,尤其是不能有成批量的、长生存时间的大对象产生,这样才能保障老年代空间的稳定。
在大多数网站形式的应用里,主要对象的生存周期都应该是请求级或者页面级的,会话级和全局级的长生命对象相对很少。只要代码写得合理,应当都能实现在超大堆中正常使用而没有 Full GC,这样的话,使用超大堆内存时,网站响应速度才会比较有保证。除此之外,如果读者计划使用 64 位 JDK 来管理大内存,还需要考虑下面可能面临的问题:
- 内存回收导致的长时间停顿。
- 现阶段,64 位 JDK 的性能测试结果普遍低于 32 位 JDK。
需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生十几 GB 乃至更大的 HeapDump 文件),哪怕产生了快照也几乎无法进行分析。
相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。
上面的问题听起来有点吓人,所以现阶段不少管理员还是选择第二种方式:使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。具体做法是在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。读者不需要太过在意均衡器转发所消耗的性能,即使使用 64 位 JDK,许多应用也不止有一台服务器,因此在许多应用中前端的均衡器总是要存在的。
考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性需求,也不需要保证每个虚拟机进程有绝对准确的均衡负载,因此使用无 Session 复制的亲合式集群是一个相当不错的选择。我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据 SessionID 分配)将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑了。
当然,很少有没有缺点的方案,如果读者计划使用逻辑集群的方式来部署程序,可能会遇到下面一些问题:尽量避免节点竞争全局的资源,最典型的就是磁盘竞争,各个节点如果同时访问某个磁盘文件的话(尤其是并发写操作容易出现问题),很容易导致 IO 异常。
很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余。尽管可以使用集中式的 JNDI,但这个有一定复杂性并且可能带来额外的性能开销。
各个节点仍然不可避免地受到 32 位的内存限制,在 32 位 Windows 平台中每个进程只能使用 2GB 的内存,考虑到堆以外的内存开销,堆一般最多只能开到 1.5GB。在某些 Linux 或 UNIX 系统(如 Solaris)中,可以提升到 3GB 乃至接近 4GB 的内存,但 32 位中仍然受最高 4GB(2^{32})内存的限制。
大量使用本地缓存(如大量使用 HashMap 作为 K/V 缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存。
介绍完这两种部署方式,再重新回到这个案例之中,最后的部署方案调整为建立 5 个 32 位 JDK 的逻辑集群,每个进程按 2GB 内存计算(其中堆固定为1.5GB),占用了 10GB 内存。另外建立一个 Apache 服务作为前端均衡代理访问门户。考虑到用户对响应速度比较关心,并且文档服务的主要压力集中在磁盘和内存访问,CPU 资源敏感度较低,因此改为 CMS 收集器进行垃圾回收。部署方式调整后,服务再没有出现长时间停顿,速度比硬件升级前有较大提升。
5.2.2 集群间同步导致的内存溢出
例如,有一个基于 B/S 的 MIS 系统,硬件为两台 2 个 CPU、8GB 内存的 HP 小型机,服务器是 WebLogic 9.2,每台机器启动了 3 个 WebLogic 实例,构成一个 6 个节点的亲合式集群。由于是亲合式集群,节点之间没有进行 Session 同步,但是有一些需求要实现部分数据在各个节点间共享。开始这些数据存放在数据库中,但由于读写频繁竞争很激烈,性能影响较大,后面使用 JBossCache 构建了一个全局缓存。全局缓存启用后,服务正常使用了一段较长的时间,但最近却不定期地出现了多次的内存溢出问题。
在内存溢出异常不出现的时候,服务内存回收状况一直正常,每次内存回收后都能恢复到一个稳定的可用空间,开始怀疑是程序某些不常用的代码路径中存在内存泄漏,但管理员反映最近程序并未更新、升级过,也没有进行什么特别操作。只好让服务带着 -XX:+HeapDumpOnOutOfMemoryError
参数运行了一段时间。在最近一次溢出之后,管理员发回了 heapdump 文件,发现里面存在着大量的 org.jgroups.protocols.pbcast.NAKACK
对象。
JBossCache 是基于自家的 JGroups 进行集群间的数据通信,JGroups 使用协议栈的方式来实现收发数据包的各种所需特性自由组合,数据包接收和发送时要经过每层协议栈的 up() 和 down() 方法,其中的 NAKACK 栈用于保障各个包的有效顺序及重发。JBossCache 协议栈如图5-1所示。
由于信息有传输失败需要重发的可能性,在确认所有注册在 GMS(Group Membership Service)的节点都收到正确的信息前,发送的信息必须在内存中保留。而此 MIS 的服务端中有一个负责安全校验的全局 Filter,每当接收到请求时,均会更新一次最后操作时间,并且将这个时间同步到所有的节点去,使得一个用户在一段时间内不能在多台机器上登录。在服务使用过程中,往往一个页面会产生数次乃至数十次的请求,因此这个过滤器导致集群各个节点之间网络交互非常频繁。当网络情况不能满足传输要求时,重发数据在内存中不断堆积,很快就产生了内存溢出。
这个案例中的问题,既有 JBossCache 的缺陷,也有 MIS 系统实现方式上缺陷。JBossCache 官方的 maillist 中讨论过很多次类似的内存溢出异常问题,据说后续版本也有了改进。而更重要的缺陷是这一类被集群共享的数据要使用类似 JBossCache 这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但不应当有过于频繁的写操作,那样会带来很大的网络同步的开销。
5.2.3 堆外内存导致的溢出错误
例如,一个学校的小型项目:基于 B/S 的电子考试系统,为了实现客户端能实时地从服务器端接收考试数据,系统使用了逆向 AJAX 技术(也称为 Comet 或者 Server Side Push),选用 CometD 1.1.1 作为服务端推送框架,服务器是 Jetty 7.1.4,硬件为一台普通 PC 机,Core i5 CPU,4GB 内存,运行 32 位 Windows 操作系统。
测试期间发现服务端不定时抛出内存溢出异常,服务器不一定每次都会出现异常,但假如正式考试时崩溃一次,那估计整场电子考试都会乱套,网站管理员尝试过把堆开到最大,而 32 位系统最多到 1.6GB 就基本无法再加大了,而且开大了基本没效果,抛出内存溢出异常好像还更加频繁了。加入 -XX:+HeapDumpOnOutOfMemoryError
,居然也没有任何反应,抛出内存溢出异常时什么文件都没有产生。无奈之下只好挂着 jstat 并一直紧盯屏幕,发现 GC 并不频繁,Eden 区、Survivor 区、老年代以及永久代内存全部都表示“情绪稳定,压力不大”,但就是照样不停地抛出内存溢出异常,管理员压力很大。最后,在内存溢出后从系统日志中找到异常堆栈,如代码清单5-1所示。
[org.eclipse.jetty.util.log]handle failed java.lang.OutOfMemoryError:null
at sun.misc.Unsafe.allocateMemory(Native Method)
at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:99)
at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:288)
at org.eclipse.jetty.io.nio.DirectNIOBuffer.<init>
……
如果认真阅读过本书的第 2 章,看到异常堆栈就应该清楚这个抛出内存溢出异常是怎么回事了。大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的 32 位 Windows 平台的限制是 2GB,其中划了 1.6GB 给 Java 堆,而 Direct Memory 内存并不算入 1.6GB 的堆之内,因此它最大也只能在剩余的 0.4GB 空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虚拟机虽然会对 Direct Memory 进行回收,但是 Direct Memory 却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后 Full GC,然后“顺便地”帮它清理掉内存的废弃对象。否则它只能一直等到抛出内存溢出异常时,先 catch 掉,再在 catch 块里面“大喊”一声:“System.gc()!"。要是虚拟机还是不听(譬如打开了 -XX:+DisableExplicitGC
开关),那就只能眼睁睁地看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。而本案例中使用的 CometD 1.1.1 框架,正好有大量的 NIO 操作需要使用到 Direct Memory 内存。
从实践经验的角度出发,除了 Java 堆和永久代之外,我们注意到下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。
Direct Memory:可通过 -XX:MaxDirectMemorySize
调整大小,内存不足时抛出 OutOfMemoryError 或者 OutOfMemoryError:Direct buffer memory。
线程堆栈:可通过 -Xss 调整大小,内存不足时抛出 StackOverflowError(纵向无法分配,即无法分配新的栈帧)或者 OutOfMemoryError:unable to create new native thread(横向无法分配,即无法建立新的线程)。
Socket 缓存区:每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出 IOException:Too many open files 异常。
JNI 代码:如果代码中使用 JNI 调用本地库,那本地库使用的内存也不在堆中。
虚拟机和 GC:虚拟机、GC 的代码执行也要消耗一定的内存。
5.2.4 外部命令导致系统缓慢
这是一个来自网络的案例:一个数字校园应用系统,运行在一台 4 个 CPU 的 Solaris 10 操作系统上,中间件为 GlassFish 服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的 mpstat 工具发现 CPU 使用率很高,并且系统占用绝大多数的 CPU 资源的程序并不是应用系统本身。这是个不正常的现象,通常情况下用户应用的 CPU 占用率应该占主要地位,才能说明系统是正常工作的。
通过 Solaris 10 的 Dtrace 脚本可以查看当前情况下哪些系统调用花费了最多的 CPU 资源,Dtrace 运行后发现最消耗 CPU 资源的竟然是"fork"系统调用。众所周知,“fork"系统调用是 Linux 用来产生新进程的,在 Java 虚拟机中,用户编写的 Java 代码最多只有线程的概念,不应当有进程的产生。
这是个非常异常的现象。通过本系统的开发人员,最终找到了答案:每个用户请求的处理都需要执行一个外部 shell 脚本来获得系统的一些信息。执行这个 shell 脚本是通过Java 的 Runtime.getRuntime().exec() 方法来调用的。这种调用方式可以达到目的,但是它在 Java 虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。Java 虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗会很大,不仅是 CPU,内存负担也很重。
用户根据建议去掉这个 Shell 脚本执行的语句,改为使用 Java 的 API 去获取这些信息后,系统很快恢复了正常。
5.2.5 服务器 JVM 进程崩溃
例如,一个基于 B/S 的 MIS 系统,硬件为两台 2 个 CPU、8GB 内存的 HP 系统,服务器是 WebLogic 9.2(就是5.2.2节中的那套系统)。正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个 hs_err_pid???.log
文件后,进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中可以看出,每个节点的虚拟机进程在崩溃前不久,都发生过大量相同的异常,见代码清单 5-2。
java.net.SocketException:Connection reset
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:218)
at java.io.BufferedInputStream.read(BufferedInputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583)
at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)……99 more
这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个 OA 门户做了集成,在 MIS 系统工作流的待办事项变化时,要通过 Web 服务通知 OA 门户系统,把待办事项的变化同步到 OA 门户之中。通过 SoapUI 测试了一下同步待办事项的几个 Web 服务,发现调用后竟然需要长达 3 分钟才能返回,并且返回结果都是连接中断。
由于 MIS 系统的用户多,待办事项变化很快,为了不被 OA 系统速度拖累,使用了异步的方式调用 Web 服务,但由于两边服务速度的完全不对等,时间越长就累积了越多 Web 服务没有调用完成,导致在等待的线程和 Socket 连接越来越多,最终在超过虚拟机的承受能力后使得虚拟机进程崩溃。解决方法:通知 OA 门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。
5.2.6 不恰当数据结构导致内存占用过大
例如,有一个后台 RPC 服务器,使用 64 位虚拟机,内存配置为 -Xms4g-Xmx8g-Xmn1g
,使用 ParNew+CMS 的收集器组合。平时对外服务的 Minor GC 时间约在 30 毫秒以内,完全可以接受。但业务上需要每 10 分钟加载一个约 80MB 的数据文件到内存进行数据分析,这些数据会在内存中形成超过 100 万个 HashMap<Long,Long>Entry
,在这段时间里面 Minor GC 就会造成超过 500 毫秒的停顿,对于这个停顿时间就接受不了了,具体情况如下面 GC 日志所示。
{Heap before GC invocations=95(full 4):
par new generation total 903168K,used 803142K[0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 100%used[0x00002aaaae770000, 0x00002aaadf770000, 0x00002aaadf770000)
from space 100352K, 0%used[0x00002aaae5970000, 0x00002aaae59c1910, 0x00002aaaebb70000)
to space 100352K, 0%used[0x00002aaadf770000, 0x00002aaadf770000, 0x00002aaae5970000)
concurrent mark-sweep generation total 5845540K,used 3898978K[0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000)
2011-10-28 T11:40:45.162+0800:226.504:[GC226.504:[ParNew:803142K->100352K(903168K), 0.5995670 secs]4702120K->4056332K(6748708K), 0.5997560 secs][Times:user=1.46 sys=0.04, real=0.60 secs]
Heap after GC invocations=96(full 4):
par new generation total 903168K,used 100352K[0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 0%used[0x00002aaaae770000, 0x00002aaaae770000, 0x00002aaadf770000)
from space 100352K, 100%used[0x00002aaadf770000, 0x00002aaae5970000,
0x00002aaae5970000)
to space 100352K, 0x00002aaaebb70000)0%used[0x00002aaae5970000, 0x00002aaae5970000,
concurrent mark-sweep generation total 5845540K,used 3955980K[0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K,used 40333K[0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000)
}
Total time for which application threads were stopped:0.6070570 seconds
观察这个案例,发现平时的 Minor GC 时间很短,原因是新生代的绝大部分对象都是可清除的,在 Minor GC 之后 Eden 和 Survivor 基本上处于完全空闲的状态。而在分析数据文件期间,800MB 的 Eden 空间很快被填满从而引发 GC,但 Minor GC 之后,新生代中绝大部分对象依然是存活的。我们知道 ParNew 收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到 Survivor 并维持这些对象引用的正确就成为一个沉重的负担,因此导致 GC 暂停时间明显变长。
如果不修改程序,仅从 GC 调优的角度去解决这个问题,可以考虑将 Survivor 空间去掉(加入参数-XX:SurvivorRatio=65536
、-XX:MaxTenuringThreshold=0
或者 -XX:+AlwaysTenure
),让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再清理它们。这种措施可以治标,但也有很大副作用,治本的方案需要修改程序,因为这里的问题产生的根本原因是用 HashMap<Long,Long>
结构来存储数据文件空间效率太低。
下面具体分析一下空间效率。在 HashMap<Long,Long>
结构中,只有 Key 和 Value 所存放的两个长整型数据是有效数据,共 16B(2×8B)。这两个长整型数据包装成 java.lang.Long
对象之后,就分别具有 8B 的 MarkWord、8B 的 Klass 指针,在加 8B 存储数据的 long 值。在这两个 Long 对象组成 Map.Entry
之后,又多了 16B 的对象头,然后一个 8B 的 next 字段和 4B 的 int 型的 hash 字段,为了对齐,还必须添加 4B 的空白填充,最后还有 HashMap 中对这个 Entry 的 8B 的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24B)×2)+Entry(32B)+HashMap Ref(8B)=88B,空间效率为 16B/88B=18%,实在太低了。
5.2.7 由 Windows 虚拟内存导致的长时间停顿
例如,有一个带心跳检测功能的 GUI 桌面程序,每 15 秒会发送一次心跳检测信号,如果对方 30 秒以内都没有信号返回,那就认为和对方程序的连接已经断开。程序上线后发现心跳检测有误报的概率,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟左右的时间完全无日志输出,处于停顿状态。
因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是 GC 导致的程序停顿,但是加入参数 -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDateStamps-Xloggc:gclog.log
后,从 GC 日志文件中确认了停顿确实是由 GC 导致的,大部分 GC 时间都控制在 100 毫秒以内,但偶尔就会出现一次接近 1 分钟的 GC。
Total time for which application threads were stopped:0.0112389 seconds
Total time for which application threads were stopped:0.0001335 seconds
Total time for which application threads were stopped:0.0003246 seconds
Total time for which application threads were stopped:41.4731411 seconds
Total time for which application threads were stopped:0.0489481 seconds
Total time for which application threads were stopped:0.1110761 seconds
Total time for which application threads were stopped:0.0007286 seconds
Total time for which application threads were stopped:0.0001268 seconds
从 GC 日志中找到长时间停顿的具体日志信息(添加了 -XX:+PrintReferenceGC
参数),找到的日志片段如下所示。从日志中可以看出,真正执行 GC 动作的时间不是很长,但从准备开始 GC,到真正开始 GC 之间所消耗的时间却占了绝大部分。
除 GC 日志之外,还观察到这个 GUI 程序内存变化的一个特点,当它最小化的时候,资源管理中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生 GC 时就有可能因为恢复页面文件的操作而导致不正常的 GC 停顿。
在 MSDN 上查证后确认了这种猜想,因此,在 Java 的 GUI 程序中要避免这种现象,可以加入参数”-Dsun.awt.keepWorkingSetOnMinimize=true"来解决。这个参数在许多 AWT 的程序上都有应用,例如 JDK 自带的 Visual VM,用于保证程序在恢复最小化时能够立即响应。在这个案例中加入该参数后,问题得到解决。
5.3 实战:Eclipse运行速度调优
很多 Java 开发人员都有这样一种观念:系统调优的工作都是针对服务端应用而言,规模越大的系统,就越需要专业的调优运维团队参与。这个观点不能说不对,5.2 节中笔者所列举的案例确实都是服务端运维、调优的例子,但服务端应用需要调优,并不说明其他应用就不需要了,作为一个普通的 Java 开发人员,前面讲的各种虚拟机的原理和最佳实践方法距离我们并不遥远,开发者身边很多场景都可以使用上面这些知识。下面通过一个普通程序员日常工作中可以随时接触到的开发工具开始这次实战。
5.3.1 调优前的程序运行状态
笔者使用 Eclipse 作为日常工作中的主要 IDE 工具,由于安装的插件比较大(如Klocwork、ClearCase LT等)、代码也很多,启动 Eclipse 直到所有项目编译完成需要四五分钟。一直对开发环境的速度感觉不满意,趁着编写这本书的机会,决定对 Eclipse 进行“动刀”调优。
笔者机器的 Eclipse 运行平台是 32 位 Windows 7 系统,虚拟机为 HotSpot VM 1.5 b64。硬件为 ThinkPad X201,Intel i5 CPU,4GB 物理内存。在初始的配置文件 eclipse.ini 中,除了指定 JDK 的路径、设置最大堆为 512MB 以及开启了JMX 管理(需要在 VisualVM 中收集原始数据)外,未做其他任何改动,原始配置内容如代码清单5-3所示。
-vm
D:/_DevSpace/jdk1.5.0/bin/javaw.exe
-startup
plugins/org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_1.0.200.v20090519
-product
org.eclipse.epp.package.jee.product
--launcher.XXMaxPermSize
256M
-showsplash
org.eclipse.platform
-vmargs
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
-Dcom.sun.management.jmxremote
为了要与调优后的结果进行量化对比,调优开始前笔者先做了一次初始数据测试。测试用例很简单,就是收集从 Eclipse 启动开始,直到所有插件加载完成为止的总耗时以及运行状态数据,虚拟机的运行数据通过 VisualVM 及其扩展插件 VisualGC 进行采集。测试过程中反复启动数次 Eclipse 直到测试结果稳定后,取最后一次运行的结果作为数据样本(为了避免操作系统未能及时进行磁盘缓存而产生的影响),数据样本如图5-2所示。
Eclipse 启动的总耗时没有办法从监控工具中直接获得,因为 VisualVM 不可能知道 Eclipse 运行到什么阶段算是启动完成。为了测试的准确性,笔者写了一个简单的 Eclipse 插件,用于统计 Eclipse 的启动耗时。由于代码很简单,并且本书不是 Eclipse RCP 开发的教程,所以只列出代码清单 5-4 供读者参考,不再延伸讲解。如果读者需要这个插件,可以使用下面代码自行编译或者发电子邮件向笔者索取。
ShowTime.java代码:
import org.eclipse.jface.dialogs.MessageDialog;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.ui.IStartup;
/**
*统计Eclipse启动耗时
*@author zzm
*/
public class ShowTime implements IStartup{
public void earlyStartup(){
Display.getDefault().syncExec(new Runnable(){
public void run(){
long eclipseStartTime=Long.parseLong(System.getProperty("eclipse.startTime"));
long costTime=System.currentTimeMillis()-eclipseStartTime;
Shell shell=Display.getDefault().getActiveShell();
String message="Eclipse启动耗时:"+costTime+"ms";
MessageDialog.openInformation(shell, "Information", message);
}
});
}
}
plugin.xml 代码:
<?xml version="1.0"encoding="UTF-8"?>
<?eclipse version="3.4"?>
<plugin>
<extension
point="org.eclipse.ui.startup">
<startup class="eclipsestarttime.actions.ShowTime"/>
</extension>
</plugin>
上述代码打包成 jar 后放到 Eclipse 的 plugins 目录,反复启动几次后,插件显示的平均时间稳定在 15 秒左右。
根据 VisualGC 和 Eclipse 插件收集到的信息,总结原始配置下的测试结果如下。
- 整个启动过程平均耗时约15秒。
- 最后一次启动的数据样本中,垃圾收集总耗时 4.149 秒,其中:
- Full GC 被触发了 19 次,共耗时 3.166 秒。
- Minor GC 被触发了 378 次,共耗时 0.983 秒。
- 加载类 9115 个,耗时 4.114 秒。
- JIT编译时间为 1.999 秒。
虚拟机 512MB 的堆内存被分配为 40MB 的新生代(31.5 的 Eden 空间和两个 4MB 的 Surviver 空间)以及 472MB 的老年代。
客观地说,由于机器硬件还不错(请读者以 2010 年普通 PC 机的标准来衡量),15秒的启动时间其实还在可接受范围以内,但是从 VisualGC 中反映的数据来看,主要问题是非用户程序时间(图5-2中的 Compile Time、Class Load Time、GC Time)非常之高,占了整个启动过程耗时的一半以上(这里存在少许夸张成分,因为如 JIT 编译等动作是在后台线程完成的,用户程序在此期间也正常执行,所以并没有占用了一半以上的绝对时间)。虚拟机后台占用太多时间也直接导致 Eclipse 在启动后的使用过程中经常有不时停顿的感觉,所以进行调优有较大的价值。
5.3.2 升级 JDK 1.6 的性能变化及兼容问题
对 Eclipse 进行调优的第一步就是先把虚拟机的版本进行升级,希望能先从虚拟机版本身上得到一些“免费的”性能提升。
每次 JDK 的大版本发布时,开发商肯定都会宣称虚拟机的运行速度比上一版本有了很大的提高,这虽然是个广告性质的宣言,经常被人从升级列表或者技术白皮书中直接忽略过去,但从国内外的第三方评测数据来看,版本升级至少某些方面确实带来了一定的性能改善,以下是一个第三方网站对 JDK 1.5、1.6、1.7 三个版本做的性能评测,分别测试了以下4个用例:
- 生成 500 万个的字符串。
- 500 万次
ArrayList<String>
数据插入,使用第一点生成的数据。 - 生成 500 万个
HashMap<String,Integer>
,每个键-值对通过并发线程计算,测试并发能力。 - 打印 500 万个
ArrayList<String>
中的值到文件,并重读回内存。
三个版本的 JDK 分别运行这 3 个用例的测试程序,测试结果如图5-4所示。
从这 4 个用例的测试结果来看,JDK 1.6 比 JDK 1.5 有大约 15% 的性能提升,尽管对 JDK 仅测试这 4 个用例并不能说明什么问题,需要通过测试数据来量化描述一个 JDK 比旧版提升了多少是很难做到非常科学和准确的(要做稍微靠谱一点的测试,可以使用 SPECjvm2008 来完成,或者把相应版本的 TCK 中数万个测试用例的性能数据对比一下可能更有说服力),但我还是选择相信这次“软广告”性质的测试,把 JDK 版本升级到 1.6 Update 21。
与所有小说作者设计的故事情节一样,获得最后的胜利之前总是要经历各种各样的挫折,这次升级到 JDK 1.6 之后,性能有什么变化先暂且不谈,在使用几分钟之后,笔者的 Eclipse 就和前面几个服务端的案例一样非常“不负众望”地发生了内存溢出。
这次内存溢出完全出乎笔者的意料之外:决定对 Eclipse 做调优是因为速度慢,但开发环境一直都很稳定,至少没有出现过内存溢出的问题,而这次升级除了 eclipse.ini 中的 JVM 路径改变了之外,还未进行任何运行参数的调整,进到 Eclipse 主界面之后随便打开了几个文件就抛出内存溢出异常了,难道 JDK 1.6 Update 21 有哪个 API 出现了严重的泄漏问题吗?
事实上,并不是 JDK 1.6 出现了什么问题,根据前面章节中介绍的相关原理和工具,我们要查明这个异常的原因并且解决它一点也不困难。打开 VisualVM,监视页签中的内存曲线部分如图5-6和图5-7所示。
在 Java 堆中监视曲线中,“堆大小”的曲线与“使用的堆”的曲线一直都有很大的间隔距离,每当两条曲线开始有互相靠近的趋势时,“最大堆”的曲线就会快速向上转向,而“使用的堆”的曲线会向下转向。“最大堆”的曲线向上是虚拟机内部在进行堆扩容,运行参数中并没有指定最小堆(-Xms)的值与最大堆(-Xmx)相等,所以堆容量一开始并没有扩展到最大值,而是根据使用情况进行伸缩扩展。“使用的堆”的曲线向下是因为虚拟机内部触发了一次垃圾收集,一些废弃对象的空间被回收后,内存用量相应减少,从图形上看,Java 堆运作是完全正常的。但永久代的监视曲线就有问题了,“PermGen大小”的曲线与“使用的PermGen”的曲线几乎完全重合在一起,这说明永久代中没有可回收的资源,所以“使用的PermGen”的曲线不会向下发展,永久代中也没有空间可以扩展,所以“PermGen大小”的曲线不能向上扩展。这次内存溢出很明显是永久代导致的内存溢出。
再注意到图 5-7 中永久代的最大容量:“67,108,864个字节”,也就是 64MB,这恰好是 JDK 在未使用 -XX:MaxPermSize
参数明确指定永久代最大容量时的默认值,无论 JDK 1.5 还是 JDK 1.6,这个默认值都是 64MB。对于 Eclipse 这种规模的 Java 程序来说,64MB 的永久代内存空间显然是不够的,溢出很正常,那为何在 JDK 1.5 中没有发生过溢出呢?
在 VisualVM 的“概述-JVM参数”页签中,分别检查使用 JDK 1.5 和 JDK 1.6 运行 Eclipse 时的 JVM 参数,发现使用 JDK 1.6 时,只有以下 3 个 JVM 参数,如代码清单5-5所示。
-Dcom.sun.management.jmxremote
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
而使用 JDK 1.5 运行时,就有 4 条 JVM 参数,其中多出来的一条正好就是设置永久代最大容量的 -XX:MaxPermSize=256M
,如代码清单5-6所示。
代码清单5-6 JDK 1.5的Eclipse运行期参数
-Dcom.sun.management.jmxremote
-Dosgi.requiredJavaVersion=1.5
-Xmx512m
-XX:MaxPermSize=256M
为什么会这样呢?笔者从 Eclipse 的 Bug List 网站上找到了答案:使用 JDK 1.5 时之所以有永久代容量这个参数,是因为在 eclipse.ini 中存在”–launcher.XXMaxPermSize 256M"这项设置,当 launcher——也就是 Windows 下的可执行程序 eclipse.exe,检测到假如是 Eclipse 运行在 Sun 公司的虚拟机上的话,就会把参数值转化为 -XX:MaxPermSize
传递给虚拟机进程,因为三大商用虚拟机中只有 Sun 系列的虚拟机才有永久代的概念,也就是只有 HotSpot 虚拟机需要设置这个参数,JRockit 虚拟机和 IBM J9 虚拟机都不需要设置。
在2009年4月20日,Oracle 公司正式完成了对 Sun 公司的收购,此后无论是网页还是具体程序产品,提供商都从 Sun 变为了 Oracle,而 eclipse.exe 就是根据程序提供商判断是否为 Sun 的虚拟机,当 JDK 1.6 Update 21 中 java.exe、javaw.exe 的"Company"属性从"Sun Microsystems Inc.“变为"Oracle Corporation"之后,Eclipse 就完全不认识这个虚拟机了,因此没有把最大永久代的参数传递过去。
了解原因之后,解决方法就简单了,launcher 不认识就只好由人来告诉它,即在 eclipse.ini 中明确指定 -XX:MaxPermSize=256M
这个参数就可以了。
5.3.3 编译时间和类加载时间的优化
从 Eclipse 启动时间上来看,升级到 JDK 1.6 所带来的性能提升是……嗯?基本上没有提升?多次测试的平均值与 JDK 1.5 的差距完全在实验误差范围之内。
各位读者不必失望,Sun JDK 1.6 性能白皮书描述的众多相对于 JDK 1.5 的提升不至于全部是广告,虽然总启动时间没有减少,但在查看运行细节的时候,却发现了一件很值得注意的事情:在 JDK 1.6 中启动完 Eclipse 所消耗的类加载时间比 JDK 1.5 长了接近一倍,不要看反了,这里写的是 JDK 1.6 的类加载比 JDK 1.5 慢一倍,测试结果如代码清单5-7所示,反复测试多次仍然是相似的结果。
使用JDK 1.6的类加载时间:
C:\Users\IcyFenix>jps
3552
6372 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
6900 Jps
C:\Users\IcyFenix>jstat-class 6372
Loaded Bytes Unloaded Bytes Time
7917 10190.3 0 0.0 8.18
使用JDK 1.5的类加载时间:
C:\Users\IcyFenix>jps
3552
7272 Jps
7216 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
C:\Users\IcyFenix>jstat-class 7216
Loaded Bytes Unloaded Bytes Time
7902 9691.2 3 2.6 4.34
在本例中,类加载时间上的差距并不能作为一个具有普遍性的测试结果去说明 JDK 1.6 的类加载必然比 JDK 1.5 慢,笔者测试了自己机器上的 Tomcat 和 GlassFish 启动过程,并未没有出现类似的差距。在国内最大的 Java 社区中,笔者发起过关于此问题的讨论,从参与者反馈的测试结果来看,此问题只在一部分机器上存在,而且 JDK 1.6 的各个 Update 版之间也存在很大差异。
多次试验后,笔者发现在机器上两个 JDK 进行类加载时,字节码验证部分耗时差距尤其严重。考虑到实际情况:Eclipse 使用者甚多,它的编译代码我们可以认为是可靠的,不需要在加载的时候再进行字节码验证,因此通过参数 -Xverify:none
禁止掉字节码验证过程也可作为一项优化措施。加入这个参数后,两个版本的 JDK 类加载速度都有所提高,JDK 1.6 的类加载速度仍然比 JDK 1.5 慢,但是两者的耗时已经接近了许多,测试数据如代码清单5-8所示。关于类与类加载的话题,譬如刚刚提到的字节码验证是怎么回事,本书专门规划了两个章节进行详细讲解,在此不再延伸讨论。
使用JDK 1.6的类加载时间:
C:\Users\IcyFenix>jps
5512 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
5596 Jps
C:\Users\IcyFenix>jstat-class 5512
Loaded Bytes Unloaded Bytes Time
6749 8837.0 0 0.0 3.94
使用JDK 1.5的类加载时间:
C:\Users\IcyFenix>jps
4724 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
5412 Jps
C:\Users\IcyFenix>jstat-class 4724
Loaded Bytes Unloaded Bytes Time
6885 9109.7 3 2.6 3.10
在取消字节码验证之后,JDK 1.5 的平均启动下降到了 13 秒,而 JDK 1.6 的测试数据平均比 JDK 1.5 快 1 秒,下降到平均 12 秒左右,如图5-8所示。在类加载时间仍然落后的情况下,依然可以看到 JDK 1.6 在性能上比 JDK 1.5 稍有优势,说明至少在 Eclipse 启动这个测试用例上,升级 JDK 版本确实能带来一些“免费的”性能提升。
前面说过,除了类加载时间以外,在 VisualGC 的监视曲线中显示了两项很大的非用户程序耗时:编译时间(Compile Time)和垃圾收集时间(GC Time)。垃圾收集时间读者应该非常清楚了,而编译时间是什么呢?程序在运行之前不是已经编译了吗?虚拟机的 JIT 编译与垃圾收集一样,是本书的一个重要部分,后面有专门章节讲解,这里先简单介绍一下:编译时间是指虚拟机的 JIT 编译器(Just In Time Compiler)编译热点代码(Hot Spot Code)的耗时。我们知道 Java 语言为了实现跨平台的特性,Java 代码编译出来后形成的 Class 文件中存储的是字节码(ByteCode),虚拟机通过解释方式执行字节码命令,比起 C/C++ 编译成本地二进制代码来说,速度要慢不少。为了解决程序解释执行的速度问题,JDK 1.2 以后,虚拟机内置了两个运行时编译器,如果一段Java 方法被调用次数达到一定程度,就会被判定为热代码交给 JIT 编译器即时编译为本地代码,提高运行速度(这就是 HotSpot 虚拟机名字的由来)。甚至有可能在运行期动态编译比 C/C++ 的编译期静态译编出来的代码更优秀,因为运行期可以收集很多编译器无法知道的信息,甚至可以采用一些很激进的优化手段,在优化条件不成立的时候再逆优化退回来。所以 Java 程序只要代码没有问题(主要是泄漏问题,如内存泄漏、连接泄漏),随着代码被编译得越来越彻底,运行速度应当是越运行越快的。Java 的运行期编译最大的缺点就是它进行编译需要消耗程序正常的运行时间,这也就是上面所说的“编译时间”。
虚拟机提供了一个参数 -Xint 禁止编译器运作,强制虚拟机对字节码采用纯解释方式执行。如果读者想使用这个参数省下 Eclipse 启动中那 2 秒的编译时间获得一个“更好看”的成绩的话,那恐怕要失望了,加上这个参数之后,虽然编译时间确实下降到 0,但 Eclipse 启动的总时间剧增到 27 秒。看来这个参数现在最大的作用似乎就是让用户怀念一下 JDK 1.2 之前那令人心酸和心碎的运行速度。
与解释执行相对应的另一方面,虚拟机还有力度更强的编译器:当虚拟机运行在 -client 模式的时候,使用的是一个代号为 C1 的轻量级编译器,另外还有一个代号为 C2 的相对重量级的编译器能提供更多的优化措施,如果使用 -server 模式的虚拟机启动 Eclipse 将会使用到 C2 编译器,这时从 VisualGC 可以看到启动过程中虚拟机使用了超过 15 秒的时间去进行代码编译。如果读者的工作习惯是长时间不关闭 Eclipse 的话,C2 编译器所消耗的额外编译时间最终还是会在运行速度的提升之中赚回来,这样使用 -server 模式也是一个不错的选择。不过至少在本次实战中,我们还是继续选用 -client 虚拟机来运行 Eclipse。
5.3.4 调整内存设置控制垃圾收集频率
三大块非用户程序时间中,还剩下 GC 时间没有调整,而 GC 时间却又是其中最重要的一块,并不只是因为它是耗时最长的一块,更因为它是一个稳定持续的过程。由于我们做的测试是在测程序的启动时间,所以类加载和编译时间在这项测试中的影响力被大幅度放大了。在绝大多数的应用中,不可能出现持续不断的类被加载和卸载。在程序运行一段时间后,热点方法被不断编译,新的热点方法数量也总会下降,但是垃圾收集则是随着程序运行而不断运作的,所以它对性能的影响才显得尤为重要。
在Eclipse启动的原始数据样本中,短短 15 秒,类共发生了 19 次 Full GC 和 378 次 Minor GC,一共 397 次 GC 共造成了超过 4 秒的停顿,也就是超过 1/4 的时间都是在做垃圾收集,这个运行数据看起来实在太糟糕了。
首先来解决新生代中的 Minor GC,虽然 GC 的总时间只有不到 1 秒,但却发生了 378 次之多。从 VisualGC 的线程监视中看到,Eclipse 启动期间一共发起了超过 70 条线程,同时在运行的线程数超过 25 条,每当发生一次垃圾收集动作,所有用户线程都必须跑到最近的一个安全点(SafePoint)然后挂起线程等待垃圾回收。这样过于频繁的 GC 就会导致很多没有必要的安全点检测、线程挂起及恢复操作。
新生代 GC 频繁发生,很明显是由于虚拟机分配给新生代的空间太小而导致的,Eden 区加上一个 Survivor 区还不到 35MB。因此很有必要使用 -Xmn 参数调整新生代的大小。
再来看一看那 19 次 Full GC,看起来 19 次并“不多”(相对于 378 次 Minor GC 来说),但总耗时为 3.166 秒,占了 GC 时间的绝大部分,降低 GC 时间的主要目标就要降低这部分时间。从 VisualGC 的曲线图上可能看得不够精确,这次直接从 GC 日志中分析一下这些 Full GC 是如何产生的,代码清单5-9中是启动最开始的 2.5 秒内发生的 10 次 Full GC 记录。
0.278:[GC 0.278:[DefNew:574K->33K(576K), 0.0012562 secs]0.279:[Tenured:1467K->997K(1536K), 0.0181775 secs]1920K->997K(2112K), 0.0195257 secs]
0.312:[GC 0.312:[DefNew:575K->64K(576K), 0.0004974 secs]0.312:[Tenured:1544K->1608K(1664K), 0.0191592 secs]1980K->1608K(2240K), 0.0197396 secs]
0.590:[GC 0.590:[DefNew:576K->64K(576K), 0.0006360 secs]0.590:[Tenured:2675K->2219K(2684K), 0.0256020 secs]3090K->2219K(3260K), 0.0263501 secs]
0.958:[GC 0.958:[DefNew:551K->64K(576K), 0.0011433 secs]0.959:[Tenured:3979K->3470K(4084K), 0.0419335 secs]4222K->3470K(4660K), 0.0431992 secs]
1.575:[Full GC 1.575:[Tenured:4800K->5046K(5784K), 0.0543136 secs]5189K->5046K(6360K), [Perm:12287K->12287K(12288K)], 0.0544163 secs]
1.703:[GC 1.703:[DefNew:703K->63K(704K), 0.0012609 secs]1.705:[Tenured:8441K->8505K(8540K), 0.0607638 secs]8691K->8505K(9244K), 0.0621470 secs]
1.837:[GC 1.837:[DefNew:1151K->64K(1152K), 0.0020698 secs]1.839:[Tenured:14616K->14680K(14688K), 0.0708748 secs]15035K->14680K(15840K), 0.0730947 secs]
2.144:[GC 2.144:[DefNew:1856K->191K(1856K), 0.0026810 secs]2.147:[Tenured:25092K->24656K(25108K), 0.1112429 secs]26172K->24656K(26964K), 0.1141099 secs]
2.337:[GC 2.337:[DefNew:1914K->0K(3136K), 0.0009697 secs]2.338:[Tenured:41779K->27347K(42056K), 0.0954341 secs]42733K->27347K(45192K), 0.0965513 secs]
2.465:[GC 2.465:[DefNew:2490K->0K(3456K), 0.0011044 secs]2.466:[Tenured:46379K->27635K(46828K), 0.0956937 secs]47621K->27635K(50284K), 0.0969918 secs]
括号中加粗的数字代表老年代的容量,这组 GC 日志显示了 10 次 Full GC 发生的原因全部都是老年代空间耗尽,每发生一次 Full GC 都伴随着一次老年代空间扩容:1536KB->1664KB->2684KB……42056KB->46828KB,10 次 GC 以后老年代容量从起始的 1536KB 扩大到 46828KB,当 15 秒后 Eclipse 启动完成时,老年代容量扩大到了 103428KB,代码编译开始后,老年代容量到达顶峰 473MB,整个 Java 堆到达最大容量 512MB。
日志还显示有些时候内存回收状况很不理想,空间扩容成为获取可用内存的最主要手段,譬如语句"Tenured:25092K->24656K(25108K),0.1112429 secs”,代表老年代当前容量为 25108KB,内存使用到 25092KB 的时候发生 Full GC,花费 0.11 秒把内存使用降低到 24656KB,只回收了不到 500KB 的内存,这次 GC 基本没有什么回收效果,仅仅做了扩容,扩容过程相比起回收过程可以看做是基本不需要花费时间的,所以说这 0.11 秒几乎是白白浪费了。
由上述分析可以得出结论:Eclipse 启动时,Full GC 大多数是由于老年代容量扩展而导致的,由永久代空间扩展而导致的也有一部分。为了避免这些扩展所带来的性能浪费,我们可以把 -Xms 和 -XX:PermSize
参数值设置为 -Xmx 和 -XX:MaxPermSize
参数值一样,这样就强制虚拟机在启动的时候就把老年代和永久代的容量固定下来,避免运行时自动扩展。
根据分析,优化计划确定为:把新生代容量提升到 128MB,避免新生代频繁 GC;把 Java 堆、永久代的容量分别固定为 512MB 和 96MB,避免内存扩展。这几个数值都是根据机器硬件、Eclipse 插件和工程数量来决定的,读者实践的时候应根据 VisualGC 中收集到的实际数据进行设置。改动后的 eclipse.ini 配置如代码清单5-10所示。
-vm
D:/_DevSpace/jdk1.6.0_21/bin/javaw.exe
-startup
plugins/org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_1.0.200.v20090519
-product
org.eclipse.epp.package.jee.product
-showsplash
org.eclipse.platform
-vmargs
-Dosgi.requiredJavaVersion=1.5
-Xverify:none
-Xmx512m
-Xms512m
-Xmn128m
-XX:PermSize=96m
-XX:MaxPermSize=96m
现在这个配置之下,GC 次数已经大幅度降低,图5-9是 Eclipse 启动后 1 分钟的监视曲线,只发生了 8 次 Minor GC 和 4 次 Full GC,总耗时为 1.928 秒。
这个结果已经算是基本正常,但是还存在一点瑕疵:从 Old Gen 的曲线上看,老年代直接固定在 384MB,而内存使用量只有 66MB,并且一直很平滑,完全不应该发生 Full GC 才对,那 4 次 Full GC 是怎么来的?使用 jstat-gccause 查询一下最近一次 GC 的原因,见代码清单5-11。
C:\Users\IcyFenix>jps
9772 Jps
4068 org.eclipse.equinox.launcher_1.0.201.R35x_v20090715.jar
C:\Users\IcyFenix>jstat-gccause 4068
S0 S1 E O P YGC YGCT FGC FGCT GCT LGCC GCC
0.00 0.00 1.00 14.81 39.29 6 0.422 20 5.992 6.414
System.gc()No GC
从 LGCC(Last GC Cause)中看到,原来是代码调用 System.gc()
显式触发的 GC,在内存设置调整后,这种显式 GC 已不符合我们的期望,因此在 eclipse.ini 中加入参数 -XX:+DisableExplicitGC
屏蔽掉 System.gc()
。再次测试发现启动期间的 Full GC 已经完全没有了,只有 6 次 Minor GC,耗时 417 毫秒,与调优前 4.149 秒的测试样本相比,正好是十分之一。进行 GC 调优后 Eclipse 的启动时间下降非常明显,比整个 GC 时间降低的绝对值还大,现在启动只需要 7 秒多。
5.3.5 选择收集器降低延迟
现在 Eclipse 启动已经比较迅速了,但我们的调优实战还没有结束,毕竟 Eclipse 是拿来写程序的,不是拿来测试启动速度的。我们不妨再在 Eclipse 中测试一个非常常用但又比较耗时的操作:代码编译。图5-11是当前配置下 Eclipse 进行代码编译时的运行数据,从图中可以看出,新生代每次回收耗时约 65 毫秒,老年代每次回收耗时约 725 毫秒。对于用户来说,新生代 GC 的耗时还好,65 毫秒在使用中无法察觉到,而老年代每次 GC 停顿接近 1 秒钟,虽然比较长时间才会出现一次,但停顿还是显得太长了一些。
再注意看一下编译期间的 CPU 资源使用状况。图 5-12 是 Eclipse 在编译期间的 CPU 使用率曲线图,整个编译过程中平均只使用了不到 30% 的 CPU 资源,垃圾收集的 CPU 使用率曲线更是几乎与坐标横轴紧贴在一起,这说明 CPU 资源还有很多可利用的余地。
列举 GC 停顿时间、CPU 资源富余的目的,都是为了接下来替换掉 Client 模式的虚拟机中默认的新生代、老年代串行收集器做铺垫。
Eclipse 应当算是与使用者交互非常频繁的应用程序,由于代码太多,笔者习惯在做全量编译或者清理动作的时候,使用"Run in Backgroup"功能一边编译一边继续工作。回顾一下在第 3 章提到的几种收集器,很容易想到 CMS 是最符合这类场景的收集器。因此尝试在 eclipse.ini 中再加入这两个参数 -XX:+UseConcMarkSweepGC
、-XX:+UseParNewGC
(ParNew 收集器是使用 CMS 收集器后的默认新生代收集器,写上仅是为了配置更加清晰),要求虚拟机在新生代和老年代分别使用 ParNew 和 CMS 收集器进行垃圾回收。指定收集器之后,再次测试的结果如图5-13所示,与原来使用串行收集器对比,新生代停顿从每次 65 毫秒下降到了每次 53 毫秒,而老年代的停顿时间更是从 725 毫秒大幅下降到了 36 毫秒。
当然,CMS 的停顿阶段只是收集过程中的一小部分,并不是真的把垃圾收集时间从 725 毫秒变成 36 毫秒了。在 GC 日志中可以看到 CMS 与程序并发的时间约为 400 毫秒,这样收集器的运作结果就比较令人满意了。
到此,对于虚拟机内存的调优基本就结束了,这次实战可以看做是一次简化的服务端调优过程,因为服务端调优有可能还会存在于更多方面,如数据库、资源池、磁盘I/O等,但对于虚拟机内存部分的优化,与这次实战中的思路没有什么太大差别。即使读者实际工作中接触不到服务器,根据自己工作环境做一些试验,总结几个参数让自己日常工作环境速度有较大幅度提升也是很划算的。
5.4 本章小结
Java 虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序的性能和稳定性有非常大的影响,在本书的第 2~5 章中,笔者从理论知识、异常现象、代码、工具、案例、实战等几个方面对其进行了讲解,希望读者有所收获。
本书关于虚拟机内存管理部分到此为止就结束了,后面将开始介绍Class文件与虚拟机执行子系统方面的知识。
2.4.6 - CH06-类文件结构
6.1 概述
越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。
6.2 无关性的基石
Java在刚刚诞生之时曾经提出过一个非常著名的宣传口号:“一次编写,到处运行(Write Once,Run Anywhere)”,这句话充分表达了软件开发人员对冲破平台界限的渴求。
“与平台无关”的理想最终实现在操作系统的应用层上:Sun公司以及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,但本节标题中刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性——语言无关性正越来越被开发者所重视。
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。作为一个通用的、机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为语言的产品交付媒介。
Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础。
6.3 Class 类文件结构
注意 任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以
_info
结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由表6-1所示的数据项构成。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。
Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。接下来我们将一起看看这个表中各个数据项的具体含义。
6.3.1 魔数与 Class 文件的版本
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
package org.fenixsoft.clazz;
public class TestClass{
private int m;
public int inc(){
return m+1;
}
}
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0032,也即是十进制的50,该版本号说明这个文件是可以被JDK 1.6或以上版本虚拟机执行的Class文件。
表6-2列出了从JDK 1.1到JDK 1.7,主流JDK版本编译器输出的默认和可支持的Class文件版本号。
6.3.2 常量池
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的,如图6-3所示,常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以把索引值置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始的。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,在JDK 1.7之前共有11种结构各不相同的表结构数据,在JDK 1.7中为了更好地支持动态语言调用,又额外增加了3种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info)。
这14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位(tag,取值见表6-3中标志列),代表当前这个常量属于哪种常量类型。这14种常量类型所代表的具体含义见表6-3。
之所以说常量池是最烦琐的数据,是因为这14种常量类型各自均有自己的结构。
回头看看图6-3中常量池的第一项常量,它的标志位(偏移地址:0x0000000A)是0x07,查表6-3的标志列发现这个常量属于CONSTANT_Class_info类型,此类型的常量代表一个类或者接口的符号引用。CONSTANT_Class_info的结构比较简单,见表6-4。
tag是标志位,上面已经讲过了,它用于区分常量类型;name_index是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,这里name_index值(偏移地址:0x0000000B)为0x0002,也即是指向了常量池中的第二项常量。继续从图6-3中查找第二项常量,它的标志位(地址:0x0000000D)是0x01,查表6-3可知确实是一个CONSTANT_Utf8_info类型的常量。CONSTANT_Utf8_info类型的结构见表6-5。
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。UTF-8缩略编码与普通UTF-8编码的区别是:从’\u0001’到’\u007f’之间的字符(相当于1~127的ASCII码)的缩略编码使用一个字节表示,从’\u0080’到’\u07ff’之间的所有字符的缩略编码用两个字节表示,从’\u0800’到’\uffff’之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。
顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,将会无法编译。
本例中这个字符串的length值(偏移地址:0x0000000E)为0x001D,也就是长29字节,往后29字节正好都在1~127的ASCII码范围以内,内容为"org/fenixsoft/clazz/TestClass",有兴趣的读者可以自己逐个字节换算一下,换算结果如图6-4选中的部分所示。
到此为止,我们分析了TestClass.class常量池中21个常量中的两个,其余的19个常量都可以通过类似的方法计算出来。为了避免计算过程占用过多的版面,后续的19个常量的计算过程可以借助计算机来帮我们完成。在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具:javap,代码清单6-2中列出了使用javap工具的-verbose参数输出的TestClass.class文件字节码内容(此清单中省略了常量池以外的信息)。前面我们曾经提到过,Class文件中还有很多数据项都要引用常量池中的常量,所以代码清单6-2中的内容在后续的讲解过程中还要经常使用到。
C:\>javap-verbose TestClass
Compiled from"TestClass.java"
public class org.fenixsoft.clazz.TestClass extends java.lang.Object
SourceFile:"TestClass.java"
minor version:0
major version:50
Constant pool:
const#1=class#2;//org/fenixsoft/clazz/TestClass
const#2=Asciz org/fenixsoft/clazz/TestClass;
const#3=class#4;//java/lang/Object
const#4=Asciz java/lang/Object;
const#5=Asciz m;
const#6=Asciz I;
const#7=Asciz<init>;
const#8=Asciz()V;
const#9=Asciz Code;
const#10=Method#3.#11;//java/lang/Object."<init>":()V
const#11=NameAndType#7:#8;//"<init>":()V
const#12=Asciz LineNumberTable;
const#13=Asciz LocalVariableTable;
const#14=Asciz this;
const#15=Asciz Lorg/fenixsoft/clazz/TestClass;
const#16=Asciz inc;
const#17=Asciz()I;
const#18=Field#1.#19;//org/fenixsoft/clazz/TestClass.m:I
const#19=NameAndType#5:#6;//m:I
const#20=Asciz SourceFile;
const#21=Asciz TestClass.java;
从代码清单6-2中可以看出,计算机已经帮我们把整个常量池的21项常量都计算了出来,并且第1、2项常量的计算结果与我们手工计算的结果一致。仔细看一下会发现,其中有一些常量似乎从来没有在代码中出现过,如"I"、“V”、“<init>”、“LineNumberTable”、“LocalVariableTable"等,这些看起来在代码任何一处都没有出现过的常量是哪里来的呢?
这部分自动生成的常量的确没有在Java代码里面直接出现过,但它们会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)引用到,它们会用来描述一些不方便使用“固定字节”进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么?因为Java中的“类”是无穷无尽的,无法通过简单的无符号字节来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。这部分内容将在后面进一步阐述。最后,笔者将这14种常量项的结构定义总结为表6-6以供读者参考。
6.3.3 访问标志
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。具体的标志位以及标志的含义见表6-7。
access_flags中一共有16个标志位可以使用,当前只定义了其中8个,没有使用到的标志位要求一律为0。以代码清单6-1中的代码为例,TestClass是一个普通Java类,不是接口、枚举或者注解,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK 1.2之后的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM这6个标志应当为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。从图6-5中可以看出,access_flags标志(偏移地址:0x000000EF)的确为0x0021。
6.3.4 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。图6-6演示了代码清单6-1的代码的类索引查找过程。
对于接口索引集合,入口的第一项——u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。代码清单6-1中的代码的类索引、父类索引与接口表索引的内容如图6-7所示。
从偏移地址0x000000F1开始的3个u2类型的值分别为0x0001、0x0003、0x0000,也就是类索引为1,父类索引为3,接口索引集合大小为0,查询前面代码清单6-2中javap命令计算出来的常量池,找出对应的类和父类的常量,结果如代码清单6-3所示。
const#1=class#2;//org/fenixsoft/clazz/TestClass
const#2=Asciz org/fenixsoft/clazz/TestClass;
const#3=class#4;//java/lang/Object
const#4=Asciz java/lang/Object;
6.3.5 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。一个字段可以包括的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。表6-8中列出了字段表的最终格式。
字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义见表6-9。
很明显,在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所决定的。
跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。现在需要解释一下“简单名称”、“描述符”以及前面出现过多次的“全限定名”这三种特殊字符串的概念。
全限定名和简单名称很好理解,以代码清单6-1中的代码为例,“org/fenixsoft/clazz/TestClass"是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。简单名称是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别是"inc"和"m”。
相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见表6-10。
对于数组类型,每一维度将使用一个前置的 [
字符来描述,如一个定义为 java.lang.String[][]
类型的二维数组,将被记录为:[[Ljava/lang/String;
,一个整型数组 int[]
将被记录为 [I
。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为”()V",方法java.lang.String toString()的描述符为"()Ljava/lang/String;",方法 int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)
的描述符为 ([CII[CIII)I
。
对于代码清单6-1中的TestClass.class文件来说,字段表集合从地址0x000000F8开始,第一个u2类型的数据为容量计数器fields_count,如图6-8所示,其值为0x0001,说明这个类只有一个字段表数据。接下来紧跟着容量计数器的是access_flags标志,值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真(ACC_PRIVATE标志的值为0x0002),其他修饰符为假。代表字段名称的name_index的值为0x0005,从代码清单6-2列出的常量表中可查得第5项常量是一个CONSTANT_Utf8_info类型的字符串,其值为"m",代表字段描述符的descriptor_index的值为0x0006,指向常量池的字符串"I",根据这些信息,我们可以推断出原代码定义的字段为:“private int m;"。
字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。对于本例中的字段m,它的属性表计数器为0,也就是没有需要额外描述的信息,但是,如果将字段m的声明改为"final static int m=123;",那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。关于attribute_info的其他内容,将在6.3.7节介绍属性表的数据项目时再进一步讲解。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。
6.3.6 方法表集合
如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项,见表6-11。这些数据项目的含义也非常类似,仅在访问标志和属性表集合的可选项中有所区别。
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见表6-12。
我们继续以代码清单6-1中的Class文件为例对方法表集合进行分析,如图6-9所示,方法表集合的入口地址为:0x00000101,第一个u2类型的数据(即是计数器容量)的值为0x0002,代表集合中有两个方法(这两个方法为编译器添加的实例构造器<init>和源码中的方法inc())。第一个方法的访问标志值为0x001,也就是只有ACC_PUBLIC标志为真,名称索引值为0x0007,查代码清单6-2的常量池得方法名为"<init>”,描述符索引值为0x0008,对应常量为"()V",属性表计数器attributes_count的值为0x0001就表示此方法的属性表集合有一项属性,属性名称索引为0x0009,对应常量为"Code",说明此属性是方法的字节码描述。
与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器"<clinit>"方法和实例构造器"<init>"方法。
在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
6.3.7 属性表集合
属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,《Java虚拟机规范(第2版)》中预定义了9项虚拟机实现应当能识别的属性,而在最新的《Java虚拟机规范(Java SE 7)》版中,预定义属性已经增加到21项,具体内容见表6-13。下文中将对其中一些属性中的关键常用的部分进行讲解。
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足表6-14中所定义的结构。
Code 属性
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性,如果方法表有Code属性存在,那么它的结构将如表6-15所示。
attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为"Code",它代表了该属性的属性名称,attribute_length指示了属性值的长度,由于属性名称索引与属性长度一共为6字节,所以属性值的长度固定为整个属性表长度减去6个字节。
max_stack代表了操作数栈(Operand Stacks)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。
max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。方法参数(包括实例方法中的隐藏参数"this")、显式异常处理器的参数(Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。另外,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
code_length和code用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。我们知道一个u1数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令,目前,Java虚拟机规范已经定义了其中约200条编码值对应的指令含义,编码与指令之间的对应关系可查阅本书的附录B“虚拟机字节码指令表”。
关于code_length,有一件值得注意的事情,虽然它是一个u4类型的长度值,理论上最大值可以达到2^{32}-1,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度,如果超过这个限制,Javac编译器也会拒绝编译。一般来讲,编写Java代码时只要不是刻意去编写一个超长的方法来为难编译器,是不太可能超过这个最大值的限制。但是,某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就可能因为方法生成字节码超长的原因而导致编译失败。
Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。了解Code属性是学习后面关于字节码执行引擎内容的必要基础,能直接阅读字节码也是工作中分析Java代码语义问题的必要工具和基本技能,因此笔者准备了一个比较详细的实例来讲解虚拟机是如何使用这个属性的。
继续以代码清单6-1的TestClass.class文件为例,如图6-10所示,这是上一节分析过的实例构造器"<init>"方法的Code属性。它的操作数栈的最大深度和本地变量表的容量都为0x0001,字节码区域所占空间的长度为0x0005。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据字节码指令表翻译出所对应的字节码指令。翻译"2A B7 00 0A B1"的过程为:
- 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个Slot中为reference类型的本地变量推送到操作数栈顶。
- 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用。
- 读入00 0A,这是invokespecial的参数,查常量池得0x000A对应的常量为实例构造器"<init>"方法的符号引用。
- 读入B1,查表得0xB1对应的指令为return,含义是返回此方法,并且返回值为void。这条指令执行后,当前方法结束。
这段字节码虽然很短,但是至少可以看出它的执行过程中的数据交换、方法调用等操作都是基于栈(操作栈)的。我们可以初步猜测:Java虚拟机执行字节码是基于栈的体系结构。但是与一般基于堆栈的零字节指令又不太一样,某些指令(如invokespecial)后面还会带有参数,关于虚拟机字节码执行的讲解是后面两章的重点,我们不妨把这里的疑问放到第8章去解决。
我们再次使用javap命令把此Class文件中的另外一个方法的字节码指令也计算出来,结果如代码清单6-4所示。
//原始Java代码
public class TestClass{
private int m;
public int inc(){
return m+1;
}
}
C:\>javap-verbose TestClass
//常量表部分的输出见代码清单6-1, 因版面原因这里省略掉
{
public org.fenixsoft.clazz.TestClass();
Code:
Stack=1, Locals=1, Args_size=1
0:aload_0
1:invokespecial#10;//Method java/lang/Object."<init>":()V
4:return
LineNumberTable:
line 3:0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lorg/fenixsoft/clazz/TestClass;
public int inc();
Code:
Stack=2, Locals=1, Args_size=1
0:aload_0
1:getfield#18;//Field m:I
4:iconst_1
5:iadd
6:ireturn
LineNumberTable:
line 8:0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lorg/fenixsoft/clazz/TestClass;
}
如果大家注意到javap中输出的"Args_size"的值,可能会有疑问:这个类有两个方法——实例构造器<init>()和inc(),这两个方法很明显都是没有参数的,为什么Args_size会为1?而且无论是在参数列表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?如果有这样的疑问,大家可能是忽略了一点:在任何实例方法里面,都可以通过"this"关键字访问到此方法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现却非常简单,仅仅是通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这个处理只对实例方法有效,如果代码清单6-1中的inc()方法声明为static,那Args_size就不会等于1而是等于0了。
在字节码指令之后的是这个方法的显式异常处理表(下文简称异常表)集合,异常表对于Code属性来说并不是必须存在的,如代码清单6-4中就没有异常表生成。
异常表的格式如表6-16所示,它包含4个字段,这些字段的含义为:如果当字节码在第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转向到handler_pc处进行处理。
异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常及finally处理机制。
代码清单6-5是一段演示异常表如何运作的例子,这段代码主要演示了在字节码层面中try-catch-finally是如何实现的。在阅读字节码之前,大家不妨先看看下面的Java源码,想一下这段代码的返回值在出现异常和不出现异常的情况下分别应该是多少?
//Java源码
public int inc(){
int x;
try{
x=1;
return x;
}catch(Exception e){
x=2;
return x;
}finally{
x=3;
}
}
//编译后的ByteCode字节码及异常表
public int inc();
Code:
Stack=1, Locals=5, Args_size=1
0:iconst_1//try块中的x=1
1:istore_1
2:iload_1//保存x到returnValue中, 此时x=1
3:istore 4
5:iconst_3//finaly块中的x=3
6:istore_1
7:iload 4//将returnValue中的值放到栈顶, 准备给ireturn返回
9:ireturn
10:astore_2//给catch中定义的Exception e赋值, 存储在Slot 2中
11:iconst_2//catch块中的x=2
12:istore_1
13:iload_1//保存x到returnValue中, 此时x=2
14:istore 4
16:iconst_3//finaly块中的x=3
17:istore_1
18:iload 4//将returnValue中的值放到栈顶, 准备给ireturn返回
20:ireturn
21:astore_3//如果出现了不属于java.lang.Exception及其子类的异常才会走到这里
22:iconst_3//finaly块中的x=3
23:istore_1
24:aload_3//将异常放置到栈顶, 并抛出
25:athrow
Exception table:
from to target type
0 5 10 Class java/lang/Exception
0 5 21 any
10 16 21 any
编译器为这段Java源码生成了3条异常表记录,对应3条可能出现的代码执行路径。从Java代码的语义上讲,这3条执行路径分别为:
- 如果try语句块中出现属于Exception或其子类的异常,则转到catch语句块处理。
- 如果try语句块中出现不属于Exception或其子类的异常,则转到finally语句块处理。
- 如果catch语句块中出现任何异常,则转到finally语句块处理。
返回到我们上面提出的问题,这段代码的返回值应该是多少?对Java语言熟悉的读者应该很容易说出答案:如果没有出现异常,返回值是1;如果出现了Exception异常,返回值是2;如果出现了Exception以外的异常,方法非正常退出,没有返回值。我们一起来分析一下字节码的执行过程,从字节码的层面上看看为何会有这样的返回结果。
字节码中第0~4行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一个本地变量表的Slot中(这个Slot里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用。为了讲解方便,笔者给这个Slot起了个名字:returnValue)。如果这时没有出现异常,则会继续走到第5~9行,将变量x赋值为3,然后将之前保存在returnValue中的整数1读入到操作栈顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。如果出现了异常,PC寄存器指针转到第10行,第10~20行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给returnValue,最后再将变量x的值改为3。方法返回前同样将returnValue中保留的整数2读到了操作栈顶。从第21行开始的代码,作用是变量x的值赋为3,并将栈顶的异常抛出,方法结束。
尽管大家都知道这段代码出现异常的概率非常小,但并不影响它为我们演示异常表的作用。如果大家到这里仍然对字节码的运作过程比较模糊,其实也不要紧,关于虚拟机执行字节码的过程,本书第8章中将会有更详细的讲解。
Exceptions 属性
这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常。它的结构见表6-17。
Exceptions属性中的number_of_exceptions项表示方法可能抛出number_of_exceptions种受查异常,每一种受查异常使用一个exception_index_table项表示,exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。
LineNumberTable 属性
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构见表6-18。
line_number_table是一个数量为line_number_table_length、类型为line_number_info的集合,line_number_info表包括了start_pc和line_number两个u2类型的数据项,前者是字节码行号,后者是Java源码行号。
LocalVariableTable 属性
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。LocalVariableTable属性的结构见表6-19。
其中,local_variable_info项目代表了一个栈帧与源码中的局部变量的关联,结构见表6-20。
start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
name_index和descriptor_index都是指向常量池中CONSTANT_Utf8_info型常量的索引,分别代表了局部变量的名称以及这个局部变量的描述符。
index是这个局部变量在栈帧局部变量表中Slot的位置。当这个变量数据类型是64位类型时(double和long),它占用的Slot为index和index+1两个。
顺便提一下,在JDK 1.5引入泛型之后,LocalVariableTable属性增加了一个“姐妹属性”:LocalVariableTypeTable,这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述符的descriptor_index替换成了字段的特征签名(Signature),对于非泛型类型来说,描述符和特征签名能描述的信息是基本一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,因此出现了LocalVariableTypeTable。
SourceFile 属性
SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构见表6-21。
sourcefile_index数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,常量值是源码文件的文件名。
ConstantValue 属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。类似"int x=123"和"static int x=123"这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量赋值的方式和时刻都有所不同。对于非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器<clinit>方法中或者使用ConstantValue属性。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在<clinit>方法中进行初始化。
虽然有final关键字才更符合"ConstantValue"的语义,但虚拟机规范中并没有强制要求字段必须设置了ACC_FINAL标志,只要求了有ConstantValue属性的字段必须设置ACC_STATIC标志而已,对final关键字的要求是Javac编译器自己加入的限制。而对ConstantValue的属性值只能限于基本类型和String,不过笔者不认为这是什么限制,因为此属性的属性值只是一个常量池的索引号,由于Class文件格式的常量类型中只有与基本属性和字符串相对应的字面量,所以就算ConstantValue属性想支持别的类型也无能为力。ConstantValue属性的结构见表6-22。
从数据结构中可以看出,ConstantValue属性是一个定长属性,它的attribute_length数据项值必须固定为2。constantvalue_index数据项代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_Integer_info、CONSTANT_String_info常量中的一种。
InnerClasses 属性
InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。该属性的结构见表6-23。
数据项number_of_classes代表需要记录多少个内部类信息,每一个内部类的信息都由一个inner_classes_info表进行描述。inner_classes_info表的结构见表6-24。
inner_class_info_index和outer_class_info_index都是指向常量池中CONSTANT_Class_info型常量的索引,分别代表了内部类和宿主类的符号引用。
inner_name_index是指向常量池中CONSTANT_Utf8_info型常量的索引,代表这个内部类的名称,如果是匿名内部类,那么这项值为0。
inner_class_access_flags是内部类的访问标志,类似于类的access_flags,它的取值范围见表6-25。
Deprecated/Synthetic 属性
Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。
Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@deprecated注释进行设置。
Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的,在JDK 1.5之后,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位,其中最典型的例子就是Bridge Method。所有由非用户代码产生的类、方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造器"<init>"方法和类构造器"<clinit>"方法。
其中attribute_length数据项的值必须为0x00000000,因为没有任何属性值需要设置。
StackMapTable 属性
StackMapTable属性在JDK 1.6发布后增加到了Class文件规范中,它是一个复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(见7.3.2节),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
这个类型检查验证器最初来源于Sheng Liang(听名字似乎是虚拟机团队中的华裔成员)为Java ME CLDC实现的字节码验证器。新的验证器在同样能保证Class文件合法性的前提下,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶段将一系列的验证类型(Verification Types)直接记录在Class文件之中,通过检查这些验证类型代替了类型推导过程,从而大幅提升了字节码验证的性能。这个验证器在JDK 1.6中首次提供,并在JDK 1.7中强制代替原本基于类型推断的字节码验证器。关于这个验证器的工作原理,《Java虚拟机规范(Java SE 7版)》花费了整整120页的篇幅来讲解描述,并且分析证明新验证方法的严谨性,笔者在此不再赘述。
StackMapTable属性中包含零至多个栈映射帧(Stack Map Frames),每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示该执行到该字节码时局部变量表和操作数栈的验证类型。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。StackMapTable属性的结构见表6-27。
《Java虚拟机规范(Java SE 7版)》明确规定:在版本号大于或等于50.0的Class文件中,如果方法的Code属性中没有附带StackMapTable属性,那就意味着它带有一个隐式的StackMap属性。这个StackMap属性的作用等同于number_of_entries值为0的StackMapTable属性。一个方法的Code属性最多只能有一个StackMapTable属性,否则将抛出ClassFormatError异常。
Signature 属性
Signature属性在JDK 1.5发布后增加到了Class文件规范之中,它是一个可选的定长属性,可以出现于类、属性表和方法表结构的属性表中。在JDK 1.5中大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都通通被擦除掉。使用擦除法的好处是实现简单(主要修改Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。关于Java泛型、Signature属性和类型擦除,在第10章介绍编译器优化的时候会通过一个具体的例子来讲解。Signature属性的结构见表6-28。
其中signature_index项的值必须是一个对常量池的有效索引。常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示类签名、方法类型签名或字段类型签名。如果当前的Signature属性是类文件的属性,则这个结构表示类签名,如果当前的Signature属性是方法表的属性,则这个结构表示方法类型签名,如果当前Signature属性是字段表的属性,则这个结构表示字段类型签名。
BootstrapMethods 属性
BootstrapMethods属性在JDK 1.7发布后增加到了Class文件规范之中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。《Java虚拟机规范(Java SE 7版)》规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性,另外,即使CONSTANT_InvokeDynamic_info类型的常量在常量池中出现过多次,类文件的属性表中最多也只能有一个BootstrapMethods属性。BootstrapMethods属性与JSR-292中的InvokeDynamic指令和java.lang.Invoke包关系非常密切,要介绍这个属性的作用,必须先弄清楚InovkeDynamic指令的运作原理,笔者将在第8章专门用1节篇幅去介绍它们,在此先暂时略过。
目前的Javac暂时无法生成InvokeDynamic指令和BootstrapMethods属性,必须通过一些非常规的手段才能使用到它们,也许在不久的将来,等JSR-292更加成熟一些,这种状况就会改变。BootstrapMethods属性的结构见表6-29。
其中引用到的bootstrap_method结构见表6-30。
BootstrapMethods属性中,num_bootstrap_methods项的值给出了bootstrap_methods[]数组中的引导方法限定符的数量。而bootstrap_methods[]数组的每个成员包含了一个指向常量池CONSTANT_MethodHandle结构的索引值,它代表了一个引导方法,还包含了这个引导方法静态参数的序列(可能为空)。bootstrap_methods[]数组中的每个成员必须包含以下3项内容。
bootstrap_method_ref:bootstrap_method_ref项的值必须是一个对常量池的有效索引。常量池在该索引处的值必须是一个CONSTANT_MethodHandle_info结构。
num_bootstrap_arguments:num_bootstrap_arguments项的值给出了bootstrap_arguments[]数组成员的数量。
bootstrap_arguments[]:bootstrap_arguments[]数组的每个成员必须是一个对常量池的有效索引。常量池在该索引处必须是下列结构之一:CONSTANT_String_info、CONSTANT_Class_info、CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_MethodHandle_info或CONSTANT_MethodType_info。
6.4 字节码指令简介
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构(这两种架构的区别和影响将在第8章中探讨),所以大多数的指令都不包含操作数,只有一个操作码。
字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不可能超过256条;又由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机处理那些超过一个字节数据的时候,不得不在运行时从字节中重建出具体数据的结构,如果要将一个16位长度的无符号整数使用两个无符号字节存储起来(将它们命名为byte1和byte2),那它们的值应该是这样的:(byte1<<8)|byte2
。
这种操作在某种程度上会导致解释执行字节码时损失一些性能。但这样做的优势也非常明显,放弃了操作数长度对齐,就意味着可以省略很多填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语言设计之初面向网络、智能家电的技术背景所决定的,并一直沿用至今。
如果不考虑异常处理的话,那么Java虚拟机的解释器可以使用下面这个伪代码当做最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效地工作:
do{
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置, 从字节码流中取出操作码;
if(字节码存在操作数)从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码流长度>0);
6.4.1 字节码与数据类型
在Java虚拟机的指令集中,大多数的指令都包含了其操作所对应的数据类型信息。例如,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助记符中没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,如无条件跳转指令goto则是与数据类型无关的。
由于Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力:如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那指令的数量恐怕就会超出一个字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会故意被设计成非完全独立的(Java虚拟机规范中把这种特性称为"Not Orthogonal",即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
表6-31列举了Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。例如,load指令有操作int类型的iload,但是没有操作byte类型的同类指令。
注意,从表6-31中可以看出,大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型(Computational Type)。
在本章中,受篇幅所限,无法对字节码指令集中每条指令进行逐一讲解,但阅读字节码作为了解Java虚拟机的基础技能,是一项应当熟练掌握的能力。笔者将字节码操作按用途大致分为9类,按照分类来为读者概略介绍一下这些指令的用法。如果读者需要了解更详细的信息,可以参考阅读笔者翻译的《Java虚拟机规范(Java SE 7版)》的第6章。
6.4.2 加载与存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之间来回传输,这类指令包括如下内容。
将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
扩充局部变量表的访问索引的指令:wide。
存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
上面所列举的指令助记符中,有一部分是以尖括号结尾的(例如iload_<n>),这些指令助记符实际上是代表了一组指令(例如iload_<n>,它代表了iload_0、iload_1、iload_2和iload_3这几条指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这若干组特殊指令来说,它们省略掉了显式的操作数,不需要进行取操作数的动作,实际上操作数就隐含在指令中。除了这点之外,它们的语义与原生的通用指令完全一致(例如iload_0的语义与操作数为0时的iload指令语义完全一致)。这种指令表示方法在本书以及《Java虚拟机规范》中都是通用的。
6.4.3 运算指令
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体上算术指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令,无论是哪种算术指令,都使用Java虚拟机的数据类型,由于没有直接支持byte、short、char和boolean类型的算术指令,对于这类数据的运算,应使用操作int类型的指令代替。整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现,所有的算术指令如下。
- 加法指令:iadd、ladd、fadd、dadd。
- 减法指令:isub、lsub、fsub、dsub。
- 乘法指令:imul、lmul、fmul、dmul。
- 除法指令:idiv、ldiv、fdiv、ddiv。
- 求余指令:irem、lrem、frem、drem。
- 取反指令:ineg、lneg、fneg、dneg。
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
- 按位或指令:ior、lor。
- 按位与指令:iand、land。
- 按位异或指令:ixor、lxor。
- 局部变量自增指令:iinc。
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。
Java虚拟机的指令集直接支持了在《Java语言规范》中描述的各种对整数及浮点数操作(参见《Java语言规范(第3版)》中的4.2.2节和4.2.4节)的语义。数据运算可能会导致溢出,例如两个很大的正整数相加,结果可能会是一个负数,这种数学上不可能出现的溢出现象,对于程序员来说是很容易理解的,但其实Java虚拟机规范没有明确定义过整型数据溢出的具体运算结果,仅规定了在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem)中当出现除数为零时会导致虚拟机抛出ArithmeticException异常,其余任何整型数运算场景都不应该抛出运行时异常。
Java虚拟机规范要求虚拟机实现在处理浮点数时,必须严格遵循IEEE 754规范中所规定的行为和限制。也就是说,Java虚拟机必须完全支持IEEE 754中定义的非正规浮点数值(Denormalized Floating-Point Numbers)和逐级下溢(Gradual Underflow)的运算规则。这些特征将会使某些数值算法处理起来变得相对容易一些。
在把浮点数转换为整数时,Java虚拟机使用IEEE 754标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近但是不大于原值的数字来作为最精确的舍入结果。
另外,Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是Java语言中的异常,请读者勿与IEEE 754规范中的浮点异常互相混淆,IEEE 754的浮点异常是一种运算信号),当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数学定义的话,将会使用NaN值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回NaN。
在对long类型数值进行比较时,虚拟机采用带符号的比较方式,而对浮点数值进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),虚拟机会采用IEEE 754规范所定义的无信号比较(Nonsignaling Comparisons)方式。
6.4.4 类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java虚拟机直接支持(即转换时无需显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversions,即小范围类型向大范围类型的安全转换):
- int类型到long、float或者double类型。
- long类型到float、double类型。
- float类型到double类型。
相对的,处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单地丢弃除最低位N个字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低N个字节的首位了。
在将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,将遵循以下转换规则:
- 如果浮点值是NaN,那转换结果就是int或long类型的0。
- 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那转换结果就是v。
- 否则,将根据v的符号,转换为T所能表示的最大或者最小正数。
从double类型到float类型的窄化转换过程与IEEE 754中定义的一致,通过IEEE 754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字。如果转换结果的绝对值太小而无法使用float来表示的话,将返回float类型的正负零。如果转换结果的绝对值太大而无法使用float来表示的话,将返回float类型的正负无穷大,对于double类型的NaN值将按规定转换为float类型的NaN值。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
6.4.5 对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令(在第7章会讲到数组和普通类的类型创建过程是不同的)。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。
- 创建类实例的指令:new。
- 创建数组的指令:newarray、anewarray、multianewarray。
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic。
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
- 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
- 取数组长度的指令:arraylength。
- 检查类实例类型的指令:instanceof、checkcast。
6.4.6 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
- 将栈最顶端的两个数值互换:swap。
6.4.7 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下。
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
- 复合条件分支:tableswitch、lookupswitch。
- 无条件分支:goto、goto_w、jsr、jsr_w、ret。
在Java虚拟机中有专门的指令集用来处理int和reference类型的条件分支比较操作,为了可以无须明显标识一个实体值是否null,也有专门的指令用来检测null值。
与前面算术运算时的规则一致,对于boolean类型、byte类型、char类型和short类型的条件分支比较操作,都是使用int类型的比较指令来完成,而对于long类型、float类型和double类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp,见6.4.3节),运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为int类型的比较操作,int类型比较是否方便完善就显得尤为重要,所以Java虚拟机提供的int类型的条件分支指令是最为丰富和强大的。
6.4.8 方法调用和返回指令
方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下5条用于方法调用的指令。
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
- invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic指令用于调用类方法(static方法)。
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
6.4.9 异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在前面介绍的整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成的。
6.4.10 同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持,譬如代码清单6-6中所示的代码。
void onlyMe(Foo f){
synchronized(f){
doSomething();
}
}
编译后,这段代码生成的字节码序列如下:
Method void onlyMe(Foo)
0 aload_1//将对象f入栈
1 dup//复制栈顶元素(即f的引用)
2 astore_2//将栈顶元素存储到局部变量表Slot 2中
3 monitorenter//以栈顶元素(即f)作为锁, 开始同步
4 aload_0//将局部变量Slot 0(即this指针)的元素入栈
5 invokevirtual#5//调用doSomething()方法
8 aload_2//将局部变量Slow 2的元素(即f)入栈
9 monitorexit//退出同步
10 goto 18//方法正常结束, 跳转到18返回
13 astore_3//从这步开始是异常路径, 见下面异常表的Taget 13
14 aload_2//将局部变量Slow 2的元素(即f)入栈
15 monitorexit//退出同步
16 aload_3//将局部变量Slow 3的元素(即异常对象)入栈
17 athrow//把异常对象重新抛出给onlyMe()方法的调用者
18 return//方法正常返回
Exception table:
FromTo Target Type
4 10 13 any
13 16 13 any
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。
从代码清单6-6的字节码序列中可以看到,为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令。
6.5 共有设计和私有实现
Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集。这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看做是程序在各种Java平台实现之间互相安全地交互的手段。
理解公有设计与私有实现之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变地逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可。
虚拟机实现者可以使用这种伸缩性来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:
- 将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。
- 将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生成技术)。
精确定义的虚拟机和目标文件格式不应当对虚拟机实现者的创造性产生太多的限制,Java虚拟机应被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的、新的、有趣的解决方案。
6.6 Class 文件结构的发展
Class文件结构自Java虚拟机规范第1版订立以来,已经有十多年的历史。这十多年间,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了1.7。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于比较稳定的状态,Class文件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对Class文件格式的改进,都集中在向访问标志、属性表这些在设计上就可扩展的数据结构中添加内容。
如果以《Java虚拟机规范(第2版)》为基准进行比较的话,那么在后续Class文件格式的发展过程中,访问标志里新加入了ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS共5个标志。而属性表集合中,在JDK 1.5到JDK 1.7版本之间一共增加了12项新的属性,这些属性大部分用于支持Java中许多新出现的语言特性,如枚举、变长参数、泛型、动态注解等。还有一些是为了支持性能改进和调试信息,譬如JDK 1.6的新类型校验器的StackMapTable属性和对非Java代码调试中用到的SourceDebugExtension属性。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。
本章小结
Class文件是Java虚拟机执行引擎的数据入口,也是Java技术体系的基础构成之一。了解Class文件的结构对后面进一步了解虚拟机执行引擎有很重要的意义。
本章详细讲解了Class文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法。通过代码清单6-1的Java代码与它的Class文件样例,以实战的方式演示了Class的数据是如何存储和访问的。从第7章开始,我们将以动态的、运行时的角度去看看字节码流在虚拟机执行引擎中是怎样被解释执行的。
2.4.7 - CH07-类加载
7.1 概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译期执行连接过程的语言不同,Java 中类型的加载、连接、初始化过程均在运行时进行。这种类加载机制会为运行时增加一些性能开销,但也给 Java 提供了高度的灵活性:基于这种运行时动态加载和连接的能力,Java 中天生就可以动态扩展语言的特性。
7.2 类加载的时机
类的完整生命周期:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析这三个部分被合称为连接。
- 大致固定的“开始顺序”:加载、验证、准备、初始化、卸载。
- 各个阶段开始后会以交叉混合方式进行,即在一个阶段的执行过程中调用、激活另一个阶段。
- 解析阶段的开始时机不一定,它在某些情况下可以在初始化节点之后进行,这是为了支持 Java 的运行时绑定(或称动态绑定、晚期绑定)。
JVM 规范并没有定义开始加载一个类的时机,因此取决于具体的 JVM 实现。但对于初始化阶段,JVM 规范严格规定了 有且仅有 5 种情况 必须立即对类执行“初始化”(因此初始化的前置阶段必须在之前开始):
- 遇到 new、getstatic、putstatic、invokestatic 这四种字节码指令时,如果没有对类进行初始化,则需要先触发对应类的初始化。
- 使用
java.lang.reflect
中的方法对类进行反射调用。 - 当初始化一个类的时候,发现其父类尚未被初始化。
- 当虚拟机启动时,用户需要制定一个将要执行的主类(包含 main 方法的类)。
- (JDK 7)如果一个
MethodHandle
实例最后的解析结果是REF_getStatic
、REF_putStatic
、REF_invikeStatic
的方法句柄对应类类没有被初始化。
这 5 种场景中的行为成为对一个类进行主动引用。除此之外,所有引用类的行为都不会触发初始化,称为被动引用。
被动引用场景
通过子类引用父类的静态字段
不会导致子类初始化。会引起父类的初始化和子类的加载。
通过数组定义来引用类
不会触发此类的初始化。
public class NotInitialization{
public static void main(String[]args){
SuperClass[]sca=new SuperClass[10];
}
}
这段代码不会触发 SuperClass 类的初始化,但会触发一个名为 [Lorg.fenixsoft.classloading.SuperClass
类的初始化,这是一个由 JVM 自动生成的、直接继承于 java.lang.Object
的子类,创建动作由字节码指令 newarray 触发。
在 JVM 内部,自动生成了一个类来封装数组数据(因此数组操作在 Java 中比在 C/C++ 中安全,后者是直接移动指针),该类值暴露了共有的 length 属性和 clone 方法。
应用类的常量字段
public class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD="hello world";
}
public class NotInitialization{
public static void main(String[]args){
System.out.println(ConstClass.HELLOWORLD);
}
}
该示例中并不会引起类 ConstClass 的加载。因为在编译阶段通过常量传播优化,已经将此常量的值存储到了类 NotInitialization 常量池中,以后 NotInitialization 对该常量的引用实质上是对自身常量池的引用。即,NotInitialization 的 Class 文件中并没有对类 ConstClass 的符号引用入口,这两个类在编译成 Class 之后便不存在任何关联了。
接口初始化
接口也有初始化过程。在类中一般使用静态块来显示初始化信息,而接口中不能使用静态块,但编译器仍然会为接口生成 <clinit>()
类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的地方在于上面所述 5 种主动引用情况的第 3 种:当一个类在初始化时,要求其父类全部都已经初始化过了;但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口时(如引用父接口中的常量)才会将其初始化。
7.3 类的加载过程
7.3.1 加载
加载阶段,虚拟机要完成的 3 件事情:
- 通过一个类的完全限定名来获取该类的二进制字节流。
- 将二进制字节流表示的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class
对象,作为在方法区中该类的各种信息的访问入口。
在 JVM 规范中并没有具体规定一定要从哪获取、怎样获取二进制字节流,因此具有很大的灵活性。基于这一点有很多有意义的实现:
- 从 ZIP 包中获取,这最终成为了日后 JAR/EAR/WAR 格式的基础。
- 从网络中获取,典型应用是 Applet。
- 运行时计算生成,应用最多的就是动态代理技术。
- 由其他文件生成,典型应用是 JSP。
- 从数据库中获取。
- 等等。
相对于类加载过程的其他阶段,一个非数组类在加载阶段的获取二进制字节流操作是可控性最强的,因为既可以使用系统提供的“引导类加载器”来加载,也可以由用户自定义的类加载器来加载,开发人员可以通过自定义的类加载器来控制字节流的获取方式。
数组类本省不能通过类加载器创建,它是由 JVM 直接创建的。但数组类仍与类加载器有着密切的联系,因为数组类的元素类型最终需要靠类加载器来创建。一个数组类(简称 C)的创建过程遵循以下规则:
- 如果数组的元素类型是引用类型,就递归采用本节中定义的加载过程来加载该元素类型,数组 C 将在加载元素类型的类加载器的名称空间上被标识(一个类必须与其加载器来确定唯一性)。
- 如果数组的元素类型不是引用类型,JVM 将会把数组 C 标记位与引导类加载关联。
- 数组类的可见性与元素类型的可见性一致,如果元素类型不是引用类型,则数组类的可见性为 public。
加载阶段与连接阶段是交叉进行的(如一部分字节码文件格式校验动作),加载阶段尚未完成,连接阶段可能已经开始,但这些夹杂在加载阶段的动作仍然属于连接阶段,这两个阶段的开始时间依然保持着固定的先后顺序。
7.3.2 验证
连接节点的第一步,为了确保 Class 文件的字节码中包含的信息符合当前虚拟机的要求,且不会危害虚拟机自身的安全。
Class 文件并不一定由 Java 源码编译而来。在字节码语言层面上,Java 代码无法做到的(比较危险的)事情都是可以实现的。虚拟机如果不检查输入的字节流而对其完全信任的话,很有可能因为载入了有害的字节流而导致系统崩溃。
验证阶段是非常重要的,该阶段是否严谨,决定了 JVM 是否能够承受恶意代码的攻击。从执行性能的角度来看,验证阶段的工作量在类加载子系统中占有相当大一部分。
验证阶段主要可以分为以下 4 个检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
详细细节可以参考虚拟机规范。
7.3.3 准备
正式为类变量(静态变量)分配内存设置初始值(零值),这些变量所使用的内存将被分配在方法区。
假设一个类变量的定义为 public static int value=123;
,那么该字段在准备阶段之后的值仍然为 int 类型的零值,即 0。因为在该阶段尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令会在程序被编译后放在类构造器的 <clinit>()
方法中,并在初始化节点执行。
一种特殊情况:如果类字段的字段属性表存在 ConstantValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值。
假设将上面的定义修改为 public static final int value=123;
,这是 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段 JVM 就会根据 ConstantValue 的设置将 value 赋值为 123。
基本数据类型的零值:
7.3.4 解析
将常量池中的符号引用替换为直接引用的过程。
符号引用:以一组符号来描述所引用的目标,符合可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
JVM 规范中并未规定解析阶段发生的具体时间,只要求在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic 这 16 个用于操作符号引用的字节码指令之前,先对其使用的符号引用进行解析。所有虚拟机实现可以根据需要来判断是要在类加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时再去解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点,这 7 类符号进行,分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 这 7 种常量类型。
这里介绍前 4 种引用的解析过程,后 3 种与动态语言的支持相关,会在第 8 章介绍完 invokedynamic 指令的语义之后再做介绍。
1. 类或接口
假设当前类为 D,如果要把一个未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,虚拟机要完成的解析过程包含以下 3 个步骤:
- 如果 C 不是一个数组类型,虚拟机将会把代表 N 的完全限定名传递给 D 的类加载器来加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,如该类的父类或父接口。一旦该过程出现任何异常,解析过程宣告失败。
- 如果 C 是一个数组类型,并且数组的元素类型为对象,即 N 的描述符会以类似
[Ljava/lang/Integer
的形式,那将会按照第一步的规则首先加载素组元素的类型。完成后由虚拟机生成一个代表次数组维度和元素的数组对象。 - 如果上述步骤没有出现任何异常,那么 C 在虚拟机中实际已经称为了一个有效的类或接口了,但在解析完成之前要进行符号引用验证,以确认 D 是否具备对 C 的访问权限。如果不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
2. 字段解析
首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果该过程中出现异常将会导致本解析过程失败;如果解析成功完成,那么将该字段所属的类或接口用 C 表示,虚拟机规范中要求按照如下步骤对 C 进行后续字段的搜索:
- 如果 C 本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果在 C 中实现了接口,将会安装继承关系从下往上递归搜索各个接口及父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
- 否则,如果 C 不是
java.lang.Object
的话,将会安装继承关系递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。 - 否则,查找是被,抛出
java.lang.NoSuchFieldError
异常。
如果查找过程成功返回了引用,将会对该字段进行权限验证,如果发现不具备对该字段的访问权限,将抛出 java.lang.IllegalAccessError
异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范更加严格,如果一个字段同时出现在 C 的接口和父类中,或者同时出现在自己或父类的多个实现接口中,那么编译器可能会直接拒绝编译。
3. 类方法解析
类方法解析的第一个步骤与字段解析一样,也需要解析出类方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用 C 表示该类,接下来虚拟机会按照如下步骤进行后续的类方法搜索:
- 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法中发现 class_index 中索引的 C 是个接口,那就直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 如果通过了第 1 步,在类 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
类为止(包括该类),查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,结束查找。 - 否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
异常。
由于接口中所有的方法默认都是 public 访问权限,因此不存在访问权限问题,即也不会抛出 java.lang.IllegalAccessError
异常。
7.3.5 初始化
这是类加载过程的最后一步。前面已介绍的过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才开始真正执行类中定义的 Java 程序字节码。
在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,收集顺序取决于语句在代码源文件中出现的顺序,静态语句块只能访问到定义在静态语句块之前的变量,但可以为定义在其后的变量赋值而不能访问。
<clinit>()
方法与类的构造函数(类实例构造器) <init>()
方法不同,它不需要显式的调用父类构造器,虚拟机会保证在子类的 <clinit>()
方法执行之前,父类的 <clinit>()
方法已经执行完毕。因此在虚拟机中第一个被执行的 <clinit>()
方法一定属于 java.lang.Object
。
如果一个类中没有静态语句块会对变量的赋值操作,那么编译器就不会为其生成该方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>()
方法。但与类不同的是,执行接口的 <clinit>()
方法不需要首先执行父接口的 <clinit>()
方法。因为当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行父接口的 <clinit>()
方法。
虚拟机会保证一个类的 <clinit>()
方法在多线程环境中能够被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行该类的 <clinit>()
方法,其他线程都有阻塞等待,直到活动线程执行 <clinit>()
方法完毕。如果在一个类的 <clinit>()
方法中有耗时很长的操作,就可能会造成多个进程阻塞。
7.4 类加载器
虚拟机设计团队把类加载阶段中:“通过一个类的完全限定名来获取描述此类的二进制字节流”,这样一个动作放到了 JVM 外部来实现,以便让应用程序自己决定如何获取需要的类。实现这个动作的代码模块被称为“类加载器”。
7.4.1 类与类加载器
在一个 JVM 实例内,对于任意一个类,都需要由加载它的类加载器和这个类本身来共同确立其唯一性,每个类加载器都有一个独立的类名称空间。
两个相等的类,意味着由同一个虚拟机加载。相等性包括 Class 对象的 equals 方法、isAssignableFrom 方法、isInstance 方法返回的结果,也包括使用 isinstanceof 关键字对对象所属的类进行关系判定等情况。
7.4.2 双亲委派模型
从 JVM 的角度来看,只有两种不同的类加载器:一种是启动类加载器,由 C++ 语言实现,是 JVM 的一部分;另一种就是所有其他的加载器,均由 Java 语言实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader
。
从应用开发的角度看,绝大部分的 Java 程序都会用到以下 3 种类加载器:
启动类加载器:负责将存放在 <JAVA_HOME>/lib
目录中、或者被 -Xbootclasspath
参数指定的路径中的、并且是虚拟机识别的(以文件名识别,如 rt.jar
)类库加载到虚拟机内存中。该加载器无法被 Java 程序直接引用,如果在编写自定义加载器时需要将加载请求委派给引导类加载器,可以直接使用 null 作为自定义加载器的父加载器。
扩展类加载器:由 sun.misc.Launcher$ExtClassLoader
实现,负责加载 <JAVA_HOME>/lib/ext
目录中的、或者被 java.ext.dirs
系统变量指定的路径中的所有类库,可以被开发者直接使用。
应用类加载器:由 sun.misc.Launcher$AppClassloader
实现。是 ClassLoader.getSystemClassLoader
的返回值,一般也称为系统类加载器。负责加载用户类路径(ClassPath)中所有的类库,可以被开发者直接使用。如果应用中没有自定义实现任何加载器,一般情况下就是程序中默认的加载器。
上图展示了这几种加载器之间的关系,称为类加载器的“双亲委派模型”。该模型要求除了顶层的启动类加载器外,其余的类加载器都应当有字节的父类加载器。这里所指的类加载器之间的父子关系不使用类继承形式来实现,而是使用组合关系来将加载请求为派给父类加载器。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,他首先不会自己去尝试执行加载,而是把该请求委派给父加载器去完成加载,每一个层次的加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成该加载请求时(在搜索范围内无法找到需要的类),子加载器才会尝试自己去加载。
这种模型的好处就是,Java 类随着其类加载器一起具备了一种优先级层次关系。比如 Objcet 类,它被存放在 rt.jar
中,无论哪个加载器要加载该类,最终都是委派给处于模型最顶层的启动类加载器进行加载,因此 Object 类在程序的各个类加载器中都是同一个类。如果没有使用双亲委派模型,而是由各个类加载器自己去加载的话,如果用户字节编写了一个名为 java.lang.Object
的类,Java 类型体系中的最基础行为就无法得到保证。
7.4.3 破坏双亲委派模型
双亲委派模型并非一个强制性的约束模型,而 Java 设计者推荐给开发者的类加载器实现方式。
下面是种对这种模型的“破坏(创新)”形式。
1. 历史遗留
第一次“被破坏”其实发生在双亲委派模型出现之前,即 JDK 1.2 发布之前。由于双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader
在早在 JDK 1.0 时代就已经存在,面对已经存在的用户自定义加载器实现,Java 设计者在引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK 1.2 之后的 java.lang.ClassLoader
添加了一个新的 protected 方法 findClass。在此之前,用户继承 ClassLoader 时唯一的目的就是重新 loadClass 方法,因为虚拟机在进行类加载时会调用加载器的私有方法 loadClassInternal,而该方法的唯一逻辑就是调用 loadClass 方法。
在 JDK 1.2 之后已经不提倡覆写 loadClass 方法,而应当把自己的类加载逻辑编写在 findClass 方法中,在 loadClass 方法的逻辑里如果父类记载失败,则会调用自己的 findClass 方法来完成加载,这样就可以保证新编写的类加载器都符合双亲委派模型。
2. 模型缺陷
双亲委派模型很好的解决了各个加载器对基础类的统一问题(越基础的类越由上层的加载器完成加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的 API。但有时候基础类也需要调用用户代码。
如 JNDI 服务,它的代码由启动类加载器完成加载(rt.jar),但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序 ClassPath 下的 JNDI 接口提供者(SPI)代码,但启动类加载器并不认识这些代码。
为了解决该问题,Java 设计团队只好引入了一个不太优雅的设计:线程上下文加载器。该加载器可以通过 Thread 类的 setContextClassLoader 方法进行设置,如果线程创建时还未设置,它将会从父线程中继承一个加载器,如果在应用程序的全局范围内都没有设置过,那么就使用应用程序类加载器。
JDNI 使用该加载器来加载所需的 SPI 代码,也就是父类加载器请求子类加载器来完成对类的加载操作,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,也违背了双亲委派模型的一般性原则。Java 中的所有 SPI 的加载动作基本都采用这种方式。
3. 动态性
这里所说的动态性指的是:代码热替换、模块热部署等。OSGI 已经成为了业界“事实上”的 Java 模块化标准,而 OSGI 实现模块化热部署的关键就是它自已定义的类加载器机制。每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起替换掉以实现代码的热替换。
在 OSGI 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载请求时,OSGI 将按照下面的顺序来搜索类:
- 将以
java.*
开头的类委派给父类加载器。 - 否则,将委派列表名单内的类委派给父类加载器。
- 否则,将 import 列表中的类委派给 Export 这个类所属的 Bundle 的类加载器。
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器。
- 否则,查找类师傅在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器。
- 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器。
- 否则,类查找失败。
以上查找顺序中只有开头两点符合双亲委派模型,其余的类查找都在平级的类加载器中机进行。
7.5 本章小结
本章介绍了类加载过程的“加载”、“验证”、“准备”、“解析”和“初始化”5个阶段中虚拟机进行了哪些动作,还介绍了类加载器的工作原理及其对虚拟机的意义。
2.4.8 - CH08-字节码执行引擎
8.1 概述
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。
8.2 运行时栈帧结构
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。每个方法调用从开始到执行完成的过程,都对应着一个栈帧在虚拟机站里面从入栈到出栈的过程。
每个栈帧中都包含了局部变量表、操作数栈、动态连接、方法返回地址等信息。栈帧中需要多大的局部变量表、多深的操作数栈会在 编译期确定,并且写入到方法表的 Code 属性中,因此一个栈帧需要分配多少内存。不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称作 当前栈帧,与该栈帧关联的方法称为 当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示。
8.2.1 局部变量表
局部变量表是一组 变量值存储空间,用于存放 方法参数 和方法内部定义的 局部变量。在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性中的 max_locals 数据项中确定了该方法需要分配的局部变量表的最大容量。
局部变量表以槽(slot)为最小单位,虚拟机规范中没有明确指定一个 Slot 应该占用多大的内存空间,但是很有导向性的说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference、returnAddress 类型的数据,这 8 种数据类型,都可以用 32 位或更小的物理内存来存放,允许 Slot 的长度随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在 64 位虚拟机中使用 64 位的物理内存空间来实现一个 slot,虚拟机仍要使用对齐和补白的手段让 slot 在外观上看起来与 32 位的虚拟机中一致。
上面提到的前 6 种数据类型无需多说,而第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范没有明确说明其长度和结构。但一般来说,虚拟机实现至少都应当能通过该引用做当两点:从此引用中直接或间接的查找到对象在 Java 堆中的数据存放的起始地址索引;此引用中直接或间接的查找到对象所属数据类型在方法区中存储的类型信息,否则无法实现 Java 语言规范中定义语法约束。
第 8 种 returnAddress 类型目前已经很少见了,它为字节码指令 jsr、jsr_w、ret 服务,指向了一条字节码指令的地址,远古 JVM 曾使用这几条指令来实现处理,现在已经由异常表代替。
对于 64 位的数据类型,虚拟机会以高位补齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32/64 位) 64 位数据类型目前只有 long、double。值得一提的是,这里把 long、double 分割存储的方法与“非原子性协定”中对这两种数据类型分割为两次读写的方式类似。不过,由于局部变量表建立在线程的堆栈上,是线程私有数据,无论读写两个连续的 slot 是否为原子操作,都不会引起数据安全性问题。
虚拟机通过索引定位的方式使用局部变量表,索引值从 0 开始只局部变量表的最大 slot 数量。如果访问的是 32 位数据类型的变量,则索引 N 就表示使用第 N 个 Slot;如果是 64 位的数据类型变量,则会同时使用 N 和 N+1 来表示两个 Slot。对于两个相邻的用于存放一个 64 位数据的 slot,不允许采用任何单独的方式来单独访问其中一个,否则将在类加载的校验阶段抛出异常。
在方法执行时,虚拟机使用局部变量表来完成参数值到参数变量列表的传递过程。如果执行的是实例方法,局部变量表中第 0 位索引的 slot 默认用于传递方法所属对象实例的引用,在方法中可以通过关键字 this 来方法该隐含参数。其余参数则按照参数列表顺序排列,占用从索引值 1 开始的局部变量 slot。参数列表分配完成之后,再根据方法体内部定义的变量顺序和作用域来分配其余的 slot。
为了尽可能的节省栈空间,局部变量表中的 slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。不过,这样的设计出了节省栈帧空间之外,还会伴随着一些额外的副作用,如在某些情况下,slot 的复用会直接影响到系统的垃圾收集行为。
slot 复用如何影响 GC?
“如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那么该变量对应的 slot 就可以交给其他变量使用。” 但是,如果并没有其他变量复用该 slot,该 slot 中原有的值会继续保持,所以作为 GC-Roots 一部分的局部变量表仍然保持着对该 slot 中的变量值的关联。 这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为 null 值这会变得有意义。 但赋值为 null 的操作可能被 JIT 优化掉,因此不能依赖这种操作。
局部变量不像前面介绍的类变量那样存在“准备阶段”。通过第 7 章的讲解,我们已经知道类(静态)变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为 Java 中任何情况下都存在诸如整型变量默认为 0,布尔型变量默认为 false 等这样的默认值。编译器会报告这种错误。
8.2.2 操作数栈
即操作栈,是一个 LIFO 栈。与局部变量表一样,其最大深度也在编译期写入到 Code 属性的 max_stacks 数据项中。操作数栈的每个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的占容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时刻,操作数栈的深度都不会超过 max_stacks 数据项中设定的最大值。
当一个方法刚刚开始执行的时候,该方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈、出栈操作。比如,在做算术运算的时候是通过操作数栈完成的,又或者在调用其他方法的时候是通过操作数栈完成参数传递的。
举个例子,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行 iadd 指令时,会将栈顶的两个 int 值出栈并相加,然后再将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验的数据流分析中还要再次验证这一点。
再以上面 iadd 指令为例,该指令用于整型加法,它在执行时,最接近栈顶的两个元素的数据类型必须都是 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
另外,在概念模型中,两个栈顶作为虚拟机栈的元素,是完全互相独立的。但在大多虚拟机的实现中会做一些优化处理,使两个栈帧出现一部分重叠。让下面栈帧的部分操作数与上面栈帧的局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递,重叠的过程如图 8-2 所示。
Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的栈就是操作数栈。
8.2.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。通过第六章的讲解,我们知道 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候被转换为直接引用,这时候的转换被称为静态解析。另外一部分将在每次运行期间转换为直接引用,这部分称为动态连接。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式可以退出该方法。
第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(即调用当前方法的方法),是否有返回值和返回值的类型将根据具体的方法返回指令来确定,这种退出方式称为正常完成出口。
另一种退出方式是在方法执行过程中遇到了异常,并且该异常没有在方法体内得到处理,无论是 JVM 内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。这种退出方式不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它上层的方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器值可以作为返回地址,栈帧中可能会保存该计数器值。而方法异常退出时,返回地址要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
8.2.5 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范中没有描述的信息到栈帧中,如调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再描述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
8.3 方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即要调用哪个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过,Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。该特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
8.3.1 解析
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的版本,并且该方法的调用版本在运行期是不可改变的。即,调用目标在程序代码编写完成、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在 Java 语言中符合“编译期可知、运行期不可变”这种要求的方法,主要包括静态方法和私有方法两大类,前者直接与类相关联,后者不可被外部访问,这两种方法各自的特点都决定了它们都不可能通过继承或别的方式被重写为其他版本,因此它们都适合在类加载阶段完成解析。
与之对应的是,在 JVM 中提供了 5 个方法调用字节码指令,分别是:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器
<init>
方法、私有方法、父类方法。 - invokevirtual:调用所有的虚方法。
- invikeinterface:调用接口方法,会在运行时确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 JVM 内部的,而 invokedynamic 指令的分派逻辑是由用户指定的引导方法决定的。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合该条件的只有静态方法、私有方法、实例构造器、父类方法 4 种,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法则被称为虚方法(final 方法除外)。
Java 中的非虚方法除了使用 invokestatic 和 invokespecial 调用的方法之外还有一种,即被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖、不会存在其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果一定是唯一的。在 Java 语言规范中明确说明了 fianl 方法是一种非虚方法。
解析调用移动是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转换为可确定的直接引用,不会延迟到运行期再进行。而分派(dispatch)调用则可能是静态或动态的,根据分派所依据的宗量数有但分派和多分派。这两类分派方式来那个量组合就构成了静态但分派、静态多分派、动态单分派、动态多分派这 4 种分派组合的情况。
8.3.2 分派
Java 是一种 OO 语言,因为它具备 OO 的 3 个基本特征:封装、继承、多态。这里讲解的分派调用过程将会揭示多态特性的一些最基本体现,如“重载”和“重写”在 JVM 中是如何实现的,即虚拟机如何确定正确的目标方法。
1. 静态分派
public class StaticDispatch{
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human guy){
System.out.println("hello,guy!");
}
public void sayHello(Man guy){
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy){
System.out.println("hello,lady!");
}
public static void main(String[]args){
Human man=new Man();
Human woman=new Woman();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
// 执行结果:
// hello,guy!
// hello,guy!
可以发现两次方法调用均选择了以 Human 的参数类型的重载方法。要理解该问题,我们先按如下定义来理解两个重要的概念。
Human man = new Man();
我们将上面代码中的 “Human” 称为变量的静态类型,或者叫做外观类型,后面的 “Man” 则称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
再回到最初的例子中。main 中的两次 sayHello 方法调用,在方法接收者已经确定是对象 sr 的前提下,使用哪个重载版本,就完全取决于传入参数的数量的类型。代码中刻意的定义了两个静态类型相同但实际类型不同的变量,但 虚拟机(更准确的说是编译器)在重载时是通过参数的静态类型而非实际类型作为判断依据的。并且静态类型是编译期可知的,因此在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本的方法,因此选择了 sayHello(Human)
作为调用目标,并把该方法的符号引用写入到了 main 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型进行方法版本定位的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译期,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个“更加合适的”版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则来理解和推断。下面的例子演示了“更合适的版本”是什么。
package org.fenixsoft.polymorphic;
public class Overload{
public static void sayHello(Object arg){
System.out.println("hello Object");
}
public static void sayHello(int arg){
System.out.println("hello int");
}
public static void sayHello(long arg){
System.out.println("hello long");
}
public static void sayHello(Character arg){
System.out.println("hello Character");
}
public static void sayHello(char arg){
System.out.println("hello char");
}
public static void sayHello(char……arg){
System.out.println("hello char……");
}
public static void sayHello(Serializable arg){
System.out.println("hello Serializable");
}
public static void main(String[]args){
sayHello('a');
}
}
// 输出
// hello char
这很好理解,'a'
是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉对应 char 参数类型的方法 sayHello(char arg)
,则输出会不变为 hello int
。
这时发生了一次自动类型转换,'a'
除了可以代表一个字符串,还可以代表数字 97(a 的 Unicode 数值为十进制的 97)。因此参数类型为 int 的重载也是合适的。如果继续去掉参数类型为 int 的方法 sayHello(int arg)
,输出则变为 hello long
。
这时发生了两次自动类型转换,'a'
转型为整数 97 之后,进一步转型为长整型 97L,匹配了参数类型为 long 的重载。例子中没有提供其他类型如 float、double 等重载方法,不过实际上自动转型还能继续发生多次,按照 char、int、long、float、double 的顺序依次转型进行匹配。但不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的。继续注释掉参数类型为 long 的方法 sayHello(long arg)
,输出则变为 hello character
。
这时发生了一次自动装箱操作,'a'
被包装为它的封装类型 java.lang.Character
,所以匹配到了参数类型为 Character 的重载,继续注释掉参数类型为 Character 类型的方法 sayHello(Character arg)
,输出则变为 hello Seralizable
。
这个输出可能会让人感到难以理解,一个字符或数字与序列化有什么关系呢?出现这种结果的原因是 java.lang.Serializable
是 java.lang.Character
的一个接口,当它自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char 可以转换为 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全的转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>
,如果同时还出现了两个参数类型分别为 Serializable
和 Comparable<Character>
的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,因此会提示模糊类型,拒绝编译。程序必须在调用时显式的指定字面量的静态类型,如:sayHello((Comparable<Character>) 'a')
,才能编译通过。如果继续将参数类型为 Comparable<Character>
的方法去掉,结果变为:hello Object
。
这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接靠上层的优先级越低。即使方法调用传入的参数值为 null,该规则仍然适用。当把参数类型为 Object 的方法去掉之后,输出将变为 hello char...
。
7 个重载方法已经被移除的只剩下一个类,可变变长参数的重载优先级是最低的,这时候 'a'
被当做一个数组元素。这里使用的是 char 类型的变长参数,读者在验证时还可以选择 int、Character、Object 类型等的变长参数重载来重新演示上面的例子。但要注意的是,有一些在单个参数中能成立的自动转型,如 char 到 int,在变长参数中是不成立的。
上面的例子演示了编译期间选择静态分派目标的过程,该过程也是 Java 语言实现方法重载的本质。比较容易混淆的是,解析与分派的关系并非二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。如前所述,静态方法会在类加载期间进行解析,而静态方法也可以拥有重载版本,选择重载版本的过程就是通过静态分派完成的。
2. 动态分派
动态分配与多态性的另外一个重要体现——重写(override)有着密切的联系。
public class DynamicDispatch{
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override
protected void sayHello(){
System.out.println("man say hello");
}
}
static class Woman extends Human{
@Override
protected void sayHello(){
System.out.println("woman say hello");
}
}
public static void main(String[]args){
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
// 结果
// man say hello
// woman say hello
// woman say hello
这里,虚拟机不再根据静态类型来选择要调用的方法,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,JVM 是如何根据实际类型来分派方法执行版本呢?这里使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如下:
public static void main(java.lang.String[]);
Code:
Stack=2, Locals=3, Args_size=1
0:new#16;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man
3:dup
4:invokespecial#18;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man."<init>":()V
7:astore_1
8:new#19;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
11:dup
12:invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa
tch $Woman."<init>":()V
15:astore_2
16:aload_1
17:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
20:aload_2
21:invokevirtual#22;//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
24:new#19;//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
27:dup
28:invokespecial#21;//Method org/fenixsoft/polymorphic/Dynam
icDispatch $Woman."<init>":()V
31:astore_1
32:aload_1
33:invokevirtual#22;//Method org/fenixsoft/polymorphic/
DynamicDispatch $Human.sayHello:()V
36:return
0~15 行的代码是准备动作,作用是建立 man 和 women 的内存空间、调用 Man 和 Women 类型的实例构造器,将这两个实例的应用存放在 1、2 两个局部变量表的 slot 之中,该动作对应代码中的两行代码:
Human man=new Man();
Human woman=new Woman();
接下来 16~21 行是关键部分,16、20 两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的 sayHello 方法的持有者,称为接收者;17、21 是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释还显示了该常量是 Human.sayHello 的符号引用)完全一样,但是这两行指令最终执行的目标方法是不同的。原因就需要从 invokevirtual 指令的多态查找过程说起,invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做 C。
- 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果校验通过则返回该方法的直接引用,查找过程结束;如果不通过,则返回非法访问异常。
- 否则,按照继承关系从下往上依次对 C 的各个父类机执行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出抽象方法异常。
由于 invokevirtual 指令指定的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,该过程就是 Java 中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3. 但分派与多分派
方法的接收者与方法的参数统称为方法的宗量,该定义最早来自《Java 与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多个宗量对目标方法进行选择。
public class Dispatch{
static class QQ{}
static class_360{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(_360 arg){
System.out.println("father choose 360");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(_360 arg){
System.out.println("son choose 360");
}
}
public static void main(String[]args){
Father father=new Father();
Father son=new Son();
father.hardChoice(new_360());
son.hardChoice(new QQ());
}
}
// 结果
// father choose 360
// son choose qq
在 main 函数中调用了两次 hardChoice 方法,这两次方法调用的选择结果在程序输出中已经显示的很清晰了。
在编译器编译期间的静态分派过程中,选择方法主要依据两点:一是静态类型是 Father 还是 Son,二是方法参数是 QQ 还是 360。这次选择的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别是常量池中指向 Father.hardChoice(360)
和 Father.hardChoice(QQ)
方法的符号引用。因为根据两个宗量进行选择,所以 Java 的静态分派属于多分派。
再看看运行时阶段虚拟机的选择,也就是动态分派过程。在执行 son.hardChoice(QQ)
这行代码对应的 invokevirtual 指令时,由于编译器已经决定目标方法的签名必须是 hardChoice(QQ)
,虚拟机此时不关心传递过来的参数是 QQ 的哪个子类实现,因为这时参数是静态类型、实际类型都对方法的选择不构成影响,唯一可以印象虚拟机选择的因素只有此方法的接收者的实际类型是 Father 还是 Son。意味只有一个宗量作为选择依据,所以 Java 的动态分派属于单分派。
根据上述论证的结果,我们可以总结出:现在的 Java(1.8之前) 是一种静态多分派、动态单分派的语言。
按照目前 Java 语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎的方式来满足动态性的需求。但是 JVM 层面上则不同,在 JDK 1.7 中实现的 JSR-292 里面就已经开始提供对动态语言的支持了,JDK 1.7 中新增的 invokedynamic 指令也成为了最复杂的一条方法调用字节码指令。
4. 虚拟机动态分派实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个续方法表(vtable,于此对应,invokeinterface 执行时也会用到接口方法表,即 itable),使用虚方法表索引来代替元数据查找以提高性能。
下图是虚方法表的数据结构示例:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了父类的方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。在上图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承而来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完成。
上文中说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程,可参考本书第 11 章。
8.3.3 动态类型语言支持
从 Sun 公司的第一款 JVM 问世至 JDK7 来临之前的十余年时间里,JVM 的字节码指令集的数量一直都没有发生过变化。随着 JDK7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic 指令。这条新增加的指令是 JDK7 实现“动态类型语言”支持而进行的改进之一,也是为 JDK8 可以顺利实现 Lambda 表达式做出技术准备。
1. 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而非编译期。相对的,在编译期进行类型检查的过程的语言就是常说的静态类型语言。
静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题在编码时就能发现,利于稳定性以及代码达到较大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,有动态类型语言来实现可能会更加清晰简洁,这也就意味着开发效率的提升。
2. JDK7 与动态类型
JVM 层面对动态类型语言的支持一直有所欠缺,主要变现在方法调用方面:JDK7 之前的字节码指令集中,4 条方法调用指令的第一个参数都是被调用方法的符号引用。前面已经说过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在 JVM 上实现的动态类型语言就不得不使用其他方式(如在编译期留一个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必会增加动态类型语言的实现难度,也可能会带来额外的性能或内存开销。尽管可以利用一些方法(如 Call site Caching)让这些开销尽可能变小,但这种底层问题终归是应当在虚拟机层次上来解决才最合适,因此在 JVM 层面上提供动态类型的直接支持就成为了 Java 平台的发展趋势之一,这就是 JDK7 中 JSR-292 invokedynamic 指令以及 java.lang.invoke 包出现的技术背景。
3. java.lang.invike 包
JDK7 实现了 JSR-292,新加入的 java.lang.invoke 包就是 JSR-292 的一个重要组成部分,该包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式之外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。
这类似于 C/C++ 中的函数指针,或者 C# 中的 Delegate 类。比如我们要实现一个带谓词的排序函数,在 C/C++ 中的做法是把谓词定义为函数,用函数指针把谓词传递给排序方法:
void sort(int list[], const int size,int(*compare)(int,int))
Java 无法把方法作为一个参数进行传递。普遍的做法是设计一个带有 compare 方法的 Comparator 接口,以实现了这个接口的对象作为参数,如 Colleciton.sort 就是这样定义的:
void sort(List list,Comparator c)
在拥有 MethodHandle 之后,Java 也可以拥有类似函数指针或委托的方法别名工具了。但在看完 MethodHandle 的用法之后大家可能会有疑惑,相同的事情,反射不是已经早就实现了吗?确实,仅站在 Java 语言的角度来看,MethodHandle 的使用方法和效果与 Reflection 有着众多相似之处,不过,它们还是有以下区别:
从本质上讲,Reflection 和 MethodHandle 都是在模拟方法调用,但 Refection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandle.lookup 中的 3 个方法——findStatic、findVirtual、findSpecial 正是为了对应于 invokestatic、invokevirtual/invokeinterface、invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 是不需要关心的。
Reflection 中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象包含的信息多。前者是在 Java 一端的全面镜像,包含了方法签名、描述符以及方法属性表中各种属性的 Java 端表示方式,还包含执行权限等运行时信息。而后者仅仅包含与执行该方法相关的信息。即,Reflection 属于重量级 API,而 MethodHandle 属于轻量级。
由于 MethodHandle 是对字节码指令指令调用的模拟,所以理论上虚拟机在这方面做得各种优化(如方法内联),在 MethodHandle 上也应当可以采用类似的思路来支持(但目前还不完善)。而通过反射调用则行不通。
MethodHandle 与 Reflection 除了上面列举的区别外,最关键的一点还在于去掉前面讨论事假的前提“仅站在 Java 语言的角度来看”:Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成客服务于所有 JVM 之上的语言,其中也包含 Java。
4. invokedynamic 指令
在某种程度上,invokedynamic 指令与 MethodHandle 机制的作用一样,都是为了解决原有 4 条 invoke 指令方法分派规则规划在虚拟机中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码中,让用户(或其他语言的设计者)拥有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层 Java 代码和 API 来实现;另一个用字节码和 Class 中的属性、常量来完成。
每一处含有 invokedynamic 指令的位置都称作“动态调用点”,该指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是变为 JKD7 新加入的 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到 3 项信息:引导方法(存放在新增的 BootstrapMethods 属性中)、方法类型、方法名称。引导方法有固定的参数,且返回值是 java.lang.invoke.CallSite 对象,该对象代表要真正执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info 中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个 CallSite 对象,最终滴啊用要执行的目标方法。
前面讲过,由于 invokedynamic 指令面向的使用者并非只有 Java 语言,而是其他 JVM 上的动态语言,因此紧靠 Java 语言的编译器 javac 没有办法生成带有 invokedynamic 指定的字节码,所以需要使用 Java 语言来演示 invokedynamic 指令只能用一些变通办法。John Rose 编写了一个把程序的字节码转换为使用 invokedynamic 指令的简单工具 INDY 来完成这件事情,我们我们可以使用该工具来生成最终需要的字节码。
5. 掌控方法分派规则
invokedynamic 指令与前面 4 条 invoke 指令的最大差别就是它的分派逻辑不是由 JVM 决定的,而是有开发者决定。下面是一个简单的例子:
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father{
void thinking(){
//请读者在这里填入适当的代码(不能修改其他地方的代码)
//实现调用祖父类的thinking()方法, 打印"i am grandfather"
}
}
在 Java 程序中,可以通过 super 关键字来滴啊用父类中的方法,但是如何调用始祖类的方法呢?
在 JDK7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生产字节码很简单,如使用 ASM 字节码生产工具),原因是在 Son 类的 thinking 方法中无法获取一个实际类型是 GrandFather 的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型来进行分派的,该逻辑是固化在 JVM 中的,开发者无法改变。在 JDK7 中,可以使用以下代码来解决该问题。
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
class Test{
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father{
void thinking(){
try{
MethodType mt=MethodType.methodType(void.class);
MethodHandle mh=lookup().findSpecial(GrandFather.class, "thinking", mt,getClass());
mh.invoke(this);
}catch(Throwable e){
}
}
}
public static void main(String[]args){
(new Test().new Son()).thinking();
}
}
8.4 基于栈的字节码解释执行引擎
上面介绍了 JVM 是如何调用方法的,下面接着介绍 JVM 是如何执行方法中的字节码指令的。很多 JVM 的执行引擎在执行 Java 代码时都有解释执行(通过解释器解释字节码来执行)和编译执行(通过编译器将字节码便以为本地代码来执行)两种选择。
8.4.1 解释执行
Java 语言经常被人们定位为解释执行的语言,在 JDK1 时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器之后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断问题。再后来,Java 也发展出了可以直接生成本地代码的编译器,而 C/C++ 也出现了通过解释器执行的版本,这时候就再笼统的说解释执行,对于整个 Java 语言来说就成了无意义的概念,只有确定过了谈论对象是某种具体的 Java 实现版本和执行引擎的运行模式时,对解释执行和编译执行的讨论才是有意义的。
无论是解析还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中最下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、JVM、或者非 Java 的其他高级语言虚拟机的语言,大多都会遵循这种现代编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器来实现,这类代表语言是 C/C++。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表就是 Java。又或者把这些步骤和执行引擎全部集中封装到一个封闭的黑匣子中,如大多数的 JavaScript 执行器。
Java 中,javac 编译器完成了程序代码经过词法分、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这部分动作是在 JVM 之外进行的,而解释器位于 JVM 内部,所以 Java 的编译就是半独立实现的。
8.4.2 基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 X86 的二地址指令集,通俗的说,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
举个简单的例子,分别使用两种指令集架构来实现 1+1
,基于栈的指令集会是这样:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈,iadd 指令把栈顶的两个值出栈、相加,然后把结果放入栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 slot 中。
如果是基于寄存器架构,程序会是这种形式:
mov eax, 1
add eax, 1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把该值加 1,结果就保存在 EAX 寄存器中。
基于栈的指令集主要优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU 则提供了 16 个 32 位的通用寄存器。如果使用基于栈的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中已获得尽量好的性能,这样实现起来也更加简单。栈架构的指令集还有一些其他优点,如代码相对更加紧凑(字节码中每个字节都对应一条指令,而多地址指令集中还需要存放参数)、编译器更加简单(不需要考虑空间分配问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会慢一点。所有主流物理机的指令集都是寄存器架构。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
8.4.3 基于栈的解释器执行过程
一个简单的算术示例:
public int calc(){
int a=100;
int b=200;
int c=300;
return(a+b)*c;
}
使用 javap 查看其字节码指令:
public int calc();
Code:
Stack=2, Locals=4, Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn
}
字节码指令中显示,需要深度为 2 的操作数栈和 4 个 slot 的局部变量空间,如下一系列的图中,展示了该段代码在执行过程中代码、操作数栈、局部变量表的变化情况。
上面的执行过程仅仅是一种概念模型,虚拟机会对执行过程做一些优化来提高运行性能,实际的运作过程不一定完全符合概念模型的描述。更加准确的说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器对输入的字节码进行优化,如,在 HotSpot 虚拟机中,有很多以 “fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出站结构指令集的一般运行过程,整个运算过程的中间变量都以操作数出入栈为信息交换途径,符合我们在前面分析的特点。
8.5 本章小结
本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法中的字节码,以及执行代码时涉及的数据结构。
2.4.9 - CH09-案例与实战
9.1 概述
在Class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不太多,Class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为了许多常用功能和程序实现的基础。
9.2 案例分析
四个分别关于类加载器和字节码的案例。
9.2.1 Tomcat:正统的类加载器架构
主流的 Java Web 服务器都实现了自定义加载器,因为一个功能健全的 Web 服务器要解决如下几个问题。
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以实现相互隔离。这是最基本的要求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求一个类库在一个服务器中只有一份,服务器应该保证两个应用程序的类库可以互相独立使用。
部署在同一个服务器上的两个 Web 应用程序所使用的 Java 类库可以互相共享。这个需求也很常见,如,用户可能有 10 个使用 Spring 组织的应用程序部署在同一台服务器上,如果把 10 份 Spring 分别存放在各个应用程序的隔离目录中,将会有很大的资源浪费——主要不是浪费磁盘的问题,而是指类库在使用时要被加载器服务器内存,如果类库不能共享,虚拟机的方法区很容易出现过度膨胀的风险。
服务器要尽可能的保证自身的安全,而不受所部署的 Web 应用程序的影响。目前,很多主流的 Java Web 服务器自身也是使用 Java 语言实现的。因此,服务器本身也有类库依赖的问题,一般基于安全因素考虑,服务器所使用的类库应该与应用程序使用的类库相互独立。
支持 JSP 应用的 Web 服务器,大多都需要支持 HotSwap 功能。我们知道,JSP 文件最终要编译成 Java Class 才能由虚拟机执行,但 JSP 文件由于其纯文本存储的特性,运行时修改的概率要远远大于第三方类库或程序自身的 Class 文件。而且 ASP、PHP、JSP 这些网页应用也把修改后无需重启作为一个很大的优势来看待,因此“主流”的 Web 服务器都会支持 JSP 生成类的热替换,当然也有非主流的,如运行在生产模式下的 WebLogic 服务器默认就不会处理 JSP 文件的变化。
由于上述问题的存在,在部署 Web 应用时,单独的一个 ClassPath 就无法满足需求了,所以各种 Web 服务器都“不约而同”的提供了好几个 ClassPath 路径来供用户存放第三方类库,这些路径一般都以 lib 或 classes 命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每个目录都会有一个相应的自定义类加载器来加载放置在其中的 Java 类库。以下以 Tomcat 服务器为例来介绍它是如何规划用户类库结构和类加载器的。
在 Tomcat 目录结构中,有 3 组目录(/common/*, /server/*, /shared/*
)可以用来存放 Java 类库,另外还可以加上 Web 应用程序自身目录的 /WEB-INF/*
,一共 4 组,把 Java 类库放置在这些目录中的含义分别如下。
- common:类库可以被 Tomcat 和所有 Web 应用共同使用。
- server:类库可以被 Tomcat 使用,对所有的 Web 应用都不可见。
- shared:类库可以被所有 Web 应用使用,但对 Tomcat 自己不可见。
- WEB-INF:尽可以被指定 Web 应用可见,但对 Tomcat 和其他所有 Web 应用都不可见。
为了支持这套目录结构,并对目录中的类库进行加载和隔离,Tomcat 定义了多个类加载器,这些类加载器按照经典的双亲委派模型来实现,其关系如下图所示:
灰色部分为 JDK 默认提供的类加载器,这里不再赘述。而 CommonClassLoader、CatalinaClassLoader、SharedClassLoader 和 WebappClassLoader 则是 Tomcat 自己定义的类加载器,它们分别用于加载上面介绍的 4 个目录中的类库。通常 WebApp 和 JSP 类加载器会存在多个实例,每个 Web 应用对应一个 WebApp 类加载器,每个 JSP 文件对应一个 JSP 类加载器。
从上图中的委派模型可以看出,CommonClassLoader 能加载的类都可以被 CatalinaClassLoader 和 SharedClassLoader 使用,而 CatalinaClassLoader 和 SharedClassLoader 自己能加载的类则与对方相互隔离。WebappClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebappClassLoader 实例之间相互隔离。而 JasperLoader 的加载范围仅仅是该 JSP 文件所编译出来的那个 Class,它出现的目的就是为了被丢弃:当服务器检测到 JSP 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过在建立一个新的 JSP 类加载器来实现 JSP 文件的 HotSwap 功能。
9.2.2 OSGI:灵活的类加载器架构
OSGI 中的每个模块(Bundle)与普通的 Java 类库区别并不大,两者一般都以 Jar 格式进行封装,并且内部存储的都是 Java Package 和 Class。但是一个 Bundle 可以声明它所依赖的 Java Package,也可以声明它允许导出发布的 Java Package。在 OSGI 里面,Bundle 之间的依赖关系从传统的上层模块依赖底层模块转换为平级别模块之间的依赖(之上表面上看是如此),而且类库的可见性能得到非常精确的控制,一个模块里只有被 Export 过的 Package 才能被外界访问,其他的 Package 和 Class 会隐藏起来。除了更加精确的模块划分和可见性控制之外,引入 OSGI 的另外一个重要的理由是,基于 OSGI 的程序很可能(并不一定会)可以实现模块级的热插拔功能,当程序升级或者调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对企业级程序开发来说是一个非常有诱惑力的特性。
OSGI 之所以能有上述优点,要归功于它灵活的类加载器架构。OSGI 和 Bundle 类加载器之间只有规则,没有固定的委派关系。如,某个 Bundle 声明了一个它依赖的 Package,如果有其它 Bundle 声明发布了这个 Package,那么所有对这个 Package 的类加载动作都会委派给发布它的 Bundle 类加载器来完成。当不涉及摸个具体的 Package 时,各个 Bundle 加载器都是平级关系,只有具体使用某个 Package 和 Class 时,才会根据 Package 的导入导出定义来构造 Bundle 之间的委派和依赖关系。
另外,一个 Bundle 类加载器为其他 Bundle 提供服务时,会根据 Export-Package 列表严格控制访问范围。如果一个类存在于 Bundle 的类库中但是没有被 Export,那么该 Bundle 的类加载器能找到这个类,但是不会提供给其他 Bundle 使用,而 OSGI 平台也不会该其他 Bundle 的类加载请求分配给这个 Bundle 来处理。
我们可以举一个更具体一些的简单例子,假设存在 Bundle A、B、C 三个模块,并且这三个 Bundle 定义的依赖关系如下:
- A:声明发布了 PackageA,依赖了 java.* 包。
- B:声明依赖了 PackageA 和 PackageC,同时也依赖了 java.* 包。
- C:声明发布了 PackageC,依赖了 PackageA。
那么,这三个 Bundle 之间的类加载器以及父类加载器之间的关系如图所示:
由于没有牵扯到具体 OSGI 实现,上图中没有指明具体的类加载器实现,只是一个体现了加载器之间关系的概念模型,并且只是体现了 OSGI 中最简单的加载器委派关系。一般来说,在 OSGI 中,加载一个类可能发生的查找行为和委派发生关系会比上图中显示的过程复杂的多,类加载时可能进行的查找规则如下。
- 以 java.* 开头的类,委派给父类加载器。
- 否则,委派列表名单内的类,委派给父类加载器加载。
- Import 列表中的类,委派给 Export 这个类的 Bundle 的类加载器加载。
- 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载。
- 否则,查找是否在自己的 Fragment Bundle 中,如果是,则委派给 Fragment Bundle 的类加载器来加载。
- 否则,查找 Dynamic Import 列表中的 Bundle,委派给对应 Bundle 的类加载器加载。
- 否则,类查找失败。
从上图中可以看出,在 OSGI 中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成了一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更好的灵活性的同时,也可能会产生很多新的隐患。
9.2.3 字节码生成技术与动态代理实现
“字节码生成”并不是什么高深的技术,读者在看到“字节码生成”这个标题时也先不必去想诸如 Javassist、CGLib、ASM 之类的字节码类库,因为 JDK 里面的 javac 命令就是字节码生成技术的“老祖宗”,并且 javac 也是一个由 Java 语言写成的程序,它的代码存放在 OpenJDK 的 langtools/src/share/classes/com/sun/tools/javac
目录中。要深入了解字节码生成,阅读 javac 的源码是个很好的途径,不过 javac 对于我们这个例子来说太过庞大了。在 Java 里面除了 javac 和字节码类库外,使用字节码生成的例子还有很多,如 Web 服务器中的 JSP 编译器,编译时植入的 AOP 框架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提高执行速度。
相信许多Java开发人员都使用过动态代理,即使没有直接使用过 java.lang.reflect.Proxy 或实现过 java.lang.reflect.InvocationHandler 接口,应该也用过 Spring 来做过 Bean 的组织管理。如果使用过 Spring,那大多数情况都会用过动态代理,因为如果 Bean 是面向接口编程,那么在 Spring 内部都是通过动态代理的方式来对 Bean 进行增强的。动态代理中所谓的“动态”,是针对使用 Java 代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点工作量,而是 实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。
下面展示了一个简单的动态代理用法,原始的逻辑是打印一句"hello world",代理类的逻辑是在原始类方法执行前打印一句"welcome"。
public class DynamicProxyTest{
interface IHello{
void sayHello();
}
static class Hello implements IHello{
@Override
public void sayHello(){
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler{
Object originalObj;
Object bind(Object originalObj){
this.originalObj=originalObj;
return
Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy,Method method,Object[]args)throws Throwable{
System.out.println("welcome");
return method.invoke(originalObj,args);
}
}
public static void main(String[]args){
IHello hello=(IHello)new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
上述代码里,唯一的“黑匣子”就是 Proxy.newProxyInstance()
方法,除此之外再没有任何特殊之处。该方法返回一个实现了 IHello 接口、且代理了 new Hello()
实例的行为的对象。跟踪该方法的源码,可以看到程序进行了验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,而在最后它调用了 sun.misc.ProxyGenerator.generateProxyClass()
方法来完成字节码的生成动作,该方法可以在运行时产生一个描述代理类的字节码 byte[]
数组。如果想看一看这个在运行时产生的代理类的内容,可以在 main 方法中添加如下代码:
System.getProperties().put(
"sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
加入该代码后再次运行,磁盘中将会产生一个名为 $Proxy().class
的代理类 Class 文件,反编译后可以看见如下清单中的内容:
package org.fenixsoft.bytecode;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello{
private static Method m3;
private static Method m1;
private static Method m0;
private static Method m2;
public $Proxy0(InvocationHandler paramInvocationHandler)
throws
{
super(paramInvocationHandler);
}
public final void sayHello()
throws
{
try
{
this.h.invoke(this,m3, null);
return;
}
catch(RuntimeException localRuntimeException)
{
throw localRuntimeException;
}
catch(Throwable localThrowable)
{
throw new UndeclaredThrowableException(localThrowable);
}
}
//此处由于版面原因, 省略equals()、hashCode()、toString()三个方法的代码
//这3个方法的内容与sayHello()非常相似。
static
{
try
{
m3=Class.forName("org.fenixsoft.bytecode.DynamicProxyTest $IHello").getMethod("sayHello", new Class[0]);
m1=Class.forName("java.lang.Object").getMethod("equals", new Class[]{Class.forName("java.lang.Object")});
m0=Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
m2=Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
return;
}
catch(NoSuchMethodException localNoSuchMethodException)
{
throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
}
catch(ClassNotFoundException localClassNotFoundException)
{
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}
该代理类的实现很简单,它为传入接口中的每个方法,以及从 java.lang.Object
中继承来的 equals/hashCode/toString
方法都生成了对应的实现,并且统一调用了 InvocationHandler 对象的 invoke 方法(代码中的 this.h
即使父类 Proxy 中保存的 InvocationHandler 实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和 Method 对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行 InvocationHandler.invoke 中的代理逻辑。
这个例子中并没有讲到 generateProxyClass() 方法具体是如何产生代理类 “$Proxy0.class” 的字节码的,大致的生成过程其实就是根据 Class 文件的格式规范去拼装字节码,但在实际开发中,以 byte 为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程很感兴趣,可以在 OpenJDK 的 jdk/src/share/classes/sun/misc
目录下找到 sun.misc.ProxyGenerator
的源码。
9.2.4 Retrotanslator:跨越 JDK 版本
Retrotranslator 的作用是将 JDK 1.5 编译出来的 Class 文件转变为可以在 JDK 1.4 或 1.3 上部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持 JDK 1.5 中新增的集合改进、并发包以及对泛型、注解等的反射操作。了解了 Retrotranslator 这种逆向移植工具可以做什么以后,现在关心的是它是怎样做到的?
要想知道 Retrotranslator 如何在旧版本JDK中模拟新版本JDK的功能,首先要弄清楚 JDK 升级中会提供哪些新的功能。JDK 每次升级新增的功能大致可以分为以下 4 类:
在编译器层面做的改进。如自动装箱拆箱,实际上就是编译器在程序中使用到包装对象的地方自动插入了很多 Integer.valueOf()、Float.valueOf() 之类的代码;变长参数在编译之后就自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
对 Java API 的代码增强。譬如 JDK 1.2 时代引入的 java.util.Collections 等一系列集合类,在 JDK 1.5 时代引入的 java.util.concurrent 并发包等。
需要在字节码中进行支持的改动。如 JDK 1.7 里面新加入的语法特性:动态语言支持,就需要在虚拟机中新增一条 invokedynamic 字节码指令来实现相关的调用功能。不过字节码指令集一直处于相对比较稳定的状态,这种需要在字节码层面直接进行的改动是比较少见的。
虚拟机内部的改进。如 JDK5 中实现的 JSR-133 规范重新定义的 Java 内存模型、CMS 收集器之类的改动。这类改动对于程序员编写的代码来说基本是透明的,但会对程序运行时产生影响。
上述 4 种功能中,Retrotranslator 只能模拟前两类,对于后面两类直接在虚拟机内部实现的改进,一般所有的逆向移植工具都是无能为力的,至少不能完整的或者在可接收的效率上完成全部模拟,否则虚拟机设计团队也没有必要舍近求远的改动处于 JDK 底层的虚拟机。在可以模拟的两类功能中,第二类模拟相对容易实现一些,如 JDK5 引入 JUC 包,实际上是由多线程大师 Doug Lea 开发的一套并发包,在 JDK5 出现之前就已经存在,所以要在旧 JDK 中支持这部分功能,以独立类库的方式便可实现。Retrotranslator 中附带了一个名为 “backport-util-concurrent.jar” 的类库来代替 JDK5 并发包。
至于JDK在编译阶段进行处理的那些改进,Retrotranslator 则是使用 ASM 框架直接对字节码进行处理。由于组成 Class 文件的字节码指令数量并没有改变,所以无论是 JDK 1.3、JDK 1.4 还是 JDK 1.5,能用字节码表达的语义范围应该是一致的。当然,肯定不可能简单地把 Class 的文件版本号从 49.0 改回 48.0 就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。以枚举为例,在 JDK 1.5 中增加了 enum 关键字,但是 Class 文件常量池的 CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过 “CONSTANT_Enum_info” 之类的“枚举符号引用”常量。所以使用 enum 关键字定义常量,虽然从 Java 语法上看起来与使用 class 关键字定义类、使用 interface 关键字定义接口是同一层次的,但实际上这是由 Javac 编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于 java.lang.Enum、自动生成了 values() 和 valueOf() 方法的普通 Java 类而已。
Retrotranslator 对枚举所做的主要处理就是把枚举类的父类从 java.lang.Enum 替换为它运行时类库中包含的 “net.sf.retrotranslator.runtime.java.lang.Enum_",然后再在类和字段的访问标志中抹去 ACC_ENUM 标志位。当然,这只是处理的总体思路,具体的实现要复杂得多。可以想象既然两个父类实现都不一样,values 和 valueOf 方法自然需要重写,常量池需要引入大量新的来自父类的符号应用,这些都是实现细节。下图使用 JDK5 编译的枚举类与被 Retrotranslator 转换处理后的字节码进行对比。
9.3 实战:实现远程执行功能
不知道读者在编写程序时是否遇到过如下情景:在排查问题过程中,想查看内存中的一些参数值,却又没有方法把这些值输出到界面或日志中,又或者定位到某个缓存数据有问题,但缺少缓存的统一万里界面,不得不重启服务才能清理该缓存。类似的需求有一个共同的特点,就是只要在服务中执行一段程序代码,就可以定位或排除问题,但就是找不到可以让服务器执行代码的途径,这时候就会希望 Java 服务器中也有提供类似 Groovy Console 的功能。
JDK6 之后提供了 Compiler API,可以动态的编译 Java 程序,虽然这样达不到动态语言的灵活度,但让服务器执行临时代码的需求就可以得到解决了。在 JDK6 之前,也可以通过其他方式实现,比如编写一个 JSP 文件上传到服务器,然后在浏览器中运行,或者在服务器程序中插入一个额 BeanShell Script、JavaScript 等语言的执行引擎来执行动态脚本。
9.3.1 目标
首先,在实现“在服务端执行临时代码”这个需求之前,先来明确一下本次实战的具体目标,我们希望最终的产品是这样的:
- 不依赖具体的 JDK 版本,能在目前普遍使用的 JDK 中部署,也能在 JDK 4~7 中部署。
- 不改变原有服务端程序的部署方式,不依赖任何三方类库。
- 不侵入原有程序,即无需改动原有程序的任何代码,也不会对原有程序的运行带来任何影响。
- 考虑到 BeanShell Script 或 JavaScript 等脚本编写起来不太方便,“临时代码”需要直接支持 Java 语言。
- “临时代码”应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是“不需要”而不是“不可以”,当“临时代码”需要引用其他类库的时也没有限制,只要服务端程序能够使用,临时代码应当都能被直接引用。
- “临时代码”的执行结果能够返回客户端,执行结果可以包括程序中输出的信息以及抛出的异常等等。
9.3.2 思路
在程序实现的过程中,我们需要解决如下 3 个问题:
- 如何编译提交到服务器的 Java 代码?
- 如何执行编译之后的 Java 代码?
- 如何收集 Java 代码的执行结果?
对于第一个问题,我们有两种思路可供选择,一种是使用 tools.jar 中的 com.sun.tools.javac.Main
类来编译 Java 文件,这其实和使用 javac 命令编译一行。这种思路的缺点是引入了额外的 Jar 包,而且把程序“绑死”在了 Sun 的 JDK 上,要部署到其他公司的 JDK 中还得把这个 tools.jar 带上。另外一种思路是直接在客户端编译好,把字节码而不是 Java 源代码直接传到服务端,这听起来有点投机取巧,一般来说确实不应该假设客户端一定具有编译代码的能力,但是然程序员会写 Java 代码给服务端来排查问题,那么很难想象他的机器上会连编译 Java 代码的环境都没有。
对于第二个问题,简单的一想:要执行编译后的 Java 代码,让类加载器加载这个类生成一个 Class 对象,然后反射代用某个方法就可以了。但我们还应该考虑的周全一点:一段程序往往不是编写、运行一次就能达到效果,同一个类可能要反复的修改、提交、执行。提交上去的类还有可能需要访问其他的类库。此外,既然提交的是临时代码,那么提交的类应该在执行完成后卸载并回收。
最后一个问题,我们想把程序往标准输出和标准错误输出中但因的信息收集起来,但标准输出是整个虚拟机进程全局共享的资源,如果使用 System.setout、System.setErr 方法把输出流重定向到自定义的 PrintStream 对象上固然可以收集到输出信息,但也会对原有程序产生影响:会把其他线程输出的信息也一并收集。虽然这并不是不能解决问题,不过为了达到完全不影响原有程序的目的,我们可以采用另外一种办法,即直接在执行的类中把 System.out 的符号引用替换为我们准备好的 PrintStream 符号引用,基于前面学习的知识,做到这一点并不难。
9.3.3 实现
首先看看实现过程中需要用到的 4 个支持类。第一个类用于实现“同一个类的代码可以被加载多次”这个需求:
/**
*为了多次载入执行类而加入的加载器<br>
*把 defineClass 方法开放出来, 只有外部显式调用的时候才会使用到loadByte方法
*由虚拟机调用时, 仍然按照原有的双亲委派规则使用loadClass方法进行类加载
*/
public class HotSwapClassLoader extends ClassLoader{
public HotSwapClassLoader(){
super(HotSwapClassLoader.class.getClassLoader());
}
public Class loadByte(byte[]classByte){
return defineClass(null,classByte, 0, classByte.length);
}
}
HotSwapClassLoader 完成的工作仅仅是公开父类中的 protected 方法 defineClass,我们将使用该方法来把提交执行的 Java 类的字节数组转换为 Class 对象。HotSwapClassLoader 中并没有重写 loadClass 或 findClass 方法,因此如果不算外部手动调用 loadByte 的话,该类加载器的类查找范围与其父类加载器是完全一致的,在被虚拟机调用时,它会按照双亲委派模型交给父类加载。构造函数中指定为加载 HotSwapClassLoader 类的类加载器作为父类加载器,这一步是实现提交的执行代码可以访问服务端引用类库的关键。
第二个类是实现 java.lang.System 替换为自定义的 PrintStream 类的过程,它直接修改符合 Class 文件格式的字节数组中的常量池部分,将常量池中指定内容的 CONSTANT_Utf8_info 常量替换为新的字符串,具体代码如代码清单所示。ClassModifier 中涉及对字节数组操作的部分,主要是将字节数组与 int 和 String 互相转换,以及把对字节数组的替换操作封装在代码清单所示的 ByteUtils 中。
/**
*修改Class文件, 暂时只提供修改常量池常量的功能
*@author zzm
*/
public class ClassModifier{
/**
*Class文件中常量池的起始偏移
*/
private static final int CONSTANT_POOL_COUNT_INDEX=8;
/**
*CONSTANT_Utf8_info常量的tag标志
*/
private static final int CONSTANT_Utf8_info=1;
/**
*常量池中11种常量所占的长度, CONSTANT_Utf8_info型常量除外, 因为它不是定长的
*/
private static final int[]CONSTANT_ITEM_LENGTH={-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5};
private static final int u1=1;
private static final int u2=2;
private byte[]classByte;
public ClassModifier(byte[]classByte){
this.classByte=classByte;
}
/**
*修改常量池中CONSTANT_Utf8_info常量的内容
*@param oldStr修改前的字符串
*@param newStr修改后的字符串
*@return修改结果
*/
public byte[]modifyUTF8Constant(String oldStr,String newStr){
int cpc=getConstantPoolCount();
int offset=CONSTANT_POOL_COUNT_INDEX+u2;
for(int i=0;i<cpc;i++){
int tag=ByteUtils.bytes2Int(classByte,offset,u1);
if(tag==CONSTANT_Utf8_info){
int len=ByteUtils.bytes2Int(classByte,offset+u1, u2);
offset+=(u1+u2);
String str=ByteUtils.bytes2String(classByte,offset,len);
if(str.equalsIgnoreCase(oldStr)){
byte[]strBytes=ByteUtils.string2Bytes(newStr);
byte[]strLen=ByteUtils.int2Bytes(newStr.length(), u2);
classByte=ByteUtils.bytesReplace(classByte,offset-u2, u2, strLen);
classByte=ByteUtils.bytesReplace(classByte,offset,len,strBytes);
return classByte;
}else{
offset+=len;
}
}else{
offset+=CONSTANT_ITEM_LENGTH[tag];
}
}
return classByte;
}
/**
*获取常量池中常量的数量
*@return常量池数量
*/
public int getConstantPoolCount(){
return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2);
}
}
/**
*Bytes数组处理工具
*@author
*/
public class ByteUtils{
public static int bytes2Int(byte[]b,int start,int len){
int sum=0;
int end=start+len;
for(int i=start;i<end;i++){
int n=((int)b[i])&0xff;
n<<=(--len)*8;
sum=n+sum;
}
return sum;
}
public static byte[]int2Bytes(int value,int len){
byte[]b=new byte[len];
for(int i=0;i<len;i++){
b[len-i-1]=(byte)((value>>8*i)&0xff);
}
return b;
}
public static String bytes2String(byte[]b,int start,int len){
return new String(b,start,len);
}
public static byte[]string2Bytes(String str){
return str.getBytes();
}
public static byte[]bytesReplace(byte[]originalBytes,int offset,int len,byte[]replaceBytes){
byte[]newBytes=new byte[originalBytes.length+(replaceBytes.length-len)];
System.arraycopy(originalBytes, 0, newBytes, 0, offset);
System.arraycopy(replaceBytes, 0, newBytes,offset,replaceBytes.length);
System.arraycopy(originalBytes,offset+len,newBytes,offset+replaceBytes.length,originalBytes.length-offset-len);
return newBytes;
}
}
经过 ClassModifer 处理后的字节数组才会传给 HotSwapClassLoader.loadByte 方法来执行类加载,字节数组在这里替换符号引用之后,与客户端直接在 Java 代码中引用 HackSystem 类在编译生成的 Class 完全一样。这样的实现避免了客户端编写临时代码时要依赖特定的类,又避免了服务端修改标准输出后影响其他程序的输出。
最后一个类是前面提到过的用来替换 java.lang.System 的 HackSystem,该类中的方法看起来不少,但其实除了把 out 和 err 两个静态变量替换为使用 ByteArrrayOutputStream 作为打印目标的同一个 PrintStream 对象,以及增加了读取、清理 ByteArrayOutputStream 中内容的 getBufferString 和 clearBuffer 方法外,就再没有其他新鲜的内容了。其余的方法全部都来自于 System 类的 public 方法,方法名称、参数、返回值都完全一样,并且实现也是直接调转了 System 类的对应方法而已。保留这些方法的目的是为了在 System 在被替换为 HackSystem 之后,执行代码中调用的 System 的其余方法仍然可以继续使用,HackSystem 的实现如下所示。
/**
*为JavaClass劫持java.lang.System提供支持
*除了out和err外, 其余的都直接转发给System处理
*
*@author zzm
*/
public class HackSystem{
public final static InputStream in=System.in;
private static ByteArrayOutputStream buffer=new ByteArrayOutputStream();
public final static PrintStream out=new PrintStream(buffer);
public final static PrintStream err=out;
public static String getBufferString(){
return buffer.toString();
}
public static void clearBuffer(){
buffer.reset();
}
public static void setSecurityManager(final SecurityManager s){
System.setSecurityManager(s);
}
public static SecurityManager getSecurityManager(){
return System.getSecurityManager();
}
public static long currentTimeMillis(){
return System.currentTimeMillis();
}
public static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length){
System.arraycopy(src,srcPos,dest,destPos,length);
}
public static int identityHashCode(Object x){
return System.identityHashCode(x);
}
//下面所有的方法都与java.lang.System的名称一样
//实现都是字节转调System的对应方法
//因版面原因, 省略了其他方法
}
我们来看看最后一个类 JavaClassExecutor,它是提供给外部调用的入口,调用前面几个直接类组装逻辑,完成类加载工作。JavaClassExecutor 只有一个 execute 方法,用输入的符合 Class 文件格式的字节数组替换 java.lang.System 的符号引用后,使用 HotSwapClassLoader 加载生成一个 Class 对象,由于每次执行 execute 方法都会生成一个新的类加载器实例,因此同一个类可以实现重复加载。然后,发射调用这个 Class 对象的 main 方法,如果期间出现任何异常,将异常信息打印到 HackSystem.out 中,最后把缓冲区中的信息作为方法的结果返回。JavaClassExecutor 的实现代码如代码清单所示。
/**
*JavaClass执行工具
*
*@author zzm
*/
public class JavaClassExecuter{
/**
*执行外部传过来的代表一个Java类的byte数组<br>
*将输入类的byte数组中代表java.lang.System的CONSTANT_Utf8_info常量修改为劫持后的HackSystem类
*执行方法为该类的static main(String[]args)方法, 输出结果为该类向System.out/err输出的信息
*@param classByte代表一个Java类的byte数组
*@return执行结果
*/
public static String execute(byte[]classByte){
HackSystem.clearBuffer();
ClassModifier cm=new ClassModifier(classByte);
byte[]modiBytes=cm.modifyUTF8Constant("java/lang/System", "org/fenixsoft/classloading/execute/HackSystem");
HotSwapClassLoader loader=new HotSwapClassLoader();
Class clazz=loader.loadByte(modiBytes);
try{
Method method=clazz.getMethod("main", new Class[]{String[].class});
method.invoke(null,new String[]{null});
}catch(Throwable e){
e.printStackTrace(HackSystem.out);
}
return HackSystem.getBufferString();
}
}
9.3.4 验证
如果只是测试的话,那么可以任意下一个 Java 类,内容无所谓,只要向 System.out 输出信息即可,取名为 TestClass,同时放到服务器 C 盘的根目录中,然后建立一个 JSP 文件并加入如下清单中的代码,就可以在浏览器中看到这个类的运行效果了。
<%@page import="java.lang.*"%>
<%@page import="java.io.*"%>
<%@page import="org.fenixsoft.classloading.execute.*"%>
<%
InputStream is=new FileInputStream("c:/TestClass.class");
byte[]b=new byte[is.available()];
is.read(b);
is.close();
out.println("<textarea style='width:1000;height=800'>");
out.println(JavaClassExecuter.execute(b));
out.println("</textarea>");
%>
当然,上面的做法只是用于测试和演示,实际使用这个 JavaExecutor 执行器的时候,如果还要手工复制一个 Class 文件到服务器上就没有什么意义了。笔者给这个执行器写了一个外壳,是一个 Eclipse 插件,可以把 Java 文件编译后传输到服务器中,然后把执行器的返回结果输出到 Eclipse 的 Console 窗口中,这样就可以在有灵感的时候随时写几行调试代码,当道测试环境的服务器上立即运行了。虽然实现简单,但效果很不错,对调试问题也非常有帮助,如下图所示:
9.4 本章小结
本书 6~9 章介绍了 Class 文件格式、类加载和虚拟机执行引擎,这些内容是虚拟机中必不可少的组成部分,只有了解了虚拟机如何执行程序,才能更好的理解怎样才能写出优秀的代码。
2.4.10 - CH10-编译期优化
从计算机程序出现的第一天起,对效率的追求就是程序员天生的鉴定信仰,这个过程犹如一场没有终点、永不停歇的 F1 方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。
10.1 概述
Java 语言的“编译期”其实是一段不确定的操作过程,因为它可能是指一个前端编译器把 java 文件转变成 class 文件的过程;也可能是值虚拟机的后端运行时编译器把字节码转换为机器码的过程;还可能是指使用静态提前编译器(AOT 编译器)直接把 java 文件编译为本地机器码的过程。下面列举了这 3 类编译过程中一些比较有代表性的编译器。
- 前端编译器:Sun 的 javac、Eclipse JDT 中的增量式编译器 ECJ。
- JIT 编译器:HotSpot VM 的 C1/C2 编译器。
- AOT 编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
这 3 类过程中最符合大家对 Java 程序编译认知的应该是第一类,在本章的后续介绍中,笔者提到的编译期和编译器都仅限于第一类编译过程,把第二类编译过程留到下一章中讨论。限制了编译范围后,我们对于“优化”二字的定义就需要宽松一些,因为 javac 这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由 javac 产生的 Class 文件也同样能够享受到编译器优化带来的好处。但是 javac 做了很多针对 Java 语言编码过程的优化措施来改善程序员的编码风格和编码效率。相当多新生的 Java 语法特性,都是靠编译器的语法糖来实现的,而不是依赖虚拟机的底层改进来支持的,可以说,Java 中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化对于程序编码来说关系更加密切。
10.2 Javac 编译器
分析源码是了解一项技术实现内幕的最有效手段,Javac 编译器不像 HotSpot 虚拟机那样使用 C++ 实现,他本身就是一个由 Java 语言编写的程序,这位纯 Java 程序员了解它的编译过程带来了很大的便利。
10.2.1 Javac 的源码与调试
Javac 的源码存放在 JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac
中,除了 JDK 自身的 API 外,就只引用了 JDK_SRC_HOME/langtools/src/share/classes/com/sun/*
里面的代码,调试环境建立起来简单方便,因为基本上不需要处理依赖关系。
虚拟机规范严格定义了 Class 文件的格式,但是“JVM 规范 2”中,虽然有专门的一章“Compiling for the Java Virtual Machine”,但多是以举例的形式描述,并没有对如何把 Java 源文件编译为 Class 文件的编译过程进行十分严格的定义,这导致 Class 文件编译在某种程度上与具体 JDK 实现相关,在一些极端情况下,可能出现对于同一段代码, Javac 编译器可以编译,而 ECJ 编译器就无法编译的问题。从 Sun Javac 的代码来看,编译过程大致可以分为 3 个过程,分别是:
- 解析与填充符号表过程。
- 插入式注解处理器的注解处理过程。
- 分析与字节码生成过程。
这 3 个步骤之间的关系与交互顺序如下图所示:
Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler
类,上述 3 个过程的代码逻辑集中在这个类的 compile 和 compile2 方法中,其中主体代码如下图所示,真个编译最关键的处理就是图中标注的 8 个方法来完成,下面我们具体看一下这 8 个方法实现了什么功能。
10.2.2 解析与填充符号表
解析步骤由上图中的 parseFiles 方法完成,解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程。
1. 词法分析,语法分析
词法分析是将源代码的字符流转换为标记(token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以称为标记。如“int a = b + 2” 这个语句包含了 6 个标记,分别是 int、a、=、b、+、2,虽然关键字 int 由 3 个字符构成,但它只是一个 token,不可在被拆分。在 Javac 源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner
类来实现。
语法分析是根据 token 序列构造抽象语法树的过程,抽闲语法树是一种用来描述程序代码语法及饿哦股的树形表示方式,语法树的每个节点都代表着程序代码中的一个语法结构,如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。
下图根据 Eclipse AST View 插件分析出来的某段代码的抽象语法树视图,读者可以通过这张图对抽象语法树有一个直观的认识。在 Javac 源码中,语法分析过程由 com.sun.tools.javac.parser.Parser
类实现,这个阶段产生的抽象语法树由 com.sun.tools.javac.tree.JCTree
类表示,经过这个步骤之后,编译器就基本不会再对代码的源文件进行操作了,后续的操作都建立在该抽象语法树之上。
2. 填充符号表
完成了语法分析和词法分析之后,下一步就是填充符号表的过程,也就是 enterTrees 方法完成的工作。符号表是由一组符号地址和符号信息构成的表格,可以把它想象成哈希表中 KV 对的形式。符号表中所记录的信息在编译的不同阶段都要用到。在语义分析中,符号表锁记录的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
在 Javac 源码中,填充符号表的过程由 com.sun.tools.javac.comp.Enter
类实现,此过程的出口是一个待处理列表,包含了一个编译单元的抽象语法树的顶级节点,以及 package-info.java 的顶级节点(如果有的话)。
10.2.3 注解处理器
在 JDK5 之后,Java 语言提供了对注解的支持,这些注解与普通的 Java 代码一样,是在运行期间发挥作用的。在 JDK6 中实现了 JSR-269 规范,提供了一组插入式注解处理器的标准 API 在编译期间对注解进行处理,可以把它看做是一组编译器插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将会回到解析及填充符号表的过程重新处理,每次循环称为一个 Round,也就是前面图示中的回环过程。
有了编译器注解处理器的标准 API 之后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员可以使用插入式注解处理器来实现很多原本只能在编码中完成的事情,本章最后会给出一个使用插入式注解处理器的简单实战。
在 Javac 源码中,插入式注解处理器的初始化过程是在 initPorcessAnnotations 方法中完成的,而它的执行过程则是在 processAnnotations 方法中完成的,该方法判断是否还有新的注解处理器需要执行,如果有的话,通过 com.sun.tools.javac.processing.JavacProcessingEnvironment 类的 doProcessing 方法生成一个新的 JavaCompiler 对象对编译的后续步骤进行处理。
10.2.4 语义分析与字节码生成
语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。举个例子,假设有如下 3 个变量定义语句:
int a=1;
boolean b=false;
char c=2;
后续可能出现的赋值运算:
int d=a+c;
int d=b+c;
char d=a+c;
后续代码中如果出现了如上 3 种赋值运算的话,那它们都能构成结构正确的语法树,但是只有第 1 种的写法在语义上是没有问题的,能够通过编译,其余两种在 Java 语言中是不合逻辑的,无法编译。
1. 标注检查
Javac 在编译过程中,语义分析过程中分为标注检查和数据与控制流分析两个步骤,分别由 attribute 和 flow 方法完成。
标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠,如果我们在代码中写了如下定义:
int a=1+2;
那么在语法树上仍然能看到字面量 1、2 和操作符 +,但是在经过常量折叠之后,它们将会被折叠为字面量 3,如下图所示,这个插入式表达式的值已经在语法树上标注出来了。由于编译期进行了常量折叠,所以在代码中的定位直接变为a=3
,并不会增加程序运行期间哪怕是仅仅一个 CPU 指令的运算量。
标注检查步骤在 Javac 源码中的实现类是 com.sun.tools.javac.comp.Attr
类和 com.sun.tools.javac.comp.Check
类。
2. 数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上是一致的,但校验范围有所区别,有一些校验项只有在编译期或运行期才能进行。下面举一个关于 final 修饰符的数据及控制流分析的例子,见如下代码清单。
//方法一带有final修饰
public void foo(final int arg){
final int var=0;
//do something
}
//方法二没有final修饰
public void foo(int arg){
int var=0;
//do something
}
在这两个 foo 方法中,第一种方法的参数和局部变量定义使用了 final 修饰符,而第二种方法则没有,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改变 arg 和 var 变量的值,但是这两段代码编译出来的 Class 文件没有任何区别,通过第 6 章的学习我们已经知道,局部变量与字段是有区别的,它在常量池中没有 CONSTANT_Fieldref_info 的符号引用,自然就没有访问标志信息,甚至可能连名称都不会保留下来,自然在 Class 文件中不能知道一个局部变量是不是声明为 fianl 了。因此,将局部变量声明为 final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期保障。在 Javac 源码中,数据以及控制流分析的入口是 flow 方法,具体操作由 com.sun.tools.javac.comp.Flow
类来完成。
3. 解语法糖
语法糖是由英国计算机科学家 Peter J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员的使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的几乎。
Java 中最常用的语法糖主要是前面提到过的泛型、变长参数、自动装拆箱,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。Java 的这些语法糖被解除后是什么样子,将在 10.3 节详细描述。
在 Javac 的源码中,解语法糖的过程是由 desugar 方法触发,在 com.sun.tools.javac.comp.TransTypes
类和 com.sun.tools.javac.comp.Lower
中完成。
4. 字节码生成
字节码生成是 Javac 编译过程的最后一个阶段,在 Javac 源码里面由 com.sun.tools.javac.jvm.Gen
类完成。字节码生成阶段不仅仅是把前面各个步骤生成的信息(语法树、符号表)转换成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如,前面章节中多次提到的实例构造器 <init>()
方法和类构造器 <clinit>()
方法就是在该阶段添加到语法树之中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器而言是 {}
块,对于类构造器而言是 static{}
块)、变量初始化、调用父类的实例构造器等操作收敛到 <init>()
和 <clinit>()
方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后再执行语句块的顺序进行,上面所述的动作由 Gen.normalizeDefs()
方法来实现。除了生成构造器之外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加发操作替换为 StringBuilder 的 append 操作。
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter
类,由这个类的 writeClass 方法输出字节码,生成最终的 Class 文件,到此为止整个编译过程宣告结束。
10.3 Java 语法糖的味道
几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用“含糖”的语法,容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。
总而言之,语法糖可以看做是编译器实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。
10.3.1 泛型与类型擦除
泛型是 JDK 1.5 的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
泛型思想早在 C++ 语言的模板中就开始生根发芽,在 Java 语言处于还没有出现泛型的版本时,只能通过 Object 是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如,在哈希表的存取中,JDK 1.5 之前使用HashMap 的 get() 方法,返回值就是一个 Object 对象,由于 Java 语言里面所有的类型都继承于 java.lang.Object,所以 Object 转型成任何对象都是有可能的。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个 Object 到底是个什么类型的对象。在编译期间,编译器无法检查这个 Object 的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期之中。
泛型技术在 C# 和 Java 之中的使用方式看似相同,但实现上却有着根本性的分歧,C# 里面泛型无论在程序源码中、编译后的 IL 中,或是运行期的 CLR 中,都是切实存在的,List<int>
与 List<String>
就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java 语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转型代码,因此,对于运行期的 Java 语言来说,ArrayList<int>
与 ArrayList<String>
就是同一个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型。
当初 JDK 设计团队为什么选择类型擦除的方式来实现 Java 语言的泛型支持呢?是因为实现简单、兼容性考虑还是别的原因?我们已不得而知,但确实有不少人对 Java 语言提供的伪泛型颇有微词,当时甚至连《Thinking in Java》一书的作者 Bruce Eckel 也发表了一篇文章《这不是泛型!》来批评 JDK 1.5 中的泛型实现。
在当时众多的批评之中,有一些是比较表面的,还有一些从性能上说泛型会由于强制转型操作和运行期缺少针对类型的优化等从而导致比 C# 的泛型慢一些,则是完全偏离了方向,姑且不论 Java 泛型是不是真的会比 C# 泛型慢,选择从性能的角度上评价用于提升语义准确性的泛型思想就不太恰当。但笔者也并非在为 Java 的泛型辩护,它在某些场景下确实存在不足,笔者认为通过擦除法来实现泛型丧失了一些泛型思想应有的优雅,例如代码清单 10-4 的例子。
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<Integer>
和 List<String>
编译之后都被擦除了,变成了一样的原生类型 List<E>
,擦除动作导致这两种方法的特征签名变得一模一样。初步看来,无法重载的原因已经找到了,但真的就是如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的其中一部分原因,请再接着看一看代码清单 10-5 中的内容。
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>());
}
}
// result
// invoke method(List<String>list)
// invoke method(List<Integer>list)
代码清单 10-5 与代码清单 10-4 的差别是两个 method 方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。这是对 Java 语言中返回值不参与重载选择的基本认知的挑战吗?
代码清单 10-5 中的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个 method() 方法加入了不同的返回值后才能共存在一个 Class 文件之中。第 6 章介绍 Class 文件方法表的数据结构时曾经提到过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法地共存于一个 Class 文件中的。
由于 Java 泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。因此,JCP 组织对虚拟机规范做出了相应的修改,引入了诸如 Signature、LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范要求所有能识别 49.0 以上版本的 Class 文件的虚拟机都要能正确地识别 Signature 参数。
从上面的例子可以看到擦除法对实际编码带来的影响,由于 List<String>
和 List<Integer>
擦除后是同一个类型,我们只能添加两个并不需要实际使用到的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案,并且存在一定语意上的混乱,譬如上面脚注中提到的,必须用 Sun JDK 1.6 的 Javac 才能编译成功,其他版本或者 ECJ 编译器都可能拒绝编译。
另外,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
10.3.2 自动装箱、拆箱与遍历循环
从纯技术的角度来讲,自动装箱、自动拆箱与遍历循环(Foreach循环)这些语法糖,无论是实现上还是思想上都不能和上文介绍的泛型相比,两者的难度和深度都有很大差距。专门拿出一节来讲解它们只有一个理由:毫无疑问,它们是 Java 语言里使用得最多的语法糖。我们通过代码清单 10-6 和代码清单 10-7 中所示的代码来看看这些语法糖在编译后会发生什么样的变化。
public static void main(String[]args){
List<Integer>list=Arrays.asList(1, 2, 3, 4);
//如果在JDK 1.7中, 还有另外一颗语法糖[1]
//能让上面这句代码进一步简写成List<Integer>list=[1, 2, 3, 4];
int sum=0;
for(int i:list){
sum+=i;
}
System.out.println(sum);
}
public static void main(String[]args){
List list=Arrays.asList(new Integer[]{
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4)});
int sum=0;
for(Iterator localIterator=list.iterator();localIterator.hasNext();){
int i=((Integer)localIterator.next()).intValue();
sum+=i;
}
System.out.println(sum);
}
代码清单 10-6 中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖,代码清单 10-7 则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的 Integer.valueOf() 与 Integer.intValue() 方法,而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。
这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,代码清单 10-8 演示了自动装箱的一些错误用法。
public static void main(String[]args){
Integer a=1;
Integer b=2;
Integer c=3;
Integer d=3;
Integer e=321;
Integer f=321;
Long g=3L;
System.out.println(c==d);
System.out.println(e==f);
System.out.println(c==(a+b));
System.out.println(c.equals(a+b));
System.out.println(g==(a+b));
System.out.println(g.equals(a+b));
}
阅读完代码清单 10-8,读者不妨思考两个问题:一是这 6 句打印语句的输出是什么?二是这 6 句打印语句中,解除语法糖后参数会是什么样子?这两个问题的答案可以很容易试验出来,笔者就暂且略去答案,希望读者自己上机实践一下。无论读者的回答是否正确,鉴于包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们 equals() 方法不处理数据转型的关系,笔者建议在实际编码中尽量避免这样使用自动装箱与拆箱。
10.3.3 条件编译
许多程序设计语言都提供了条件编译的途径,如 C/C++ 中使用预处理器指示符(#ifdef)来完成条件编译。C/C++ 的预处理器最初的任务是解决编译时的代码依赖关系(如非常常用的 #include 预处理命令),而在 Java 语言之中并没有使用预处理器,因为 Java 语言天然的编译方式(编译器并非一个个地编译 Java 文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。那 Java 语言是否有办法实现条件编译呢?
Java 语言当然也可以进行条件编译,方法就是使用条件为常量的 if 语句。如代码清单 10-9 所示,此代码中的 if 语句不同于其他 Java 代码,它在编译阶段就会被“运行”,生成的字节码之中只包括 System.out.println("block 1");
一条语句,并不会包含 if 语句及另外一个分子中的 System.out.println("block 2");
。
public static void main(String[]args){
if(true){
System.out.println("block 1");
}else{
System.out.println("block 2");
}
}
上述代码编译后 Class 文件的反编译效果:
public static void main(String[]args){
System.out.println("block 1");
}
只能使用条件为常量的 if 语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如代码清单 10-10 所示的代码就会被编译器拒绝编译。
public static void main(String[]args){
//编译器将会提示"Unreachable code"
while(false){
System.out.println("");
}
}
Java 语言中条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。由于这种条件编译的实现方式使用了 if 语句,所以它必须遵循最基本的 Java 语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个 Java 类的结构。
除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java 语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的 switch 支持、try 语句中定义和关闭资源等,读者可以通过跟踪 Javac 源码、反编译 Class 文件等方式了解它们的本质实现,囿于篇幅,笔者就不再一一介绍了。
10.4 实战:插入式注解处理器
JDK 编译优化部分在本书中并没有设置独立的实战章节,因为我们开发程序,考虑的主要是程序会如何运行,很少会有针对程序编译的需求。也因为这个原因,在 JDK 的编译子系统里面,提供给用户直接控制的功能相对较少,除了第 11 章会介绍的虚拟机 JIT 编译的几个相关参数以外,我们就只有使用 JSR-296 中定义的插入式注解处理器 API 来对 JDK 编译子系统的行为产生一些影响。
但是笔者并不认为相对于前两部分介绍的内存管理子系统和字节码执行子系统,JDK 的编译子系统就不那么重要。一套编程语言中编译子系统的优劣,很大程度上决定了程序运行性能的好坏和编码效率的高低,尤其在 Java 语言中,运行期即时编译与虚拟机执行子系统非常紧密地互相依赖、配合运作。了解 JDK 如何编译和优化代码,有助于我们写出适合 JDK 自优化的程序。下面我们回到本章的实战中,看看插入式注解处理器 API 能实现什么功能。
10.4.1 实战目标
通过阅读 Javac 编译器的源码,我们知道编译器在把 Java 程序源码编译为字节码的时候,会对 Java 程序源码做各方面的检查校验。这些校验主要以程序“写得对不对”为出发点,虽然也有各种 WARNING 的信息,但总体来讲还是较少去校验程序“写得好不好”。有鉴于此,业界出现了许多针对程序“写得好不好”的辅助校验工具,如 CheckStyle、FindBug、Klocwork 等。这些代码校验工具有一些是基于 Java 的源码进行校验,还有一些是通过扫描字节码来完成,在本节的实战中,我们将会使用注解处理器 API 来编写一款拥有自己编码风格的校验工具:NameCheckProcessor。
当然,由于我们的实战都是为了学习和演示技术原理,而不是为了做出一款能媲美 CheckStyle 等工具的产品来,所以 NameCheckProcessor 的目标也仅定为对 Java 程序命名进行检查,根据《Java语言规范(第3版)》中第 6.8 节的要求,Java 程序命名应当符合下列格式的书写规范。
- 类:符合驼峰命名法,首字母大写。
- 方法:符合驼式命名法,首字母小写。
- 类或实例变量:符合驼式命名法,首字母小写。
- 常量:要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。
10.4.2 代码实现
要通过注解处理器 API 实现一个编译器插件,首先需要了解这组 API 的一些基本知识。
我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor
,这个抽象类中只有一个必须覆盖的 abstract 方法:“process()",它是 Javac 编译器在执行注解处理器代码时要调用的过程,我们可以从这个方法的第一个参数"annotations"中获取到此注解处理器所要处理的注解集合,从第二个参数"roundEnv"中访问到当前这个 Round 中的语法树节点,每个语法树节点在这里表示为一个 Element。在 JDK 1.6 新增的 javax.lang.model
包中定义了 16 类 Element,包括了 Java 代码中最常用的元素,如:
- PACKAGE
- ENUM
- CLASS
- ANNOTATION_TYPE
- INTERFACE
- ENUM_CONSTANT
- FIELD
- PARAMETER
- LOCAL_VARIABLE
- EXCEPTION_PARAMETER
- METHOD
- CONSTRUCTOR
- STATIC_INIT
- INSTANCE_INIT
- TYPE_PARAMETER
- OTHER
除了 process() 方法的传入参数之外,还有一个很常用的实例变量"processingEnv”,它是 AbstractProcessor 中的一个 protected 变量,在注解处理器初始化的时候(init() 方法执行的时候)创建,继承了 AbstractProcessor 的注解处理器代码可以直接访问到它。它代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量。
注解处理器除了 process() 方法及其参数之外,还有两个可以配合使用的 Annotations:@SupportedAnnotationTypes
和 @SupportedSourceVersion
,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号 *
作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的 Java 代码。
每一个注解处理器在运行的时候都是单例的,如果不需要改变或生成语法树的内容,process() 方法就可以返回一个值为 false 的布尔值,通知编译器这个 Round 中的代码未发生变化,无须构造新的 JavaCompiler 实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此 process() 方法的返回值都是 false。关于注解处理器的 API,笔者就简单介绍这些,对这个领域有兴趣的读者可以阅读相关的帮助文档。下面来看看注解处理器 NameCheckProcessor 的具体代码,如代码清单 10-11 所示。
//可以用"*"表示支持所有Annotations
@SupportedAnnotationTypes("*")
//只支持JDK 1.6的Java代码
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class NameCheckProcessor extends AbstractProcessor{
private NameChecker nameChecker;
/**
*初始化名称检查插件
*/
@Override
public void init(ProcessingEnvironment processingEnv){
super.init(processingEnv);
nameChecker=new NameChecker(processingEnv);
}
/**
*对输入的语法树的各个节点进行名称检查
*/
@Override
public boolean process(Set<?extends TypeElement>annotations,RoundEnvironment roundEnv){
if(!roundEnv.processingOver()){
for(Element element:roundEnv.getRootElements())
nameChecker.checkNames(element);
}
return false;
}
}
从上面代码可以看出,NameCheckProcessor 能处理基于 JDK 1.6 的源码,它不限于特定的注解,对任何代码都“感兴趣”,而在 process() 方法中是把当前 Round 中的每一个 RootElement 传递到一个名为 NameChecker 的检查器中执行名称检查逻辑,NameChecker 的代码如代码清单 10-12 所示。
/**
*程序名称规范的编译器插件:<br>
*如果程序命名不合规范, 将会输出一个编译器的WARNING信息
*/
public class NameChecker{
private final Messager messager;
NameCheckScanner nameCheckScanner=new NameCheckScanner();
NameChecker(ProcessingEnvironment processsingEnv){
this.messager=processsingEnv.getMessager();
}
/**
*对Java程序命名进行检查, 根据《Java语言规范(第3版)》第6.8节的要求, Java程序命名应当符合下列格式:
*
*<ul>
*<li>类或接口:符合驼式命名法, 首字母大写。
*<li>方法:符合驼式命名法, 首字母小写。
*<li>字段:
*<ul>
*<li>类、实例变量:符合驼式命名法, 首字母小写。
*<li>常量:要求全部大写。
*</ul>
*</ul>
*/
public void checkNames(Element element){
nameCheckScanner.scan(element);
}
/**
*名称检查器实现类, 继承了JDK 1.6中新提供的ElementScanner6<br>
*将会以Visitor模式访问抽象语法树中的元素
*/
private class NameCheckScanner extends ElementScanner6<Void,Void>{
/**
*此方法用于检查Java类
*/
@Override
public Void visitType(TypeElement e,Void p){
scan(e.getTypeParameters(), p);
checkCamelCase(e,true);
super.visitType(e,p);
return null;
}
/**
*检查方法命名是否合法
*/
@Override
public Void visitExecutable(ExecutableElement e,Void p){
if(e.getKind()==METHOD){
Name name=e.getSimpleName();
if
(name.contentEquals(e.getEnclosingElement().getSimpleName()))
messager.printMessage(WARNING, "一个普通方法""+name+""不应当与类名重复, 避免与构造函数产生混淆", e);
checkCamelCase(e,false);
}
super.visitExecutable(e,p);
return null;
}
/**
*检查变量命名是否合法
*/
@Override
public Void visitVariable(VariableElement e,Void p){
//如果这个Variable是枚举或常量, 则按大写命名检查, 否则按照驼式命名法规则检查
if(e.getKind()==ENUM_CONSTANT||e.getConstantValue()!=null||heuristicallyConstant(e))
checkAllCaps(e);
else
checkCamelCase(e,false);
return null;
}
/**
*判断一个变量是否是常量
*/
private boolean heuristicallyConstant(VariableElement e){
if(e.getEnclosingElement().getKind()==INTERFACE)
return true;
else if(e.getKind()==FIELD&&e.getModifiers().containsAll(EnumSet.of(PUBLIC,STATIC,FINAL)))
return true;
else{
return false;
}
}
/**
*检查传入的Element是否符合驼式命名法, 如果不符合, 则输出警告信息
*/
private void checkCamelCase(Element e,boolean initialCaps){
String name=e.getSimpleName().toString();
boolean previousUpper=false;
boolean conventional=true;
int firstCodePoint=name.codePointAt(0);
if(Character.isUpperCase(firstCodePoint)){
previousUpper=true;
if(!initialCaps){
messager.printMessage(WARNING, "名称""+name+""应当以小写字母开头", e);
return;
}
}else if(Character.isLowerCase(firstCodePoint)){
if(initialCaps){
messager.printMessage(WARNING, "名称""+name+""应当以大写字母开头", e);
return;
}
}else
conventional=false;
if(conventional){
int cp=firstCodePoint;
for(int i=Character.charCount(cp);i<name.length();i+=Character.charCount(cp)){
cp=name.codePointAt(i);
if(Character.isUpperCase(cp)){
if(previousUpper){
conventional=false;
break;
}
previousUpper=true;
}else
previousUpper=false;
}
}
if(!conventional)
messager.printMessage(WARNING, "名称""+name+""应当符合驼式命名法(Camel Case Names)", e);
}
/**
*大写命名检查, 要求第一个字母必须是大写的英文字母, 其余部分可以是下划线或大写字母
*/
private void checkAllCaps(Element e){
String name=e.getSimpleName().toString();
boolean conventional=true;
int firstCodePoint=name.codePointAt(0);
if(!Character.isUpperCase(firstCodePoint))
conventional=false;
else{
boolean previousUnderscore=false;
int cp=firstCodePoint;
for(int i=Character.charCount(cp);i<name.length();i+=Character.charCount(cp)){
cp=name.codePointAt(i);
if(cp==(int)'_'){
if(previousUnderscore){
conventional=false;
break;
}
previousUnderscore=true;
}else{
previousUnderscore=false;
if(!Character.isUpperCase(cp)&&!Character.isDigit(cp))
{
conventional=false;
break;
}
}
}
}
if(!conventional)
messager.printMessage(WARNING, "常量""+name+""应当全部以大写字母或下划线命名, 并且以字母开头", e);
}
}
}
NameChecker 的代码看起来有点长,但实际上注释占了很大一部分,其实即使算上注释也不到 190 行。它通过一个继承于 javax.lang.model.util.ElementScanner6
的 NameCheckScanner 类,以 Visitor 模式来完成对语法树的遍历,分别执行 visitType()、visitVariable() 和 visitExecutable() 方法来访问类、字段和方法,这 3 个 visit 方法对各自的命名规则做相应的检查,checkCamelCase() 与 checkAllCaps() 方法则用于实现驼式命名法和全大写命名规则的检查。
整个注解处理器只需 NameCheckProcessor 和 NameChecker 两个类就可以全部完成,为了验证我们的实战成果,代码清单 10-13 中提供了一段命名规范的“反面教材”代码,其中的每一个类、方法及字段的命名都存在问题,但是使用普通的Javac编译这段代码时不会提示任何一个 Warning 信息。
public class BADLY_NAMED_CODE{
enum colors{
red,blue,green;
}
static final int_FORTY_TWO=42;
public static int NOT_A_CONSTANT=_FORTY_TWO;
protected void BADLY_NAMED_CODE(){
return;
}
public void NOTcamelCASEmethodNAME(){
return;
}
}
10.4.3 运行与测试
我们可以通过 Javac 命令的"-processor"参数来执行编译时需要附带的注解处理器,如果有多个注解处理器的话,用逗号分隔。还可以使用 -XprintRounds
和 -XprintProcessorInfo
参数来查看注解处理器运作的详细信息,本次实战中的 NameCheckProcessor
的编译及执行过程如代码清单 10-14 所示。
D:\src>javac org/fenixsoft/compile/NameChecker.java
D:\src>javac org/fenixsoft/compile/NameCheckProcessor.java
D:\src>javac-processor org.fenixsoft.compile.NameCheckProcessor org/fenixsoft/compile/BADLY_NAMED_CODE.java
org\fenixsoft\compile\BADLY_NAMED_CODE.java:3:警告:名称"BADLY_NAMED_CODE"应当符合驼式命名法(Camel Case Names)
public class BADLY_NAMED_CODE{
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:5:警告:名称"colors"应当以大写字母开头
enum colors{
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"red"应当全部以大写字母或下划线命名, 并且以字母开头
red,blue,green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"blue"应当全部以大写字母或下划线命名, 并且以字母开头
red,blue,green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6:警告:常量"green"应当全部以大写字母或下划线命名, 并且以字母开头
red,blue,green;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:9:警告:常量"_FORTY_TWO"应当全部以大写字母或下划线命名, 并且以字母开头
static final int_FORTY_TWO=42;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:11:警告:名称"NOT_A_CONSTANT"应当以小写字母开头
public static int NOT_A_CONSTANT=_FORTY_TWO;
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:13:警告:名称"Test"应当以小写字母开头
protected void Test(){
^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:17:警告:名称"NOTcamelCASEmethodNAME"应当以小写字母开头
public void NOTcamelCASEmethodNAME(){
^
10.4.4 其他应用案例
NameCheckProcessor 的实战例子只演示了 JSR-269 嵌入式注解处理器 API 中的一部分功能,基于这组 API 支持的项目还有用于校验 Hibernate 标签使用正确性的Hibernate Validator Annotation Processor、自动为字段生成 getter 和 setter 方法的 Project Lombok 等,读者有兴趣的话可以参考它们官方站点的相关内容。
10.5 本章小结
在本章中,我们从编译器源码实现的层次上了解了 Java 源代码编译为字节码的过程,分析了 Java 语言中泛型、主动装箱/拆箱、条件编译等多种语法糖的前因后果,并实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。如本章概述中所说的那样,在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把 Javac 这类将 Java 代码转变为字节码的编译器称做“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于虚拟机内部的“后端编译器”完成了从字节码生成本地机器码的过程,即前面多次提到的即时编译器或JIT编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。
2.4.11 - CH11-运行时优化
11.1 概述
在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。
即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。在本章中,我们将走进虚拟机的内部,探索即时编译器的运作过程。
由于 Java 虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容,如无特殊说明,本章提及的编译器、即时编译器都是指 HotSpot 虚拟机内的即时编译器,虚拟机也是特指 HotSpot 虚拟机。不过,本章的大部分内容是描述即时编译器的行为,涉及编译器实现层面的内容较少,而主流虚拟机中即时编译器的行为又有很多相似和相通之处,因此,对其他虚拟机来说也具有较高的参考意义。
11.2 HotSpot VM 内的即时编译器
在本节中,我们将要了解 HotSpot 虚拟机内的即时编译器的运作过程,同时,还要解决以下几个问题:
- 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?
- 为何 HotSpot 虚拟机要实现两个不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译为本地代码?
- 如何从外部观察即时编译器的编译过程和编译结果?
11.2.1 解释器与编译器
尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行(部分没有解释器的虚拟机中也会采用不进行激进优化的 C1 编译器担任“逃生门”的角色),因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作,如图 11-1 所示。
HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称为 C1 编译器和 C2 编译器(也叫Opto编译器)。目前主流的 HotSpot 虚拟机(Sun系列JDK 1.7及之前版本的虚拟机)中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用"-client"或"-server"参数去强制指定虚拟机运行在 Client 模式或 Server 模式。
无论采用的编译器是 Client Compiler 还是 Server Compiler,解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”,用户可以使用参数"-Xint"强制虚拟机运行于“解释模式”,这时编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数"-Xcomp"强制虚拟机运行于“编译模式”,这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程,可以通过虚拟机的"-version"命令的输出结果显示出这3种模式,如代码清单 11-1 所示,请注意黑体字部分。
C:\>java-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,mixed mode)
C:\>java-Xint-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,interpreted mode)
C:\>java-Xcomp-version
java version"1.6.0_22"
Java(TM)SE Runtime Environment(build 1.6.0_22-b04)
Dynamic Code Evolution 64-Bit Server VM(build 0.2-b02-internal, 19.0-b04-internal,compiled mode)
由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机还会逐渐启用分层编译的策略,分层编译的概念在 JDK 1.6 时期出现,后来一直处于改进阶段,最终在 JDK 1.7 的 Server 模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 0,程序解释执行,解释器不开启性能监控功能(Profiling),可触发第 1 层编译。
- 1,也称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
- 2,也称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler 和 Server Compiler 将会同时工作,许多代码都可能会被多次编译,用 Client Compiler 获取更高的编译速度,用 Server Compiler 来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。
11.2.2 编译对象与触发条件
上文中提到过,在运行过程中会被即时编译器编译的“热点代码”有两类,即:
- 被多次调用的方法。
- 被多次执行的循环体。
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体的问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。
对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。
读者可能还会有疑问,在上面的文字描述中,无论是“多次执行的方法”,还是“多次执行的代码块”,所谓“多次”都不是一个具体、严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是虚拟机如何统计一个方法或一段代码被执行过多少次呢?解决了这两个问题,也就回答了即时编译被触发的条件。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种,分别如下。
基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
在 HotSpot 虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
我们首先来看看方法调用计数器。顾名思义,这个计数器就用于统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10 000 次,这个阈值可以通过虚拟机参数 -XX:CompileThreshold
来人为设定。当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。整个 JIT 编译的交互过程如图 11-2 所示。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用 -XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
现在我们再来看看另外一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。
关于回边计数器的阈值,虽然 HotSpot 虚拟机也提供了一个类似于方法调用计数器阈值 -XX:CompileThreshold
的参数 -XX:BackEdgeThreshold
供用户设置,但是当前的虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数 -XX:OnStackReplacePercentage
来间接调整回边计数器的阈值,其计算公式如下。
虚拟机运行在 Client 模式下,回边计数器阈值计算公式为:方法调用计数器阈值(CompileThreshold) × OSR 比率(OnStackReplacePercentage) /100。
其中 OnStackReplacePercentage 默认值为 933,如果都取默认值,那 Client 模式虚拟机的回边计数器的阈值为 13995。
虚拟机运行在 Server 模式下,回边计数器阈值的计算公式为:
方法调用计数器阈值(CompileThreshold) × (OSR比率(OnStackReplacePercentage) - (解释器监控比率(InterpreterProfilePercentage) / 100。
其中 OnStackReplacePercentag e默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那 Server 模式虚拟机回边计数器的阈值为 10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加 1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图 11-3 所示。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
最后需要提醒一点,图 11-2 和图 11-3 都仅仅描述了 Client VM 的即时编译方式,对于 Server VM 来说,执行情况会比上面的描述更复杂一些。从理论上了解过编译对象和编译触发条件后,我们再从 HotSpot 虚拟机的源码中观察一下,在 MethodOop.hpp (一个methodOop对象代表了一个Java方法)中,定义了 Java 方法在虚拟机中的内存布局,如下所示:
11.2.3 编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数 -XX:-BackgroundCompilation
来禁止后台编译,在禁止后台编译后,一旦达到 JIT 的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
那么在后台执行编译的过程中,编译器做了什么事情呢?Server Compiler 和 Client Compiler 两个编译器的编译过程是不一样的。对于 Client Compiler 来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion, HIR)。HIR 使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成 HIR 之前完成。
在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation, LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。
最后阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码。Client Compiler 的大致执行过程如图 11-4 所示。
而 Server Compiler 则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到 GNU C++ 编译器使用 -O2 参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除、空值检查消除等。另外,还可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。本章的下半部分将会挑选上述的一部分优化手段进行分析和讲解。
Server Compiler 的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,Server Compiler 无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler 编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用 Server 模式的虚拟机运行。
在本节中,涉及了许多编译原理和代码优化中的概念名词,没有这方面基础的读者,阅读起来会感觉到抽象和理论化。有这种感觉并不奇怪,JIT 编译过程本来就是一个虚拟机中最体现技术水平也是最复杂的部分,不可能以较短的篇幅就介绍得很详细,另外,这个过程对 Java 开发来说是透明的,程序员平时无法感知它的存在,还好 HotSpot 虚拟机提供了两个可视化的工具,让我们可以“看见” JIT 编译器的优化过程,在稍后笔者将演示这个过程。
11.2.4 查看及分析即时编译结果
一般来说,虚拟机的即时编译过程对用户程序是完全透明的,虚拟机通过解释执行代码还是编译执行代码,对于用户来说并没有什么影响(执行结果没有影响,速度上会有很大差别),在大多数情况下用户也没有必要知道。但是虚拟机也提供了一些参数用来输出即时编译和某些优化手段(如方法内联)的执行状况,本节将介绍如何从外部观察虚拟机的即时编译行为。
本节中提到的运行参数有一部分需要 Debug 或 FastDebug 版虚拟机的支持,Product 版的虚拟机无法使用这部分参数。如果读者使用的是根据本书第 1 章的内容自己编译的 JDK,注意将 SKIP_DEBUG_BUILD 或 SKIP_FASTDEBUG_BUILD 参数设置为 false,也可以在 OpenJDK 网站上直接下载 FastDebug 版的 JDK。注意,本节中所有的测试都基于代码清单 11-2 所示的 Java 代码。
public static final int NUM=15000;
public static int doubleValue(int i){
//这个空循环用于后面演示JIT代码优化过程
for(int j=0;j<100000;j++);
return i*2;
}
public static long calcSum(){
long sum=0;
for(int i=1;i<=100;i++){
sum+=doubleValue(i);
}
return sum;
}
public static void main(String[]args){
for(int i=0;i<NUM;i++){
calcSum();
}
}
首先运行这段代码,并且确认这段代码是否触发了即时编译,要知道某个方法是否被编译过,可以使用参数 -XX:+PrintCompilation
要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来,如代码清单 11-3 所示(其中带有“%”的输出说明是由回边计数器触发的 OSR 编译)。
VM option'+PrintCompilation'
310 1 java.lang.String:charAt(33 bytes)
329 2 org.fenixsoft.jit.Test:calcSum(26 bytes)
329 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)
332 1%org.fenixsoft.jit.Test:main@5(20 bytes)
从代码清单 11-3 输出的确认信息中可以确认 main()、calcSum() 和 doubleValue() 方法已经被编译,我们还可以加上参数 -XX:+PrintInlining
要求虚拟机输出方法内联信息,如代码清单 11-4 所示。
VM option'+PrintCompilation'
VM option'+PrintInlining'
273 1 java.lang.String:charAt(33 bytes)
291 2 org.fenixsoft.jit.Test:calcSum(26 bytes)
@9 org.fenixsoft.jit.Test:doubleValue inline(hot)
294 3 org.fenixsoft.jit.Test:doubleValue(4 bytes)
295 1%org.fenixsoft.jit.Test:main@5(20 bytes)
@5 org.fenixsoft.jit.Test:calcSum inline(hot)
@9 org.fenixsoft.jit.Test:doubleValue inline(hot)
从代码清单 11-4 的输出中可以看到方法 doubleValue() 被内联编译到 calcSum() 中,而 calcSum() 又被内联编译到方法 main() 中,所以虚拟机再次执行 main() 方法的时候(举例而已,main()方法并不会运行两次),calcSum() 和 doubleValue() 方法都不会再被调用,它们的代码逻辑都被直接内联到 main() 方法中了。
除了查看哪些方法被编译之外,还可以进一步查看即时编译器生成的机器码内容,不过如果虚拟机输出一串 0 和 1,对于我们的阅读来说是没有意义的,机器码必须反汇编成基本的汇编语言才可能被阅读。虚拟机提供了一组通用的反汇编接口,可以接入各种平台下的反汇编适配器来使用,如使用 32 位 80x86 平台则选用 hsdis-i386 适配器,其余平台的适配器还有 hsdis-amd64、hsdis-sparc 和 hsdis-sparcv9 等,可以下载或自己编译出反汇编适配器,然后将其放置在 JRE/bin/client 或 /server 目录下,只要与 jvm.dll 的路径相同即可被虚拟机调用。在为虚拟机安装了反汇编适配器之后,就可以使用 -XX:+PrintAssembly
参数要求虚拟机打印编译方法的汇编代码了,具体的操作可以参考本书 4.2.7 节。
如果没有 HSDIS 插件支持,也可以使用 -XX:+PrintOptoAssembly
(用于Server VM)或 -XX:+PrintLIR
(用于Client VM) 来输出比较接近最终结果的中间代码表示,代码清单 11-2 被编译后部分反汇编(使用 -XX:+PrintOptoAssembly
)的输出结果如代码清单 11-5 所示。从阅读角度来说,使用 -XX:+PrintOptoAssembly
参数输出的伪汇编结果包含了更多的信息(主要是注释),利于阅读并理解虚拟机 JIT 编译器的优化结果。
……
000 B1:#N1<-BLOCK HEAD IS JUNK Freq:1
000 pushq rbp
subq rsp, #16#Create frame
nop#nop for patch_verified_entry
006 movl RAX,RDX#spill
008 sall RAX, #1
00a addq rsp, 16#Destroy frame
popq rbp
testl rax, [rip+#offset_to_poll_page]#Safepoint:poll for GC
……
前面提到的使用 -XX:+PrintAssembly
参数输出反汇编信息需要 Debug 或者 FastDebug 版的虚拟机才能直接支持,如果使用 Product 版的虚拟机,则需要加入参数 -XX:+UnlockDiagnosticVMOptions
打开虚拟机诊断模式后才能使用。
如果除了本地代码的生成结果外,还想再进一步跟踪本地代码生成的具体过程,那还可以使用参数 -XX:+PrintCFGToFile
(使用Client Compiler) 或 -XX:PrintIdealGraphFile
(使用Server Compiler) 令虚拟机将编译过程中各个阶段的数据(例如,对 C1 编译器来说,包括字节码、HIR 生成、LIR 生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用 Java HotSpot Client Compiler Visualizer(用于分析Client Compiler) 或 Ideal Graph Visualizer(用于分析Server Compiler) 打开这些数据文件进行分析。以 Server Compiler 为例,笔者分析一下 JIT 编译器的代码生成过程。
Server Compiler 的中间代码表示是一种名为 Ideal 的 SSA 形式程序依赖图,在运行 Java 程序的 JVM 参数中加入 -XX:PrintIdealGraphLevel=2-XX:PrintIdealGraphFile=ideal.xml
,编译后将产生一个名为 ideal.xml 的文件,它包含了 Server Compiler 编译代码的过程信息,可以使用 Ideal Graph Visualizer 对这些信息进行分析。
Ideal Graph Visualizer 加载 ideal.xml 文件后,在 Outline 面板上将显示程序运行过程中编译过的方法列表,如图 11-5 所示。这里列出的方法是代码清单 11-2 中的测试代码,其中 doubleValue() 方法出现了两次,这是由于该方法的编译结果存在标准编译和 OSR 编译两个版本。在代码清单 11-2 中,笔者特别为 doubleValue() 方法增加了一个空循环,这个循环对方法的运算结果不会产生影响,但如果没有任何优化,执行空循环会占用 CPU 时间,到今天还有许多程序设计的入门教程把空循环当做程序延时的手段来介绍,在 Java 中这样的做法真的能起到延时的作用吗?
展开方法根节点,可以看到下面罗列了方法优化过程的各个阶段(根据优化措施的不同,每个方法所经过的阶段也会有所差别)的 Ideal 图,我们先打开"After Parsing"这个阶段。上文提到,JIT 编译器在编译一个 Java 方法时,首先要把字节码解析成某种中间表示形式,然后才可以继续做分析和优化,最终生成代码。“After Parsing"就是 Server Compiler 刚完成解析,还没有做任何优化时的 Ideal 图表示。在打开这个图后,读者会看到其中有很多有颜色的方块,如图 11-6 所示。每一个方块就代表了一个程序的基本块,基本块的特点是只有唯一的一个入口和唯一的一个出口,只要基本块中第一条指令执行了,那么基本块内所有执行都会按照顺序仅执行一次。
代码清单 11-2 的 doubleValue() 方法虽然只有简单的两行字,但是按基本块划分后,形成的图形结构要比想象中复杂得多,这一方面是要满足 Java 语言所定义的安全需要(如类型安全、空指针检查)和 Java 虚拟机的运作需要(如 Safepoint 轮询),另一方面是由于有些程序代码中一行语句就可能形成好几个基本块(例如循环)。对于例子中的 doubleValue() 方法,如果忽略语言安全检查的基本块,可以简单理解为按顺序执行了以下几件事情:
- 程序入口,建立帧栈。
- 设置 j=0,进行 Safepoint 轮询,跳转到 4 的条件检查。
- 执行 j++。
- 条件检查,如果 j<100000,跳转到 3。
- 设置
i=i*2
,进行 SafePoint 检查,函数返回。
以上几个步骤,反映到 Ideal Graph Visualizer 的图上,就是如图 11-7 所示的内容。这样我们要看空循环是否优化,或者何时优化,只要观察代表循环的基本块是否消除,或者何时消除就可以了。
要观察到这一点,可以在 Outline 面板上右键点击"Difference to current graph”,让软件自动分析指定阶段与当前打开的Ideal图之间的差异,如果基本块被消除了,将会以红色显示。对"After Parsing"和"PhaseIdealLoop 1"阶段的 Ideal 图进行差异分析,发现在"PhaseIdealLoop 1"阶段循环操作被消除了,如图 11-8 所示,这也就说明空循环实际上是不会被执行的。
从"After Parsing"阶段开始,一直到最后的"Final Code"阶段,可以看到 doubleValue() 方法的 Ideal 图从繁到简的变迁过程,这也是 Java 虚拟机在尽力优化代码的过程。到了最后的"Final Code"阶段,不仅空循环的开销消除了,许多语言安全和 Safepoint 轮询的操作也一起消除了,因为编译器判断即使不做这些安全保障,虚拟机也不会受到威胁。
最后提醒一下读者,要输出 CFG 或 IdealGraph 文件,需要一个 Debug 版或 FastDebug 版的虚拟机支持,Product 版的虚拟机无法输出这些文件。
11.3 编译优化技术
Java 程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中,因此一般来说,即时编译器产生的本地代码会比 Javac 产生的字节码更加优秀。下面,笔者将介绍一些 HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术。
11.3.1 优化技术概览
在 Sun 官方的 Wiki 上,HotSpot 虚拟机设计团队列出了一个相对比较全面的、在即时编译器中采用的优化技术列表(见表 11-1),其中有不少经典编译器的优化手段,也有许多针对 Java 语言(准确地说是针对运行在 Java 虚拟机上的所有语言)本身进行的优化技术,本节将对这些技术进行概括性的介绍,在后面几节中,再挑选若干重要且典型的优化,与读者一起看看优化前后的代码产生了怎样的变化。
上述的优化技术看起来很多,而且从名字看都显得有点“高深莫测”,虽然实现这些优化也许确实有些难度,但大部分技术理解起来都并不困难。为了消除读者对这些优化技术的陌生感,笔者举一个简单的例子,即通过大家熟悉的 Java 代码变化来展示其中几种优化技术是如何发挥作用的(仅使用Java代码来表示而已)。首先从原始代码开始,如代码清单 11-6 所示。
static class B{
int value;
final int get(){
return value;
}
}
public void foo(){
y=b.get();
//……do stuff……
z=b.get();
sum=y+z;
}
首先需要明确的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在 Java 源码之上的,为了展示方便,笔者使用了 Java 语言的语法来表示这些优化技术所发挥的作用。
代码清单 11-6 的代码已经非常简单了,但是仍有许多优化的余地。第一步进行方法内联,方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。内联后的代码如代码清单 11-7 所示。
public void foo(){
y=b.value;
//……do stuff……
z=b.value;
sum=y+z;
}
第二步进行冗余访问消除,假设代码中间注释掉的"dostuff……“所代表的操作不会改变 b.value 的值,那就可以把"z=b.value"替换为"z=y”,因为上一句"y=b.value"已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了。如果把 b.value 看做是一个表达式,那也可以把这项优化看成是公共子表达式消除,优化后的代码如代码清单 11-8 所示。
public void foo(){
y=b.value;
//……do stuff……
z=y;
sum=y+z;
}
第三步我们进行复写传播,因为在这段程序的逻辑中并没有必要使用一个额外的变量"z",它与变量"y"是完全相等的,因此可以使用"y"来代替"z"。复写传播之后程序如代码清单 11-9 所示。
public void foo(){
y=b.value;
//……do stuff……
y=y;
sum=y+y;
}
第四步我们进行无用代码消除。无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为"Dead Code",在代码清单 11-9 中,“y=y"是没有意义的,把它消除后的程序如代码清单 11-10 所示。
public void foo(){
y=b.value;
//……do stuff……
sum=y+y;
}
经过四次优化之后,代码清单 11-10 与代码清单 11-6 所达到的效果是一致的,但是前者比后者省略了许多语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。编译器的这些优化技术实现起来也许比较复杂,但是要理解它们的行为对于一个普通的程序员来说是没有困难的,接下来,我们将继续查看如下的几项最有代表性的优化技术是如何运作的,它们分别是:
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组范围检查消除。
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
11.3.2 公共子表达式消除
公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。举个简单的例子来说明它的优化过程,假设存在如下代码:
int d=(c * b)*12+a+(a+b * c);
如果这段代码交给 Javac 编译器则不会进行任何优化,那生成的代码将如代码清单 11-11 所示,是完全遵照 Java 源码的写法直译而成的。
iload_2//b
imul//计算b * c
bipush 12//推入12
imul//计算(c * b)*12
iload_1//a
iadd//计算(c * b)*12+a
iload_1//a
iload_2//b
iload_3//c
imul//计算b * c
iadd//计算a+b * c
iadd//计算(c * b)*12+a+(a+b * c)
istore 4
当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到 c * b
与 b * c
是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此,这条表达式就可能被视为:
int d=E*12+a+(a+E);
这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简,把表达式变为:
int d=E*13+a*2;
表达式进行变换之后,再计算起来就可以节省一些时间了。如果读者还对其他的经典编译优化技术感兴趣,可以参考《编译原理》中对应的章节。
11.3.3 数组边界检查消除
数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。我们知道 Java 语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样在本质上是裸指针操作。如果有一个数组 foo[]
,在 Java 语言中访问数组元素 foo[i]
的时候系统将会自动进行上下界的范围检查,即检查 i 必须满足 i>=0 && i<foo.length
这个条件,否则将抛出一个数组越界异常。这对软件开发者来说是一件很好的事情,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。
无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如 foo[3]
,只要在编译期根据数据流分析来确定 foo.length
的值,并判断下标“3”没有越界,执行的时候就无须判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [0,foo.length)
之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
将这个数组边界检查的例子放在更高的角度来看,大量的安全检查令编写 Java 程序比编写 C/C++ 程序容易很多,如数组越界会得到数组越界异常,空指针访问会得到空指针异常,除数为零会得到除零异常等,在 C/C++ 程序中出现类似的问题,一不小心就会出现 Segment Fault 信号或者 Windows 编程中常见的“xxx内存不能为Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致了相同的程序,Java 要比 C/C++ 做更多的事情(各种检查判断),这些事情就成为一种隐式开销,如果处理不好它们,就很可能成为一个 Java 语言比 C/C++ 更慢的因素。要消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,另外还有一种避免思路——隐式异常处理,Java 中空指针检查和算术运算中除数为零的检查都采用了这种思路。举个例子,例如程序中访问一个对象(假设对象叫 foo)的某个属性(假设属性叫 value),那以 Java 伪代码来表示虚拟机访问 foo.value 的过程如下。
if(foo!=null){
return foo.value;
}else{
throw new NullPointException();
}
在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。
try{
return foo.value;
}catch(segment_fault){
uncommon_trap();
}
虚拟机会注册一个 Segment Fault 信号的异常处理器(伪代码中的 uncommon_trap()),这样当 foo 不为空的时候,对 value 的访问是不会额外消耗一次对 foo 判空的开销的。代价就是当 foo 真的为空时,必须转入到异常处理器中恢复并抛出 NPE,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当 foo 极少为空的时候,隐式异常优化是值得的,但假如 foo 经常为空的话,这样的优化反而会让程序更慢,还好 HotSpot 虚拟机足够“聪明”,它会根据运行期收集到的 Profile 信息自动选择最优方案。
与语言相关的其他消除操作还有不少,如自动装箱消除、安全点消除、消除反射等,笔者就不再一一介绍了。
11.3.4 方法内联
在前面的讲解之中我们提到过方法内联,它是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,如代码清单 11-12 所示的简单例子就揭示了内联对其他优化手段的意义:事实上 testInline() 方法的内部全部都是无用的代码,如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何"Dead Code”,因为如果分开来看,foo() 和 testInline() 两个方法里面的操作都可能是有意义的。
public static void foo(Object obj){
if(obj!=null){
System.out.println("do something");
}
}
public static void testInline(String[]args){
Object obj=null;
foo(obj);
}
方法内联的优化行为看起来很简单,不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程远远没有那么简单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数的 Java 方法都无法进行内联。
无法内联的原因其实在第 8 章中讲解 Java 方法解析和分派调用的时候就已经介绍过。只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法以及使用 invokestatic 指令进行调用的静态方法才是在编译期进行解析的,除了上述 4 种方法之外,其他的 Java 方法调用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收者(最多再除去被 final 修饰的方法这种特殊情况,尽管它使用 invokevirtual 指令调用,但也是非虚方法,Java 语言规范中明确说明了这点),简而言之,Java 语言中默认的实例方法是虚方法。
对于一个虚方法,编译期做内联的时候根本无法确定应该使用哪个方法版本,如果以代码清单 11-7 中把"b.get()“内联为"b.value"为例的话,就是不依赖上下文就无法确定 b 的实际类型是什么。假如有 ParentB 和 SubB 两个具有继承关系的类,并且子类重写了父类的 get() 方法,那么,是要执行父类的 get() 方法还是子类的 get() 方法,需要在运行期才能确定,编译期无法得出结论。
由于 Java 语言提倡使用面向对象的编程方式进行编程,而 Java 对象的方法默认就是虚方法,因此 Java 间接鼓励了程序员使用大量的虚方法来完成程序逻辑。根据上面的分析,如果内联与虚方法之间产生“矛盾”,那该怎么办呢?是不是为了提高执行性能,就要到处使用 final 关键字去修饰方法呢?
为了解决虚方法的内联问题,Java 虚拟机设计团队想了很多办法,首先是引入了一种名为“类型继承关系分析”(CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个“逃生门”(Guard条件不成立时的Slow Path),称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。
如果向 CHA 查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。
所以说,在许多情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能的商用虚拟机中很常见,除了内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。
11.3.5 逃逸分析
逃逸分析是目前 Java 虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示。
栈上分配:Java 虚拟机中,在 Java 堆上分配创建对象的内存空间几乎是 Java 程序员都清楚的常识了,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
标量替换:标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java 中的对象就是最典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
关于逃逸分析的论文在 1999 年就已经发表,但直到 Sun JDK 1.6 才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。如果要完全准确地判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响。这是一个相对高耗时的过程,如果分析完后发现没有几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分析。还有一点是,基于逃逸分析的一些优化手段,如上面提到的“栈上分配”,由于 HotSpot 虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在 HotSpot 中暂时还没有做这项优化。
在测试结果中,实施逃逸分析后的程序在 MicroBenchmarks 中往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)有所下降,所以在很长的一段时间里,即使是 Server Compiler,也默认不开启逃逸分析,甚至在某些版本(如 JDK 1.6 Update 18)中还曾经短暂地完全禁止了这项优化。
如果有需要,并且确认对程序运行有益,用户可以使用参数 -XX:+DoEscapeAnalysis
来手动开启逃逸分析,开启之后可以通过参数 -XX:+PrintEscapeAnalysis
来查看分析结果。有了逃逸分析支持之后,用户可以使用参数 -XX:+EliminateAllocations
来开启标量替换,使用 +XX:+EliminateLocks
来开启同步消除,使用参数 -XX:+PrintEliminateAllocations
查看标量的替换情况。
尽管目前逃逸分析的技术仍不是十分成熟,但是它却是即时编译器优化技术的一个重要的发展方向,在今后的虚拟机中,逃逸分析技术肯定会支撑起一系列实用有效的优化技术。
11.4 Java 与 C/C++ 的编译器对比
大多数程序员都认为 C/C++ 会比 Java 语言快,甚至觉得从 Java 语言诞生以来“执行速度缓慢”的帽子就应当扣在它的头顶,这种观点的出现是由于 Java 刚出现的时候即时编译技术还不成熟,主要靠解释器执行的 Java 语言性能确实比较低下。但目前即时编译技术已经十分成熟,Java 语言有可能在速度上与 C/C++ 一争高下吗?要想知道这个问题的答案,让我们从两者的编译器谈起。
Java 与 C/C++ 的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比,很大程度上也决定了 Java 与 C/C++ 的性能对比的结果,因为无论是 C/C++ 还是 Java 代码,最终编译之后被机器执行的都是本地机器码,哪种语言的性能更高,除了它们自身的 API 库实现得好坏以外,其余的比较就成了一场“拼编译器”和“拼输出代码质量”的游戏。当然,这种比较也是剔除了开发效率的片面对比,语言间孰优孰劣、谁快谁慢的问题都是很难有结果的争论,下面我们就回到正题,看看这两种语言的编译器各有何种优势。
Java 虚拟机的即时编译器与 C/C++ 的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势:
第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。
第二,Java 语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等。对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗不少的运行时间。
第三,Java 语言中虽然没有 virtual 关键字,但是使用虚方法的频率却远远大于 C/C++ 语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于 C/C++ 语言,也意味着即时编译器在进行一些优化(如前面提到的方法内联)时的难度要远大于 C/C++ 的静态优化编译器。
第四,Java 语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。
第五,Java 语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。而 C/C++ 的对象则有多种内存分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++ 中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也比垃圾收集机制要高。
上面说了一大堆 Java 语言相对 C/C++ 的劣势,不是说 Java 就真的不如 C/C++ 了,相信读者也注意到了,Java 语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些“拖后腿”的特性都为 Java 语言的开发效率做出了很大贡献。
何况,还有许多优化是 Java 的即时编译器能做而 C/C++ 的静态优化编译器不能做或者不好做的。例如,在 C/C++ 中,别名分析的难度就要远高于 Java。Java 的类型安全保证了在类似如下代码中,只要 ClassA 和 ClassB 没有继承关系,那对象 objA 和 objB 就绝不可能是同一个对象,即不会是同一块内存两个不同别名。
void foo(ClassA objA,ClassB objB){
objA.x=123;
objB.y=456;
//只要objB.y不是objA.x的别名, 下面就可以保证输出为123
print(objA.x);
}
确定了 objA 和 objB 并非对方的别名后,许多与数据依赖相关的优化才可以进行(重排序、变量代换)。具体到这个例子中,就是无须担心 objB.y 其实与 objA.x 指向同一块内存,这样就可以安全地确定打印语句中的 objA.x 为 123。
Java 编译器另外一个红利是由它的动态性所带来的,由于 C/C++ 编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测、分支频率预测、裁剪未被选择的分支等,这些都会成为 Java 语言独有的性能优势。
11.5 本章小结
第 10~11 两章分别介绍了 Java 程序从源码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。
本章中,我们着重了解了虚拟机的热点探测方法、HotSpot 的即时编译器、编译触发条件,以及如何从虚拟机外部观察和分析JIT编译的数据和结果,还选择了几种常见的编译期优化技术进行讲解。对 Java 编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。
2.4.12 - CH12-内存模型与线程
12.1 概述
多任务处理在现代计算机操作系统中几乎已经以必备功能了。在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力变得强大了,还有一个很重要的原因是计算机的运算速度,与它的存储和通讯子系统的速度,两者之间的差距越来越大:大部分时间都花在了磁盘 IO、网络通讯和数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力压榨出来,否则就会造成很大的浪费,而让计算机同时处理几项任务则是最容易想到、也被证明是非常有效的“压榨”手段。
除了充分利用计算机处理器的能力之外,一个服务端同时对多个客户端提供服务则是另一个更加具体的并发应用场景。衡量一个服务的性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一,它代表着一秒内服务平均能够响应的请求总数,而 TPS 值与程序的并发能力又有着密切的关系。对于计算量相同的任务,程序并发协调的越有条不紊,效率自然就会越高;反之,线程之间频繁阻塞甚至死锁,将会大大降低程序的并发能力。
服务端是 Java 语言最擅长的领域之一,这个领域占有了 Java 应用中最大的一块份额,不过如何写好并发应用程序却是程序开发的难点之一,处理好并发方面的问题通常需要很多经验。幸好 Java 语言和虚拟机提供了很多工具,把并发编程的门槛降低了不少。另外,各种中间件服务器、各类框架都努力的替程序员处理尽可能多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是花费大部分时间来关注服务会同时被多少人调用。但是无论语言、中间件和框架如何先进,我们都不能期望它们能够独立完成并发处理的所有事情,了解并发的内幕也是称为一个高级程序员不可缺少的课程。
12.2 硬件效率与一致性
在正式讲解 JVM 并发相关的知识之前,我们先花费一点时间来了解物理计算机中的并发问题,物理机遇到的并发问题与 JVM 中的情况有不少相似之处,物理机解决并发的方式对 JVM 的实现由很大的参考意义。
“让计算机并发执行多个运算任务”与“充分利用计算机处理器的效能”之间的因果关系看起来顺理成章,实际上并没有想象中那么容易实现,因为所有的运算任务都不可能仅靠处理器的“计算”就能完成,至少要与内存交互,如读取运算数据、存储运算结果等,仅靠寄存器也无法实现。由于计算机存储设备的访问速度与处理器的运算速度之间有着几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为处理器与内存之间的缓冲:将运算中用到的数据复制到缓存中以加速运算,当运算结束后在从缓存同步回内存,以避免处理器等待缓慢的内存访问。
基于高速缓存的存储交互很好的解决了处理器与内存之间的速度矛盾,但是也引入了新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又都共享同一内存,如下图所示。当多个处理器的运算任务都涉及同一块内存区域时,将可能导致搁置的缓存数据不一致,如果真的发生这种情况,将各个处理器缓存中的数据同步回内存时又以谁的缓存为准呢?为了解决一致性问题,需要各个处理器访问缓存时都遵循一定的协议,在读写时要根据协议来进程操作,这类协议有 MSI、MESI、MOSI、Firefly、Dragon Protocol 等。JVM 内存模型中定义的内存访问操作与硬件的缓存访问操作是具有可比性的。
除此之外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结构一致,但并不保证程序中各个语句计算的顺序与代码输入时的顺序一致,因此如果一个计算任务依赖于另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,JVM 的即时编译器中也有类似的指令重排优化。
12.3 Java 内存模型
JVM 规范中试图定义一种内存模型(JMM,JSR-133)来屏蔽掉各种硬件和操作系统在内存访问上的差异,以实现 Java 程序在各种平台下都保持一致的并发效果。在此之前,主流语言(如 C/C++)直接使用物理硬件(或者说是操作系统的内存模型),因此,会由于不同平台的内存差异导致程序在一些平台上的并发效果正常,但在另一些平台上出现非预期的并发效果,因此需要经常针对不同的平台来编写程序。
定义 JMM 并非易事,该模型必须定义的足够严谨,才能让 Java 的并发操作不会产生歧义:但是,也必须定义的足够宽松,使得虚拟机的实现能够由足够的自由空间去利用硬件的各种特性(如寄存器、高速缓存等)来获得更好的执行速度。经过长时间的验证和修补,在 JDK 1.5(实现了 JSR-133)发布后,JMM 就已经成熟和完善起来了。
12.3.1 主内存与工作内存
JMM 的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。这里所说的变量与 Java 编程中所说的变量略有区别,它包括实例字段、静态字段和构造数组对象的元素,不包括局部变量和方法参数,因为后者是“线程私有”的,不会在线程间共享,自然也就不存在竞争问题。为了获得更好的执行效能,JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制 JIT 调整代码执行顺序的这类权利。
线程私有:如果局部变量是一个引用类型,它引用的对象在堆中可被各个线程共享,但是引用本身位于 Java 栈的局部变量表中,它是线程私有的。
JMM 规定了所有的变量都存储在主内存中(这里是 JVM 内存的一部分,类似于物理机中的内存)。每条线程还有自己的工作内存(类比于 CPU 的高速缓存,但属于线程所有),线程的工作内存中保存了被该线程使用到的变量,这些变量是来自主内存的副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写“主内存中的变量”。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存之间的交互关系如下图。
主内存中的变量:根据 JVM 规范,volatile 变量依然用工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如图直接操作主内存一样。但 volatile 并不例外。
这里所讲的主内存、工作内存与本书第二章所讲的 Java 内存区域中的 Java 堆、栈、方法区等并非同一层次的内存划分。如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要是对应于 Java 堆中对象的实例数据部分,而工作内存则对应于 JVM 栈中的部分区域。从更低的层次来说,主内存就是硬件的内存,而为了获得更好的运行速度,JVM 及硬件系统可能会让工作内存优先存储在寄存器或高速缓存中。
12.3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,JMM 中定义了以下 8 种“操作”来完成:
- lock:作用于主内存中的变量,将变量标识为被一条线程独占。
- unlock:作用于主内存中的变量,将被标识为被一条线程独占状态的变量从该线程释放,释放后该变量可以被其他线程 lock。
- read:作用于主内存中的变量,将变量的值从主内存传输到工作内存,以便后续的 load 使用。
- load:作用于工作内存中的变量,将 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use:作用于工作内存中的变量,将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将执行该操作。
- assign:作用于工作内存中的变量,将一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时将执行该操作。
- store:作用于主内存中的变量,将工作内存中一个变量的值传送到主内存,以便随后的 write 操作使用。
- write:作用于主内存中的变量,将 store 操作从工作内存中得到的变量值放入主内存的变量中。
操作:实现虚拟机时必须保证每一种“操作”都是原子的、不可再分的。对于 double 和 long 类型的变量来说,在操作主内存时会出现字撕裂。
如果要把一个变量从主内存复制到工作内存,就要按顺序执行 read、load 操作;如果要把变量从工作内存同步回主内存,就要按顺序执行 store、write 操作。注意,JMM 只要求上述两个操作必须按顺序执行,而没有要求必须是连续执行,即 read 和 load 之间、store 和 write 之间是可以插入其他指令的(如对另外一些变量的存取操作指令)。除此之外,JMM 还规定了在执行上述 8 种基本操作时必须满足以下规则:
- read 和 laod、store 和 write 必须成对出现。
- 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
- 不允许一个线程无故(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存。
- 一个新的变量必须在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load/assign)的变量,即,对一个变量实施 use 和 store 操作之前,必须已经执行过 assign 和 load 操作。
- 一个变量在同一时刻仅允许一个线程对其进行 lock,但 lock 可以被同一线程重复执行多次,多次执行 lock 之后,只有执行相同次数的 unlock 才能完成解锁。
- 如果对一个变量执行 lock,将会清空工作内存中该变量的值而直接使用主内存中该变量的值(lock 引用在主内存),在执行引擎使用该变量前,需要重新执行 load 或 assign 来初始化变量的值。
- 如果一个变量事先没有被 lock 操作锁定,则不允许对其执行 unlock;也不允许 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 前,必须先把此变量同步回主内存。
12.3.3 volatile 变量的特殊规则
关键字 volatile 可以说是 JVM 提供的最轻量级的同步机制,但是它并不容易被正确完整的理解。
当一个变量被定义为 volatile,它将具备两种特性,第一是保证此变量对所有线程可见:一旦一条线程修改了该变量,新值对于所有其他线程来说是可以立即得知的。而普通变量无法做到这一点,变量值在线程间的传递需要通过主内存来完成,如:线程 A 修改一个普遍变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成之后再从主内存读取该变量的新值,这时新值才对线程 B 可见。
这种可见性经常被误解,认为以下描述成立:“volatile 对所有线程是立即可见的,对 volatile 变量的所有写操作都能立即反应到其他线程之中,换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是安全的”。这句话的论据部分没有错,但是其论据并不能得出“基于 volatile 变量的运算在并发下是安全的”这个结论。volatile 变量在各个线程的工作内存中不存在一致性的问题(在各个线程的工作内存中 volatile 变量也会存在不一致的情况,由于每次使用 volatile 变量之前都会先进行刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),“但是 Java 中的运算并非原子操作”,导致 volatile 变量的运算在并发下变得不安全。下面看一个例子。
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String...args) {
Thread[] threads = new Thread[THREADS_COUNT];
for(int i=0; i<THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable(){
@Override
public void run() {
for(int i=0; i<10000; i++){
increase();
}
}
});
threads[i].start();
}
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
如果上述代码正确的话,最终的输出结果应该是 200000。但实际的运行结果并非如此。问题就出现在自增运算 race++
之中,我们用 javap 反编译这段代码后会得到下面的代码清单,发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成的,从字节码层面很容易就分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,其他线程就可能已经把 race 的值增加了,而在操作栈顶的值就成了过期数据,所以 putstatic 指令执行后就可能把较小的 race 值同步回主内存中。
public static void increase();
Code:
Stack=2, Locals=0, Args_size=0
0: getstatic #13; //Field race: I
3: iconst_1
4: iadd
5: putstatic #13: //field race: I
8: return
LineNumberTable:
line 14: 0
line 15: 8
实事求是的说,笔者在此使用字节码来分析并发问题,仍然是不严谨的,因为即便编译出来只有一条字节码指令,也并不意味着执行这条指令就是原子操作。一条字节码指令在解释执行时,解释器将要运行许多代码才能实现它的语义,如果是编译执行,一条字节码指令也可能转换成若干条本地机器码指令,此处使用 -XX:+PrintAssembly 参数输出反汇编来分析会更加严谨一些,但是考虑到读者阅读的方便,并且字节码已经能够说明问题,所以此处使用字节码来分析。
由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(会用 synchronized 或 JUC 中的原子类)来保证原子性。
- 运算结果并不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
而在像如下代码清单中所示的这类场景就很适合使用 volatile 变量来控制并发,当 shutdown() 方法被调用时,能够保证所有线程中执行的 doWork() 方法都立即停下来。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested) {
// do stuff
}
}
使用 volatile 变量的第二个语义是禁止指令重排序优化,普通变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致。因为在一个线程的方法执行过程中无法感知到这一点,这也就是 JMM 中描述的所谓“线程内表现为串行的语义”。
如下伪代码所示:
Map configOptions;
char[] configText;
// 此时变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后
// 将 initialized 设置为 true 来通知其他线程配置已经可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
inintialized = true;
// 假设以下代码在线程 B 中执行
// 等待 initialized 为 true,代表线程 A 已经把配置信息初始化完成
while (!initialized) {
sleep();
}
// 使用线程 A 初始化好的配置信息
doSomethingWithConfig();
以上描述的场景十分常见,只是我们在处理配置文件时一般不会出现并发而已。如果定义 initialized 变量时没有使用 volatile 修饰,就可能由于指令重排的优化,导致位于线程 A 中最后一句的代码“initialized = true”被提前执行,这样在线程 B 中使用配置信息的代码就可能出现问题,而 volatile 关键字则可以避免此类请求的发生。
解决了 volatile 语义的问题,再来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他同步工具时更快吗?确实在某些情况下,volatile 同步机制的性能要优于锁(即 synchronized 或 JUC 中的锁),但是由于虚拟机对锁实行的很多消除和优化,使得我们很难量化的说 volatile 就是会比 synchronized 快上多少。如果让 volatile 自己与自己比较,则可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会满上一些,因为它需要在本地代码中插入许多内存屏障(Memory Barrier)指令来保证处理器不会发生乱序执行。不过即便如此,大多数场景下 volatile 的总开销仍然要比锁低,我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。
本节的最后,我们再来看看 JMM 中对 volatile 变量定义的特殊规则。假设 T 表示一个线程,V 和 W 分别表示两个 volatile 变量,那么在执行 read、load、use、assign、store、write 操作时需要满足如下规则:
- 只有线程 T 对变量 V 执行的前一个动作是 load 的时候,T 才能对 V 执行 use 操作;并且,只有当 T 对 V 执行的后一个动作是 use 时,T 才能对 V 执行 load 动作。T 对 V 的 use 动作可以认为是与 T 对变量 V 的 load 和 read 动作是相关联的,必须一起“连续出现”。(该规则要求在工作内存中,每次使用 V 之前都先从主内存刷新最新值,用于保证能看见其他线程对 B 执行修改后的值)
- 只有当线程 T 对变量 V 执行的前一个操作是 assign 时,T 才能对 V 执行 store 操作;并且,只有当 T 对 V 执行的后一个动作是 store 时,T 才能对 V 执行 assign 操作。T 对 V 的 assign 动作可以认为是 T 对 V 的 store 和 write 动作相关联的,必须一起连续出现。(该规则要求在工作内存中,每次修改 V 后都必须立刻将其同步回主内存,用于保证其他线程可以看到当前线程对 V 所做的修改)
- 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定动作 F 是与动作 A 相关联的 load 或 store 操作,假定动作 P 是与动作 F 相应的对变量 V 的 read 或 write 动作;类似的,假定动作 B 是线程 T 对变量 W 实施的 use 或 assign 动作,假定动作 G 是与动作 B 相关联的 load 或 store 操作,假定动作 Q 是与动作 G 相关联的对变量 W 的 read 或 write 动作。如果 A 先于 B,那么 P 先于 Q。(这条规则要求 volatile 修饰的变量不会被指令重排序优化,保证代码执行的顺序与程序代码的顺序相同)
12.3.4 对于 long 和 double 型变量的特殊规则
JMM 要求 lock、unlock、read、load、assign、use、store、write 这 8 种操作都具有原子性,但是对于 64 位的数据类型,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证数据类型为 long 和 double 的非原子性协定。
如果有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读到一个即非原值又非其他线程修改值的“半个变量”数值。
不过这种读取到“半个变量”的情况非常罕见,因为 JMM 虽然允许虚拟机不把 long 和 double 变量的读写操作实现成原子操作,但允许虚拟机选择把这些操作实现为具有原子性的操作,而且还“强烈建议”虚拟机这么做。在实际开发中,目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作实现为原子操作,因此我们在编写代码时一般不需要把 long 的 double 变量专门声明为 volatile。
12.3.5 原子性、可见性、有序性
介绍完 JMM 相关的操作和规则,我们再整体回顾一下该模型的特征。JMM 是围绕着在并对过程中如何处理原子性、可见性、有序性这三个特征来建立的,我们逐个来看一下哪些操作实现了这三个特性。
“原子性(Atomicity)”:由 JMM 来直接保证的原子性变量操作包括 read、load、assign、use、store、write 这 6 个,我们大致认为基本数据类型的访问读写是具备原子性的(long 和 double 除外)。
如果应用场景需要一个更大范围的原子性保证,JMM 还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式的使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。
“可见性(Visibility)”:可见性就是指当一个线程修改了共享变量的值,其他线程能够立即获得这些最新的值。上文在讲解 volatile 变量的时候我们已经详细讨论过这一点。JMM 是通过在变量修改后将新值同步回主内存、在变量去读前从主内存刷新变量值这个种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,普通变量与 volatile 变量的区别在于 volatile 的特殊规则保证了新值能够立即同步主内存,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了 volatile 之外,Java 还提供了两个关键字实现可见性,即 synchronized 和 final。同步块的可见性是由“对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中(store & write)”这条规则获得的;而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this 引用逸出是一件很危险的事情,其他线程可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看到final 字段的值。如下代码所示,变量 i 与 j 都具备可见性,它们无需同步就能被其他线程正确的访问。
public static final int i;
public finl int j;
static {
i = 0;
j = 0; // 也可以选择则构造函数中初始化 j
}
“有序性(Ordering)”:JMM 的有序性前面讲解 volatile 时也详细讨论过,Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由“一个变量在同一时刻仅允许一条线程对其进行 lock 操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。
介绍完并发的三种重要特性,读者有没有发现 synchronized 关键字在需要这三种特性时都可以作为一种解决方案?看起来是万能的?的确,大部分的开发控制操作都能通过使用 synchronized 来完成。synchronized 的“万能”间接造就了它被开发者滥用的局面,越“万能”的并发控制,通常也伴随着越大的性能影响。
12.3.6 先行发生(happens-before)原则
如果 JMM 中所有的有序性都只靠 volatile 和 synchronized 来完成,那么有一些操作就会变得很啰嗦,但是我们在编写 Java 并发代码的时候并没有感觉到这一点,这是因为 Java 语言中有一个“先行发生”原则。该原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据。基于该原则,我们可以通过几条规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题。
先行发生是 JMM 中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,其实就是说在发生 B 之前,操作 A 产生的影响能被操作 B 观察到,“影响”包括对内存中共享变量值的修改、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明,如下所示的三行伪代码:
// 线程 A
i = 1;
// 线程 B
j = i;
// 线程 C
i = 2;
假设线程 A 中的操作线性发生于线程 B 中的操作,那么我们就可以确定在线程 B 的操作执行之后,变量 j 的值一定为 1。得出该结论的依据有两个,一是根据现行发生原则,“i=1”的结果可以被观察到;二是线程 C 登场之前,线程 A 操作结束之后没有其他线程会修改变量 i 的值。现在再来考虑线程 C,我们依然保持线程 A 和 B 之前的线性发生关系,而 C 出现在线程 A 和 B 的操作之间,但是 C 与 B 没有先行发生关系,那么 j 的值会是多少呢?答案是不确定。1 或是 2,因为线程 C 对变量 i 的影响可能会被线程 B 观察到,也可能不会,这时候线程 B 就存在读取到过期数据的风险,不具备多线程安全性。
下面是 JMM 中一些“天然的”线性发生关系,这些线性发生关系无需任何同步协助就已经存在,可以直接在编码时使用。如果两个操作之间的关系不在次列,并且也无法从下列规则中推导出来的话,它们就没有顺序性保证,虚拟机可以对他们进行随意重排。
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于发生在后面的操作。准确的说应该是控制流程序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管理锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
- volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样指的是时间上的先后顺序。
- 线程启动规则:Thread 对象的 start 方法先行发生于此线程内的所有动作。
- 线程终止规则:线程内的所有操作都先行发生于对此线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值等手段检测到线程已经终止执行。
- 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize 方法的开始。
- 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,则操作 A 先行发生于操作 C。
Java 语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些了,笔者演示一下如何使用这些规则去判定操作间是否具有顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面的例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。
private int value = 0;
public void setValue(int value) {
this.value = value;
}
public int getValue() {
return value;
}
上述代码显示的是一组再普通不过的 getter/setter 方法,假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?
我们依次分析一下先行发生规则,由于两个方法分别是由线程 A 和 B 调用,不再一个线程中,所以程序次序在这里不适用;由于没有同步块,自然就不会发生 lock 和 unlock 操作,所以管理程序规则不适用;由于 value 变量没有被 volatile 修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和代码中的操作扯不上关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程 A 的操作在时间上先于线程 B,但是无法确定 B 中 getValue 的返回结果,换句话说,这里的操作不是线程安全的。
那么如何修复这个问题呢?我们至少有两种比较简单的方案可供选择:呀么把 getter/setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖于 value 的原值,满足了 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。
通过上面的例子,我们可以得出一个结论:一个操作“时间上先行发生”不代表这个操作会是“先行发生”,那么如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的场景就是多次提到的“指令重排序”,如下代码所示:
// 以下操作在同一个线程中执行
int i = 1;
int j = 2;
根据程序次序规则,第一行的操作先先行发生于第二行的操作,但是第二行代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程中没有办法感知到这一点。
上面两个例子综合得出一个结论:时间上的先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
12.4 Java 与线程
并发不一定要依赖多线程(如 PHP 中很常见的多进程并发),但是在 Java 里面谈论并发,大多数与线程脱不开关系。因此我们就从 Java 线程在虚拟机中的实现讲起。
12.4.1 线程的实现
我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址、文件 IO 等),又可以独立调度(线程是 CPU 调度的最基本单位)。
主流的操作系统都提供了线程实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个 java.lang.Thread 类的实例就代表了一个线程。不过 Thread 类与大部分的 Java API 有着显著的区别,它的所有关键方法都被实现为 Native。在 Java API 中一个 Native 方法可能意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能为了执行效率而使用 Native 方法,不过通常最高效率的手段也就是平台相关的手段)。正因为如此,作者把本节标题定位“线程的实现”,而非“Java 线程的实现”。
实现线程主要有三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。
1. 使用内核线程实现
内核线程(KLT)就是直接由操作系统内核支持的线程,这些线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程都可以看做是内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LWP)。轻量级进程就是我们通常意义上所说的线程,由于每个轻量级进程都有一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的冠以称为“一对一的线程模型”,如下图所示:
由于内核线程的支持,每个轻量级进程都称为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种进程操作,如创建、撤销及同步都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2. 使用用户线程实现
广义来讲,一个线程只要不是内核线程,就可以被认为是用户线程(UT),因此从这个定义上来讲轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核线程之上的,许多操作都需要使用系统调用,因此效率也会受限制。
而狭义上的用户线程值得是完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之前是 1:N 的关系称为 “一对多的线程模型”,如下图:
使用用户线程的优势在于不需要内核支援,劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换、调度都是需要考虑的问题,而且由于操作系统只能把处理器资源分配到进程级别,那些诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其它处理器上”这类问题解决起来都会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,处理以前在不支持多线程的操作系统中的多线程程序与少数具有特殊需求的程序之外,现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,但最终都放弃了。
3. 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现外,还有一种将内核线程与用户线程结合的实现方式。在这种混合实现中,既存在用户线程,也存在轻量级继承。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、销毁等操作依然廉价,并且可以支持大规模的用户级线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以利用内核提供的线程调度功能及处理器映射功能,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了进程被阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比例是不一定的,是 M:N 的关系,被称为“多对多线程模型”,如下图:
许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 M:N 的线程模型实现。
4. Java 线程的实现
Java 线程在 JDK 1.2 之前,是基于名为“绿色线程(Green Threads)” 的用户线程实现的,而在 JDK 1.2 中,线程模型被替换为基于操作系统原生线程模型来实现。因此在目前版本的 JDK 中,操作系统支持怎样的线程模型,在很大程度上决定了 JVM 虚拟机的线程是怎样的映射的,这点在不同平台上没有办法达成一致,虚拟机规范中也并未限定 Java 线程需要使用哪些线程模型来实现。线程模型只对线程的并发规模和操作成本产生影响,对 Java 程序的编码和运行过程来说,这些差异都是透明的。
对于 Sun JDK 来说,它的 Windows 与 Linux 版都是使用一对一的线程模型来实现的,一条 Java 线程就映射到一条轻量级线程之中,因为 Windows 和 Linux 系统提供的线程模型就是一对一的(尽管存在一些多对多模型的实现,但没有成为主流)。
而在 Solaris 平台中,由于操作系统的线程特性可以同时支持一对一(通过 Bound Threads 或 Alternate Libthread)和多对多(通过 LMP/Thread Based Synchronization 实现)的线程模型,因此在 Solaris 版的 JDK 中也对应提供了两个平台专有的虚拟机参数:-XX:+UseLWPSynchronization(默认值) 和 -XX:+UseBoundThreads 来明确指定虚拟机要使用的线程模型。
12.4.2 Java 线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,协同式(Cooperative)和抢占式(Preemptive)。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情完成之后才会进行线程切换,切换操作对线程自己来说是可以预知的,所以没有什么线程同步的问题。Lua 语言中的“协同例程”就是这类实现。它的缺点很明显:线程执行时间不可控,甚至如果一个线程编写有问题,一直不告诉系统进行线程切换,那么程序会一直阻塞在那里。很久以前的 Windows 3.x 系统就是使用协同式来实现多进程多任务的,那是相当的不稳定,一个进程坚持不让出 CPU 执行时间就会导致整个系统崩溃。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在 Java 中,Thread.yield 可以让出执行时间,但是线程本身没办法主动获得执行时间)。在这种实现线程调度的方式下,线程的执行实现是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java 使用的线程调度方式就是抢占式调度。与前面所说的 Windows 3.x 的例子相对,在 Windows 9x/NT 内核中就是使用抢占式来实现的多进程,当一个进程出了问题,我们还可以使用任务管理器来把这个进程杀掉,而不至于导致系统崩溃。
虽然说 Java 多线程调度是由系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一些执行时间,另外的一些线程多则可以少分配一些——这项操作可以通过设置线程优先级来完成。Java 语言一共设置了 10 个级别的线程优先级,在两个线程同时处于 Read 状态时,优先级越高的线程越容易被系统选择执行。
但是线程优先级并不是很靠谱,原因是 Java 的线程是被映射到系统的原生线程(内核线程)上来实现的,所以线程调度最终还是由操作系统说了算,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与 Java 线程的优先级实现一一对应,如 Solaris 中有 2147483648 种优先级,但 Windows 中仅有 7 种,比 Java 线程优先级多的系统还好说,中间留下一点空位就是了,但比 Java 线程优先级少的系统,就不得不出现几个优先级相同的情况了,下表展示了 Java 线程优先级与 Window 线程优先级之间的对应关系,Windows 平台的 JDK 中使用了除 THREAD_PRIORITY_IDLE 之外的其余 6 种线程优先级。
上文说到“线程优先级并不是很靠谱”,不仅仅是说在一些平台上不同的优先级实际会变得相同这一点,还有其他情况让我们不能太依赖优先级:优先级可能会被系统自动改变。例如在 Windows 系统中存在一个名为“优先级推进器”的功能,它的大致作用即使当系统发现一个线程被执行的“特别勤奋努力”的话,可能会越过线程优先级来为其分配执行时间。因此我们不能在程序中通过优先级来完全准确的判断一组状态都为 Ready 的线程中哪个会先执行。
12.4.3 状态转换
Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态:
- New:创建后但尚未启动。
- Runnable:包括了操作系统线程状态的 Running 和 Ready,也就是处于这种状态的线程有可能在执行,也可能正在等着操作系统为其分配执行时间。
- Waiting:无限期等待,不会被分配 CPU 执行时间,需要等待被其他线程显式的唤醒。以下方法会让线程陷入 Waiting 状态:
- Object.wait()
- Thread.join()
- LockSupport.park()
- Timed Waiting:期限等待,不会被分配 CPU 执行时间,在一定时间后它们会由操作系统自动唤醒。以下方法会让线程陷入 Timed Waiting 状态:
- Thread.sleep()
- Object.wait(time)
- Thread.join(time)
- LockSupport.parkNanos(nanos)
- LockSupport.parkUntil(time)
- Blocked:线程被阻塞。“阻塞状态”与“等待状态”的区别是,“阻塞状态”在等着获得一个排他锁,这个事件将在另一个线程放弃该锁的时候发生;而“等待状态”则是在等待一段时间、发生唤醒动作。在程序等待进入同步区域的时候,线程将进入这种状态。
- Terminated:线程已被终止,线程已被结束执行。
上述 5 种状态在约到特定事件时会发生切换,它们之间的转换关系如下图:
12.5 总结
本章中,我们了解了虚拟机 JMM 的结构及操作,并讲解了原子性、可见性、有序性在 JMM 中的体现,介绍了先行发生原则的规则及应用。另外,我们还了解了线程在 Java 语言中的实现方式。
2.4.13 - CH13-线程安全与锁优化
13.1 概述
在软件业发展的初期,程序的编写都是以算法为核心,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式是直接站在计算机的角度去抽象和解决问题,称为面向过程的编程思想。于此相对,面向对象的思想则是站在现实世界的角度去抽象和解决问题,它把数据和行为都看作是对象的一部分,这样可以让程序员以符合现实世界的思维方式来编写和组织程序。
面向过程的编程思想极大的提升了现代软件开发的生产效率和软件可以达到的规模,但是现实世界与计算机世界之间不可避免的存在一些差异。例如,人们很难想象现实中的对象在一项工作进行期间,会被不停的中断可切换,对象的属性可能会在中断期间被修改和变脏,而这些事件在计算机世界中则是很正常的现象。有时候,良好的设计原则不得不向现实做出一些让步,我们必须让程序在计算机中正确无误的运行,然后在考虑如何将代码组织的更好,让程序运行的更快。对于这部分的主题“高效并发”来将,首先需要保证并发的正确性,然后在此基础上实现高效。
13.2 线程安全
“线程安全”这个名词,相信稍有经验的开发者都有听过,甚至在编写和走查代码的时候还会经常挂在嘴边,但是如何找到一个不太拗口的概念来定义线程安全却不是一件容易的事情。
笔者认为 “Java Concurrency In Practice” 的作者 Brian Goetz 对“线程安全”有一个比较恰当的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外同步,或者在调用方进行任务其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的”。
这个定义很严谨,它要求了线程安全的代码必须都具备一个特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程问题,更无须自己实现任何措施来保证多线程的正确调用。这点并不容易做到,在大多数场景中,我们都会将这个定义弱化一些,如果把“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述也能成立的话,我们就可以称之为线程安全了。
13.2.1 Java 中的线程安全
为了更深入理解线程安全,我们可以不把线程安全当做一个非真即假的二元排他选项来看待,安装线程安全的“安全程度”由强至弱来排序,我们可以将 Java 语言中各种操作共享的数据分为以下五类:
1. 不可变
在 Java 语言里面,不可变对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不再需要进行任何的线程安全保障措施,在上一章中我们谈到的 final 关键字带来的可见性时曾提到过这一点,只要一个不可变的对象被正确的构建出来(没有发生 this 引用逸出的情况),则其外部的可见状态永远都不会改变,永远也不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性也是最简单最纯粹的。
Java 语言中,如果共享的数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,比如 String 类。
保证对象行为不影响自身状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为 final,这样在构造函数结束之后它就是不可变的。
在 Java API 中符合不可变要求的类型,除了上面提到的 String 之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型;但同为 Number 子类的原子类 AtomicInteger 和 AtomicLong 则是可变的。
2. 绝对线程安全
绝对的线程安全完全满足 Brian Goetz 对线程安全的定义,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。在 Java API 中标注为线程安全的类中,大多数都不是绝对的线程安全。我们可以通过 Java API 中一个不是“绝对线程安全”的线程安全类来看看这类的“绝对”是什么意思。
java.util.Vector 是一个线程安全的容器,因为它所有类似 add、get 的方法都被 synchronized 修饰,尽管这样效率很低,但是确实是安全的。但是,即使它所有的方法都被修饰为同步,也不意味着调用它的时候就无需考虑同步了,如下代码示例:
private static Vector<Integer> vector = new Vector<>();
public static void main(String...args) {
while(true) {
for(int i=0; i<10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0; i<vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0; i<vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
// 避免同时产生过多线程后操作系统假死
while(Thread.activeCount() > 20);
}
}
运行后将会抛出数组索引越界异常。很明显,尽管这里使用到的 get、remove、size 方法都是同步的,但是在多线程环境中,如果不再方法调用端做任何同步措施,使用这段代码仍然不安全,因为如果另一个线程恰好的时间删除了一个元素,导致序号 i 已经不再可用的话,get(i) 将会抛出数组越界异常。如果要保证这段代码能够正确运行,则要进行如下修改:
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for(int i=0; i<vector.size(); i++) {
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for(int i=0; i<vector.size(); i++) {
System.out.println(vector.get(i));
}
}
}
});
3. 相对线程安全
相对线程安全就是我们通常意义上所说的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。上面的例子可以作为说明。
在 Java 语言中,大部分的线程安全类都属于这种类型,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包装的集合等。
4. 线程兼容
线程兼容指的是对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中安全的使用,我们平常说一个类不是线程安全的,绝大多数指的是这种情况。Java API 中大部分类都是线程兼容的,如与前面的 Vector 和 HashTable 相对应的集合类 ArrayList 和 HashMap。
4. 线程对立
线程对立这得是不管调用段是否采取了同步措施,都无法在多线程环境中并发使用的代码。由于 Java 语言天生就具有多线程特性,线程对立这种排斥多线程的代码是很少出现的,而且通常是有害的,应当尽量避免。
一个线程对立的例子是 Thread 类的 suspend 和 resume 方法,如果有两个线程同时持有一个 Thread 对象,一个尝试去中断线程、一个尝试去恢复线程,如果并发执行这种操作,无论调用时是否进行了同步,目标线程都会存在死锁风险。如果 suspend 中断的线程就是即将要执行 resume 的那个线程,那就肯定要产生死锁了。常见的线程对立的操作还有 System.setIn()、System.setOut()、System.runFinalizersOnExit() 等。
13.2.2 实现线程安全
1. 互斥同步
互斥同步是最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一条(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现方式。因此在这四个字面中,互斥是因,通过是果,互斥是方法,同步是目的。
在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,经过编译之后,会在同步块的前后分别形成 monitorenter 和 monitorexit 这两个字节码指令,这两个指令都需要一个 reference 类型的参数来指明要锁定和解锁的对象。如果 Java 程序中的 synchronized 明确指定了对象参数,那就是这个对象的 reference;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是类方法,来取对应的对象实例或 Class 对象作为对象锁。
根据 JVM 规范的要求,在执行 monitorenter 指令时,首先要尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经持有了该对象的锁,把锁的计数器加 1;相应的,在执行 monitorexit 指令时会将锁的计数器减 1,当计数器为 0 时,所就被释放了。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
在 JVM 规范对 monitorenter 和 monitorexit 的行为描述中,有两点是需要特别关注的。首先,synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。上一章讲过,Java 线程是映射到操作系统原生线程上的,如果要阻塞或唤醒一条线程,都需要依赖操作系统调用,这就需要从用户态切换到内核态,因此状态转换需要耗费很多处理器时间。对于代码中简单的同步块(如被 synchronized 修饰的 getter/setter 方法),状态切换消耗的时间可能比用户代码执行的时间还要长。所以 synchronized 是 Java 语言中一个重量级操作,有经验的程序员都会在确实必要的情况才使用这种操作。而虚拟机本身也会进行一些优化,比如在通知操作系统阻塞线程之前先加入一段自旋等待过程,避免频繁的切入内核态。
除了 synchronized 之外,我们还可以使用 JUC 中的重入锁来实现同步,在基本用法上,重入锁与 synchronized 很相似,它们都具备一样的线程重入特性,只是代码写法上有些区别,一个表现为 API 层面的互斥锁(lock 和 unlock 方法配合 try/finally 语句块来完成),一个表现为原生语法层面的互斥锁。不过重入锁比 synchronized 增加了一些高级功能,主要有以下三项:
- “等待可中断”是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理器其他任务,可中断特性对处理的执行时间非常长的同步块很有帮助。
- “公平锁”是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized 中的锁是非公平的,重入锁默认情况下也是非公平的,但可以通过构造参数设置。
- “锁绑定多个条件”是指一个重入锁对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait 和 notify、notifyAll 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外的添加一个锁,而重入锁无需这么做,只需要多次调用 newCondition 方法即可。
如果需要使用到上述功能,选用重入锁是一个很好的选择,那如果是基于性能考虑呢?关于 synchronized 和重入锁的性能问题,Brian Goetz 对这两种锁在 JDK 1.5、单核处理器及双 Xeon 处理器环境做了一组吞吐量对比试验,如下图:
从上面两组试验结构可以看出,多线程环境下 synchronized 的吞吐量下降的非常严重,而重入锁则能基本保持在同一个比较稳定的水平上。与其说重入锁性能好,倒不如说 synchronized 还有非常大的优化空间。后续的技术发展也证明了这一点,JDK 1.6 中加入了很多针对锁的优化措施,这时两者的性能基本持平。因此性能因素不再是选择重入锁的理由了,JVM 在未来的性能改进中肯定也会更加偏向于原生的 synchronized,所以还是提倡在 synchronized 能够实现需求的情况下有限选择。
2. 非阻塞同步
互斥同步最主要的问题就是进行线程阻塞和换新所带来的性能问题,因此这种同步也被称为阻塞同步。另外,它属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(即加锁),那就肯定会出问题,无论共享数据是否真的会出现竞争,它都要求进行加锁(这里说的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态与内核态切换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等操作。随着硬件指令集的发展,我们有了另一种选择:基于冲突检测的乐观并发策略,通俗的说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据被争用,这时就产生了冲突,然后再进行其他补偿措施(最常见的补偿措施就是不断重试直到成功),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作被称为非阻塞同步。
为什么说使用乐观并发策略需要“硬件指令集的发展”才能进行呢?因为我们需要操作和冲突检测这两个步骤具有原子性,靠什么来保证呢?如果这里再使用互斥同步来保证就是去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成,这类指令常用的有:
- 测试并设置,TAS
- 获取并增加,FAI
- 交换,Swap
- 比较并交换,CAS
- 加载链接/条件存储,LL/SC
其中,前面的三条是上个世纪就存在于大多数指令集中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能是类似的。在 IA64、x86 指令集中通过 cmpxchg 指令完成 CAS 功能,在 sparc-STO 中也有 casa 指令实现,而在 ARM 和 PowerPC 架构下,则需要使用一对 ldrex/strex 指令来完成 LL/SC 功能。
CAS 指令需要有三个操作数,分别是内存位置(即 Java 中变量的内存地址,V)、旧的预期值(A)、新值(B)。CAS 指令执行时,当且仅当 V 符合旧的预期值 A 时,处理器用新值 B 更新 V 的值,否知它就不执行更新,但是不管是否更新了 V 的值,都会返回 旧 V 的值,上述的处理过程是一个原子操作。
在 JDK 1.5 之后,Java 程序中才可以使用 CAS 操作,该操作由 sun.misc.Unsafe 类中的 compareAndSwap 和 compareAndSwapLong 等几个方法提供,JVM 内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器 CAS 指令,没有方法调用的过程,或者可以认为是无条件内联进去了。
由于 Unsafe 类不是提供给用户程序调用的类(Unsafe.getUnsafe 的代码中限制了只有启动类加载器加载的类才可以访问它),如果不采用反射手段,我们只能通过其他 Java API 来间接访问它,如 JUC 包中的整数原子类。
尽管 CAS 看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景,并且 CAS 从语义上来说并不完美,存在这样一个逻辑漏洞:如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查它仍然是 A 值,那我们就能说它的值没有被其他线程改变过吗?如果在这段时间内它的值曾经被改为了 B,后来又被改回了 A,那 CAS 操作就会误认为它从来没有被改变过。该漏洞被称为 CAS 操作的 ABA 问题。JUC 为了解决该问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本来保证 CAS 的正确性。不过目前来说这个类比较鸡肋,大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更有效。
3. 无同步方案
要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保障共享数据争用时的正确性手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步措施来保证正确性,因此会有一些代码天生就是线程安全的。
“可重入代码”:这种代码也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它自身),而在控制权返回后,原来的程序不会出现任何错误。相对线程安全来说,可重入性是更基本的特性,它可以保证线程安全,即所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
可重入代码有一些共同的特征:比如不依赖存储在堆上的数据和公共的系统资源、用到的状态都由参数传入、内部不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具有可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性的要求,当然它就是线程安全的。
“线程本地存储”:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程内,这样,无需同步也能保证线程间不出现数据争用的问题。
符合这种特点的应用很常见,大部分使用消费队列的架构模式(如生产者——消费者)都会将产品的消费过程尽量在一个线程中消费完,其中最重要的一个应用实例就是经典 Web 交互模式中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得 Web 服务器的很多应用都可以使用线程本地存储来解决线程安全问题。
Java 语言中,如果一个变量要被多线程访问,可以使用 volatile 关键字声明它为“易变的”;如果一个变量要被某个线程独享,因为 Java 中没有类似 C++ 中 __declspec(thread)
这样的关键字,不过可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。每一个线程的 Thread 对象中都有一个 ThreadLocalMap 对象,这个对象存储了一组以 ThreadLocal.threadLocalHashCode 为键,以本地线程变量为值的 K-V 键值对,ThreadLocal 对象就是当前线程的 ThreadLocalMap 的访问入口,每一个 ThreadLocal 对象都包含了一个独一无二的 threadLocalHashCode 值,使用这个值就可以在线程 K-V 键值对中找回对应的本地线程变量。
13.3 锁优化
HotSpot JVM 开发团队在 JDK 1.6 版本上花费了大量精力来实现各种锁的优化技术,所有这些优化都是为了在线程间更加高效的共享数据,以解决竞争问题,从而提高程序的执行效率。
13.3.1 自旋锁与自适应自旋
前面我们讨论同步的时候,提到了互斥同步对性能影响最大的是阻塞式的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统并发性带来了很大的压力。同时,虚拟机开发团队也注意到在很多应用上,共享数据的锁定状态一般会持续很短一段时间,为了这段很短的时间去挂起和恢复线程很不值得。如果物理机有一个以上的处理器,能让两个或两个以上的线程同时并行执行,我们就可以让后面请求锁的线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就能释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
自旋锁自 JDK 1.4.2 引入,只不过是默认关闭的,可以使用 -XX:UseSpinning 参数来开启,到了 1.6 就已经改为默认开启了。自旋锁不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它要是要占用处理器执行时间的,所以如果被占用的时间很短,自旋等待的效果会很好,反之,自旋的线程只会白白消耗掉处理器资源,造成性能浪费。因此自旋等待的时间必须要有一个限度,如果自旋超过了限定次数仍然没有获得锁,那就应当使用传统的方式去挂起线程。自旋次数的默认值是 10,用户可以使用参数 -XX:PreBlockSpin 来修改。
在 JDK 1.6 中引入了自适应自旋锁。自适应意味着自旋的次数不再固定,而是由前一次在同一个锁上自旋的时间及锁的拥有者线程的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋很有可能会再次成功,进而它将允许自旋等待持续相对更长的时间,比如 100 个循环。另一方面,如果对于某个锁,自旋很少获得成功,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器时间。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况监控就会越来越准确,虚拟机就会变得越来越聪明了。
13.3.2 锁消除
锁消除指的是虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在数据竞争的锁进行消除。锁消除的主要判断依据来自逃逸分析和数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然也就无需运行。
也许读者会有疑问,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据竞争的情况下要求同步呢?答案是有许多同步措施并非由程序员加入,同步代码在 Java 程序中的普遍程度也许超过了大部分读者的想象。比如下面一段代码:
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
由于 String 是一个不可变类,对字符串的连接操作总是通过生成新的 String 对象进行的,因此 javac 编译器会对 String 连接做自动优化。在 JDK 1.5 之前,会转换为 StringBuffer 对象的连续 append 操作,在 JDK 1.5 及之后的版本中,会转换为 StringBuilder 对象的连续 append 操作。
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
现在大家还认为这段代码没有涉及同步吗?每个 StringBuffer.append 方法中都有一个同步块,锁就是 sb 对象。虚拟机观察变量 sb,很快就发现它的动态作用域被限制在 concatString 方法内部。即 sb 的所有引用永远不会逃逸到 concatString 方法的外部,其他线程也就无法访问它,所以这里虽然有锁,但是可以被安全的消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
13.3.3 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也可以尽快得到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续读操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁的进行同步操作也会带来不必要的性能损耗。
上述代码中连续 append 方法就属于这类情况。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以上述字符串连接为例,就是扩展到第一个 append 操作之前直至最后一个 append 操作之后,这样一来仅需加锁一次就够了。
13.3.4 轻量级锁
轻量级锁是 JDK 1.6 中加入的新型锁机制,“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此后者也可以被称为“重量级锁”。首先需要强调的一点是,轻量级锁并非用来替代重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用系统互斥量带来的性能损耗。
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从 HotSpot 虚拟机的对象(对象头部分)的内存布局开始介绍。HotSpot 虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 个和 64 个 Bits,官方称它为“Mark Word”,它是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象的类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定数据及饿哦股以便在极小的空间内存储尽可能多的信息,它会根据对象的状态复用自己的存储空间。例如在 32 位的 HotSpot 虚拟机中对象未被锁定的状态下,Mark Word 的 32 个 Bits 空间中的 25 Bits 用于存储对象哈希码,4 Bits 用于存储对象的分代年龄,2 Bits 用于存储锁标志位,1 Bit 固定为 0,在其他状态(轻量级锁定、重量级锁定、GC 标记、可偏向)下对象的存储内容如下表:
在代码进入同步块时,如果此同步对象没有被锁定(锁标志位为 01 状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝(官方把这分拷贝加了一个 Displaced 前缀,即 Displaced Mark Word),这时候线程堆栈与对象头的状态如下图所示。
然后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。如果该更新动作成功,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位(Mark Word 的最后两 Bits)将转变为 “00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈及对象头的状态如下图所示。
如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两个以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
上面买描述的是轻量级锁的加锁过程,它的解锁过程也是通过 CAS 操作来进行的,如果对象的 Mark Word 仍然指向着线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能够提高程序同步性能的依据是,“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一条经验数据。如果没有竞争,轻量级锁使用 CAS 操作就避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了 CAS 操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
13.3.5 偏向锁
偏向锁也是 JDK 1.6 引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的执行性能。如果说轻量级锁是在无竞争情况下使用 CAS 操作来消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不做了。
偏向锁的“偏”类似于“偏心”、“偏袒”中的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再次进行同步。
如果读者读懂了前面轻量级锁中关于对象头 Mark Word 与线程之前的操作过程,那偏向锁的原理理解起来就会很简单。假设当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取时,虚拟机就会把对象头中的标志位设置为 “01”,即偏向模式。同时使用 CAS 操作把获取到该锁的线程 ID 记录在对象头 Mark Word 中,如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(如 Locking、Unlocking 及对 Mark Word 的 Update 等)。
当另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁定对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定(标志位为“01”)或轻量级锁定状态(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。偏向锁、轻量级锁的状态转换及对象 Mark Word 的关系如下图所示。
偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,即并不一定总是对程序的运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数 -XX:UseBiasedLocking 来禁止偏向锁优化反而可以提升性能。
13.4 本章小结
本章介绍了线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机为了实现高效并发所采取的一系列锁优化措施。
许多资深的程序员都说过,能够写出高伸缩性的并发程序是一门艺术,而了解并发在系统底层是如何实现的,则是掌握这门艺术的前提条件,也是成长为高级程序员的必备知识之一。
2.4.14 - Endix-B-字节码指令
2.4.15 - Endix-C-虚拟机参数
使用 -XX:+PrintFlagsFinal
可以输出所有参数的名称和默认值。应用参数的方式有以下 3 种:
-XX:+<option>开启option参数
-XX:-<option>关闭option参数
-XX:<option>=<value>将option参数的值设置为value
以下是 JDK6 中常用的参数。
内存管理参数
参数 | 默认值 | 使用介绍 |
---|---|---|
DisableExplicitGC | 默认关闭 | 忽略来自 System.gc() 方法触发的垃圾回收 |
ExplicitGCInvokesConcurrent | 默认关闭 | 当收到 System.gc() 方法提交的垃圾回收申请时,使用 CMS 收集器进行收集 |
UseSerialGC | Client 模式 的虚拟机默认开启,其他模式默认关闭 | 虚拟机运行在 Client 模式下的默认值,打开此开关后,使用 Serial+Serial Old 的收集器组合进行内存回收 |
UseParNewGC | 默认关闭 | 打开此开关后,使用 ParNew+Serial Old 的收集器组合进行内存回收 |
UserConcMarkSweepGC | 默认关闭 | 打开此开关后,使用 ParNew+CMS+Serial Old 的收集器组合进行内存回收。如果 CMS 收集器出现 Concurrent Mode Failure,则 Serial Old 收集器将作为后备收集器 |
UserParallelGC | Server 模式的虚拟机默认开启,其他模式默认关闭 | 虚拟机运行在 Server 模式下的默认值,打开此开关后,使用 Parallel Scavenge+Serial Old 的收集器组合进行内存回收 |
UseParallelOldGC | 默认关闭 | 打开此开关后,使用 Parallel Scavenge+Parallel Old 的收集器组合进行内存回收 |
ServivorRatio | 默认为 8 | 新生代中 Eden 区域与 Survivor 区域的容量比值 |
PretenureSizeThreshold | 无默认值 | 直接晋升到老年代的对象大小,设置该参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 默认为 15 | 晋升到老年代的对象年龄。每个对象在坚持过一次 Mirror GC 之后,年龄就加 1,当超过该参数值时进入老年代 |
UseAdaptiveSizePolicy | 默认开启 | 动态调整 Java 堆中各个区域的大小及进入老年代的年龄 |
HandlePromotionFailure | JDK 1.5 及之前的版本默认关闭,JDK 1.6 之后默认开启 | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况 |
ParallelGCThreads | 少于或等于 8 个 CPU 时默认为 CPU 的数量值,多余 8 个 CPU 时比 CPU 的数量之小 | 设置并行 GC 时进行内存回收的线程数 |
GCTimeRatio | 默认为 99 | GC 时间占总时间的比例,默认允许 1% 的 GC 时间。仅在使用 Parallel Scavenge 收集器时生效 |
MaxGCPauseMillis | 无默认值 | 设置 GC 的最大停顿时间。仅在使用 Parallel Scavenge 收集器时生效 |
CMSInitiatingOccupancyFraction | 默认 68 | 设置 CMS 收集器在老年代空间被使用多少后触发垃圾回收,仅在使用 CMS 收集器时生效 |
UseCMSCompactAtFullCollection | 默认开启 | 设置 CMS 收集器在完成垃圾收集后是否需要进行一次内存碎片整理。仅在使用 CMS 收集器时生效 |
CMSFullGCsBeforeCompaction | 无默认值 | 设置 CMS 收集器在进行若干次垃圾收集后在启动一次内存碎片整理。仅在使用 CMS 收集器时生效 |
ScavengeBeforeFullGC | 默认开启 | 在 Full GC 发生之前触发一次 Mirror GC |
UseGCOverheadLimit | 默认开启 | 禁止 GC 过程无限制的执行,如果过于频繁,就直接发生 OutOfMemory 异常 |
UseTLAB | Server 模式默认开启 | 优先在本地线程缓冲区中分配对象,避免分配内存时的锁定过程 |
MaxHeapFreeRatio | 默认为 70 | 当 Xmx 比 Xms 值大时,堆可以动态收缩和扩展,该参数控制当堆空闲大于指定比例时自动收缩 |
MinHeapFreeRatio | 默认为 70 | 当 Xmx 比 Xms 值小时,对可以动态收缩或扩展,该参数控制当对空闲小于指定比率时自动扩展 |
MaxPermSize | 大部分情况下默认为 64MB | 永久代的最大值 |
即时编译参数
参数 | 默认值 | 使用介绍 |
---|---|---|
CompileThreshold | Client 模式下默认为 1500,Server 模式下为 1000 | 触发方法即时编译的阈值 |
OnStackReplaceRercentage | Client 模式下为 933,Server 模式下为 140 | OSR 比率,它是 OSR 即时编译阈值计算公式的一个参数,用于代替 BckEdgeThreshold 参数控制回边计数器的实际溢出阈值 |
ReversedCodeCacheSize | 大部分情况下是 32MB | 即时编译器编译的代码缓存的最大值 |
类型加载参数
参数 | 默认值 | 使用介绍 |
---|---|---|
UseSplitVerifer | 默认开启 | 使用依赖 StackMapTable 信息的类型检查代替数据流分析,以加快字节码校验速度 |
FailOverToOldVerifier | 默认开启 | 当类型校验失败时,是否允许回到老的类型推导校验方式重新校验,如果开启则允许 |
RelaxAccessControlCheck | 默认关闭 | 在检验阶段放松对类型访问性的限制 |
多线程相关参数
参数 | 默认值 | 使用介绍 |
---|---|---|
UseSpinning | JDK 1.6 默认开启,JDK 1.5 默认关闭 | 开启自旋锁以避免线程频繁挂起和唤醒 |
PreBlockSpin | 默认为 10 | 使用自旋锁时默认的自旋次数 |
UseThreadPriorities | 默认开启 | 使用本地线程优先级 |
UseBiasedLocking | 默认开启 | 使用使用偏向锁 |
UseFastAccessorMethods | 默认开启 | 当频繁反射执行某个方法时,生成字节码来加快反射的执行速度 |
性能参数
参数 | 默认值 | 使用介绍 |
---|---|---|
AggressiveOpts | JDK 1.6 默认开启,JDK 1.5 默认关闭 | 使用激进的优化特性,这些特性一般是具备正面和负面双重影响的,需要根据具体应用特点来分析才能判定是否对性能有益 |
UseLargePages | 默认开启 | 如果可能,使用大内存分页,该特性需要操作系统的支持 |
LargePageSizeInBytes | 默认为 4MB | 使用指定大小的内存分页,该特性需要操作系统的支持 |
StringCache | 默认开启 | 使用使用字符串缓存 |
调试参数
参数 | 默认值 | 使用介绍 |
---|---|---|
HeapDumpOnOutOfMemoryError | 默认关闭 | 在发生内存溢出时是否生成堆转储快照 |
OnOutOfMemoryError | 无默认值 | 当虚拟机抛出内存溢出异常时,执行指定的命令 |
OnError | 无默认值 | 当虚拟机抛出 ERROR 异常时,执行指定的命令 |
PrintClassHistogram | 默认关闭 | 使用 [ctrl]-[break] 快捷键输出类统计状态,相当于 jmap-histo 的功能 |
PrintConcurrentLocks | 默认关闭 | 打印 JUC 中锁的状态 |
PrintCommandLineFlags | 默认关闭 | 打印启动虚拟机时输入的非稳定参数 |
PrintCompilation | 默认关闭 | 打印方法的即时编译信息 |
PrintGC | 默认关闭 | 打印 GC 信息 |
PrintGCDetails | 默认关闭 | 打印 GC 详细信息 |
PrintGCtimeStamps | 默认关闭 | 打印 GC 停顿耗时 |
PrintTenuringDistribution | 默认关闭 | 打印 GC 后新生代各个年龄对象的大小 |
TracClassLoading | 默认关闭 | 打印类加载信息 |
TraceClassUnloading | 默认关闭 | 打印类卸载信息 |
PringInlining | 默认关闭 | 打印方法的内联信息 |
PrintCFGToFile | 默认关闭 | 将 CFG 图信息输出到文件,只有 DEBUG 版虚拟机才支持该参数 |
PrintIdealGraphFile | 默认关闭 | 将 Ideal 图信息输出到文件,只有 DEBUG 版虚拟机才支持该参数 |
UnlockDiagnosticVM Options | 默认关闭 | 让虚拟机进入诊断模式,一些参数(如 PrintAssembly)需要在诊断模式下才能使用 |
PrintAssembly | 默认关闭 | 打印即时编译后的二进制信息 |
2.5 - JVM 深入理解 V3
Working In Progress
3 - JVM 并发
3.1 - Java 并发实战
3.1.1 - CH01-简介
线程是 Java 语言中不可或缺的重要功能,它们能使复杂的异步代码更加简单,从而极大简化复杂系统的开发。此外,要想充分发挥多处理器的强大计算能力,最简单的方式就是使用线程。
1.1 并发简史
操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:“操作系统为各个独立执行的进程分配各种资源,包括内存、文件句柄及安全证书等。”如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量及文件等。
在计算机中加入操作系统来实现多个程序的同时执行,主要是为了:
- 资源利用率。如果在等待某些外部输入的同时能够运行另一个程序,可以提高资源的利用率。
- 公平性。高效的运行方式是通过粗粒度的时间片使多个用户能够共享计算机资源,而不是由一个程序运行到尾、然后再启动另一个程序。
- 便利性。通常在计算多个任务时应该编写多个程序,每个程序执行一个任务并在必要时进行互相通信,这比仅编写一个程序来计算所有任务来说要更容易实现。
在早期的“分时系统”中,每个进程相当于一台“虚拟的”冯诺依曼计算机,它拥有存储指令和数据的内存空间,根据机器语言的语义以串行方式执行指令,并通过一组 IO 指令与外部设备通信。对于每条被执行的指令,都有相应的“下一条指令”,程序中的“控制流”是按照指令集的规则来确定的。当前,几乎所有的主流编程语言都遵循这种“串行编程模型”,并且在这些语言的规范中也都清晰定义了在某个动作之后需要执行的“下一个动作”。
“串行编程模型”的优势在于其直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事情,做完之后再做下一件。在编程语言中,这些现实世界的动作可以被进一步抽象为一组更细粒度的动作。例如,喝早茶的动作可以被进一步细化:打开橱柜,挑选喜欢的茶叶,将一些茶叶倒入杯中,看看茶壶中是否有足够的水,如果没有的话则添加足够的水,将茶壶放到火炉上,点燃火炉,然后等水烧开等等。在最后一步等水烧开的过程中包含了一定程度的“异步性”。当正在烧水时,你可以干等着,也可以做些其他事情,例如开始烤面包(这是一个异步任务)或者看报纸,同时留意茶壶中的水是否已经烧开。茶壶和面包机的生产商都很清楚:用户通常会采用异步方式来使用他们的产品,因此当这些机器完成任务时都会发出声音提示。“但凡做事高效的人,总能在串行性和异步性之间找到合理的平衡,对于程序来说同样如此。”
“这些促使进程出现的因素(资源利用率、公平性、便利性)同样也促使着线程的出现”。“线程允许在同一个进程内同时存在多个程序控制流”。线程会共享进程范围内的资源,但每个线程都有各自的程序计数器、栈、局部变量。线程还提供了一种直观的“分解模式”来充分利用多处理器系统中的“硬件并行性”,而在同一个程序中的多个线程也可以同时被“调度”到多个 CPU 上运行。
“线程被称为轻量级进程”。在大多数现代操作系统中,都是以线程为基本的“调度单位”,而不是进程。如果没有明确的“(线程间的)协同机制”,那么线程将彼此独立的运行。由于同一个进程中的所有线程都将共享进程的内存地址,因此这些线程都能访问相同的变量,并在同一个堆上分配对象,这就需要一种比在进程间共享数据粒度更细的“数据共享机制”。如果没有明确的“同步机制”来协同对共享数据的访问,那么当一个线程正在使用变量时,另一个线程可能同时访问这个变量,这将造成不可预测的结果。
1.2 线程的优势
如果使用得当,线程可以有效降低程序的开发和维护成本,同时提升应用程序的性能。“线程能够将大部分的异步工作流转换为串行工作流”,因此能够更好的模拟人类的工作和交互方式。此外,线程还可以降低代码的复杂度,使代码更容易编写、阅读、维护。
在 GUI 程序中,线程可以提供用户界面的响应灵敏度。而在服务器应用程序中,可以提升资源利用率及系统吞吐量。线程还可以简化 JVM 的实现,垃圾收集器通常在一个或多个专门的线程中运行。在许多重要的 Java 程序中,都在一定程度上使用了线程。
1.2.1 发挥多处理器的强大能力
过去,多处理器系统是非常昂贵和稀少的,通常只有在大型数据中心和科学计算设备中才会使用多处理器系统。但现在,多处理器系统整日益普及,并且价格也在不断的降低,即使在底端服务器和中端桌面系统中,通常也会采用多个处理器。这种趋势还将进一步加快,因为通过提高时钟频率来提升性能已变得越来越困难,处理器生产厂商都开始转而在单个芯片上放置多个处理器核心。所有的主流芯片厂商都开始了这种转变,而我们已经看到了在一些机器上出现了更多的处理器。
由于基本的调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行。在双处理器系统上,单线程的程序只能使用一半的 CPU 资源,而在拥有 100 个处理器的系统上,将有 99% 的资源无法使用。另一方面,多线程程序可以同时在多个处理器上执行。如果设计正确,多线程程序可以通过提高处理器资源利用率来提升系统吞吐率。
使用多个线程还有助于在单处理器系统上获得更高的吞吐率。如果程序是单线程的,那么当程序等待某个同步 IO 操作完成时,处理器将处于空闲状态。而在多线程程序中,如果一个线程在等待 IO 操作完成,另一个线程可以继续运行,使得程序能够在 IO 阻塞期间继续运行。(这就好比在等待烧水的同时看报纸,而不是等到水烧开之后再开始看报纸)
1.2.2 建模的简单性
通常,当只需要执行一种类型的任务时,在时间管理方面比执行多种类型的任务要简单。当只有一种类型的任务需要完成时,只需要埋头工作,直到完成所有的任务,你不需要花任何精力来琢磨下一步该干什么。而另一方面,如果需要完成多种类型的任务,那么需要管理不同任务之间的优先级和执行时间,并在任务之间进行切换,这将带来额外的开销。
对于软件来说同样如此:如果在程序中仅包含一种类型的任务,那么比包含多种不同类型的任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度机制的细节、交替执行的操作、异步 IO、资源等待等问题分类出来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
我们可以通过一些现有的框架来实现上述目标,例如 Servlet 和 RMI。框架负责解决一些细节问题,例如请求管理、线程创建、负载均衡,并且在正确的时刻将请求分发给正确的应用线程组件。编写 Servlet 的开发任务不需要了解有多少请求在同一时刻要被处理,也不需要了解套接字的输入输出流是否被阻塞。当调用 Servlet 的 service 方法来响应 Web 请求时,可以以同步的方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,并缩短掌握这种框架的学习时间。
1.2.3 异步事件的简化处理
服务器应用程序在接收来自多个远程客户端的套接字连接请求时,如果为每个连接都分配其各自的线程且使用同步 IO,那么会降低这类程序的开发难度。
如果某个应用程序对套接字执行读操作,而此时还没有数据到来,那么这个读操作将一直阻塞到有数据抵达。在单线程程序中,这不仅意味着在处理请求的过程中停顿,而且还意味着在该线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞 IO,这种 IO 的复杂性要远远高于同步 IO,并且很容易出错。然而,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
早期的操作系统通常会将进程中可创建的线程数量限制在一个较低的阈值内,大约在数百个甚至更少。因此,操作系统提供了一些高效的方法来实现多路 IO,例如 Unix 的的 select 和 poll 等系统调用,要调用这些方法,Java 类库需要获得一组实现非阻塞 IO 的包(java.nio)。然而,在现代操作系统中,线程数量已经得到极大的提升,这使得在某些平台上,即使有更多的客户端,为每个客户分配一个线程也是可行的。
1.2.4 响应灵敏的用户界面
传统的 GUI 程序都是单线程的,从而在代码的各个位置都需要调用 poll 方法来获得输入事件(这种方式将给代码带来极大的混乱),或者通过一个“主事件循环”来间接的执行应用程序的所有代码。如果在主事件循环中调用的代码需要很长时间才能执行完成,那么用户界面就会“冻结”,直到代码执行完成。这是因为只有当执行控制权返回到主事件循环后,才能处理后续的用户界面事件。
在现代的 GUI 框架中,例如 AWT 和 Swing 等工具,都采用一个事件分发线程来替代主事件循环。当某个用户界面事件发生时(如按下一个按钮),在事件线程中将调用应用程序的事件处理器。由于大多数 GUI 框架都单线程子系统,因此到目前为止仍然存在主事件循环,但其它线程处于 GUI 工具的控制下并在其自己的线程中运行,而不是在应用程序的控制下。
如果在事件线程中执行的任务都是短暂的,那么界面的响应灵敏度就较高,因为事件线程很够很快的处理用户的动作。然而,如果事件线程中的任务需要很长的执行时间,例如对一个大型文档进行拼写检查,或者从网络上获得一个资源,那么界面的响应灵敏度就会降低。如果用户在执行这类任务时触发了某个动作,那么必须等待很长时间才能获得响应,因为事件线程要先执行完该任务。更糟糕的是,不仅界面失去响应,而且即使在界面上包含了“取消”按钮,也无法取消这个长事件执行的任务,因为事件线程只有在执行完该任务后才能响应“取消”按钮的点击事件。然而,如果将这个长时间运行的任务放在一个单独的线程中运行,那么事件线程就能及时的处理其他界面事件,从而使得用户界面具有更高的灵敏度。
1.3 线程带来的风险
Java 对线程的支持其实是一把双刃剑。虽然 Java 提供了相应的语言和库,以及一种明确的“跨平台内存模型”,这些工具简化了并发应用程序的开发,但同时也提高了对开发人员的技术要求,因为在更多的程序中会使用线程。当线程还是一项鲜为人知的技术时,并发性是一个“高深的”话题,但现在,主流开发任务都必须了解线程方面的内容。
1.3.1 安全性问题
线程安全性是非常复杂的,在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的执行结果。如程序清单 1-1 的 UnsafeSequence 类中将产生一个整数值序列,该序列中的每个值都是唯一的。在这个类中简要说明了多个线程间的交替操作将如何导致出乎预料的结果。在单线程环境中,这个类能够正常工作,但在多线程环境中则不行。
@NotThreadSafe
public class UnsafeSequnce {
private int value;
/* 返回一个唯一的数值 */
public int getNext() {
return value++;
}
}
UnsafeSequnce 的问题在于,如果执行时机不对,那么两个线程在调用 getNext 时会得到相同的值。在图 1-1 中给出了这种错误情况。虽然递增运算 someVariable++
看上去是单个操作,但事实上包含 3 个独立的操作:读取 value、将 value 加一、将结果写入 value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而使他们得到相同的值,并都将该值加 1。结果就是,在不同线程中的调用返回了相同的数值。
在 UnsafeSequnce 中使用了一个非标准的注解:@NotThreadSafe
。这是在本书中使用的几个自定义注解之一,用于说明类和成员的并发属性。线程安全性注解在许多方面都是有用的。如果使用 @ThreadSafe 类标注某个类,那么开发人员可以放心的在多线程环境中使用该类,维护人员也会发现它能保证线程安全性,而软件分析工具还可以识别出潜在的编码错误。
在 UnsafeSequnce 类中说明的是一种常见的并发安全问题,称为竟态条件(Race Condition)。在多线程环境下,getValue 是否返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这并不是我们希望看到的情况。
由于多线程有共享相同的内存地址空间,并且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但它同样也带来的巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行模型编程中引入非串行因素,而这种非串行性是很难分析的。要使多线程程序的行为可以被预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。幸运的是,Java 提供了各种同步机制来协同这种访问。
通过将 getNext 修改为一个同步方法,可以修复 UnsafeSequnce 中的错误,如程序清单 1-2 中的 Sequnce,该类可以防止图 1-1 中错误的交替执行情况。
@ThreadSafe
public class Sequence {
@GuardedBy("this") private int value;
public synchronized int getNext() {
return value++;
}
}
如果没有同步,那么无论是“编译器、硬件、运行时”,都可以随意安排操作的执行时间和顺序,例如对寄存器或处理器中的变量进行缓存,而这些被缓存的变量对于其他线程来说是暂时的(甚至永久)不可见的。虽然这些技术有助于实现更优的性能,并且通常也是值得采用的方法,但这也给开发人员带来了负担,因为开发人员必须找出这些数据在那些位置被多个线程共享,只有这样才能使这些优化措施不破坏线程安全性。
1.3.2 活跃性问题
在编写并发代码时,一定要注意线程安全性是不可破坏的。安全性不仅对于多线程程序很重要,对于单线程程序也是如此。此外,线程还会导致一些在单线程程序中不会出现的问题,比如活跃性问题。
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将到来其他一些活跃性问题。如,如果线程 A 在等待线程 B 释放其持有的资源,而线程 B 永远都不释放该资源,那么 A 就会永久的等待下去。与大多数并发性错误一样,导致活跃性问题的错误同样是难以分析的,因为它们依赖于不同线程的事件发生时序,因此在开发或测试中并不总是能够重现。
1.3.3 性能问题
与活跃性密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生。但这还不够好,我们通常希望正确的事情尽快发生。性能问题包括多个方面,如服务时间过长、响应不够灵敏、吞吐率过低、资源消耗过高、可伸缩性较低等。与安全性和活跃性一样,在多线程程序中不仅存在与单线程程序相同的性能问题,而且还存在由于使用线程而引入的其他性能问题。
在设计良好的并发程序中,线程能够提升程序的性能,但无论如何,线程总会带来某种程度的运行时开销。在多线程程序中,当线程调度器临时挂起活跃线程转而运行另一个线程时,就会频繁的出现上下文切换,这种操作将带来极大的开销:保存和恢复执行上下文、丢失现场,并且 CPU 时间将更多的花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都将带来额外的性能开销。
1.4 线程无处不在
即使在程序中没有显式的创建线程,但在框架中人可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。这将给开发人员在设计和实现上带来沉重负担,因为开发线程安全的类比开发非线程安全的类需要更加谨慎和细致。
每个 Java 程序都会使用线程。当 JVM 启动时,它将为 JVM 的内部任务(如垃圾回收、终结操作)创建后台线程,并创建一个主线程来运行 main 方法。AWT 和 Swing 的用户界面框架将创建线程来管理用户界面事件。Timer 将创建线程来执行延时任务。一些组件框架,如 Servlet 和 RMI,都会创建线程池并调用这些线程中的方法。
如果要使用这行功能,那么就必须熟悉并发性和线程安全性,因为这些框架将创建线程并且在这些线程中调用程序中的代码。虽然将并发性认为是一种“可选的”或“高级的”语言功能固然很理想,但现实情况是,几乎所有的 Java 程序都是多线程的,因此在使用这些框架时仍然需要对应用程序状态的访问进行协同。
当某个框架在应用程序中引入并发性时,通常不可能将并发性仅仅局限于框架代码,因为框架本身会回调应用程序代码,而这些代码将访问应用程序的状态。同样,对线程安全性的需求也不能局限于被调用的代码,而是要延伸到需要访问这些代码所访问的程序状态的所有代码路径。因此,对线程安全性的需求将在程序中蔓延开来。
下面给出的模块都将在应用程序之外的线程中调用应用程序的代码。尽管线程安全性需求可能源自这些模块,但却不会止步于它们,而是会延伸到整个应用程序。
Timer。Timer 类的作用是使任务在稍后的时刻运行,运行一次或周期性的运行。引入 Timer 可能会使串行程序变得复杂,因为 TimerTask 访问了应用程序中其他线程访问的数据,那么不仅 TimerTask 需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问该数据。通常,要实现这个目标,最简单的方式是确保 TimerTask 访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部。
Servlet 和 JSP。Servlet 框架用于部署网页应用程序以及分来来自 HTTP 客户端的请求。到达服务器的请求可能会通过一个过滤器链被分发到正确的 Servlet 或 JSP。每个 Servlet 都表示一个程序逻辑组件,在高吞吐率的网站中,多个客户端可能同时请求一个 Servlet 的服务。在 Servlet 规范中,Servlet 同样需要满足被多个线程同时调用,换句话说,Servlet 需要是线程安全的。
即使你可以确保每次只有一个线程调用某个 Servlet,但在构建网页应用程序时仍然必须注意线程安全性。Servlet 通常会访问与其他 Servlet 共享的信息,例如应用程序中的对象或者会话中的对象。当一个 Servlet 访问在多个 Servlet 或者请求中共享的对象时,必须正确的协同对这些对象的访问,因为多个请求可能在不同的线程中同时访问这些对象。Servlet 和 JSP,以及在 ServletContext 和 HttpSession 等容器中保存的 Servlet 过滤器和对象等,都必须是线程安全的。
远程方法调用,RMI。RMI 使代码能够调用位于其他 JVM 中运行的对象。当通过 RMI 调用某个远程方法时,传递给方法的参数必须被打包到一个字节流中,通过网络传输给远程 JVM,然后由远程 JVM 拆包并传递给远程方法。
当 RMI 代码调用远程对象时,这个调用将在哪个线程中执行?你并不知道,但肯定不会在你创建的线程中,而是将在一个由 RMI 管理的线程中调用对象。RMI 会创建多给少个线程?同一个对象上的同一个远程方法会不会在多个 RMI 线程中被同时调用?
远程对象必须注意两个线程安全性问题:正确的协同在多个对象中共享的状态,以及对远程对象本身状态的访问。与 Servlet 相同,RMI 对象应用做好被多个线程同时调用的准备,并且必须确保它们自身的线程安全性。
Swing 和 AWT。GUI 程序的一个固有属性就是异步性。用户可以在任何时刻选择执行一个菜单项或按下一个按钮,应用程序会及时响应,即使应用程序当时正在执行其他的任务。Swing 和 AWT 很好的解决了这个问题,他们创建了一个单独的线程来处理用户触发的事件,并对呈现给用户的图形界面进行了更新。
Swing 的一些组件并不是线程安全的,例如 JTable。相反,Swing 程序通过将所有对 GUI 组件的访问局限在事件线程以实现线程安全性。如果某个程序希望在事件线程之外控制 GUI,那么必须将控制 GUI 的代码放在事件线程中运行。
当用户触发某个 UI 动作时,在事件线程中就会有一个事件处理器被调用以执行用户请求的操作。如果事件处理器需要访问由其他线程同时访问的应用程序状态,那么这个事件处理器,以及访问这个状态的所有其他代码,都必须采用一种线程安全的方式来访问该状态。
3.1.2 - CH02-线程安全性
你或许会感到奇怪,线程或锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。要建造一座坚固的桥梁,必须正确的使用大量的铆钉和工字梁。同理,在构件稳健的并发程序时,必须正确的使用线程和锁。但这些终归是一些机制。要编写线程安全的代码,“其核心在于对状态访问操作进行管理,特别是对共享的可变状态的访问”。
从非正式的意义上来说,对象的状态是存储在状态变量中的数据。对象的状态可能包括其他依赖对象的域。例如,HashMap 的状态不仅存储在 HashMap 对象本身,还存储在 Map.Entry 对象中。对象的状态中包含了任何可能影响其外部可见行为的数据。
“共享”意味着变量可以被多个线程同时访问,而“可变”则意味着变量的值在其声明周期内可以发生变化。我们将像讨论代码一样来讨论线程安全性,但更侧重于如何防止在数据上发生不受控的并发访问。
一个对象是否需求提供线程安全性,取决于它是否会被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。
当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中的主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量、显式锁、原子变量。
在上述规则中并不存在一些“想象中的例外情况”。即使在某个程序中省略了必要的同步机制并且看上去似乎能正确执行,而且通过了测试并在随后几年时间里都能正确运行,但程序仍可能在某个时刻发生错误。
如果当多个线程访问同一个可变状态变量时没有使用合适的同步,那么程序会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量。
- 将状态变量修改为不可变变量。
- 在访问状态变量时使用同步。
如果在设计类的时候没有考虑并发访问的情况,那么在采用上述方法时可能需要对设计进行重大修改,因此要修复这个问题可谓是知易行难。如果从一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易的多。
在一些大型程序中,要找出多个线程在哪些位置上将访问同一个变量是非常复杂的。幸运的是,面向对象这种技术不仅有助于编写出结构优雅、可维护性高的类,还有助于编写出线程安全的类。访问某个变量的代码越少,就越容易确保对变量的所有访问都正确的实现了同步,同时也更容易找出变量在哪些条件下被访问。Java 语言并没有强制要求将状态都封装在类中,开发人员完全可以将状态保存在某个公开的域(甚至是公开的静态域)中,或者提供一个对内部对象的公开引用。然而,程序状态的封装性越好,就越容易实现程序的线程安全性,并且代码的维护人员也越容易保持这种方式。
当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。
在某些情况下,良好的面向对象设计技术与实际的需求并不一致。在某些情况下,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容性。有时候,面向对象中的抽象和封装会降低程序的性能,但是在编写并发应用程序时,一种正确的编程方法就是:“首先使代码正确运行,然后再提高代码的运行速度”。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能够带来提升时,才进行优化。
如果你必须打破封装,那么也并非不可,你仍然可以实现程序的线程安全性,只是更困难。而且,程序的线程安全性将更加脆弱,不仅增加了成本和风险,而且也增加了维护的成本和风险。
到目前为止,我们使用了“线程安全类”和“线程安全程序”两个术语,二者的含义基本相同。线程安全的程序是否完全由线程安全的类构成?答案是否定的,完全由线程安全的类构成的程序并不一定是一个线程安全的程序,而在线程安全的程序中也可以包含非线程安全的类。在任何情况下,“只有当类中仅包含自己的状态时,线程安全类才是有意义的”。线程安全性是一个在代码上使用的术语,但它只是与状态相关的,因此只能应用于封装其状态的整个代码,这可能是一个对象,也可能是整个程序。
2.1 什么是线程安全性
要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供有实际意义的指导建议,而且也很难从直观上去理解。因此,下面给出了一些非正式的描述,看上去令人困惑。比如:
- ……可以在多个线程中调用,并且在线程之间不会出现错误的交互。
- ……可以同时被多个线程调用,而调用者无需执行额外的同步动作。
看看这些定义,难怪我们会对线程安全性感到困惑。他们听起来非常像“如果这个类可以在线程中安全的使用,那么他就是一个线程安全的类”。对于这种说法,虽然没有太多的争议,但同样也不会带来太多的帮助。我们如何区分线程安全的类以及非线程安全的类?进一步说,“安全”的含义是什么?
在线程安全性的定义中,最核心的概念是正确性。如果对线程安全的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。
“正确性的定义是,某个类的行为与其规范完全一致”。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会对类编写详细的规范,那么如何知道这些类是正确的呢?我们无法知道,但这并不妨碍我们在确信“类的代码能够工作”后使用它们。这种“代码可信性”非常接近于我们对正确性的理解,因此我们可以将单线程的正确性近似定义为“所见即所得”。在对“正确性”给出一个较为清晰的定义后,就可以定义线程安全性:“当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。”
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
由于单线程程序也可以被看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定也不是线程安全的。如果正确的实现了某个对象,那么在任何操作中(包括调用对象的公有方法或者对其公有域进行读写操作)都不会违背不变性条件或后验条件。在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。
在线程安全类中封装了必要的同步机制,因此客户端无需进一步采取同步措施。
示例:一个线程安全的 Servlet
我们在第一章中列出了一组框架,其中每个框架都能创建多个线程并在这些线程中调用你编写的代码,因此你需要保证编写的代码是线程安全的。通常,线程安全性的需求并非来源于线程的直接调用,而是使用像 Servlet 这样的框架。我们来看一个简单的示例——一个基于 Servlet 的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。
程序清单 2-1 给出了一个简单的因素分解 Servlet。这个 Servlet 从请求中提取出数值,执行因数分解,然后将结果封装到该 Servlet 的响应中。
@ThreadSafe
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(resp, factors);
}
}
与大多数 Servlet 相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类的域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问 StatelessFactorizer 的线程不会影响另一个访问同一个 StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。由于线程访问无状态对象的行为不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
无状态对象一定是线程安全的。
大多数 Servlet 都是无状态的,从而极大降低了在实现 Servlet 线程安全性时的复杂性。只有当 Servlet 在处理请求时需要保存一些信息,线程安全性才会成为一个问题。
2.2 原子性
当我们在无状态对象中增加一个状态时,会出现什么状况?假设我们希望增加一个“命中计数器”来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将该值加 1,如程序清单 2-2 所示:
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
public long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(factors);
}
}
不幸的是,UnsafeCountingFactorizer 并非是线程安全的,尽管它在单线程环境中能正确运行。与前面的 UnsafeSequence 一样,这个类很可能会丢失一些更新操作。虽然递增操作 ++count
是一种紧凑的语法,使其看上去只是一个操作,但是该操作并非是原子的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取 count 值、将值加 1、然后将计算结果写入 count。这是一个“读取——修改——写入”的操作序列,并且其结果状态依赖于之前的状态。
图 1-1 给出了两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为 9,那么在某些情况下,每个线程读到的值都为 9,接着执行递增操作,并且都将计数器的值设为 10。显然,这并不是我们希望看到的结果,如果有一次递增操作丢失了,命中计数器的值就将偏差 1。
你可能会认为,在基于 Web 的服务中,命中计数器的少量偏差或许是可以接受的,在某些情况下也确实如此。但如果计数器被用来生成数值序列或唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而出现不正确结果是一种非常重要的情况,他有一个正式的名字:竟态条件。
2.2.1 竟态条件
在 UnsafeCountingFactorizer 中存在多个竟态条件,从而使结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会出现竟态条件。换句话说,就是正确的结果要取决于运气。最常见的竟态条件类型是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。
在实际情况中经常会遇到竟态条件。例如,假定你计划中午在 University Avenue 的星巴克与一位朋友烩面。但当你达到那里时,发现在 University Avenue 上有两家星巴克,并且你不知道约定的是哪一家。在 12:10 时,你没有在星巴克 A 看到朋友,那么就会去星巴克 B 看看他是否在那里,但他也不在那里。这有几种可能:你的朋友迟到了,还没有抵达任何一家星巴克;你的朋友在你离开后到了星巴克 A;你的朋友在星巴克 B,但是当它去星巴克 A 找你时,你此时正在赶往星巴克 B 的途中。我们假设的最后一种情况最为糟糕。现在是 12:15,你们两个都去过了两家星巴克,并且都开始怀疑对方失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们整天都在 University Avenue 上走来走去,倍感沮丧。
在“我去看看他是否在另一家星巴克”这种方法中,问题在于:当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看了看星巴克 A,发现“他不在”,并且开始去找他。你可以在星巴克 B 中做同样的选择,但不是同时发生。两家星巴克之间有几分钟的路程,而就在这几分钟时间里,系统的状态可能会发生变化。
在星巴克这个示例中说明了一种竟态条件,因为要获得正确的结果(与朋友会面),必须取决于事件的发生时序(当你们到达星巴克时,在离开去另一家星巴克之前会等待剁成事件……)。当你迈出前门时,你在星巴克 A 的观察结果将变得无效,你的朋友可能从后门进来了,而你却不知道。这种观察结果的失效就是大多数竟态条件的本质——“基于一种可能失效的观察结果来做出判断或者执行某个计算”。这种类型的竟态条件被称为“先检查后执行”:首先观察到某个条件为真,然后根据观察结果采用相应的动作,但事实上,在你观察到这个结果以后以及开始执行动作之前,观察结构可能变得无效,从而导致各种问题。
竟态条件这个术语经常与另一个相关术语“数据竞争(Data Race)”相混淆。数据竞争是指,如果在访问共享的非 final 类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程之间没有使用同步,那么就可能出现数据竞争。在 Java 内存模型中,如果代码中存在数据竞争,那么这段代码就没有确定的语义。并非所有竟态条件都是数据竞争,同样并非所有的数据竞争都是竟态条件,但二者都可能使并发程序失败。
2.2.2 示例:延迟初始化中的竟态条件
使用“先检查后执行”的一种常见情况就是“延迟初始化”。延迟初始化的目的是将对象的初始化操作推迟到第一次实际被使用时才进行,同时要确保只被初始化一次。在程序清单 2-3 中的 LazyInitRace 说明了这种延迟初始化情况。getInstance 方法首先判断 ExpensiveObject 是否已经被初始化,如果已经初始化则返回现有的实例,否则他将创建一个新的实例并返回一个引用,从而在后来的调用中值无需执行这段高开销的代码路径。
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if(instance == null)
intstance = new ExpensiveObject();
return instance;
}
}
LazyInitRace 中包含一个竟态条件,他可能会打破这个类的正确性。假定线程 A 和 B 同时执行 getInstance。A 看到 instance 为空,因而创建一个新的 ExpensiveObject 实例。B 同样需要判断 instance 是否为空。此时的 instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时 instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。
在 UnsafeCountingFactorizer 的统计命中计数操作中存在另一种竟态条件。在“读取——修改——写入”这种操作中,基于对象之前的状态来定义对象状态的转换。要递增一个计数器,你必须知道它之前的值,并确保在执行更新过程中没有其他线程会修改或使用这个值。
与大多数并发错误一样,竟态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竟态条件也可能导致严重的问题。假定 LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个执行行为对同一个组注册对象表现出不一致的视图。如果将 UnsafeSequence 用于在某个持久化框架中生成对象的标识,那么两个不同的对象最终将获得相同的标识,这就违反了标识的完整性约束条件。
2.2.3 复合操作
LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子方式执行(不可分割)的操作。要避免竟态条件问题,就必须在某个线程修改变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,从而避免在修改状态的过程中观察到失效状态。
假定两个操作 A 和 B,如果从执行 A 的线程来看,当另一个线程执行 B 时,要么将 B 全部执行完,要么完全不执行 B,那么 A 和 B 对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
如果 UnsafeSequence 中的递增操作是原子操作,那么图 1-1 中的竟态条件就不会发生,并且递增操作在每次执行时都会把计数器加 1。为了确保线程安全性,“先检查后执行”和“读取—修改-写入”等操作都必须是原子的。我们将“先检查后执行”以及“读取—修改-写入”等操作统称为“复合操作”:“包含了一组必须以原子方式执行的操作以确保线程安全性”。在 2.3 节中,我们将介绍锁机制,这是 Java 中用于确保原子性的内置机制。就目前而言,我们先采用另一种凡事来修复这个问题,即使用一个现有的线程安全类,如程序清单 2-4 中的 CountingFactorizer 所示:
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() { return count.get(); }
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(factors);
}
}
在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值的对象引用上的原子状态转换。通过用 AtomicLong 来代替 long 类型的计数器,能够确保所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的 Servlet 也是线程安全的。
我们在因数分解的 Servlet 中增加了一个计数器,并且通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。挡在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。然而,在 2.3 节你将看到,当状态变量的数量不再是一个时,并不会像仅有一个状态变量时那么简单。
在实际情况中,应尽可能的使用现有的线程安全对象来管理状态。与非线程安全对象相比,判断线程安全对象的状态空间及其状态迁移情况要更为容易,从而也更容易维护和验证线程安全性。
2.3 加锁机制
当在 Servlet 中添加一个状态变量时,可以通过线程安全的对象来管理 Servlet 的状态以维护 Servlet 的线程安全性。但如果想在 Servlet 中添加更多的状态,那么是否只需要添加更多的线程安全状态变量就足够了?
假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需重新计算。要实现该策略,需要保存两个状态:最近执行因数分解的数值,和分解结果。
我们曾通过 AtomicLong 以线程安全的方式来管理计数器的状态,那么,在这里是否可以使用类似 AtomicReference 来管理最近执行因数分解的数值及其分解结果吗?在程序清单 2-5 中的 UnsafeCachingFactorizer 实现了这种思想。
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private fianl AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber.get())) {
encodeIntoResponse(resp, lastFactors.get());
}
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(factors);
}
}
}
然而,这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在 UnsafeCachingFactorizer 中存在着竟态条件,这可能产生错误的结果。
在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。只有确保了这个不变性条件不被破坏,上面的 Servlet 才是正确的。当在不变性条件中涉及到多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某个变量时,需要在同一个原子操作中对其他变量同时进行更新。
在某些执行时序中,UnsafeCachingFactorizer 可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对 set 方法的每次调用都是原子的,但仍然无法同时更新 lastNumber 和 lastFactors。如果只修改了其中一个变量,那么在两次修改操作之间,其他线程发现不变性条件被破坏了。同样,我们也不能保证会同时获取两个值:在线程 A 获取两个值的过程中,线程 B 可能修改了它们,这样线程 A 就发现不变性条件被破坏了。
要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。
2.3.1 内置锁
Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包含两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。
synchronized (lock) {
// 访问或修改由锁保护的共享状态
}
每个 Java 对象都可以用作一个实现同步的锁,这些所被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java 的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程 A 尝试获取一个右线程 B 持有的锁时,线程 A 必须等待或阻塞,直到线程 B 释放这个锁。如果 B 永远不释放锁,那么 A 也将永远的等下去。
由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会互相干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义——“一组语句作为一个不可分割的单元被执行”。任何一个执行同步代码块的线程,都不可能看到其他线程正在执行由同一个锁保护的同步代码块。
这种同步机制使得要确保因数分解 Servlet 的线程安全性变得更加简单。在程序清单 2-6 中使用了关键字 synchronized 来修饰 service 方法,因此在同一时刻只有一个线程可以执行 service 方法。现在的 SynchronizedFactorizer 是线程安全的。然而,这种方法却过于极端,因为多个客户端无法同时使用因素分解 Servlet,服务的响应性非常低,令人无法接受。这是一个性能问题,而非线程安全问题,我们将在 2.5 中解决该问题。
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardBy("this") private BigInteger lastNumber;
@GuardBy("this") private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extactFromRequest(req);
if(i.equals(lastNumber)) {
encodeIntoResponse(resp, lastFactors);
}
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(factors);
}
}
}
2.3.2 重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,“由于内置锁是可重入的”,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”(同一个线程可以发起多次调用)。重入的一种实现方式是,为每个锁关联一个获取计数值和一个拥有者线程。当计数器为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记录锁的持有者,并且将获取计数值设为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数值为 0 时,这个锁将被释放。
“重入”进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。在程序清单 2-7 的代码中,子类改写了父类的 synchronized 方法,然后调用父类中的方法,此时如果没有可重入锁,那么这段代码将产生死锁。由于 Widget 和 LoggingWidget 中 doSomething 方法都是 synchronized 方法,因此每个 doSomething 方法在执行前都会获取 Widget 上的锁。然而,如果内置锁不是可重入的,那么在调用 super.doSomething 时将无法获得 Widget 上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁的发生。
public class Widget {
public synchronized void doSomething() {...}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
2.4 用锁来保护状态
由于锁能使其保护的代码路径以“串行形式”被访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
访问共享状态的复合操作,例如命中计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竟态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步块。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。
一种常见的错误是认为,只有在写入共享变量时才需要同步,然而事实并非如此。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量由这个锁保护。
在程序清单 2-6 的 SynchronizedFactorizer 中,lastNumber 和 lastFactors 这两个变量都是由 Servlet 对象的内置锁来保护的,在注解 @GuardBy 中也已经说明了这一点。
对象的内置锁与其状态之间没有内置的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁类保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式的创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中至始至终的使用它们。
每个共享的可变的变量都应该由一个锁来保护,从而使维护人员知道是哪一个锁。
一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有的访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种(或其他)模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。
并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。第一章曾介绍过,当添加一个简单的异步事件时,例如 TimerTask,整个程序都需要满足线程安全性要求,尤其是当程序状态的封装性比较糟糕时。考虑一个处理大规模数据的单线程程序,由于任何数据都不会在多个线程之间共享,因此在单线程程序中不需要同步。现在,假设希望添加一个功能,即定期对数据处理进度生成快照,这样当程序崩溃或必须停止时无需再次从头开始。你可能会选择使用 TimerTask,每十分钟触发一次,并将程序状态保存到一个文件中。
由于 TimerTask 在另一个(由 Timer 管理的)线程中调用,因此现在就有两个线程同时访问快照中的数据:程序的主线程与 Timer 线程。这意味着,当访问程序的状态时,不仅 TimerTask 代码必须使用同步,而且程序中所有访问相同数据的代码路径也必须使用同步。原本在程序中不需要使用同步,现在变成了在程序的各个位置都需要使用同步。
当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另一个需求:在不变性条件中的每个变量都需要由同一把锁来保护。因此可以在单个原子操作中访问或更新所有这些变量,从而确保不变性条件不被破坏。在 SynchronizedFactorizer 类中说明了这条规则:缓存的数值和因数分解结果都由 Servlet 对象的内置锁来保护。
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
如果同步可以避免竟态条件问题,那么为什么不在每个方法声明时都使用关键字 synchronized ?事实上,如果不加区别的滥用 synchronized,可能导致程序中出现过多的同步。此外,如果只是将每个方法都作为同步方法,例如 Vector,那么并不足以确保 Vector 上复合操作都是原子的:
if(!vector.contains(element))
vector.add(element);
虽然 contains 和 add 等方法都是原子的,但在上面这个“如果不存在则添加(put-if-absent)”的操作中仍然存在竟态条件。虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并成一个复合操作,还是需要额外的加锁机制。此外,将每个方法都作为同步方法还可能导致活跃性问题或性能问题,我们在 SynchronizedFactorizer 中已经看到了这些问题。
活跃性与性能
在 UnsafeCachingFactorizer 中,我们通过在因数分解 Servlet 中引入了缓存机制来提升性能。在缓存中需要使用共享状态,因此需要通过同步来维护状态的完整性。然而,如果使用 SynchronizedFactorizer 中的同步方式,那么代码的执行性能将非常糟糕。SynchronizedFactorizer 中采用的同步策略是,通过 Servlet 对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个 service 方法进行同步。虽然这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。
由于 service 是一个 synchronized 方法,因此每次只有一个线程可以执行。这就背离了 Servlet 的初衷,即 Servlet 需要能够同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验。如果 Servlet 在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一致等待,知道 Servlet 处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个 CPU,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长的时间,因为这些请求都必须等待前一个请求执行完成。
图 2-1 给出了当多个请求同时到达因数分解 Servlet 时发生的情况:这些请求将排队等待处理。我们将这种 Web 应用程序称之为不良并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身架构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保 Servlet 的并发性,同时又维护线程安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。
程序清单 2-8 中的 CachedFactorizer 将 Servlet 的代码修改为使用两个独立的同步代码块,每个同步代码块都只包含一小段代码。其中一个同步代码块负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列,另一个同步代码块则负责确保对缓存的数值和因数分解结果进行同步更新。此外,我们还重新引入了“命中计数器”,添加了一个“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。由于这两个计数器也是共享可变状态的一部分,因此必须在所有访问它们的位置上使用同步。位于同步代码块之外的代码将以独占的方式来访问局部(位于栈上)的变量,这些变量不会在多个线程间共享,因此不需要同步。
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber;
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long hits;
@GuardedBy("this") private long cacheHits;
public synchronized long getHits() { return hits; }
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronzied (this) {
++hits;
if(i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if(factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
在 CachedFactorizer 中不再使用 AtomicLong 类型的命中计数器,而是使用了一个 long 类型的变量。当然也可以使用 AtomicLong 类型,但使用 CountingFactorizer 带来的好处更多。对于在单个变量上实现原子操作来说,原子变量是很有用的,但由于我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,而不在性能和安全性上带来任何好处,因此这里不再使用原子变量。
重新构造后的 CachedFactorizer 实现了在简单性(对整个方法进行同步)与并发性(对仅可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上需要一定的开销,因此如果将同步代码块分解的过细(例如将 ++hits
分解到它自己的同步代码块中),那么通常并不好,尽管这样做不会破坏原子性。当访问状态变量或者在复合操作的执行期间,CachedFactorizer 需要持有锁,但在执行时间较长的因素分解运算之前要释放锁。这样既能确保线程安全性,也不会过多的影响并发性,而且在每个同步代码块中的代码路径都“足够短”。
要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这是必要需求)、简单性、性能。有时候,在简单性与性能之间会发生冲突,但在 CachedFactorizer 中已经说明了,在二者之间通常能够找到某种合理的平衡。
通常,在简单性和性能之间存在相互制约因素。当实现某个同步策略时,一定不要盲目的为了性能而牺牲简单性(这可能会破坏安全性)。
当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。
当执行时间较长的计算或者可能无法快速完成的操作时,如 IO、控制台 IO,一定不要持有锁。
3.1.3 - CH03-对象共享
第二章的开头曾指出,要编写正确的并发程序,关键问题在于:“在访问共享的可变状态时需要正确的管理”。第二章介绍了如何通过同步来避免多个线程在同一时刻访问相同的数据,而本章将介绍“如何共享和发布对象”,从而使它们能够安全的同时被多个线程访问。这两章合在一起,就形成了构建线程安全类以及通过 JUC 类库来构建并发应用程序的重要基础。
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字 synchronized 只能用于实现原子性或者确定“临界区”。同步还有一个重要的方面:“内存可见性”。“我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改状态,而且希望确保当一个线程修改了对象状态之后,其他线程能够看到发生的状态变化”。如果没有同步,那么这种情况就无法实现。你可以通过显式的同步或者类库中内置的同步来保证对象被安全的发布。
3.1 可见性
可见性是一种复杂的属性,因为可见性中的错误总是违背我们的直觉。在单线程环境中,如果想某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这看起来很自然。然而,当读操作和写操作在不同的线程中执行时,情况却并非如此,这听起来或许有些难以接受。通常,我们无法确保执行读操作的线程能实时的看到其他线程写入的值,有时甚至是不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
在程序清单 3-1 中的 NoVisibility 说明了当多个线程在没有同步的情况下共享数据时出现的错误。在代码中,主线程和读线程都将访问变量 ready 和 number。主线程启动读线程,然后将 number 设为 42,并将 ready 设为 true。读线程一直循环直到发现 ready 的值变为 true,然后输出 number 的值。虽然 NoVisibility 看起来会输出 42,但事实上可能输出 0,或者根本无法终止。这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的 ready 值和 number 值对于读线程来说是可见的。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void mian(String...args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility 可能会一致循环下去,因为读线程可能永远也看不到 ready 的值。一种更奇怪的现象是,NoVisibility 可能会输出 0,因为读线程可能看到了写入 ready 的值,但却没有看到只有写入 number 的值,这种现象被称为“重排序”。只要在某个线程中无法检测到重排序的情况(即使在其他线程中可以很明显的看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行。当主线程首先写入 number,然后在没有同步的情况下写入 ready,那么读线程看到的顺序可能与写入的顺序完全相反。
在没有同步的情况下,编译器、处理器以及运行时都有可能对操作的顺序进行一些意想不到的调整。在缺乏同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得出正确的结论。
NoVisibility 是一个简单的并发程序,只包含两个线程和两个共享变量,但即便如此,在判断程序的执行结果以及是否会结束时仍然很容易得出错误结论。要对那些缺乏足够同步的并发程序的执行情况进行推断是十分困难的。
这听起来有点恐怖,但实际情况也确实如此。幸运的是,有一种简单的方法能够避免这些复杂的问题:“只要有数据在多个线程间共享,就使用正确的同步”。
3.1.1 失效数据
NoVisibility 展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据。当读线程查看 ready 变量时,可能会得到一个已经失效的值。除非在每次访问变量时都使用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:“一个线程可能获得某个变量的最新值,而获得另一个变量的失效值”。
通常,当食物过期(失效)时,还是可以食用的,只不过味道差了些。但失效的数据可能会导致更危险的情况。虽然在 Web 应用程序中失效的命中计数器可能不会导致太糟糕的情况,但在其他情况中,失效值可能会导致一些严重的安全问题或者活跃性问题。在 NoVisibility 中,失效数据可能会导致输出错误的值,或者使程序无法结束。如果对象的引用(如链表中的指针)失效,那么情况会更加复杂。失效数据还可能导致一些令人困惑的故障,如意料之外的异常、被破坏的数据结构、不精确的计算以及无线循环等。
程序清单 3-2 中的 MutableInteger 不是线程安全的,因为 get 和 set 都是在没有同步的情况下访问 value 的。与其他问题相比,失效值问题更容易出现:如果某个线程调用了 set,那么另一个正在调用 get 的线程可能会看到更新后的 value 的值,也可能看不到。
@NotThreadSafe
public class MutableInteger {
private int value;
public int get() { return value; }
public void set(int value) { this.value = value; }
}
在程序清单 3-3 的 SynchronizedInteger 中,通过对 get 和 set 等方法进行同步,可以使 MutableInteger 称为一个线程安全的类。“仅对 set 方法进行同步是不够的,调用 get 的线程仍然会看见失效的值”。
@ThreadSafe
public class SynchronizedInteger {
@GuardBy("this") private int value;
public synchronized int get() { return value; }
public synchronized void set(int value) {
this.value = value;
}
}
3.1.2 非原子的 64 位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为“最低安全性”。
最低安全性适用于绝大多数变量,但是存在一个例外:非 volatile 类型的 64 位数值变量(long/double)。Java 内存模型要求,变量的读取和写入操作都必须是原子的,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将 64 位的读写操作分解为两个对 32 位数值的操作。当读取一个非 volatile 类型的 long 变量时,如果对该变量的读写操作在不同的线程中执行,那么很可能会读取到某个值的高 32 位或另一个值的低 32 位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的 long 和 double 等类型的变量也是不安全的,除非用关键字 volatile 来声明它们,或者用锁保护起来。
3.1.3 加锁与可见性
“内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果”,如图 3-1 所示。当线程 A 执行某个同步代码块时,线程 B 随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A 看到的变量值在 B 获得锁后同样可以由 B 看到。换句话说,当线程 B 执行由锁保护的同步代码块时,可以看到线程 A 之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证。
现在,我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。否则,如果一个线程在未持有正确的锁的情况下,那么读到的可能是一个失效值。
加锁的含义不仅仅局限于互斥访问,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或写操作的线程都必须在同一个锁上同步。
3.1.4 Volatile 变量
Java 语言提供了一种稍弱的同步机制,即 volatile 变量,“用来确保将变量的更新操作通知到其他线程”。当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
理解 volatile 变量的一种有效方法是,将它们的行为想象成程序清单 3-3 中 SynchronizedInteger 的类似行为,并将 volatile 变量的读写操作分别替换为 get 和 set 方法。然而,在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量一种比 synchronized 关键字更加轻量的同步机制。
volatile 变量对可见性的影响比 volatile 变量本身更为重要。当线程 A 首先写入一个 volatile 变量并且线程 B 随后读取该变量时,在写入 volatile 变量之前对 A 可见的所有变量的值,在 B 读取了 volatile 变量后,对 B 也是可见的。因此,从内存可见性的角度来看,写入 volatile 变量相当于退出同步代码块,而读取 volatile 变量相当于进入同步代码块。然而,我们并不建议过度依赖 volatile 变量提供的可见性。如果在代码中依赖 volatile 变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。
仅当 volatile 变量能简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不需要使用 volatile 变量。volatile 变量的正确用法包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生,如初始化和关闭。
程序清单 3-4 给出了 volatile 变量的一种典型用法:检查某个状态标记以判断是否退出循环。在这个示例中,线程试图通过一种数绵羊的传统方法进入休眠状态。为了使这个示例能够正确执行,asleep 必须为 volatile 变量。否则,当 volatile 被另一个线程修改时,执行判断的线程却发现不了。我们也可以用锁定来确保 volatile 更新操作的可见性,但这将使代码变得更加复杂。
volatile boolean asleep;
...
while(!asleep) {
countSomeSheep();
}
虽然 volatile 变量很方便,但也存在一些局限性。volatile 变量通常用作某个操作完成、发生中断或状态的标记,例如程序清单 3-4 中的 asleep 标记。尽管 volatile 也可以用于表示其他的状态信息,但在使用时要非常小心。例如,volatile 的语义不足以确保递增操作(count++)的原子性,除非你能确保只有一个线程对变量执行写操作。(原子变量提供了“读-改-写”的原子操作,并且常常用作“更好的 volatile 变量”)
加锁机制既可以确保可见性又可以确保原子性,而 volatile 变量只能确保可见性。
当且仅当满足以下条件时,才应该使用 volatile 变量:
- 对变量的写入不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起被纳入不变性条件。
- 在访问变量时不需要加锁。
3.2 发布与逸出
“发布(Publish)” 一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向对象的引用保存到其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。在许多情中,我们要确保对象及其内部状态不会被发布。而在某些情况下,我们又需要发布某个对象,但如果在发布时需要确保线程安全性,则可能需要同步。发布内部状态可能会破坏封装性,并使得程序难以维持不变性条件。例如,如果在对象的构造过程完成之前就发布该对象,就会破坏线程安全性。“当某个不应该发布的对象被发布时,这种情况就被称为逸出。” 3.5 节将介绍安全发布对象的一些方法。现在我们首先来看看一个对象是如何逸出的。
发布对象的最简单方法是将对象的引用保存到一个共有的静态变量中,以便任何类和线程都能看见该对象,如程序清单 2-5 所示。在 initialize 方法中实例化一个新的 HashSet 对象,并将对象的引用保存到 knownSecrets 中以发布该对象。
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrets = new HashSet<Secret>():
}
当发布某个对象时,可能会间接的发布其他对象。如果将一个 Secret 对象添加到集合 knownSecrets 中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新 Secret 对象的引用。同样,如果从非私有方法中返回一个引用,那么同样会发布返回的对象。程序清单 3-6 中的 UnsafeStates 发布了本应为私有的状态数组。
class UnsafeStates {
private String[] states = new String[]{"AK","AL",...}
public String[] getStates() { return states; }
}
如果按照上述方法来发布 states,就会出现问题,因为任何调用者都可以修改这个数组的内容。在这个示例中,数组 states 已经逸出了它所在的作用域,因为这个本应该是私有的变量已经被发布了。
当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。一般来说,如果一个已经发布的对象能够通过非私有的变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。
假定有一个类 C,对于 C 来说,“外部方法”是指行为并不完全由 C 来规定的方法,包括其他类中定义的方法以及类 C 中可以被改写的方法(既不是 private 方法也不是 final 方法)。当把一个对象传递给外部方法时,就相当于发布了该对象。你无法知道哪些方法会被执行,也不知道在外部方法中究竟会发布这个对象,还是会保留对象的引用并在随后由另一个线程使用。
无论其他的线程会对已经发布的引用执行任何操作,其实都不重要,因为误用该引用的风险始终存在。当某个对象逸出后,你必须假设有某个类或线程最终可能会误用该对象。这正是需要使用封装的最主要原因:封装能够使得对程序的正确性进行分析变得可能,并使得无意中破坏设计约束条件变得更难。
最后一种发布对象或其内部状态的机制就是发布一个内部的类实例,如程序清单 3-7 的 ThisEscape 所示。当 ThisEscape 发布 EventListener 时,也隐含的发布了 ThisEscape 实例本身,因为在这个内部类的实例中包含了对 ThisEscape 实例的隐含引用。
public class ThisEscape {
public ThisScape(EventSource source) {
source.registerListener(
new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
}
);
}
}
安全的对象构造过程
在 ThisEscape 中给出了逸出的一个特殊示例,即 this 引用在构造函数中逸出。当内部的 EventListener 实例发布时,在外部封装的 ThisEscape 实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态。因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。即使发布对象的语句位于构造函数的最后一行也是如此。如果 this 引用在构造过程中逸出,那么这种对象就被认为是不正确的构造。
不要在构造过程中使 this 引用逸出。
在构造过程中使 this 引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式创建(通过将它传递给构造函数)还是隐式创建(由于 Thread 或 Runnable 是该对象的一个内部类),this 引用都会被新创建的线程共享。在对象尚未完全构造之前,新的线程就可以看到它。在构造函数中创建线程并没有错误,但是最好不要立即启动它,而是通过一个 start 或 initialize 方法来启动它。在构造函数中调用一个可改写的实例方法时(既不是私有方法、也不是终结方法),同样会导致 this 引用在构成过程中溢出。
如果想在构造函数中注册一个事件监听器或启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程,如程序清单 3-8 中 SafeListener 所示。
public class SafeListener {
private final EventListener listener;
private SafeListener() {
listener = new EventListener() {
public void onEvent(Event e) {
doSomething(e);
}
};
}
public static SafeListener newInstance(EventSource source) {
SafeListener safe = new SafeListener();
source.registerListener(safe.listener);
return safe;
}
}
具体来说,只有当构造函数返回时,this 引用才应该从线程中逸出。构造函数可以将 this 引用保存在某个地方,只要其他线程不会在构造函数完成之前使用它,上面的 SafeListener 就使用了这种技术。
3.3 线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。这种技术被称为线程封闭,它是实现线程安全性的最简单方式之一。当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,即使被封闭的对象本身不是线程安全的。
Swing 中大量使用了线程封闭技术。Swing 的可视化组件和数据模型都不是线程安全的,Swing 将它们封闭到 Swing 的事件分发线程中来实现线程安全性。要想正确的使用 Swing,在除了事件线程之外的其他线程中就不能访问这些对象(为了进一步简化 Swing 的使用,Swing 还提供了 inbokeLater 机制,用于将一个 Runnable 实例调度到事件线程中执行)。Swing 程序的许多并发错误都是由于错误的在另一个线程中使用了这些被封闭的对象。
线程封闭技术的另一个常见应用是 JDBC 的 Connection 对象。JDBC 规范并不要求 Connection 对象必须是线程安全的。在典型的服务器应用中,线程从连接池中获得一个 Connection 对象,并且用该对象来处理请求,使用完成后将对象返还给连接池。由于大多数请求都是由单个线程采用同步的方式来处理,并且在 Connection 对象返回之前,连接池也不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含的将 Connection 对象封闭在线程内。
在 Java 语言中并没有强制规定某个变量必须由锁来保护,同样在 Java 语言中也无法强制将对象封闭在某个线程中。线程封闭是在程序设计中的一个考虑因素,必须在程序中实现。Java 语言及其核心库提供了一些机制来帮助维持线程封闭性,例如局部变量和 ThreadLoal 类,但即便如此,程序员仍需要负责确保封闭在线程内的对象不会从线程中逸出。
3.3.1 Ad-hoc 线程封闭
Ad-hoc 线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc 线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(如 GUI 应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。
当决定使用线程封闭技术时,通常是因为要将某个特定的子系统实现为一个单线程子系统。在某些情况下,单线程子系统提供的简单性要胜过 Ad-hoc 线程封闭技术的脆弱性。
在 volatile 变量上存在一种特殊的线程封闭。只要你能确保只有单个线程对共享的 volatile 变量执行写入操作,那么就可以安全的在这些共享的 volatile 变量上执行“读-改-写”操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竟态条件,并且 volatile 变量的可见性确保了其他线程能够看到最新的值。
由于 Ad-hoc 线程封闭技术的脆弱性,因此在程序中尽量少用它,在可能的情况下,应该使用更强的线程封闭技术(如栈封闭或 ThreadLocal 类)。
3.3.2 栈封闭
栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。正如封装能使得代码更容易维持不变性条件那样,同步变量也能使对象更易于封闭在线程中。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。栈封闭(也被称为线程内部使用或线程局部使用,不要与核心类库中的 ThreadLocal 混淆)比 Ad-hoc 线程封闭更易于维护,也更加健壮。
对于基本类型的局部变量,例如程序清单 3-9 中的 loadTheArk 方法的 numPairs,无论如何都不会破坏栈封闭性。由于任何方法都不会获得基本类型的引用,因此 Java 语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。
public int loadTheArk(Collection<Animal> candidates) {
SortedSet<Animal> animals;
int numPairs = 0;
Animal candidate = null;
// animals 被封闭在方法中,不要使它们逸出
animals = new TreeSet<Animal>(new SpeciesGenderComparator());
animals.addAll(candidates);
for(Animal a: animals) {
if(candidate == null || !candidate.isPotentialMate(a))
candidate = a;
else {
ark.load(new AnimalPari(candidate, a));
++numPairs;
candidate = null;
}
}
return numPairs;
}
在维持对象引用的栈封闭性时,程序员需要多做一些工作以确保被引用的对象不会逸出。在 loadTheArk 中实例化一个 TreeSet 对象,并将指向该对象的一个引用保存到 animals 中。此时,只有一个引用指向集合 animals,这个引用被封装在局部变量中,因此也被封装在执行线程中。然而,如果发布了对象集合 animals(或者该对象中的任何内部数据)的引用,那么封闭性将被破坏,并导致对象 animals 的逸出。
如果在线程内部上下文中使用非线程安全的对象,那么该对象仍然是线程安全的。然而,要小心的是,只有编写代码的开发人员才知道哪些对象需要被封闭到执行线程内,以及被封闭的对象是否是线程安全的。如果没有明确的说明这些需求,那么后续的维护人员很容易错误的使对象逸出。
3.3.3 ThreadLocal
维持线程封闭性的一种更规范的方法是使用 ThreadLocal,该类能使线程中的某个值与保存值的对象关联起来。ThreadLocal 提供了 get 与 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。
“ThreadLocal 对象通常用于放置对可变的单实例变量或全局变量进行共享”。例如,在单线程应用程序中可能会维持一个全局的数据库连接,并在程序启动时初始化这个连接对象,从而避免在调用这个方法时都要传递一个 Connection 对象。由于 JDBC 的连接对象不一定是线程安全的,因此,当多线程程序在没有协同的情况下使用全局变量时,就不是线程安全的。通过将 JDBC 的连接保存到 ThreadLocal 对象中,每个线程都会拥有属于自己的连接,如程序清单 3-10 中的 ConnectionHolder 所示。
private static ThreadLocal<Connection> connectionHolder =
new ThreadLocal<Connection>() {
public Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return conenctionHolder.get();
}
当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都分配该临时对象时,就可以使用这项技术。例如在 Java 5.0 之前,Integer.toString() 方法使用 ThreadLocal 对象来保存一个 12 字节大小的缓冲区,用于对结果进行格式化,而不是使用共享的静态缓冲区(这需要使用锁机制)或者在每次调用时都分配一个新的缓冲区。
当某个线程初次调用 ThreadLocal.get 方法时,就会调用 initialValue 来获取初始值。从概念上来看,你可以将 ThreadLocal
假设你需要将一个单线程应用程序移植到多线程环境中,通过将共享的全局变量转换为 ThreadLocal 对象(如果全局变量的语义允许这么做),可以维持线程安全性。然而,如果将应用程序范围内的缓存转换为线程局部的缓存,就不会有太大作用。
在实现应用程序框架时大量使用了 ThreadLocal。例如,在 EJB 调用期间,J2EE 容器需要将一个事务上下文与某个执行中的线程关联起来。通过将事务上下文保存在静态的 ThreadLocal 对象中,可以很容易的实现这个功能:当框架代码需要判断当前运行的是哪一个事务时,只需要从 ThreadLocal 对象中读取事务上下文。这种机制很方便,因为它避免了在调用每个方法时都要传递执行上下文信息,然而这也将该机制的代码与框架耦合在一起。
开发人员经常滥用 ThreadLocal,例如将所有全局变量都作为 ThreadLocal 对象,或者作为一种“隐藏”方法参数的手段。ThreadLocal 变量类似于全局变量,它会降低代码的可重用性,并在类之间引入隐含的耦合性,因此在使用时要格外小心。
3.4 不变性
满足同步需求的另一种做法是使用不可变对象。到目前为止,我们介绍了许多原子性和可见性相关的问题,例如得到失效数据、丢失更新操作或观察到某个对象处于不一致的状态等等,都与多线程视图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性自然也就消失了。
如果某个对象在被创建后其状态不能被修改,那么这个对象就称为不可变对象。线程安全性是不可变对象的固有属性之一,它们的不变性条件是由构造函数创建的,只要它们的状态不改变,那么这些不变性条件就能得以维持。
不可变对象一定是线程安全的。
不可变对象很简单。它们只有一种状态,并且该状态由构造函数来控制。在程序设计中,一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。
同样,不可变对象也更加安全。如果将一个可变对象传递给不可信的代码,或者将该对象发布到不可信代码可以访问到的地方,那么就很危险——不可信代码会改变它们的状态,更糟糕的是,在代码中将保留一个对该对象的引用或者有问题的代码破坏,因此可以安全的共享和发布这些对象,而无需创建保护性的副本。
虽然在 Java 语言规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为 final 类型,即使对象中所有的域都是 final 类型的,这个对象仍然是可变的,因为在 final 类型的域中可以保存可变对象的引用。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能被修改。
- 对象的所有域都是 final 类型。
- 对象是正确创建的(在对象的创建期间,this 引用没有逸出)。
在不可变对象的内部仍然可以使用可变对象来管理其状态,如程序清单 3-11 中的 ThreeStooges 所示。尽管保存姓名的 Set 对象是可变的,但是从 ThreeStooges 的设计中可以看到,在 Set 对象构造完成后无法对其进行修改。stooges 是一个 final 类型的引用变量,因此所有对象状态都通过一个 final 域来访问。最后一个要求是“正确的构造对象”,这个要求很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。
@Immutable
public final class ThreeStooges {
private final Set<String> stooges = new HashSet<>();
public ThreeStooges() {
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
}
public boolean isStooge(String name) {
return stooges.contains(name);
}
}
由于程序的状态总是在不断改变,你可能会认为需要使用不可变对象的地方不多,但实际情况并非如此。在“不可变对象”与“不可变的对象引用”之间存在很多差异。保存在不可变对象中的状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。
3.4.1 Final
关键字 final 可以视为 C++ 中 const 机制的一种受限版本,用于构造不可变的对象。final 类型的域是不能被修改的,但如果 final 域所引用的对象是可变的,那么这些被引用的对象是可以修改的。然而,在 Java 内存模型中,final 域还有着特殊的语义。final 域能够确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。
即使对象是可变的,通过将对象的某些域声明为 fianl 类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为 fianl 类型,也相当于告诉维护人员这些域是不会变化的。
3.4.2 示例:使用 volatile 类型来发布不可变对象
在前面的 UnsafeCachingFactorizer 类中,我们尝试用 AtomicReference 变量来保存最新的数值机器因数分解结果,但这种方式并非线程安全,因为我们无法以原子方式来同时读取或更新两个相关的值。同样,用 volatile 类型的变量来保存这些值也不是线程安全的。然而,在某些情况下,不可变对象能够提供一种弱形式的原子性。
因数分解 Servlet 将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如程序清单 3-12 中的 OneValueCache。
@Immutable
class OneValueCache {
private final BigInteger lastNumber;
private final BigInteger[] lastFactors;
public OneValueCache(BitInteger i, BigInteger[] factors) {
lastNumber = i;
lastFactors = Arrays.copy(factors, factors.length);
}
public BigInteger[] getFactors(BigInteger i) {
if(lastNumber == null || !lastNumber.equals(i))
return null;
else
return Arrays.copy(lastFactors, lastFactors.length);
}
}
对于在访问和更新多个相关变量时出现的竟态条件问题,可以通过将所有这些变量保存在一个不可变对象中来消除。如果是一个可变的对象,就必须使用锁来确保原子性。如果是一个不可变的对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原子变量的线程仍然会看到对象处于一致的状态。
程序清单 3-13 中的 VolatileCachedFactorizer 使用了 OneValueCache 来保存缓存的数值机器因数。当一个线程将 volatile 类型的 cache 设置为引用一个新的 OneValueCache 时,其他线程就会立即看到新缓存的数据。
@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
private volatile OneValueCache cache =
new OneValueCache(null, null);
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BitInteger[] factors = cache.getFactors(i);
if(factors == null) {
factors = factor(i);
cache = new OneValueCache(i, factors);
}
encodeIntoResponse(resp, factors);
}
}
与 cache 相关的操作不会互相干扰,因为 OneValueCache 是不可变的,并且在每条相应的代码路劲中只会访问它一次。通过使用包含多个状态变量的容器来维持不变性条件,并使用一个 volatile 类型的引用来确保可见性,使得 VolatileCachedFactorizer 在没有使用显式锁的情况下依然是线程安全的。
3.5 安全发布
到目前为止,我们重点讨论的是如何确保对象不会被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全的进行共享。然而,如果只是像程序清单 3-14 那样将对象引用保存到公有域中,那么还不足以安全的发布这个对象。
// 不安全的发布
public Holder holder;
public void initialize() {
holder = new Holder(42);
}
你可能会奇怪,这个看似没有问题的示例为何会运行失败。由于存在可见性问题,其他线程看到的 Holder 对象将处于不一致的状态,即便在该对象的构建函数中已经正确的构建了不变性条件。这种不正确的发布导致其他线程能够看到尚未创建完成的对象。
3.5.1 不正确的发布:正确的对象被发布
你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果程序清单 3-15 中的 Holder 使用程序清单 3-14 中的不安全方式发布,那么另一个线程在调用 assertSanity 时将抛出 AssertionError。
public class Holder {
private int n;
public Holder(int n) {this.n = n;}
public void assertSanity() {
if(n != n)
throw new AssertionError()
}
}
由于没有使用同步来确保 Holder 对象对其他线程可见,因此将 Holder 称为“未被正确发布”。在未被正确发布的对象中存在两个问题。首先,除了发布对象的线程外,其他线程可以看到 Holder 域是一个失效值,因此将看到一个空引用或者之前的旧值。然而,更糟糕的情况是,线程看到 Holder 引用的值是最新的,某个线程在第一次读取域时得到失效值,而在此读取这个域时会得到一个更新值,这也是 assertSainty 抛出异常的原因。
如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。
3.5.2 不可变对象与初始化安全性
由于不可变对象是一种非常重要的对象,因此 Java 内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态呈现出一致的视图,就必须使用同步。
另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全的访问该对象。为了维持这种初始化安全性保证,必须满足不可变性的所有需求:状态不可修改、所有域都是 final 类型、以及正确的构造过程。
任何线程都可以在不需要额外同步的情况下安全的访问不可变对象,即使在发布这些对象时没有使用同步。
这种保证还将延伸到被正确创建对象中所有 final 类型的域。在没有额外同步的情况下,也可以安全的访问 final 类型的域。然而,如果 final 类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。
3.5.3 安全发布的常用模式
可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全的发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到 volatile 类型的域或者 AtomicReference 对象中。
- 将对象的引用保存到某个正确构造对象的 fianl 类域中。
- 将对象的引用保存到一个由锁保护的域中。
在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如 Vector 或者 synchronizedList 时,将满足上述最后一条需求。如果线程 A 将对象 X 放入一个线程安全的容器,随后线程 B 读取这个对象,那么可以确保 B 看到 A 设置的 X 状态,即便在这段 读/写 X 的应用程序代码中没有包含显式的同步。尽管 Javadoc 在这个主题上没有给出清晰的说明,但线程安全库中的容器类提供了以下安全发布保证:
- 通过将一个键或值放入到 Hashtable、synchronizedList 或 ConcurrentMap 中,可以安全的将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
- 通过将某个元素放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、SynchronizedList、SynchronizedSet 中,可以将该元素安全的发布到任何从这些容器中访问该元素的线程。 通过将某个元素放入 BlockingQueue 或 ConcurrentLinkedQueue 中,可以将该元素安全的帆布到任何从这些队列中访问该元素的线程。
类库中的其他数据传递机制(如 Future 或 Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。
通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(42);
静态初始化器由 JVM 在类的初始化阶段执行。由于 JVM 内部存在同步机制,因此通过这种方式初始化的任何对象都可以被安全的发布。
3.5.4 事实不可变对象
如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全的访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程都是可见的,并且如果对象状态不会再改变,那么久足以保证任何访问都是安全的。
如果对象从技术上看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”。这些对象不需要满足 3.4 节中提出的不可变性的严格定义。在这些对象发布后,程序只需要将它们视为不可变对象即可。通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。
没有额外的同步的情况下,任何线程都可以安全的使用被安全发布的事实不可变对象。
例如,Date 本身是不可变的,但如果将其作为不可变对象来使用,那么在多个线程之间共享 Date 对象时,就可以省去对锁的使用。假设要维护一个 Map 对象,其中保存了每位用户的最近登录时间:
public Map<String, Date> lastLogin =
Collection.synchronizedMap(new HashMap<>());
如果 Date 对象的值在被放入 Map 后就不再会被改变,那么 synchronizedMap 中的同步机制就足以使 Date 值被安全的发布,并且在访问这些 Date 值时不需要使用额外的同步。
3.5.5 可变对象
如果对象在构造之后可以被修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全的共享可变对象,这些对象就必须被安全的发布,并且必须是线程安全的或者由某个锁保护起来。
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布。
- 事实不可变对象必须通过安全方式来发布。
- 可变对象必须通过安全方式来发布,并且必须是线程安全的或者由某个锁保护起来。
3.5.6 安全地共享对象
当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否要获得一个锁?是否可以修改它的状态?或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确的说明对象的访问方式。
在并发编程中使用和共享对象时,可以使用一些实用的策略,包括:
- 线程封闭。线程封闭的对象只能由一个线程拥有,对象对封闭在该线程内,并且只能由这个线程修改。
- 只读共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而无需进一步的同步。
- 保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
3.1.4 - CH04-对象组合
到目前为止,我们已经介绍了关于线程安全与同步的一些基础知识。然而,我们并不希望对每一次的内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式,这些模式能够使一个类更容易实现线程安全,并且在维护这些类时不会在无意中破坏类的安全性保证。
4.1 设计线程安全的类
在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中,但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证,并且在修改时也更难以始终确保其线程安全性。通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。程序清单 4-1 中的 Counter 只有一个域——value,因此这个域就是 Counter 的全部状态。对于含有 n 个基本类型的域的对象,其状态就是这些域构成的 n 元组。例如,二维点的状态就是它的坐标值 (x,y)。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList 的状态就包括该链表中所有节点对象的状态。
@ThreadSafe
public final class Counter {
@GuardedBy("this") private long value = 0;
public synchronized long getValue() {
return value;
}
public synchronized long increment() {
if(value == Long.MAX_VALUE)
throw new IllegalStateException();
return ++value;
}
}
“同步策略”定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭、加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析和维护,就必须将同步策略写为正式文档。
4.1.1 收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final 类型的域越多,就越能简化对象可能状态的分析过程。(在极端情况下,不可变对象只有唯一的状态)
在许多类中都定义了一些不可变条件,用于判断状态的有效性。Counter 中的 value 域是 long 类型的变量,其状态空间从 Long.MIN_VALUE 到 Long.MAX_VALUE,但 Counter 中 value 在取值范围上存在着一个限制,即不能是负值。
同样,在操作中还会包含一些后验条件来判断状态迁移是否有效。如果 Counter 的当前状态为 17,那么下一个有效状态只能是 18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。并非所有的操作都会在状态转换上施加限制,例如,当更新一个保存当前温度的变量时,该变量之前的值并不会影响计算结果。
由于不变性条件以及后验条件在状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
在类中可以包含同时约束多个状态变量的不变性条件。在一个表示数值范围的类中可以包含两个状态变量,分别表示范围的上下界。这些变量必须遵循的约束是,下界值应该小于等于上界值。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在原子操作中进行读取或更新。不能首先更新一个变量值然后释放锁并再次获得锁,然后再更新其他变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持持有保护这些变量的锁。
如果不了解对象的不变性条件和后验条件,就不能确保线程安全性。要满足在状态变量的有效值或在状态转换上的各种约束条件,就需要借助于原子性和封装性。
4.1.2 依赖状态的操作
类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。在某些对象的方法中还包含一些基于状态的先验条件。例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于“非空的”状态。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由其他线程执行的操作而变为真。在并发程序中要一直等到先验条件为真,然后再执行该操作。
在 Java 中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确的使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类来实现依赖状态的行为。
4.1.3 状态的所有权
4.1 节曾指出,如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。为什么是一个子集?在从该对象可以到达的所有域中,需要满足哪些条件才不属于该对象状态的一部分?
在定义将由哪些变量构成对象的状态时,只考虑对象拥有的数据。所有权在 Java 中并没有得到充分的体现,而是属于类设计中的一个要素。如果分配并填充了一个 HashMap 对象,那么就相当于创建了多个对象:HashMap 对象,在 HashMap 对象中包含的多个对象,以及在 Map.Entry 中可能包含的内部对象。HashMap 对象的逻辑状态包含所有的 Map.Entry 对象以及内部对象,即使这些对象都是一些独立的对象。
无论如何,垃圾回收机制使我们避免了如何处理所有权的问题。在 C++ 中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的所有权。在 Java 中同样存在这样的所有权模型,只不过垃圾回收器为我们减少了许多在引用共享方面常见的错误,因此降低了在所有权处理上的开销。
许多情况下,所有权与封装性总是互相关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个不可变对象的引用,那么久不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是专门设计为转义传递进来的对象的所有权的。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有自身的状态,而客户端代码则拥有容器中各个对象的状态。Servlet 框架中的 ServletContext 就是其中一个示例。ServletContext 为 Servlet 提供了类似于 Map 形式的对象容器服务,在 ServletContext 中可以通过名称来注册或获取应用程序对象。由 Servlet 容器实现的 ServletContext 对象必须是线程安全的,因为它肯定会被多个线程同时访问。当调用 setAttribute 和 getAttribute 时,Servlet 不需要使用同步,但当使用保存在 ServletContext 中的对象时,则可能需要使用同步。这些对象由应用程序拥有,Servlet 容器只是替应用程序保管它们。与所有共享对象一样,它们必须安全的被共享。为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。
4.2 实例封闭
如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全的使用。你可以确保该对象只能由单个线程访问(线程封闭),或者由锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它实现了一种实例封闭机制,通常简称为“封闭”。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超出它们既定的作用域。对象可以封装在类的一个实例(如作为类的私有成员)中,或者封装在一个作用域内(如作为一个局部变量),再或者封闭在线程内(如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程间共享该对象)。当然,对象本身不会逸出——发生逸出通常是由于开发人员在发布对象时超出了对象既定的作用域。
程序清单 4-2 中的 PersonSet 说明了如何通过封闭与加锁等机制是一个类称为线程安全的。PersonSet 的状态由 HashSet 来管理,而 HashSet 并非线程安全,但由于 mySet 是私有的且不会逸出,因此 HashSet 被封闭在 PersonSet 中。唯一能访问 mySet 的代码路径是 addPerson 与 containsPerson,在执行它们时都需要获得一个 PersonSet 上的锁。PersonSet 的状态完全由它的内置锁保护,因而 PersonSet 是一个线程安全的类。
@ThreadSafe
public class PersonSet {
@GuardedBy("this")
private final Set<Person> mySet = new HashSet<>();
public synchronized void addPerson(Person p) {
mySet.add(p);
}
public synchronized boolean contains(Person p) {
return mySet.contains(p);
}
}
这个示例并未对 Person 类的线程安全性做任何假设,但如果 Person 类是可变的,那么在访问从 PersonSet 中获得的 Person 对象时,还需要额外的同步。要想安全的使用 Person 对象,最可靠的方法就是使 Person 成为一个线程安全的类。另外,也可以使用锁来保护 Person 对象,并确保所有客户端代码在访问 Person 对象之前都已经获得了正确的锁。
实例封闭是构建线程安全的一个最简单方式,它还是的在锁策略的选择上拥有了更多的灵活性。在 PersonSet 中使用了它的内置锁来保护状态,但对于其他形式的锁来说,只要至始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。
在 Java 平台的类库中还有很多线程封闭的实例,其中有些类的唯一用途就是将非线程安全的类转换为线程安全。一些基本容器并非线程安全,例如 ArrayList 和 HashMap,但类库提供了包装工厂方法,使得这些非线程安全的类可以在多线程环境中安全的使用。这些工厂方法通过“装饰器”模式将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么他就是线程安全的。在这些方法的 Javadoc 中指出,对底层容器对象的所有访问必须通过包装器来进行。
当然,如果将一个本该封闭的对象发布出去,那么也能破坏封装性。如果一个对象本应该封闭在特定的作用域中,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接的发布被封闭的对象,同样会使封闭对象逸出。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无需检查整个程序。
4.2.1 Java 监视器模式
从线程封闭原则及其逻辑推论可以得出 Java 监视器模式。遵循 Java 监视器模式的对象会把对象的所有可变状态都封装起来,并由对象的内置锁来保护。
在代码清单 4-1 的 Counter 中给出了这种模式的一个典型示例。在 Counter 中封装了一个状态变量 value,对该变量的所有访问都需要通过 Counter 的方法来执行,并且这些方法都是同步的。
在许多类中都使用了 Java 监视器模式,例如 Vector 和 Hashtable。在某些情况下,程序需要一种更复杂的同步策略。第 11 章将介绍如何通过细粒度的加锁策略来提高可伸缩性。Java 监视器模式的主要优势在于它的简单性。
Java 监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要至始至终的使用该锁对象,都可以用来保护对象的状态。程序清单 4-3 给出了如何使用私有锁来保护状态。
public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;
void someMethod() {
synchronized(myLock) {
// 访问或修改 Widget 的状态
}
}
}
使用私有锁对象而不是内置锁(或其他任何可以通过共有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,使客户端代码无法获得锁,但客户端可以通过公有方法访问锁,以便参与到它的同步策略中。如果客户端代码错误的获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有方法的锁在程序中是否本正确的使用,则需要检查整个程序,而不是单个的类。
4.2.2 示例:车辆追踪
程序清单 4-1 中的 Counter 是一个简单但用处不大的 Java 监视器示例。我们来看一个更有用处的示例:一个用于调度车辆的“车辆追踪器”,例如出租车、警车、货车等。首先使用监视器模式来构建追踪器,然后再尝试放宽某些封装性需求同时又保证线程安全性。
每台车都由一个 String 对象来标识,并且拥有一个相应的坐标位置 (x,y)。在 VehicleTracker 类中封装了车辆的标识和位置,因而它非常适合作为基于 MVC 模式的 GUI 应用程序中的数据模型,并且该模型将由一个视图线程和多个执行更新操作的线程共享。视图线程会读取车辆的标识和位置,并将它们展示在界面上:
Map<String,Point> locations = vehicles.getLocations();
for(String key: locations.keySet())
renderVehicle(key, locations.get(key));
类似的,执行更新操作的线程通过从 GPS 设备上获取的数据或者调度员从 GUI 界面上输入的数据来修改车辆的位置。
void vehicleMoved(VehicleMovedEvent evt) {
Point loc = evt.getNewLocation();
vehicles.setLocation(evt.getVehicleId(), loc.x, loc.y);
}
从视图线程与执行更新操作的线程将并发的访问数据模型,因此该模型必须是线程安全的。程序清单 4-4 给出了一个基于 Java 监视器模式实现的“车辆追踪器”,其中使用了程序清单 4-5 中的 MutanlePoint 来表示车辆位置。
@ThreadSafe
public class MonitorVehicleTracker {
@GuardedBy("this")
private final Map<String, MutablePoint> locations;
public MonitorVehicleTracker( Map<String, MutablePoint> locations) {
this.locations = deepCopy(locations);
}
public synchronized Map<String, MutablePoint> getLocations() {
return deepCopy(locations);
}
public synchronized MutablePoint getLocation(String id) {
MutablePoint loc = locations.get(id);
return loc == null ? null : new MutablePoint(loc);
}
public synchronized void setLocation(String id, int x, int y) {
MutablePoint loc = locations.get(id);
if (loc == null)
throw new IllegalArgumentException("No such ID: " + id);
loc.x = x; loc.y = y;
}
private static Map<String, MutablePoint> deepCopy( Map<String, MutablePoint> m) {
Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
for (String id : m.keySet())
result.put(id, new MutablePoint(m.get(id)));
return Collections.unmodifiableMap(result);
}
}
public class MutablePoint { /* Listing 4.5 */ }
虽然类 MutablePoint 不是线程安全的,但追踪器类是线程安全的。它所包含的 Map 对象和可变的 Point 对象都未曾发布。当需要返回车辆的位置时,通过 MutablePoint 拷贝构造函数或者 deepCopy 方法来复制正确的值,从而生成一个新的 Map 对象,并且该对象中的值与原有 Map 对象中的 key、value 值都相同。
@NotThreadSafe
public class MutablePoint {
public int x, y;
public MutablePoint() { x = 0; y = 0; }
public MutablePoint(MutablePoint p) {
this.x = p.x;
this.y = p.y;
}
}
在某种程度上,这种实现方式是通过在返回客户端代码之前复制可变数据来维持安全性的。通常情况下,这并不存在性能问题,但在车辆容器非常大的情况下将极大降低性能。此外,由于每次调用 getLocation 就要复制数据,因此将出现一种错误的情况——虽然车辆的实际位置发生了改变,但返回的信息却保持不变。这种情况的好坏,取决于你的需求。如果在 location 集合上存在内部的一致性需求,那么这就是有点,在这种情况下返回一致的快照就非常重要。然而,如果调用者需要每辆车的最新信息,那么这就是缺点,因为这需要非常频繁的刷新快照。
4.3 线程安全性的委托
大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合成一个类时,Java 监视器模式是非常有用的。但是,如果类中的各个组件都已经是线程安全的,会是什么情况呢?我们是否需要再增加一个额外的线程安全层?答案是“视情况而定”。在某些情况下,通过多个线程安全类组合而成的类是线程安全的,而在某些情况下,这仅仅是一个好的开端。
在前面的 CountingFactorizer 类中,我们在一个无状态的类中增加了一个 Atomiclong 类型的域,并且得到的组合对象仍然是线程安全的。由于 CountingFactorizer 的状态就是 AtomicLong 的状态,而 AtomicLong 是线程安全的,因此 CountingFactorizer 不会对 counter 的状态施加额外的有效性约束,所以很容易知道 CountingFactorizer 是线程安全的。我们可以说 CountingFactorizer 将它的线程安全性委托给 AtomicLong 来保证:之所以 CountingFactorizer 是线程安全的,是因为 AtomicLong 是线程安全的。
4.3.1 示例:基于委托的车辆追踪器
下面将介绍一下更实际的委托示例,构造一个委托给线程安全类的车辆追踪器。我们将车辆的位置保存到一个 Map 对象中,因此首先要实现一个线程安全的 Map 类,ConcurrentHashMap。我们还可以用一个不可变的 Point 类来代替 MutablePoint 以保存位置,如程序清单 4-6 所示。
@Immutable
public class Point {
public final int x,y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
由于 Point 类是不可变的,因而它是线程安全的。不可变的值可以被自由的共享与发布,因此在返回 location 时不需要复制。
在程序清单 4-7 的 DelegatingVehicleTracker 中没有使用任何显式的同步,所有对状态的访问都由 ConcurrentHashMap 来管理,而且 Map 所有的键和值都是不可变的。
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String,Point> locations;
private final Map<String,Poing> unmodifiableMap;
public DelegatingVehicleTracker(Map<String,Poing> points) {
locaitions = new ConcurrentHashMap<>(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String,Poing> getLocations() {
return unmodifiableMap;
}
public Point geg Location(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if(locations.replace(id, new Point(x,y)) == null) {
throw new IllegalArgumentException();
}
}
}
如果使用最初的 MutablePoint 而不是 Point 类就会破坏封装性,因为 getLocations 会发布一个指向可变状态的引用,而这个引用不是线程安全的。需要注意的是,我们稍微改变了车辆追踪器的行为。在使用监视器模式的车辆追踪器中返回的是车辆位置的快照,而在使用委托的车辆追踪器中放回的是一个不可修改但却保持实时的车辆位置视图。这意味着,如果线程 A 调用 getLocations,而线程 B 在随后修改了某些点的位置,那么在返回给线程 A 的 Map 中将反应出这些变化。在前面提到过,这可能是一种优点,也可能是一种缺点,取决于具体需求。
如果需要一个不会发生变化的车辆视图,那么 getlocations 可以返回对 locations 这个 Map 对象的一个浅拷贝。由于 Map 的内容是不可变的,因此只需复制 Map 的结构,而不用复制它的内容,如程序清单 4-8 所示。
public Map<Stirng,Point> getLocations() {
return Collections.unmodifiableMap(new HashMap<>(locations));
}
4.3.2 独立的状态变量
到目前为止,这些委托实例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即“组合而成的类不会在其包含的多个状态变量上增加任何不变性条件”。
程序清单 4-9 中的 VisualConponet 是一个图形组件,允许客户端程序注册鼠标事件和键盘事件的监听器。因为每种类型的事件都备有一个已注册监听器列表,因此当某个事件发生时,就会调用相应的监听器。然而,在鼠标事件监听器与键盘事件监听器之间不存在任何关联,二者是彼此独立的,因此 VisualComponet 可以将其线程安全性委托给这两个线程安全的监听器列表。
public class VisualComponet {
private final List<KeyListener> keyListeners =
new CopyOnWriteArrayList<>();
private final List<MouseListener> mouseListeners =
new CopyOnWriteArrayList<>();
public void addKeyListener(KeyListener listener) {
keyListeners.add(listener);
}
public void addMouseListener(MouseListener listener) {
mouseListeners.add(listener);
}
public void removeKeyListener(KeyListener listener) {
keyListeners.remove(listener);
}
public void removeMouseListener(MouseListener listener) {
mouseListeners.remove(listener);
}
}
VisualComponet 使用 CopyOnWriteArrayList 来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。每个链表都是线程安全的,此外,由于各个状态之间不存在耦合关系,因此 VisualComponet 可以将它的线程安全性委托给 mouseListeners 和 keyListeners。
4.3.3 当委托失效时
大多数组合对象都不会像 VisualComponet 这样简单:在它们的状态变量之间存在着某些不变性条件。程序清单 4-10 中的 NumberRange 使用了两个 AtomicInteger 来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。
public class NumberRange {
// 不变性条件:lower <= upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i) {
// 注意:不安全的“先检查后执行”
if(i < upper.get()) {
throw new ...
}
lower.set(i);
}
public void setUpper(int i) {
// 注意:不安全的“先检查后执行”
if(i < lower.get) {
throw new ...
}
upper.set(i);
}
}
NumberRange 不是线程安全的,没有维持对上界和下界进行约束的不变性条件。setLower 和 setUpper 等方法都尝试维持不变性条件,但却无法做到。setLower 和 setUpper 都是“先检查后执行”操作,但它们没有使用足够的加锁机制来保证这些操作的原子性。假设取值范围为 (0,10),如果一个线程调用 setLower(5),另一个线程调用 setUpper(4),那么在一些错误的执行时序中,这两个调用都将通过检查,并且都能设置成功。结果得到的取值范围是 (5,4),那么这是一个无效的状态。因此,虽然 AtomicInteger 是线程安全的,但经过组合得到的类却不是。由于状态变量 lower 和 upper 不是彼此独立的,因此 NumberRange 不能将线程安全委托给它的线程安全状态变量。
NumberRange 可以通过加锁机制来维护不变性条件以确保其线程安全性,例如使用一个锁来保护 lower 和 upper。此外,它还必须避免发布 lower 和 upper,从而防止客户端代码破坏其不变性条件。
如果某各类含有复合操作,例如 NumberRange,那么紧靠委托并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作,除非整个复合操作都可以委托给状态变量。
如果一个类是由多个独立且线程安全的变量组成的,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
即使 NumberRange 的各个组件部分都是线程安全的,也不能保证 NumberRange 的线程安全性,这种问题非常类似于 3.1.4 节介绍的 volatile 变量规则:仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为 volatile 变量。
4.3.4 发布底层的状态变量
当把线程安全性委托给某个对象底层的状态变量时,在什么条件下才可以发布这些变量从而使其他类能够修改它们?答案仍然取决于在类中对这些变量施加了哪些不变性条件。虽然 Counter 中的 value 域可以为任意整数值,但 Counter 施加的约束条件是只能取正整数,此外递增操作同样约束了下一个状态的有效取值范围。如果将 value 声明为一个共有域,那么客户端代码可以将它修改为一个无效值,因此发布 value 会导致这个类出错。另一方面,如果某个变量表示的是当前温度或者最近登录用户的 ID,那么即使另一个类在某个时刻修改了这个值,也不会破坏任何不变性条件,因此发布这个变量也是可以接受的。(这或许不是个好注意,因为发布可变的变量将对下一步的开发和派生子类带来限制,但不会破坏类的线程安全性。)
例如,发布 VisualComponent 中的 mouseListeners 和 keyListeners 等变量是线程安全的。由于 VisualComponet 并没有在其监听器列表的合法状态上施加任何约束,因此这些域可以声明为公有域或者发布,而不会破坏线程安全性。
4.3.5 示例:发布状态的车辆追踪器
我们来构造车辆追踪器的另一个版本,并在这个版本中发布底层的可变状态。我们需要修改接口以适应这种变化,即使用可变且线程安全的 Point 类。
程序清单 4-11 中的 SafePoint 提供的 get 方法同时获得 x 和 y 的值,并将二者放在一个数组中返回。如果 x 和 y 分别提供 get 方法,那么在获得者两个不同坐标的操作之间,x 和 y 的值发生变化,从而导致调用者看到不一致的值:车辆重来没有到达过位置 (x,y)。通过使用 SafePoint,可以构造一个发布其底层可变状态的车辆追踪器,还能确保其线程安全性不被破坏,如程序清单 4-12 中的 PublishingVehicleTracker 类所示。
@ThreadSafe
public class SafePoint {
@GuardedBy("this") private int x, y;
private SafePoint(int[] a) { this(a[0], a[1]); }
public SafePoint(SafePoint p) { this(p.get()); }
public SafePoint(int x, int y) {
this.x = x; this.y = y;
}
public synchronized int[] get() {
return new int[] { x, y };
}
public synchronized void set(int x, int y) {
this.x = x; this.y = y;
}
}
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, SafePoint> locations;
private final Map<String, SafePoint> unmodifiableMap;
public PublishingVehicleTracker( Map<String, SafePoint> locations) {
this.locations = new ConcurrentHashMap<String, SafePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
public Map<String, SafePoint> getLocations() {
return unmodifiableMap;
}
public SafePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException( "invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
PublishingVehicleTracker 将其线程安全性委托给底层的 PublishingVehicleTracker,只是 Map 中的元素是线程安全的且可变的 Point,而并非不可变的。getLocation 方法返回底层 Map 对象的一个不可变副本。调用者不能增加或删除车辆,但却可以通过修改返回 Map 中的 SafePoint 值来改变车辆的位置。再次指出,Map 的这种“实时特性”究竟是带来了好处还是坏处,仍然取决于实际的需求。PublishingVehicleTracker 是线程安全的,但如果它在车辆位置的有效值上施加了任何约束,那么就不再是线程安全的了。如果需要对车辆位置的变化进行判断或者当位置变化时执行一些操作,那么 PublishingVehicleTracker 中采用的方式并不合适。
4.4 在现有的线程安全类中添加功能
Java 类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新类:重用能降低开发工作量、开发风险(因为现有的类都已通过测试)以及维护成本。有时候,某个现有的线程安全类能支持我们需要的所有工作,但更多时候,现有的类只能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新的操作。
例如,假设需要一个线程安全的链表,它需要提供一个原子的“若没有则添加”的操作。同步的 List 类已经实现了大部分的功能,我们可以根据它提供的 contains 方法和 add 方法构造一个这样的操作。
“若没有则添加”的概念很简单,在向容器添加元素前,首先检查该元素是否已经存在,如果存在就不再添加。由于这个类必须是线程安全的,因此就隐含的增加了另一个需求,即“若没有则添加”这个操作必须是原子的。这意味着,如果在链表中没有包含对象 X,那么在执行两次“若没有则添加” X 后,在容器中只能包含一个 X 对象。然而,如果“若没有则添加”并非原子操作,那么在某些执行情况下,有两个线程将看到 X 不再容器中,并且都执行了添加 X 的操作,从而使容器中包含两个相同的 X 对象。
要添加一个新的原子操作,最安全的方式是修改原始类,但这通常无法做到,因为你可能无法访问或修改类的原始代码。要想修改原始的类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源代码文件中,从而更容易理解和维护。
另一种方法是扩展这个类,假定在设计这个类的时候考虑了扩展性。程序清单 4-13 中的 BetterVector 对 Vector 进行了扩展,并添加了一个新的 putIfAbsent。扩展 Vector 很简单,但并非所有的类都想 Vector 那样将状态向子类公开,因此也就不适合采用这种方法。
@ThreadSafe
public class BetterVector<E> extends Vector<E> {
public synchronized boolean putIfAbsend(E e) {
boolean absent = !contains(e);
if(absent) add(e);
return absent;
}
}
“扩展方法”比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。(在 Vector 的规范中定义了它的同步策略,因此 BetterVector 不存在该问题。)
4.4.1 客户端加锁机制
对于由 Collections.synchronizedList 封装的 ArrayList,这两种方法在原始类中添加一个方法或者对类进行扩展都行不通,因为客户端代码并不知道在同步封装器工厂方法中返回的 List 对象的类型。第三中策略是扩展类的功能,但并不扩展类本身,而是将扩展放入一个辅助类。
程序清单 4-14 实现了一个包含“若没有则添加”操作的辅助类,用于对线程安全的 List 执行操作,但其中的代码是错误的。
@NotThreadSafe
public class ListHelper<E> {
public List<E> list =
Collections.synchronizedList(new ArrayList<E>());
...
public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if(absent) {
list.add(x);
}
return absent;
}
}
为什么这种方式不能实现线程安全性?毕竟,putIfAbsent 已经声明为 synchronized 类型的变量,对不对?问题在于在错误的锁上进行了同步。无论 List 使用哪个锁来保护它的状态,可以确定的是,这个锁不会是 ListHelper 上的锁。ListHelper 只是带来了同步的假象,尽管所有的链表操作都被声明为 synchronized,但却使用了不同的锁,这意味着 putIfAbsent 相对于 List 的其他同步操作来说并不是原子的,因此就无法确保当 putIfAbsent 执行时另一个线程不会修改链表。
要想使这个方法变得有效,必须使 List 在实现客户端加锁或外部加锁时使用同一个锁。客户端加锁指的是,对于使用某个对象 X 的客户端代码,使用 X 本身用于保护其状态的锁来保护这段客户端代码。要使用客户端加锁,你必须知道对象 X 使用的是哪个锁。
在 Vector 和同步封装器类的文档中指出,他们通过使用 Vector 或封装器容器的内置锁来支持客户端加锁。程序清单 4-15 给出了在线程安全的 List 上执行 putIfAbsent 操作,其中使用了正确的客户端加锁。
@ThreadSafe
public class ListHelper<E> {
public List<E> list =
Collections.synchronizedList(new ArrayList<E>());
...
public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent) list.add(x);
return absent;
}
}
}
通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱,因为它将类 C 的加锁代码放到与 C 完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有很多相同点,二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性,客户端加锁同样会破坏同步策略的封装性。
4.4.2 组合
当为现有的类添加一个原子操作时,有一种更好的方法:组合。程序清单 4-16 中的 ImporvedList 通过将 List 对象的操作委托给底层的 List 实例来实现 List 的操作,同时还添加了一个原子的 putIfAbsent 方法。(与 Collections.synchronizedList 和其他容器封装器一样,ImprovedList 假设把某个链表对象传递给构造函数之后,客户端代码就不会再直接使用这个对象,而只能通过 ImprovedList 来访问它。)
@ThreadSafe
public class ImprovedList<T> implements List<T> {
private final List<T> list;
public ImprovedList(List<T> list) {
this.list = list;
}
public synchronized boolean putIfAbsent(T x) {
boolean contains = list.contains(x);
if (contains) list.add(x);
return !contains;
}
public synchronized void clear() { list.clear(); }
// ... similarly delegate other List methods
}
ImprovedList 通过自身的内置锁增加了一层额外的加锁。它并不关心底层的 List 是否是线程安全的,即使 List 不是线程安全的或者修改了它的加锁实现,ImprovedList 也会提供一致性的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失,但与模拟另外一个对象的加锁策略相比,ImprovedList 更加健壮。事实上,我们使用了 Java 监视器模式来封装现有的 List,并且只要在类中拥有指向底层 List 的唯一外部引用,就能确保线程安全性。
4.5 将同步策略文档化
在维护线程安全性时,文档是最强大的工具之一。用户可以通过查阅文档来判断某个类是否是线程安全的,而维护人员也可以通过查阅文档来理解其中的实现策略,避免在维护过程中破坏安全性。然而,通常人们从文档中获取的信息少之又少。
synchronized、volatile 或者任何一个线程安全类都对应一个同步策略,用于在并发访问时保证数据的完整性。这种策略是程序设计的要素之一,因此应该将其文档化。当然,设计阶段是编写设计决策文档的最佳时间。这之后经过数周或数月后,一些设计细节会逐渐变得模糊,因此一定要在忘记之前将他们记录下来。
在设计同步策略时需要考虑多个方面,例如,将哪些变量声明为 volatile 类型,哪些变量用锁来保护,哪些锁保护哪些变量,哪些变量必修是不可变的或者被封闭在线程内,哪些操作必须是原子的等。其中某些方面是严格的实现细节,应该将他们文档化以便日后维护。还有一些方面会影响类中加锁行为的外在表现,也应该将其作为规范的一部分写入文档。
最起码,应该保证类中的线程安全性文档化。它是否是线程安全的?在执行回调时是否持有一个锁?是否有某些特定的锁会影响其行为?不要让客户端冒着风险去猜测。如果你不想支持客户端加锁也是可以的,但一定要明确的指出来。如果你希望客户端代码能够在类中添加新的原子操作,如 4.4 节所示,那么就需要在文档中索命需要获得哪些锁才能实现安全的原子操作。如果使用锁来保护状态,那么也要将其写入文档以便日后维护,这很简单,只需要使用注解 @GuardedBy
即可。如果要使用更复杂的方法来维护线程安全性,那么一定要将它们写入文档,因为维护者们通常很难发现它们。
甚至在平台类库中,线程安全性方面的文档也是很难令人满意。当你阅读某个类的 Javadoc 时,是否曾怀疑过他是否是线程安全的?大多数类都没有给出任何提示。许多正式的 Java 技术规范,例如 Servlet 和 JDBC,也没有在它们的文档中给出线程安全性的保证和需求。
尽管我们不应该对规范之外的行为进行猜测,但有时候处于工作需要,将不得不面对各种糟糕的假设。我们是否应该因为某个对象看上去是线程安全的就假设它是安全的?是否可以假设通过获取对象的锁来确保对象访问的线程安全性?(只有当我们能够控制所有访问该对象的代码时,才能使用这种带有风险的技术,否则,这这能带来线程安全性的假象。)无论做出哪种选择都很难令人满意。
更糟糕的是,我们的直觉通常是错误的:我们认为“可能是线程安全的”的类通常不是线程安全的。例如,java.text.SimpleDateFormat 并不线程安全,但 JDK 1.4 之前的 Javadoc 并没有提到这点。许多开发人员都对这个事实感到惊讶。有多少程序已经错误的生成了这种非线程安全的对象,并在多线程中使用它们?这些程序没有意识到这将在高负载的情况下导致错误的结果。
如果某个类没有明确的声明是线程安全的,那么就不要假设它是线程安全的,从而有效的避免类似于 SimpleDateFormat 中的问题。而另一方面,如果不对容器提供对象(如 HttpSession) 的线程安全性做出某种有问题的假设,也就不可能开发出一个基于 Servlet 的应用程序。不要使你的客户或同事做这样的猜测。
解释含糊的文档
许多 Java 技术规范都没有说明接口的线程安全性,例如 ServletContext、HttpSession、DataSource。这些接口是由容器或者数据库供应商来实现的,而你通常无法通过查看其源码来了解细节功能。此外,你也不希望依赖于某个特定的 JDBC 驱动的实现细节——你希望遵从标准,这样代码可以基于任何一个 JDBC 驱动工作。但在 JDBC 的规范中从未出现“线程”和“并发”这些术语,同样在 Servlet 规范中也很少提到。那么你改做些什么?
你只能取猜测。一个提高猜测准确性的做法是,从实现者的角度去解释规范,而不是从使用者的角度去解释。Servlet 通常是在容器管理的线程中调用的,因此可以安全的假设:如果有多个这样的线程在运行,那么容器是知道这种情况的。Servlet 容器能生成一些为多个 Servlet 提供服务的对象,例如 HttpSession 或 ServletContext。因此,Servlet 容器应该预见到这些对象将被并发访问,因为它创建了多个线程,并且从这些线程中调用像 Servlet.service 这样的方法,而这个方法很可能会访问 ServletContext。
由于这些对象在单线程的上下文中很少是有用的,因此我们不得不假设它们已被实现为线程安全的,即使在规范中没有明确的说明。此外,如果需要客户端加锁,那么客户端代码应该在哪个锁上同步?在文档中没有说明这一点,而要猜测的话也不知从何猜起。在规范和正式手册中给出的如何访问 ServletContext 或 HttpSession 的示例中进一步强调了这种合理的假设,因为么有使用任何客户端同步。
另一方面,通过把 setAttribute 放到 ServletContext 中或者将 HttpSession 的对象由 Web 应用程序拥有,而不是由 Servlet 容器拥有。在 Servlet 规范中没有给出任何机制来协调对这些共享属性的并发访问。因此,由容器代替 Web 应用程序来保存这些属性应用是线程安全的,或者是不可变的。如果容器的工作只是代替 Web 应用程序来保存这些属性,那么当从 Servlet 应用程序代码访问他们时,应该确保他们始终由同一个锁保护。但由于容器可能需要序列化 HttpSession 中的对象复制或者钝化等操作,并且容器不可能知道你的加锁协议,因此你要自己确保这些对象是线程安全的。
3.1.5 - CH05-基础构建块
第四章介绍了构建线程安全类时采用的一些技术,例如将线程安全性委托给现有的线程安全类。委托是创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可。
Java 平台类库包含了丰富的并发基础构建块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流的同步工具(synchronizer)。本章将介绍其中一些最有用的并发构建模块,特别是在 Java 5 和 6 中引入的新模块,以及在使用这些模块来构建并发应用程序时的一些常用模式。
5.1 同步容器类
同步容器类包括 Vector 和 Hashtable,二者是早期 JDK 的一部分,此外还包括一些在 JDK 1.2 中添加的功能相似的类,这些同步的封装容器类是由 Collections.synchronizedXxx 等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
5.1.1 同步容器类的问题
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来来保护复合操作。容器上最常见的复合操作包括:迭代、跳转、条件运算,例如“若没有则添加”。在同步容器中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但在其他线程并发的修改容器时,它们可能会表现出意料之外的行为。
程序清单 5-1 给出了 Vector 中定义的两个方法:getLast 和 deleteLast,它们都会执行“先检查再运行”。每个方法首先都获得数组的大小,然后通过结果来获取或删除最后一个元素。
public static Object getLast(Vector list) {
int lastIndex = list.size -1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() -1;
list.remove(lastIndex);
}
这些方法看似没有任何问题,从某种程度上来看也确实如此——无论多少个线程同时调用它们也不会破坏 Vector。但从这些方法的调用者角度来看,情况就不同了。如果线程 A 在包含 10 个元素的 Vector 上调用 getLast,同时线程 B 在同一个 Vector 上调用 deleteLast,这些操作的交替执行如图 5-1 所示,getLast 将抛出数组索引越界异常。在调用 size 与调用 getLast 这两个操作之间,Vector 变小了,因此在调用 size 时得到的索引将不再有效。这种情况很好的遵循了 Vector 的规范——如果请求一个不存在的元素,那么将抛出一个异常。但这并不是 getLast 的调用者希望得到的结果(即使是在并发修改的情况下也不希望看到),除非 Vector 从一开始就是空的。
由于同步容器类要遵循同步策略,即支持客户端加锁,因此可能会创建一些新的操作,只要我们知道应该使用哪个锁,那么这些操作就会与容器的其他操作一样都是原子的。同步容器类通过其自身的锁来保护它的每个方法。通过获得容器类的锁,我们可以使 getLast 和 deleteLast 成为原子操作,并确保 Vector 的大小在调用 size 和 get 之间不会发生变化,如程序清单 5-2 所示。
public static Object getLast(Vector list) {
synchronized(list){
int lastIndex = list.size -1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized(list){
int lastIndex = list.size() -1;
list.remove(lastIndex);
}
}
在调用 size 和相应的 get 之间,Vector 的长度可能会发生变化,这种风险在于对 Vector 中的元素进行迭代时仍然会出现,如程序清单 5-3 所示。
for(int i=0; i<vector.size(); i++){
doSomething(vector.get(i));
}
这种迭代操作的正确性要依赖运气,即在调用 size 和 get 之间没有线程会修改 Vector。在单线程环境下,这种假设完全成立,但在有其他线程并发的修改 Vector 时,则可能导致麻烦。与 getLast 一样,如果在对 Vector 进行迭代时,另一个线程删除了一个元素,并且这两个操作交替执行,那么这种迭代方法将抛出数组索引越界异常。
虽然在程序清单 5-3 的迭代操作中可能抛出异常,并并不意味着 Vector 就不是线程安全的。Vector 的状态仍然是有效的,而抛出的异常也与其规范保持一致。然而,像在读取最后一个元素或者迭代等这样简单的操作中抛出异常显然不是人们所期望的。
我们可以通过在客户端加锁来解决不可迭代的问题,但要牺牲一些伸缩性。通过在迭代期间持有 Vector 的锁,可以防止其他线程在迭代期间修改 Vector,如程序清单 5-4 所示。然而,着同样会导致其他线程在迭代期间无法访问它,因此降低了并发性。
synchronized (vector) {
for(int i=0; i<vector.size(); i++){
doSomething(vector.get(i));
}
}
5.1.2 迭代器与 ConcurrentModificationException
为了将问题阐述清楚,我们使用了 Vector,虽然这是一个古老的容器类。然而,许多现代的容器类也并没有消除复合操作中的问题。无论直接迭代还是 Java 5.0 引入的 foreach 循环语法中,对容器类迭代的标准方式都是使用 Iterator。然而,如果有其他线程并发的修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,并且它们表现出的行为是“及时失败”的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出 ConcurrentModificationException。
这种“及时失败”的迭代器并不是一种完备的处理器机制,而只是“善意的”捕获并发错误。因此只能作为并发问题的预警指示器。它们采用的实现方式是,将计数器的变化与容器关联起来:如果迭代器件计数器被修改,那么 hasNext 或 next 将抛出异常。然而,这种检查是在没有同步的情况下进行的,因此可能会看到失效的计数值,而迭代器可能并没有意识到已经发生了修改。这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能带来的影响。
程序清单 5-5 说明了如何使用 for-each 循环语法对 List 容器进行迭代。从内部来看,javac 将生成使用 iterator 的代码,返回调用 hasNex 和 next 来迭代 List 对象。与迭代 Vector 一样,要想避免出现并发修改异常,就必须在迭代过程中持有锁。
List<Widget> widgetList =
Collections.synchronizedList(new ArrayList<>());
...
// 可能抛出并发修改异常
for(Widget w: widgetList)
doSomething(w);
然而,有时候开发人员并不希望在迭代期间对容器加锁。例如,某些线程在可以访问容器之前,必须等待迭代过程结束,如果容器的规模很大,或者在每个元素上执行操作的时间很长,那么这些线程将长时间等待。同样,如果容器像程序清单 5-4 中那样的锁,那么在调用 doSomething 时将持有一个锁,还可能会产生死锁。即使不存在饥饿或死锁等风险,长时间对容器加锁也会降低程序的可伸缩性。持有锁的时间越长,那么在锁上的竞争就可能越激烈,如果许多线程都在等待锁被释放,那么将极大的降低吞吐量和 CPU 的利用率。
如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出并发修改异常。在克隆容器时存在显著的性能开销。这种方式的好坏取决于多个因素,包括容器的大小、在每个元素上执行的工作,迭代操作相对于容器其他操作的调用频率,以及在响应时间和吞吐量等方面的需求。
5.1.3 隐藏迭代器
虽然加锁可以放置迭代器抛出并发修改异常,但你必须记住在所有对共享容器进行迭代的地方都需要锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来,如程序清单 5-6 中的 HiddenIterator 所示。在 HiddenIterator 中没有显式的迭代操作,但在粗体标出的代码中将执行迭代操作。编译器将字符串的连接操作转换为调用 StringBuilder.append(Object),而这个方法又会调用容器的 toString 方法,标准容器的 toString 方法将迭代容器,并在每个元素上调用 toString 来生成容器内容的格式化表示。
public class HiddenIterator {
@GuardedBy("this")
private final Set<Integer> set = new HashSet<>();
public synchronized void add(Integer i) {
set.add(i);
}
public synchronized void remove(Integer i){
set.remove(i);
}
public void addTenThings() {
Random r = new Random();
for(int i=0; i<10; i++)
add(r.nexInt());
System.out.println("DEBUG..." + set);
}
}
addTenThings 方法可能会抛出并发修改异常,因为在生成调试信息的过程中,toString 对容器进行迭代。当然,真正的问题在于 HiddenIterator 不是线程安全的。在使用 printlng 中的 set 之前必须首先获取 HiddenIterator 的锁,但在调试代码和日志代码中通常会忽视这个要求。
这里得到的教训是,如果状态与保护它的代码之间相隔甚远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果 HiddenIterator 用 synchronizedSet 来包装 HashSet,并且对同步代码进行封装,那么就不会发生这种错误。
正如封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保实施同步策略。
容器的 hashCode 和 equals 等方法也会间接的执行迭代操作,当容器作为另一个容器的元素或键值时,就会出现这种情况。同样,containsAll、removeAll、retainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出并发修改异常。
5.2 并发容器
Java 5.0 提供了多种并发容器来改进同步容器的性能。同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方法的代价是验证降低并发性,当多个线程竞争容器的锁时,吞吐量会严重降低。
另一方面,并发容器是针对多个线程并发访问设计的。在 Java 5.0 中增加了 ConcurrentHashMap,用来代替同步且基于散列的 Map,以及 CopyOnWriteArrayList,用于在遍历操作为主要操作的情况下代替同步的 List。在新的 ConcurrentMap 接口中增加了对一些常见复合操作的支持,例如“若没有则添加”、替换、有条件删除等。
通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险。
Java 5.0 增加了两种新的容器类型:Queue 和 BlockingQueue。Queue 用来临时保存一组等待处理的元素。它提供了几组实现,包括:ConcurrentLinkedQueue,这是一个传统的 FIFO 队列,以及 PriorityQueue,这是一个(非并发的)优先级队列。Queue 上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用 List 来模拟 Queue 的行为——事实上,正是通过 LinkedList 来实现 Queue 的,但还是需要一个 Queue 的类,因为它能去掉 List 的随机访问需求,从而实现高效的并发。
BlockingQueue 扩展了 Queue,增加了可阻塞的插入和获取等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
正如 ConcurrentHashMap 用于代替基于散列的同步 Map,Java 6 也引入了 ConcurrentSkipListMap 和 ConcurrentSkipListSet,分别作为同步的 SortedMap 和 SortedSet 的并发替代品。
5.2.1 ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。在一些操作中,例如 HashMap.get 或 List.contains,可能包含大量的工作:当遍历散列桶或链表来查找特定对象时,必须在许多元素上调用 equals(这包含一定的计算量)。在基于散列的容器中,如果 hashCode 不能很均匀的分布散列值,那么容器中的元素就不会均匀的分布在整个容器中。某些情况下,某个糟糕的散列函数还会把一个散列表编程线性链表。当遍历很长的链表并且在某些或全部元素上调用 equals 方法时,会花费很长的时间,而其他线程在这段时间内都不能访问该容器。
与 HashMap 一样,ConcurrentHashMap 也是一个基于散列的 Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性。ConcurrentHashMap 并不是将每个方法多在同一个锁上同步并且每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更更大程度的共享,这种机制称为“分段锁”。在这种机制中,任意数量的读取线程都可以并发的访问 Map,执行读取操作的线程和执行写入操作的线程可以并发的访问 Map,并且一定数量的写入线程可以并发的修改 Map。ConcurrentHashMap 带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只有非常小的性能损失。
ConcurrentHashMap 与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出并发修改异常,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap 返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
尽管有这些改进,但仍存存在一些需要权衡的因素。对于一些需要在整个 Map 上进行计算的方法,比如 size 和 isEmpty,这些方法的语义被略微减弱了,以反映容器的并发特性。由于 size 放回的结果在计算时可能已经过期了,它实际上只是一个估计值,因此允许 size 返回一个近似值而非精确值。虽然看上去有些令人不安,但事实上 size 和 isEmpty 这样的方法在并发环境下的用处很小,因为它们的返回值总是在不断变化。因此,这些操作的需求被弱化了,以换取对其他更重要操作的性能优化,比如 get、put、containsKey、remove 等。
在 ConcurrentHashMap 中没有实现对 Map 加锁以提供独占访问。在 Hashtable 和 synchronizedMap 中,获得 Map 的锁能防止其他线程访问该 Map。在一些不常见的情况中需要这种功能,例如通过原子方式添加一些映射,或者对 Map 迭代若干次并在此期间保持元素顺序相同。然而,总体来说这种权衡是合理的,因为并发容器的内容会持续变化。
与 Hashtable 和 synchronizedMap 相比,ConcurrentHashMap 有着更多的优势以及更少的劣势,因此在大多数情况下,用 ConcurrentHashMap 来代替同步 Map 能进一步提高代码的可伸缩性。只有当应用程序需要加锁 Map 以进行独占访问时,才应该放弃使用 ConcurrentHashMap。
5.2.2 额外的原子 Map 操作
由于 ConcurrentHashMap 中不能被加锁以执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作,例如 4.4.1 节中对 Vector 增加原子操作“若没有则添加”。但是,一些常见的复合操作,例如“若没有则添加”、“若相等则移除”和“若相等则替换”等,都已经实现为原子操作并且在 ConcurrentMap 中声明为接口,如程序清单 5-7 所示。如果你需要在现有的 Map 中添加这样的功能,那么很可能就意味着应该考虑使用 ConcurrentMap 了。
public interface ConcurrentMap<K,V> extends Map<K,V> {
V putIfAbsent(K key, V value);
boolean remove(K key, V value);
boolean replace(K key, V oldValue, V newValue);
V replace(K key, V newValue);
}
5.2.3 CopyOnWriteArrayList
CopyOnWriteArrayList 用于替代同步 List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。
“写时复制”容器的线程安全性在于,只要正确的发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于他不会被修改,因此在对其进行同步时只需要确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写时复制”容器返回的迭代器不会抛出并发修改异常,并且返回的元素与迭代器创建时的元素保持一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写时复制”容器。这个准则很好的描述了许多事件通知系统:在分发通知时需要迭代已注册的监听器列表,并调用每个监听器,在大多数情况下,注册和注销事件监听器的操作远少于接收事件通知的操作。
5.3 阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的 put 和 take 方法,以及支持定时的 offer 和 poll 方法。如果队列已经满了,那么 put 方法将持续阻塞直到有空间可用;如果队列为空,那么 take 方法将只需阻塞直到有元素可用。队列可以是有界的或无界的,无界队列永远不会满,因此无界队列上的 put 操作永远不会阻塞。
阻塞队列支持生产者-消费者设计模式。该模式将“找出需要完成的工作”与“执行工作”这两个过程分离,并把工作项放入一个“待完成”列表中以便在随后处理,而不是找出后立即处理。生产者—消费者模式能简化开发过程,因为它消除了生产者类和消费者类之间的代码依赖性,此外,该模式还将生产数据的过程与使用数据的过程解耦开来以简化工作负载的管理,因为这两个过程在处理数据的速率上可以有所不同。
在基于阻塞队列的生产者—消费者设计中,当生产数据时,生产者将数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需要将数据放入队列即可。同样,消费者也不需要知道生产者是谁,或者工作来自何处。BlockingQueue 简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。一种最常见的生产者-消费者设计模式就是将线程池与工作队列的组合,在 Executor 任务执行框架中体现了这种模式。
一两个人洗盘子为例,二者的劳动分工也是一种生产消费模式:其中一个人把喜好的盘子放在架子上,而另一个人从架子上取出盘子并把它们烘干。在这个实例中,架子相当于阻塞队列。如果架子上没有盘子,那么消费者会一直等待,知道有盘子需要烘干。如果架子上放满了,那么生产者会停止清洗直到架子上有更多的空间。我们可以将这种类比扩展为多个生产者和多个消费者,每个工人只需要与架子打交道。人们不需要知道究竟有多少生产者或消费者,或者是谁生产了某个指定的工作项。
“生产者”和“消费者”的角色是相对的,某种环境中的消费者在另一种不同的环境中可能会成为生产者。烘干盘子的工人将“消费”洗干净的盘子,而产生干燥的盘子。第三个工人把烘干后的盘子整理好,在这种情况下,烘干盘子的工人既是生产者、又是消费者,从而就有了两个共享的工作队列。
阻塞队列简化了消费者程序的编码,因为 take 操作会一直阻塞直到有可用的数据。如果生产者不能尽快产生工作项使消费者保持忙碌,那么消费者就只能保持等待,直到有工作可做。在某些情况下,这种方式是非常合适的,而在其他一些情况下,这也表示需要调整生产者线程数量和消费者线程数量之间的比例,从而实现更高的资源利用率。
如果生产者生成工作的效率比消费者处理工作的效率快,那么工作项会在队列中基类起来,最终耗尽内存。同样,put 方法的阻塞特性也极大地简化了生产者的编码。如果使用有界队列,那么当队列充满时,生产者将阻塞并且不能继续生成工作,而消费者就有时间来赶上工作处理速度。
阻塞队列同样提供了一个 offer 方法,如果数据项不能被添加到队列中,那么将返回一个失败状态。这样你就能够创建更多灵活的策略来处理符合过载的情况,例如减轻负载,将多余的工作项序列化并写入磁盘,减少生产者线程的数量,或者通过某种方式来抑制生产者线程。
在构建可靠的应用程序时,有界队列是一种强大的资源管理工具:它能能抑制并防止产生过多的工作项,使应用程序在符合过载的情况下变得更加健壮。
虽然生产者和消费者模式能够将生产者和消费者的代码彼此解耦,但它们的行为依然会通过共享队列间接耦合在一起。开发人员总会假设消费者处理工作的速率能够赶上生产者生成工作项的速率,因此通常不会为工作队列的大小设置边界,但这将导致在之后需要重新设计系统架构。因此,应该尽早通过阻塞队列在设计中构建资源管理机制——这件事情做得越早就越容易。在许多情况下,阻塞队列能够使这项工作更加简单,如果阻塞队列并不完全符合设计需求,那么还可以通过信号量来创建其他的阻塞数据结构。
在类库中包含了 BlockingQueue 的多种实现,其中 LinkedBlockingQueue 和 ArrayBlockingQueue 是 FIFO 队列,二者分别于 LinkedList 和 ArrayList 类似,但比同步 List 拥有更好的并发性能。PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是 FIFO 来处理元素时,这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue 既可以根据元素的自然顺序来比较元素,也可以自定义 Comparator。
最后一个 BlockingQueue 实现是 SynchronousQueue,实际上他不是一个真正的队列,因为他不会为队列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移出队列。如果以洗盘子的比喻为例,那么就相当于没有架子,而是将喜好的盘子直接放入下一个空闲的烘干机中。这种实现队列的方式看似很奇怪,但由于可以直接交付工作,从而降低了间数据从生产者移动到消费者的延迟。直接交付方式还会将更多关于任务状态的信息反馈给生产者。当交付被接受时,它就知道消费者已经得到了任务,而不是把任务简单的放入一个队列——这种区别就好比将文件直接交给同事,还是将文件放到它的邮箱中并希望它尽快拿到文件。因为 SynchronousQueue 没有存储功能,因此 put 和 take 会一直阻塞,知道有另一个线程已经准备好参与到交付工作中。仅当有足够多的消费者,并且总有一个消费者准备好后去交付的工作时,才适合使用同步队列。
5.3.1 示例:桌面搜索
有一种类型的程序适合被分解为生产者和消费者,如代理程序,它将扫描本地驱动器上的文件并建立索引以便随后进行搜索,类似于这些桌面搜索程序或者 Windows 索引服务。在程序清单 5-8 的 DiskCrawler 中给出了一个生产者任务,即在某个文件层次架构中搜索符合索引标准的文件,并将它们的名称放入工作队列。而且,在 Indexer 中还给出了一个消费者任务,即从队列中取出文件名称并对它们建立索引。
public class FileCrawler implements Runnable {
private final BlockingQueue<File> fileQueue;
private final FileFilter fileFilter;
pribate final File root;
...
public void run() {
try {
crawl(root);
} catch (InterruptedException e) {
Thread.currentThread.interrupt();
}
}
private void crawl(File root) thorows InterruptedException {
File[] entries = root.listFiles(fileFilter);
if(entries != null) {
for(File entry : entries){
if(entry.isDierctory())
crawl(entry);
else if(!alreadyIndexed(entry))
fileQueue.put(entry);
}
}
}
}
public class Indexer implements Runnable {
private final BlockingQueue<File> queue;
public Indexer(BlockingQueue<File> queue) {
this.queue = queue;
}
public void run() {
try {
while(true){
indexFile(queue.take());
}
} catch(InterruptedException e) {
Thread.currentThread.interrupt();
}
}
}
生产者消费者模式提供了一种适合线程的方法将桌面搜索问题分解为更简单的组件。将文件遍历与建立索引等功能分解为独立的操作,比将所有功能都放在一个操作中实现有着更高的代码可读性和可重用性:每个操作只需完成一个任务,并且阻塞队列将负责所有的控制流,因此每个功能的代码更加清晰简单。
生产消费模式同样能带来许多性能优势。生产者和消费者可以并发的执行。如果一个是 IO 密集型,另一个是 CPU 密集型,那么并发执行的吞吐率要高于串行执行的吞吐率。如果生产者和消费者的并行度不同,那么将它们紧密耦合在一起会把整体并行度降低为二者中更小的并行度。
在程序清单 5-9 中启动了多个爬虫程序和索引建立程序,每个程序都在各自的线程中运行。前面曾讲,消费者线程永远不会退出,因而程序无法终止,第 7 章将介绍多种技术来解决这个问题。虽然这个示例使用了显式管理的线程,但许多生产消费者设计也可以通过 Executor 任务执行框架来实现,其本身也使用了生产消费模式。
5.3.2 串行线程封闭
在 JUC 中实现的各种阻塞队列都包含了足够的内部同步机制,从而安全的将对象从生产者线程发布到消费者线程。
对于可变对象,生产消费这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全的发布该对象来转移所有权。在转移所有权之后,也只有另一个线程能获得这个对象的访问权限,并且发布该对象的线程无法再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象被封闭在新的线程中。新的所有者线程可以对该线程做任意修改,因为它拥有独占的访问权。
对象池利用了串行封闭技术,将对象“借给”一个请求线程。只要对象池包含足够的内部同步来安全的发布池中的对象,并且只要客户端本身不会发布池中的对象,或者在将对象返回给对象池后就不再使用它,那么就可以安全的在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接收被转移的对象。阻塞队列简化了这项工作。除此之外,可以通过 ConcurrentMap 的原子方法 remove 或 AtomicReference 的原子方法 compareAndSet 来完成这项工作。
5.3.3 双端队列与工作密取
Java 6 增加了两种容器类型,Deque(读作 “deck”) 和 BlockingQueue,它们分别对 Queue 和 BlockingQueue 进行了扩展。Deque 是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现还包括 ArrayDeque、LinkedBlockingDeque。
正如阻塞队列适用于生产消费模式,双端队列同样适用于另一种相关的模式,即工作窃取(Work Stealing)。在生产消费设计中,所有消费者有一个共享的工作队列,而在工作窃取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己的双段队列的全部工作,那么它可以从其他消费者双端队列末尾窃取工作。窃取工作模式比传统的的生产消费模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享队列上发生竞争。在大多数时候,它们只是访问自己的双端队列,从而极大减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
工作窃取非常适用于既是消费者又是生产者的问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多图搜索算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作窃取机制来实现高效并发。当一个工作线程找到新的任务单元时,它会将其放到自己队列的末尾(或者在工作共享设计模式中,放入其他工作者线程的队列中)。当双端队列为空时,它会在另一个线程的队列末尾查找新的任务,从而确保每个线程都保持忙碌状态。
5.4 阻塞方法与中断方法
线程可能会阻塞或暂停执行,原因有多种:等待 IO 操作结束,等待获得一个锁,等待从 Thread.sleep 方法中醒来,或者是等待另一个线程的计算结果。当线程阻塞时,它通常会被挂起,并处于某种阻塞状态(BLOCKED/WAITING/TIMED_WAITING)。阻塞操作与执行时间很长的普通操作的差别在于,被阻塞的线程必须等待某个不受它控制的事件的发生后才能继续执行,比如等待 IO 操作完成,等待某个锁变为可用,或者等待外部计算结束。当某个外部事件发生时,线程被置回 RUNNABLE 状态,并可以再次被调用执行。
BlockingQueue 的 put 和 take 等方法会抛出受检异常 InterruptedException,这与类库中其他一些方法的做法相同,例如 Thread.sleep。当某个方法被声明为会抛出中断异常时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
Thread 提供了 interrupt 方法,用于中断线程或查询线程是否已经被中断。每个线程都有一个布尔值属性,表示线程的中断状态,当中断线程时将设置该状态为 true。
中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程 A 中断 B 时,A 仅仅是要求 B 在执行到某个可暂停的地方时停止正在执行的操作——前提是如果线程 B 愿意停下来。虽然在 API 或者语言规范中并没有为中断定义任何特定应用级别的语义,但最常用中断的情况就是取消某个操作。方法对中断请求的响应度越高,就越容易及时取消那些执行时间很长的操作。
当在代码中调用一个将抛出中断异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理中断的响应。对于库代码来说,有两种方案可供选择:
- 传递中断异常。避开这个异常通常是最明智的选择——只需要把中断异常继续传递给外层调用者。传递中断异常的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的轻量工作后再次抛出该异常。
- 恢复中断。有时候不能抛出中断异常,例如当代码是 Runnable 的一部分时。在有些情况下,必须捕获中断异常,并通过调用当前线程上的 interrupt 方法恢复到中断状态,这样在调用栈中更高层的代码将看到引发了一个中断,如程序清单 5-10 所示。
public class TaskRunnable implements Runnable {
BlockingQuque<Task> queue;
public void run() {
try {
process(queue.take());
} catch(InterruptedException e) {
// 恢复被中断的状态
Thread.currentThread().interrupt();
}
}
}
还可以采用一些更复杂的中断处理方法,但上述两种方法已经可以应付大多数场景了。然而在出现中断异常时不应该做的事情是,捕获它但不做任何响应。这将使调用栈上更高层的代码无法对中断采取处理措施,因为线程被中断的证据已经丢失。只有在一种特殊的情况下才可以屏蔽中断,即对 Thread 进行扩展,并且能够控制调用栈上所有更高层的代码。第 7 章将进一步介绍取消和中断操作。
5.5 同步工具类
在容器类中,阻塞队列是一种特殊的类:它们不仅作为保存对象的容器,还能协调生产者和消费者之间的控制流,因为 take 和 put 等方法将阻塞,直到队列达到期望的状态(对既非空也不满)。
同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为一个同步工具类,其他类型的同步工具类还有信号量(Semaphore)、栅栏(Barrier)、闭锁(Latch)。在平台类库中还包含一些其他同步工具类,如果这些类无法满足需要,那么可以按照第 14 章给出的机制来构建自己的同步工具类。
所有的同步工具类都包含一些特定的结构化属性:它们封装了状态,这些状态将决定执行同步工作类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效的等待同步工具类进入预期状态。
5.5.1 闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程可以通过,当到达状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会在改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续进行,比如:
- 确保某个计算在其需要的所有资源都被初始化之后才继续进行。二元闭锁(包含两个状态)可以用来表示“资源 R 已经被初始化”,而所有需要 R 的操作都必须先在这个闭锁上等待。
- 确保某个服务在其依赖的所有其他服务都已启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务 S 时,将首先在 S 依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁 S,这样其他依赖 S 的服务才能继续执行。
- 等待直到某个操作的所有参与者(例如在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况下,当所有玩家都准备继续时,闭锁将到达结束状态。
CountDownLatch 是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown 方法将递减计数器,表示已经发生了一个事件,而 await 方法等待计数器到达零,或者等待中的线程中断,或者等待超时。
在程序清单 5-11 中的 TestHarness 中给出了闭锁的两种常见方法。TestHarness 创建一定数量的线程,利用它们并发执行指定的任务。它使用两个闭锁,分别表示“起始门”和“结束门”。起始门的计数器为 1,而结束门的计数器为工作线程的数量。每个工作线程首先要做的事就是在启动门上等待,从而确保所有线程都已就绪后才开始执行。而每个线程要做的最后一件事就是调用结束门的 countDown 方法将计数器递减 1,这能使主线程高效的等待直到所有工作线程都执行完成,因此可以统计所消耗的时间。
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate =
new CountDownLatch(1);
final CountDownLatch endGate =
new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try { task.run(); }
finally { endGate.countDown(); }
} catch (InterruptedException ignored) { }
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end-start;
}
}
这里为什么要使用闭锁,而不是在线程创建后直接启动?或许,我们希望测试 N 个线程并发执行某个任务时需要的时间。如果在创建线程后立即启动,那么先启动的线程将“领先”后启动的线程,并且活跃线程数量会随着时间的推移而增加或减少,蒋政程度也在不断的发生变化。启动门将使得主线程能够同时释放所有都工作线程,而将结束门则使主线程能够等待最后一个线程执行完成,而不是顺序的等待各个线程执行完成。
5.5.2 FutureTask
FutureTask 也可以用作闭锁。FutureTask 实现了 Future 语义,表示一种可生产结果的计算。FutureTask 表示的计算是通过 Callable 来实现的,相当于一种可生成结果的 Runnable,并且可以处于以下三种状态:等待运行、正在运行、运行完成。“执行完成”表示计算的所有可能的结束方式,包括正常结束、由于取消而结束、由于异常而结束等。当 FutureTask 进入完成状态后,它会永远停止在这个状态上。
Future.get 的行为取决于任务的状态。如果任务已经完成,会立即返回结果,否则,get 调用将阻塞直到任务进入完成状态,然后返回结果或抛出异常。FutureTask 将计算结果从执行计算的线程传递到获取结果的线程,而 FutureTask 的规范确保了这种传递过程能实现结果的安全发布。
FutureTask 在 Executor 框架中表示异步任务,此外还可以用于表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。程序清单 5-12 中的 Preloader 就使用了 FutureTask 来执行一个高开销的计算,并且计算结果将在稍后使用。通过提前启动计算,可以减少在等待结束时需要的时间。
public class Preloader {
private final FutureTask<ProductInfo> future =
new FutureTask<ProductInfo>(new Callable<>(){
public ProductInfo call() throws DataLoadException {
return loadProductInfo();
}
});
private final Thread thread = new Thread(future);
public void start() { thread.start(); }
public ProductInfo get() throws ex..{
try {
return future.get();
} catch (ExecutionException e){
Throwable cause = e.getCause();
if (cause instanceof DataLoadException)
throw (DataLoadException) cause;
else throw launderThrowable(cause);
}
}
}
Preloader 创建了一个 FutureTask,其中包含从数据库加载产品信息的任务,以及一个执行运算的线程。由于在构造函数或静态初始化方法中启动线程并不是一种好方法,因此提供了一个 start 方法来启动线程。当程序随后需要 ProductInfo 时,可以调用 get 方法,如果数据已经加载,那么将返回这些数据,否则将等待直到加载完成后再返回。
Callable 表示的任务可以抛出受检异常或未检异常,并且任何代码都可能抛出一个 Error。无论任务代码抛出什么异常,都会被封装到一个 ExecutionException 中,并在 Future.get 中重新被抛出。这将使调用 get 的代码变得复杂,因为不仅要处理可能出现的 ExecutionException,而且还由于 ExecutionException 是座椅个 Throwable 返回的,因此处理起来并不容易。
在 Preloader 中,当 get 方法抛出 ExecutionException 时,可能是以下三种情况之一:Callable 抛出的受检异常、RuntimeException、Error。我们必须对每种情况进行单独处理,但我们将使用程序清单 5-13 中的 launderThrowable 辅助方法来封装一些复杂的异常处理逻辑。在调用 launderThrowable 之前,Preloader 会首先检查已知的受检异常,并重新抛出它们,Preloader 将调用 launderThrowable 并抛出结果。如果 Throwable 传递给 launderThrowable 的是一个 Error,那么 launderThrowable 将直接再次将其抛出;如果不是 RuntimeException,那么将抛出一个非法状态异常表示这是一个逻辑错误。剩下的 RuntimeException,launderThrowable 将把它返回给调用者,而调用者通常会重新抛出这些异常。
/** If the Throwable is an Error, throw it; if it is a
* RuntimeException return it, otherwise throw IllegalStateException
*/
public static RuntimeException launderThrowable(Throwable t) {
if (t instanceof RuntimeException)
return (RuntimeException) t;
else if (t instanceof Error)
throw (Error) t;
else
throw new IllegalStateException("Not unchecked", t); }
5.5.3 信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
Semaphore 中管理着一组虚拟许可,许可的和数量可以通过构造函数来指定。在执行操作时可以首先获得许可(如果还有剩余的许可),并在使用后释放许可。如果没有许可,那么 acquire 将阻塞直到有许可(或者直到被中断或者操作超时)。release 方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为 1 的 Semaphore。二值信号量可以用作互斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。
Semaphore 可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,当池为空时,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。如果将 Semaphore 的计数值初始化为池的大小,并在池中获取一个资源之前首先调用 acquire 方法获得一个许可,在将资源返回给池之后调用 release 释放许可,那么 acquire 将一直阻塞直到资源池不为空。在第 12 章的有界缓冲类中将使用这项技术。(在构造阻塞对象池时,一种更简单的方法是使用 BlockingQueue 来保存池的资源。)
同样,你也可以使用 Semaphore 将任何一种容器变为有界阻塞容器,如程序清单 5-14 中的 BoundledHashSet 所示。信号量的计数值会初始化为容器容量的最大值。add 操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果 add 操作没有添加任何一个元素,那么会立刻释放许可。同样,remove 操作释放一个许可,使更多元素能够添加到容器中。底层的 Set 实现并不知道关于边界的任何信息,这是由 BoundedHashSet 来处理的。
public class BoundedHashSet<T> {
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound) {
this.set =
Collecitons.synchronizedSet(new HashSet<T>());
this.sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
} finally {
if(!wasAdded) sem.release();
}
}
public boolean remove(Object o) {
boolean wasRemoved = set.remove(o);
if(wasRemoved)
sem.release();
return wasRemoved;
}
}
5.5.4 栅栏
我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能再被重置。
栅栏类似于闭锁,它能阻塞一组线程直到某个事件繁盛。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议,例如几个家庭决定在某个地方回合:“所有人 6:00 在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情。”
CyclicBarrier 可以使用一定数量的参与方反复的在栅栏处汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列互相独立的子问题。当线程到达栅栏位置时将调用 await 方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对 await 的调用超时,或者 await 阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的 await 调用都将终止并抛出 BrokenArrierException。如果成功通过栅栏,那么 await 将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。CyclicBarrier 还可以使你将一个栅栏操作传递给构造函数,这是一个 Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
在模拟程序中通常要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都执行完毕才能进入下一个步骤。例如,在 n-body 粒子迷模拟系统中,每个步骤都根据其他粒子的位置和属性来计算各个粒子的新位置。通过在么两次调用之间等待栅栏,能够确保在第 K 步中的所有操作都已经计算完成,才进入第 k+1 步。
在程序清单 5-15 的 CellularAutomata 中给出了如何通过栅栏来计算细胞的自动化模拟,例如 Conway 的生命游戏。在吧模拟过程并行化后,为么个元素(在该例子中相当于一个细胞)分配一个独立的线程是不现实的,因为浙江产生过多的线程,而在协调这些线程上导致的开销将降低计算性能。合理的做法是,将问题分解成一定数量的子问题,为每个子问题分配一个线程来进行求解,之后再将所有的结果合并起来。CellularAutomata 将问题分解为 N(CPU) 个子问题,其中 N 等于 CPU 的数量,并将每个子问题分配给一个线程。在每个步骤中,工作线程都为各自问题中的所有细胞计算新值。但所有工作线程都到达栅栏时,栅栏会把这些新值提交给数据模型。在栅栏的操作执行完成以后,工作线程将开始下一步的计算,包括调用 isDone 方法来判断是否还需要进行下一步迭代。
public class CellularAutomata {
private final Board minBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomate(Board board) {
this.mainBoard = board;
int count = Runtime.getRuntime().availiableProcessessors();
this.barrier = new CyclicBarrier(count,
new Runnable() {
public void run() {
mainBoard.commitNewValues();
}});
this.workers = new Wokrer[count];
for(int i=0; i<count; i++)
workers[i] =
new Worker(mainBoard.getSubBoard(count, i));
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) {
this.board = board;
}
public void run() {
while(!board.hasConverged()) {
for(int x=0; x<board.getMaxX(); x++)
for(int y=0; y<board.getMaxY(); y++)
board.setNewValue(
x,y,computeValue(x,y));
try {
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex){
return;
}
}
}
}
}
另一种形式的栅栏是 Excheanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger 会非常有用,比如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用 Exchanger 来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过 Exchanger 交换对象时,这种交换就把这两个对象安全的发布给对方。
数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,由清空任务进行交换。这样会把需要交换的次数将至最低,但如果新数据的到达率不可预测,那么一些数据的处理过程就将延迟。另一个方法是,不仅当缓冲区被填满时进行交换,并且当缓冲被填充到一定程度并保持一段时间后,也进行交换。
5.6 构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的计算结果能降低延迟,提供吞吐量,但却需要消耗更多的内存。
像许多重复发明的轮子一样,缓存看上去都非常简单。然而,简单的缓存可能会将性能瓶颈转换为可伸缩性瓶颈,即使缓存是用于提升单线程的性能。本节我们将开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数。我们首先从简单的 HashMap 开始,然后分析它的并发缺陷性,并讨论如何修复它。
在程序清单 5-16 的 Computable<A,V>
接口中声明了一个函数 Computable,其输入类型为 A,输出类型为 V。在 ExpensiveFunction 中实现的 Conputable,需要很长时间来计算结果,我们将创建一个 Computable 包装器,用于记住之前的计算结果,并将缓存过程封装起来。这被称为记忆化。
public interface Computable<A,V> {
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String,BigInteger> {
public BigInteger compute(String arg) {
// 在经过长时间的计算后
return new BigInteger(arg);
}
}
public class Memoizer1<A,V> implements Computable<A,V> {
@GuardedBy("this")
private fianl Map<A,V> cache = new HashMap<>();
private fianl Computable<A,V> c;
public Memorizer(Computable<A,V> c) {
this.c = c;
}
public synchronized V compute(A arg)
throws InterruptedEx {
V result = cache.get(arg);
if(result == null){
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
在程序清单 5-16 中的 Memorizer1 中给出了第一种尝试:使用 HashMap 来保存之前计算的结果。compute 方法首先将检查需要的结果是否已经在缓存中,如果存在则返回之前计算的结果。否则,将把计算结果缓存在 HashMap,然后再返回。
HashMap 不是线程安全的,因此要确保两个线程不会同时访问 HashMap,Memorizer1 采用了一种保守的方法,即对整个 compute 方法进行同步。这种方法能确保线程安全性,但会带来一种明显的可伸缩性问题:每次只有一个线程能够执行 compute。如果另一个线程正在计算结果,那么其他调用 conpute 的线程可能需要被阻塞很长一段时间。如果有多个线程在排队等待还未计算出的结果,那么 compute 方法的计算时间可能比没有记忆操作的计算时间更长。在图 5-2 中给出了当多个线程使用这种方法中的记忆操作时发生的情况,这显然不是我们希望通过缓存获得的性能提升结果。
程序清单 5-17 中的 Memorizer2 用 ConcurrentHashMap 代替 HashMap 来改进 Memorizer1 中糟糕的并发行文。由于 ConcurrentHashMap 是线程安全的,因此在访问底层 Map 时就不需要进行同步,因而避免了在对 Memorizer1 中的 compute 方法进行同步时带来的串行性。
Memorizer2 比 Memorizer1 有着更好的并发行为:多线程可以并发的使用它。但它在作为缓存时仍然存在一些不足——当两个线程同时调用 compute 时存在一个漏洞,可能会导致计算得到相同的值。在使用 memorization 的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单词初始化的对象缓存来说,这个漏洞就会带来安全风险。
public class Memorizer2<A,V> implements Computable<A,V> {
private final Map<A,V> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizer2(Computable<A,V> c) {
this.c = c;
}
public V compute(A arg) throws InterrputedEx {
V result = cache.get(arg);
if(result == null){
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memorizer2 的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复计算,如图 5-3 所示。我们系统通过某种方法来表达“线程 X 正在计算 f(27)” 这种情况,这样当另一个线程查找 f(27) 时,它能够知道最高效的方法是等待线程 X 计算完成,然后再去查询缓存。
我们已经知道有一个类能够实现这个功能:FutureTask。FuturTask 表示一个计算的过程,该过程可能已经计算完成,也可能正在进行。如果有结果可用,那么 FutureTask.get 将立即返回结果,否则它会一直阻塞,直到结果结算出来再将其返回。
程序清单 5-18 中的 Memorizer3 将用于缓存值的 Map 重新定义为 ConcurrentHashMap<A, Future
public class Memorizer3<A, V> implements Computable<A, V> { private final Map<A, Future<V>> cache =
new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memorizer3(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedEx { Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedException {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(eval);
f = ft;
cache.put(arg, ft);
ft.run(); // call to c.compute happens here
}
try {
return f.get();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
Memorizer3 的实现几乎是完美的:它表现出了非常好的并发性(基本上是源自 ConcurrentHashMap 的并发性),如果结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。该漏洞这时的发生概率要远小于 Memorizer2,但由于 conpute 方法中的 if 代码块仍然是非原子的“先检查再执行”操作,因此两个线程仍有可能在同一时间内调用 compute 来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。这个错误的执行时序如图 5-4 所示:
Memorizer3 中存在这个问题的原因是,符合操作是在底层的 Map 对象上执行的,而这个对象无法通过加锁来确保原子性。程序清单 5-19 中的 Memorizer 使用了 ConcurrentMap 中的原子方法 putIfAbsent,避免了 Memorizer3 的漏洞。
public class Memorizer<A, V> implements Computable<A, V> {
private final ConcurrentMap<A, Future<V>> cache =
new ConcurrentHashMap<>();
private final Computable<A, V> c;
public Memorizer(Computable<A, V> c) { this.c = c; }
public V compute(final A arg) throws InterruptedEx {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> eval = new Callable<V>() {
public V call() throws InterruptedEx {
return c.compute(arg);
}};
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft; ft.run();
}
}
try { return f.get(); }
catch (CancellationException e) {
cache.remove(arg, f);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}
当缓存的是 Future 而不是值时,将导致缓存污染问题:如果某个计算被取消或失败,那么在计算这个结果时将指明计算过程被取消或失败。为了避免这种情况,如果 Memorizer 发现计算被取消,那么将把 Future 从缓存中删除。如果检测到 RuntimeException,那么也会移除 Future,这样将来的计算结果才能成功。Memorizer 同样没有解决缓存逾期的问题,但它可以通过使用 FutureTask 子类来解决,在子类中为每个结果指定一个预期时间,并定期扫描缓存中逾期的元素。(同样,它没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算腾出空间,从而避免缓存效果过多的内存)。
在完成并发缓存的实现后,就可以为第二章中因式分解 servlet 添加结果缓存。程序清单 5-20 中的 Factorizer 使用 Memorizer 来缓存之前计算的结果,这种方式不仅高效,而且扩展性也高。
@ThreadSafe public class Factorizer implements Servlet {
private final Computable<BigInteger, BigInteger[]> c =
new Computable<BigInteger, BigInteger[]>() {
public BigInteger[] compute(BigInteger arg) {
return factor(arg);
}
};
private final Computable<BigInteger, BigInteger[]> cache =
new Memorizer<BigInteger, BigInteger[]>(c);
public void service(ServletRequest req, ServletResponse resp) {
try {
BigInteger i = extractFromRequest(req);
encodeIntoResponse(resp, cache.compute(i));
} catch (InterruptedException e) {
encodeError(resp, "factorization interrupted");
}
}
}
第一部分小节
- 可变状态是至关重要的。所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全。
- 尽量将域声明为 final 类型,除非需要它们是可变的。
- 不可变对象一定是线程安全的。不可变对象能极大的降低并发编程的复杂性。它们更为简单且安全,可以任意共享而无需使用加锁或保护性复制等机制。
- 封装有助于管理复杂性。在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么还要封装?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
- 用锁来保护每个可变变量。
- 当保护一个不变形条件中的所有变量时,要使用同一个锁。
- 在执行符合操作期间,要持有锁。
- 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出错。
- 不要故作聪明的推断出不需要使用同步。
- 在设计过程中考虑线程安全,或者在文档中明确指出是否是线程安全的。
- 将同步策略文档化。
3.1.6 - CH06-任务执行
大多数并发应用都是围绕“任务执行”来构造的:任务通常是一些抽象的离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。
6.1 在线程中执行任务
当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。在理想情况下,各个任务之间是相互独立的:任务并不依赖于其他任务的状态、结果或边界效应。独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。为了在调度与负载均衡过程中实现更高的灵活性,每项任务还应该表示应用程序的一部分处理能力。
在正常的负载下,服务器应用程序应该同时表现出良好的吞吐量和快速的响应性。应用程序提供商希望程序支持尽可能多的用户,从而降低每用户的服务成本,而用户则希望获得尽快的响应。而且,当符合过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。
大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。Web 服务器、邮件服务器、文件服务器、EJB 容器以及数据库服务器等,这些服务器都能通过网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。例如,在向邮件服务器提交一个消息得到的结果,并不会受其他正在处理的消息的影响,而在处理消息时通常只需要服务器总处理能力的很小一部分。
6.1.1 串行的执行任务
在应用程序中可以通过多种策略来调度任务,而其中一些策略能够更好的利用潜在的并发性。最简单的策略就是在单个线程中串行执行各项任务。程序清单 6-1 中的 SingleThreadWebServer 将串行的处理它的任务。至于如何处理请求的细节问题,在这里并不重要,我们感兴趣的是如何表征不同调度策略的同步特性。
class SingleThreadWebServer {
public static void main(String...args) throws IOEx {
ServerSocket socket = new ServerSocket(80);
while(true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
SingleThreadWebServer 很简单,且在理论上是正确的,但在实际生产环境中的执行性能却很糟糕,因为它每次只能处理一个请求。主线程在接受与处理请求等操作之间不断的交替运行。当服务器正在处理请求时,新到来的连接必须等待直到请求处理完成,然后服务器将再次调用 accept。如果处理请求的速度很快并且 handleRequest 可以立即返回,那么这种方法是可行的,但现实世界中的 Web 服务器的情况却并非如此。
在 Web 请求的处理中包含了一组不同的运算与 IO 操作。服务器必须处理套接字 IO 以读取请求和写回响应,这些操作通常会由于网络拥堵或连通性问题而被阻塞。此外,服务器还可能处理文件 IO 或数据库请求,这些操作同样会阻塞。在单线程的服务器中,阻塞不仅会推迟当前请求的完成时间,而且还将彻底阻止等待中的请求被处理。如果请求阻塞的时间过长,用户将任务服务器是不可用的,因为服务器看似失去了响应。同时,服务器的资源利用率却非常低,因为当单线程在等待 IO 操作完成时,CPU 将处于空闲状态。
在服务器应用程序中,串行处理机制通常无法提供高吞吐率或快速响应性。也有一些例外,例如,当任务数量很少且执行时间很长时,或者当服务器只需要单个用户提供服务,并且该用户每次只发出一个请求——但大多数服务器应用程序并不是按照这种方式工作的。
6.1.2 显式的为任务创建线程
通过为每个请求创建一个新的线程来提供服务,从而实现更好的响应性,如程序清单 6-2 中的 ThreadPerTaskWebServer 所示。
class ThreadPerTaskWebServer {
public static void main(String...args) {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
new Thread(task).start();
}
}
}
ThreadPerTaskWebServer 在架构上类似于前面的单线程版本——主线程仍然不断的交替执行“接受外部连接”与“分发请求”等操作。区别在于,对于每个连接,主循环都将创建一个新线程来处理请求,而不是在主循环中进行处理。由此可以得出 3 个主要结论:
- 任务处理过程从主线程中分离出来,使得主循环能够更快的重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受更多的请求,从而提高响应性。
- 任务可以并行处理,从而能同时服务多个请求。如果有多个服务器,或者任务由于某种原因被阻塞,例如等待 IO 完成、获取锁或资源可用性等,程序的吞吐量将得到提升。
- 任务处理代码必须是线程安全的,因为会有多个任务并发的调用这段代码。
在正常负载情况下,“为每个任务分配一个线程”的方法能提升串行执行的性能。只要请求的到达率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。
6.1.3 无限制创建线程的不足
在生产环境中,“为每个任务分配一个线程”这种方法是存在缺陷的,尤其是当需要创建大量线程时。
线程生命周期的开销很高。 线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也会有所不同,但线程的创建过程都需要时间,延迟处理的请求,并且需要 JVM 和操作系统提供一些辅助操作。如果请求的到达率非常高且处理过程是轻量级的,例如大多数服务器应用程序就是这种情况,那么为么个请求创建一个新的线程将消耗大量计算资源。
资源消耗。 活跃的线程会消耗系统资源,尤其是内存。如果可运行的线程数量对于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争 CPU 资源时还将产生其他的性能开销。如果你已经拥有足够多的线程使所有 CPU 保持忙碌状态,那么再创建更多的线程反而会降低性能。
稳定性。 在可创建线程的数量上存在一个限制。这个限制值将随平台的不同而变化,并且受多个因素影响,包括 JVM 的启动参数、Thread 构造函数中请求的栈大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么很可能抛出 OOM 异常,要想从这种错误中恢复过来是非常危险的,更简单的办法设是通过构造程序来避免超出这些限制。
在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度,并且如果过多的创建一个线程,那么整个应用程序将会崩溃。要想避免这种危险,就应该对应用程序可以创建的线程数量进行限制,并且全面的测试应用程序,从而确保在线程数量到达限制时,程序也不会耗尽资源。
“为每个任务分配一个线程”这种方法的问题在于,他没有限制可创建线程的数量,只限制了远程用户提交 HTTP 请求的速率。与其他的并发危险一样,在原型设计和开发阶段,无限制的创建线程或许还能较好的运行,但在应用程序部署后并处于高负载运行时,才会有问题不断的暴露出来。因此,某个恶意的用户或过多的用户,都会使 Web 服务器的负载达到某个阈值,从而使服务器崩溃。如果服务器需要提供高可用性,并且在高负载情况下能平缓的降低性能,那么这将是一个严重的故障。
6.2 Executor 框架
任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。我们已经分析了两种通过线程来执行任务的策略,即把所有任务都放在单个线程中串行执行,以及将每个任务放在各自的线程中执行。这两种方式都存在一些严格的限制:串行执行的问题在于其糟糕的响应性和吞吐量,而“为每个任务分配一个线程”的问题在于资源管理的复杂性。
在第 5 章,我们介绍了如何通过有界队列来防止高负载的应用程序耗尽内存。线程池简化了线程的管理工作,并且 JUC 提供了一种灵活的线程池实现作为 Executor 框架的一部分。在 Java 类库中,任务执行的只要抽闲不是 Thread,而是 Executor,如程序清单 6-3 所示。
public interface Executor {
void execute(Runnable command);
}
虽然 Executor 是一个简单的接口,但它却为灵活且强大的异步任务执行框架提供了基础,该框架能支持多种不同类型的任务执行策略。它提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并且用 Runnable 来表示任务。Executor 的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视器等机制。
Executor 基于生产者——消费者模式,提交的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)。如果要在程序中实现一个生产消费设计,那么最简单的方式通常就是使用 Executor。
6.2.1 示例:基于 Executor 的 Web 服务器
基于 Executor 来构建 Web 服务器是非常容易的。在程序清单 6-4 中用 Executor 代替了硬编码的线程创建过程。在这种情况下使用了一种标准的 Executor 实现,即一个固定长度的线程池,可以容纳 100 个线程。
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec =
Executor.newFixedThreadPool(NTHREADS);
public static void main(String..args) throws IOEx {
ServerSocket socket = new ServerSocket(80);
while(true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
在 TaskExecutionWebServer 中,通过使用 Executor,将请求处理任务的提交与任务的实际执行解耦开来,并且只需要采用另一种不同的 Executor 实现,就可以改变服务器的行为。改变 Executor 实现或配置所带来的影响要远远小于改变任务提交方式带来的影响。通常,Executor 的配置是一次性的,因此在部署阶段可以完成,而提交任务的代码却会不断的扩散到整个程序中,增加了修改的难度。
我们可以很容易的将 TaskExecutionWebServer 修改为类似 ThreadPerTaskWebServer 的行为,只需要使用一个为每个请求创建新线程的 Executor。编写这样的 Executor 很简单,如程序清单 6-5 中的 ThreadPerTaskExecutor 所示。
public class ThreadPerTaskExecutor implements Executor {
public void execute(Runnable r) {
new Thread(r).start();
}
}
同样,还可以编写一个 Executor 使 TaskExecutionWebServer 的行为类似于单线程的行为,即以同步的方式执行每个任务,然后再返回,如程序清单 6-6 中的 WithinThreadExecutor 所示。
public class WithinThreadExecutor implements Executor {
public void execute(Runnable r) {
r.run();
}
}
6.2.2 执行策略
通过将任务的提交与执行解耦出来,从而无需太大的困难就可以为某种类型的任务指定或修改执行策略。在执行策略中定义了任务执行的 What、Where、When、How 等方面,包括:
- 在什么(What)线程中执行任务?
- 任务按照什么(What)属性怒执行(FIFO/LIFO/优先级)?
- 有多少个(How many)任务并发执行?
- 在队列中有多少个(How many)任务在等待执行?
- 如果系统由于过载而需要拒绝一个任务,那么应该选择拒绝哪个任务?另外,如何通知应用程序有任务被拒绝?
- 在执行一个任务之前或之后,应该进行哪些动作?
各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。通过限制并发任务的数量,可以确保应用程序不会由资源耗尽而失败,或者由于在稀缺资源上发生竞争而严重影响性能。通过将任务的提交与任务的执行策略分离开来,有助于在部署阶段选择与硬件资源匹配的执行策略。
6.2.3 线程池
线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列密切相关的,其中在工作队列保存了所有了所有等待执行的任务。工作者线程的任务很简单:从工作队列中取出一个任务,执行任务,然后返回线程池并等待下一个任务。
“在线程池中执行任务”比“没每个任务分配一个线程”优势更多。通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会犹豫等待创建线程而延迟任务的执行,从而提高了响应性。通过适当调整线程池的大小,可以创建足够的线程以便使处理器保持忙碌状态,同时还可以防止过多线程互相竞争资源而使应用程序耗尽内存或失败。
类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用 Executors 中的静态工厂方法之一来创建线程池实例:
newFixedThreadPool 将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的异常而结束,那么线程池将会补充一个新的线程)。
newCachedThreadPool 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
newSingleThreadExecutor 是一个单线程的 Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。它能确保任务在队列中的顺序来串行执行(如 FIFO/LIFO/优先级)。
newScheduledThreadPool 创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于 Timer。
newFixedThreadPool/newCachedThreadPool 这两个工厂方法返回通用的 ThreadPoolExecutor 实例,这些实例可以直接用来构造专门用途的 executor。我们将在第八章深入讨论线程池的各个配置项。
TaskExecutionWebServer 中的 Web 服务器使用了一个带有有界线程池的 Executor。通过 execute 方法将任务提交到工作队列中,工作线程反复的从工作队列中取出任务并执行它们。
从“为每任务分配一个线程”策略变成基于线程池的策略,将对应用程序的稳定性产生重大的影响:Web 服务器不会再在高负载情况下失败。由于服务器不会创建数千个线程来争夺有限的 CPU 和内存资源,因此服务器的性能将平缓的降低。通过使用 Executor,可以实现各种调优、管理、监视、记录日志、错误报告和其他功能,如果不使用任务执行框架,那么要增加这些功能是非常困难的。
6.2.4 Executor 的生命周期
我们已经知道如何创建一个 Executor,但并没有讨论如何关闭它。Executor 的实现通常会创建线程来执行任务。但 JVM 只有在所有(非守护)线程全部终止后才会退出。因此,如果无法正确的关闭 Executor,那么 JVM 将无法结束。
由于 Executor 以异步方式来执行任务,因此在任何时刻,之前提交任务的状态不是立即可见的。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。当关闭应用程序时,可能采用最平缓的关闭方式(完成所有已启动的任务,并且不再接受任何新的任务),也可能采用最粗暴的关闭方式(直接关闭机房的电源),以及其他各种可能的形式。既然 Executor 是为应用程序提供服务的,因而它们也是可关闭的(无论采用平缓或粗暴的方式),并将在关闭操作中受影响的任务的状态反馈给应用程序。
为了解决执行服务的生命周期问题,Executor 扩展了 ExecutorService 接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法)。在程序清单 6-7 中给出了 ExecutorService 中的生命周期管理方法。
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit)
thorws InterruptedException
// 其他用于任务提交的便利方法...
}
ExecutorService 的生命周期有三种状态:运行、关闭、已终止。ExecutorService 在初始化创建时处于运行状态。shutdown 方法将执行平缓的关闭过程:不再接受新任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow 方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
在 ExecutorService 关闭后提交的任务将由“拒绝执行处理器”来处理,它会抛弃任务,或者使得 execute 方法抛出一个未检的 RejectedExecutionException。等所有任务都完成后,ExecutorService 将转入终止状态。可以调用 awaitTermination 来等待 ExecutorService 到达终止状态,或者通过调用 isTerminated 来轮询 ExecutorService 是否已经终止。通常在调用 awaitTermination 之后会立即调用 shutdown,从而产生同步的关闭 ExecutorService 的效果。(第七章将进一步介绍 Executor 的关闭和任务取消等方面的内容)
程序清单 6-8 的 LifecycleWebServer 通过增加生命周期支持来扩展 Web 服务器的功能。可以通过两种方法来关闭 Web 服务器:在程序中调用 stop,或者以客户端请求的形式向 Web 服务器发送一个特定格式的 HTTP 请求。
class LifecycleWebServer {
private final ExecutorService exec = ...;
public void start() throws IOException {
ServerSocker socket = new ServerSocket(80);
while(!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
public void run() {
handleRequest(conn);
}
});
} catch (RejectedExecutionException e) {
if(!exec.isShutdown())
log("task submission rejected", e);
}
}
}
public void stop() {
exec.shutdown();
}
void handleRequest(Socket conn) {
Request req = readRequest(conn);
if(isShutdownRequest(req))
stop();
else
dispatchRequest(req);
}
}
6.2.5 延时任务与周期性任务
Timer 类负责管理延迟任务以及周期性任务。然而,Timer 存在一些缺陷,因此应该考虑使用 ScheduledThreadPoolExecutor 来代替它。可以通过 ScheduledThreadPoolExecutor 的构造函数或 newScheduledThreadPool 工厂方法来创建类的对象。
Timer 在执行所有定时任务时只会创建一个线程。如果某个任务的执行实现过长,那么将破坏其他 TimerTask 的定时精确性。例如某个周期 TimerTask 需要每 10ms 执行一次,而另一个 TimerTask 需要执行 40ms,那么这个周期性任务或者在 40ms 任务执行完成后快速连续的调用 4 次,或者彻底丢失 4 次调用(取决于它是基于固定速率来调度还是基于固定延时来调度)。线程池能弥补这个缺陷,它可以提供多个线程来执行延时任务和周期任务。
Timer 的另一个问题是,如果 TimerTask 抛出一个未检异常,那么 Timer 将表现出糟糕的行为。Timer 线程并不捕获异常,因此当 TimerTask 抛出未检异常时将终止定时线程。这种情况下,Timer 也不会恢复线程的执行,而是会错误的任务整个 Timer 被取消了。因此,已经被调度但尚未执行的 TimerTask 将不会再执行,新的任务也不能被调度。该问题被称为线程泄露,7.3 将介绍如何避免该问题。
在程序清单 6-9 的 OutOfTime 中给出了 Timer 中为什么会出现这种问题,以及如何使得视图提交 TimerTask 的调用者出现问题。你可能任务程序会运行 6 秒后退出,但实际情况是运行 1 秒就结束了,并抛出了一个异常消息“Timer already cancelled”。ScheduledThreadPoolExecutor 能正确处理这些表现出错误行为的任务。在 Java 5.0 或更高的 JDK 中,将减少使用 Timer。
public class OutOfTime {
public static void main(String...args) throws Exception {
Timer timer = new Timer();
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(1);
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(5);
}
static class ThrowTask extends TimerTask {
public void run() {
throw new RuntimeException();
}
}
}
如果要构建自己的调度服务,那么可以使用 DelayQueue,它实现了 BlockingQueue,并为 ScheduledThreadPoolExecutor 提供调度功能。DelayQueue 管理着一组 Delayed 对象。每个 Delayed 对象都有一个相应的延迟时间:在 DelayQueue 中,只有某个元素逾期后,才能从 DelayQueue 中执行 take 操作。从 DelayQueue 中返回的对象将根据它们的延迟时间进行排序。
6.3 找出可利用的并行性
Executor 框架帮助指定执行策略,但如果要使用 Executor,必须将任务表述为一个 Runnable。在大多数服务器应用程序中都存在一个明显的任务边界:单个客户请求。但有时候,任务边界并非是显而易见的,例如在很多桌面应用程序中。即使是服务器应用程序,在单个客户请求中仍可能存在可发掘的并行性,例如数据库服务器。
本节我们将开发一些不同版本的组件,并且每个版本都实现了不同程度的并发性。该示例组件实现浏览器中的页面渲染功能,它的作用是将 HTML 页面绘制到图像缓存中。为了简便,假设 HTML 页面只包含标签文本,以及预定义大小的图片和 URL。
6.3.1 示例:串行的页面渲染器
最简单的方法是对 HTML 文档进行串行化处理。当遇到文本标签时,将其绘制到图像缓存中。当遇到图像引用时,先通过网络获取它,然后将其绘制到图像缓存中。这很容易实现,程序只需要将输入中的元素处理一次(甚至不需要缓存文档),但这种方法可能会令用户感到烦恼,他们必须等待很长时间,直到显示所有文本。
另一种串行执行方法更好一些,它绘制文本元素,同时为图像预留出矩形的占位空间,在处理完第一遍文本后,程序开始下载图像,并将他们绘制到相应的占位空间中。在程序清单 6-10 的 SingleThreadRenderer 中给出了这种方法的实现。
public class SingleThreadRenderer {
void renaderPage(CharSequence source) {
renaderText(suorce);
List<ImageInfo> imageData = new ArrayList<>();
for(ImageInfo imageInfo : scanFroImageInfo(source)) {
imageData.add(imageInfo.downloadImae());
}
for(ImageData data : imageData) {
renderImage(data);
}
}
}
图像下载过程的大部分时间都是在等待 IO 操作执行完成,在这期间 CPU 几乎不做任何工作。因此,这种串行执行方法没有充分利用 CPU,使得用户在看到最终页面之前需要等待过长的时间。通过将问题分解为多个独立的任务并发执行,能够获得更好的 CPU 利用率和响应灵敏度。
6.3.2 携带结果的任务:Calllable 与 Future
Executor 框架使用 Runnable 作为其基本的任务表示形式。Runnable 是一种局限很大的抽象,虽然 run 能写入到日志文件或者将结果放入某个数据结构,但它不能返回一个值或抛出一个受检异常。
许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable 是一种更好的抽象:它认为主入口点(call)将返回一个值,并可抛出一个异常。在 Executor 中包含了一些辅助方法能将其他类型的任务封装为一个 Callable,例如 Runnable 和 java.security.PrivilegedAction。
Runnable 和 Callable 描述的都是抽象的计算任务。这些任务通常是有范围的,即都有一个明确的起始点,并且最终会结束。Executor 执行的任务有 4 个生命周期:创建、提交、开始、完成。由于有些任务可能需要执行很长时间,因此通常希望能够取消这些任务。在 Executor 框架中,已提交单尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有当他们能够响应中断时,才能被取消。取消一个已经完成的任务不会有任何影响。
Future 表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果或取消任务等。在程序清单 6-11 中给出了 Callable 和 Future。在 Future 规范中包含的隐含意义是,任务的生命周期只能前进不能后退,就像 ExecutorService 的生命周期一样。当某个任务完成后,它就永远停留在“完成”状态上。
get 方法的行为取决于任务的状态,如果任务已经完成,那么 get 会立即返回或抛出一个异常,如果任务没有完成,那么 get 将阻塞并直到任务完成。如果任务抛出了异常,那么 get 将该异常封装为 ExecutionException 并重新抛出。如果任务被取消,那么 get 将抛出 CancellationException。如果 get 抛出了 ExecutionException,那么可以通过 getCause 来获得被封装的原始异常。
public interface Callable<V> {
V call() throws Exception;
}
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled(); boolean isDone();
V get() throws
InterruptedException,
ExecutionException,
CancellationException;
V get(long timeout, TimeUnit unit) throws
InterruptedException,
ExecutionException,
CancellationException,
TimeoutException;
}
可以通过多种方法创建一个 Future 来描述任务。ExecutorService 中的所有 submit 方法都将返回个 Future,从而将一个 Runnable 或 Callable 提交给 Executor,并得到一个 Future 用来获得任务的执行结果或取消任务。还可以显式的为某个指定的 Runnable 或 Callable 实例化一个 FutureTask。(由于 FutureTask 实现了 Runnable,因此可以将它提交给 Executor 来执行,或者直接调用它的 run 方法。)
从 Java 6 开始,ExecutorService 实现可以改写 AstractExecutorService 中的 newTaskFor 方法,从而根据已提交的 Runnable 或 Callable 来控制 Future 的实例化过程。在默认实现中仅创建了一个新的 FutureTask,如程序清单 6-12 所示。
protected <T> RunnableFuture<T> newTaskFor(Callable<T> task) {
return new FutureTask<T>(task);
}
在将 Runnable 或 Callable 提交到 Executor 的过程中,包含了一个安全发布过程,即将 Runnable 或 Callable 从提交线程发布到最终执行任务的线程。类似的,在设置 Future 结果的过程也包含了一个安全发布,即将这个结果从计算它的线程发布到任何通过 get 获得它的线程。
6.3.3 示例:使用 Future 实现页面渲染器
为了使页面渲染器实现更高的并发性,首先将渲染过程分解为两个任务,一个是渲染所有文本,另一个是下载所有的图像。(因为其中一个任务是 CPU 密集型,而另一个任务是 IO 密集型,因此这种方法即使应用在单 CPU 系统上也能提升性能)。
Callable 和 Future 有助于表示这些协同任务之间的交互。在程序清单 6-13 的 FutureRenderer 中创建了一个 Callable 来下载所有图像,并将其提交到一个 ExecutorService。这将返回一个描述任务执行情况的 Future。当主任务需要图像时,它会等待 Future.get 的调用结果。如果幸运的话,当开始请求时所有图像就已经下载完成了,即使没有,至少图像的下载任务也已经提前开始了。
public class FutureRenderer {
private final ExecutorService executor = ...;
void renderPage(CharSequence source) {
final List<ImageInfo> imageInfos =
scanForImageInfo(source);
Callable<List<ImageData>> task =
new Callable<List<ImageData>>() {
public List<ImageData> call() {
List<ImageData> result =
new ArrayList<ImageData>();
for (ImageInfo imageInfo : imageInfos)
result.add(imageInfo.downloadImage());
return result;
}
};
Future<List<ImageData>> future = renderText(source);
executor.submit(task);
try {
List<ImageData> imageData = future.get();
for (ImageData data : imageData)
renderImage(data);
} catch (InterruptedException e) {
// Re-assert the thread's interrupted status
Thread.currentThread().interrupt();
// We don't need the result, so cancel the task too
future.cancel(true);
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
get 方法拥有“状态依赖”的内在特性,因而调用者不需要知道任务的状态,此外在任务提交和获得结果中包含的安全发布属性也确保了这个方法是线程安全的。Future.get 的异常处理代码将处理两个可能问题:任务遇到一个异常,或者调用 get 的线程在获得结果之前被中断。
FutureRenderer 使得渲染文本任务与下载图像数据的任务并发的执行。当所有图像下载完成后,会显示到页面上。这将提升用户体验,不仅使用户更快的看到结果,还有效利用了并行性,但我们还可以做得更好。用户不必等到所有的图像都下载完成,而希望看到每当下载一完一副图像时就立即显示出来。
6.3.4 在异构任务并行化中存在的局限
在上个例子中,我们尝试并行的执行两个不同类型的任务——下载图像与渲染页面。然而,通过对异构任务执行并行化来获得重大的性能提升是很困难的。
两个人可以很好的分担洗碗的工作:其中一个人负责清洗,另一个人负责烘干。然而,要将不同类型的任务平均分配给每个工人并不容易。但人数增加时,如何确保他们能帮忙而不是妨碍其他人工作,或者在重新分配工作时,并不是很容易的事情。如果没有在相似的任务之间找出细粒度的并行性,那么这种方法带来的好处将减少。
当在多个工人之间分配异构的任务时,还有一个问题就是各个任务的大小可能完全不同。如果将两个任务 A 和 B 分配给两个工人,但 A 的执行时间是 B 的 10 倍,那么整个过程也只能加速 9%。最后,当在多个工人之间分解任务时,还需要一定的任务协调开销:为了使任务分解能提高性能,这种开销不能高于并行性实现的提升。
FutureRenderer 使用了两个任务,其中一个负责渲染文本,另一个负责下载图像。如果渲染文本的速度远远高于下载图像的速度,那么程序的最终性能与串行执行时的性能差别不大,而代码却更加复杂了。当使用两个线程时,至多能将速度提升一倍。因此,虽然做了许多工作来并发执行异构任务以提高并发度,但从中获得的并发性却是十分有限的。
只有当大量相互独立且同构的任务可以并行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升。
6.3.5 CompletionService:Executor 与 BlockingQueue
如果向 Executor 提交一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的 Future,然后反复使用 get 方法,同时将参数 timeout 设定为 0,从而通过轮询来判断任务是否完成。这种方法虽然可行,但却十分繁琐。幸运的是,还有一种更好的方法:CompletionService。
CompletionService 将 Executor 与 BlockingQueue 的功能融合在一起。你可以将 Callable 任务提交给它来执行,然后使用类似于队列操作的 take 和 poll 等方法来获得已完成的结果,而这些结果会在完成时将被封装为 Future。ExecutorCompletionService 实现了 CompletionService,并将计算部分委托给一个 Executor。
ExecutorCompletionService 的实现非常简单。在构造函数中创建一个 BlockingQueue 来保存计算完成的结果。当计算完成时,调用 FutureTask 中的 done 方法。当提交某个任务时,该任务首先将包装为一个 QueueingFuture,这是 FutureTask 的一个子类,然后再改写子类的 done 方法,并将结果放入 BlockingQueue 中,如程序清单 6-14 所示。take 和 poll 方法委托给了 BlockingQueue,这些方法会在得出结果之前阻塞。
private class QueueingFuture<V> extends FutureTask<V> {
QueueingFuture(Callable<V> c) { super(c); }
QueueingFuture(Runnable t, V r) { super(t,r); }
protected void done() {
completionQueue.add(this);
}
}
6.3.6 示例:使用 CompletionService 实现页面渲染器
可以通过 CompletionService 从两个方面来提高页面渲染器的性能:缩短总运行时间以及提高响应性。为每个图像的下载都创建一个独立任务,并在线程池中执行他们,从而将串行的下载过程转换为并行的过程:这将减少下载所有图像的总时间。此外,通过从 CompletionService 中获得结果以及使每张图片在下载完成后立即显示出来,能使用户得到一个更加动态和更高响应性的用户界面。如程序清单 6-15 的 Renderer 所示。
public class Renderer {
private final ExecutorService executor;
Renderer(ExecutorService executor) {
this.executor = executor;
}
void renderPage(CharSequence source) {
final List<ImageInfo> info = scanForImageInfo(source); CompletionService<ImageData> completionService =
new ExecutorCompletionService<>(executor);
for (final ImageInfo imageInfo : info)
completionService.submit(new Callable<>() {
public ImageData call() {
return imageInfo.downloadImage();
}
});
renderText(source);
try {
for (int t = 0, n = info.size(); t < n; t++) {
Future<ImageData> f =
completionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
多个 ExecutorCompletionService 可以共享同一个 Executor,因此可以创建一个对于特定计算私有,又能共享一个公共 Executor 的 ExecutorCompletionService。因此,CompletionService 的作用就相当于一组计算的句柄,这与 Future 作为单个计算的句柄是非常类似的。通过记录提交给 CompletionService 的任务数量,并计算出已经获得的已经完成计算结果的数量,即使使用一个共享的 Executor,也能知道已经获得了所有任务结果的时间。
6.3.7 为任务设置时限
有时候,如果某个任务无法在指定时间内完成,那将不再需要它的结果,此时可以放弃这个任务。例如,某个 Web 应用程序从外部的广告服务器上获取广告信息,但如果该应用程序在两秒钟内得不到响应,那么将显示一个默认的广告,这样即使不能获得广告信息,也不会降低站点的响应性能。类似的,一个门户网站可以从多个数据源并行的获取数据,但可能只会在指定的时间内等待数据,如果超出了等待时间,那么只显示已经获得的数据。
在有限时间内执行任务的主要困难在于,要确保得到答案的时间不会超过限定的时间,或者在限定时间内无法得到答案。在支持时间限制的 Future.get 中支持这种需求:当结果可用时将理解返回,如果在指定的时间内没有计算出结果,那么将抛出 TimeoutException。
在使用限时任务时需要注意,当这些任务超时后应该立即停止执行,从而避免为继续计算一个不再使用的结果而浪费计算资源。要实现这个功能,可以由任务本身来管理它的限定时间,并且在超时后中止执行或取消任务。此时可以再次使用 Futute,如果一个限时的 get 方法抛出了 TimeoutException,那么可以通过 Future 来取消任务。如果编写的任务是可取消的,那么可以提前中止它,以免消耗过多资源。在程序清单 6-13 和 6-16 中的代码使用了这项技术。
程序清单 6-16 给出了限时 Future.get 的一种典型应用。在它生成的页面中包括响应用户请求的内容以及从广告服务器上获得的广告。它将获取广告的任务提交给一个 Executor,然后计算剩余的文本页面的内容,最后等待广告信息,直到超出指定的时间。如果 get 超时,那么将取消广告获取任务,并转而使用默认的广告信息。
Page renderPageWithAd() throws InterrutedException {
long endNanos = System.nanoTime() + TIME_BUDGET;
Future<Ad> f = exec.submit(new FetchAdTask());
// 在等待广告的同时显示页面
Page page = renderPageBody();
Ad ad;
try {
// 等待指定的时长
long timeLeft = endNanos - System.nanoTime();
ad = f.get(timeLeft, NANOSECONDS);
} catch (ExecutionException e) {
ad = DEFAULT_AD;
} catch (TimeoutException e) {
ad = DEFAULT_AD;
f.cancel(true);
}
page.setAd(ad);
return page;
}
6.3.8 示例:旅行预订网站
“预订时间”方法可以很容易的扩展到任意数量的任务上。考虑这样一个旅行预订网站:用户输入旅行的日期和其他要求,网站获取并展示来自多条航线、旅店或汽车租赁公司的报价。在获取不同公司报价的过程中,可能会调用 Web 服务、访问数据库、执行一个 EDI 事务或其他机制。在这种情况下,不宜让页面的响应时间受限于最慢的响应时间,而应该只显示在指定时间内收到的信息。对于没有及时响应的服务提供者,页面可以忽略它们,或者显示一个提示信息。
从一个公司获得报价的过程与从其他公司获得报价的过程无关,因此可以将获取报价的过程当成一个任务,从而使得获取报价的过程并发执行。创建 N 个任务,将其提交到一个线程池,保留 N 个 Future 并使用限时的 get 方法通过 Future 串行的获取每一个结果,这一切都很简单,但还有一个更简单的方法——invokeAll。
程序清单 6-17 使用了支持限时的 invokeAll,将多个任务提交到一个 ExecutorService 并获得结果。InvokeAll 方法参数为一组任务,并返回一组 Future。这两个集合有着相同的结构。invokeAll 按照任务集合中迭代器的顺序将所有的 Future 添加到返回的集合中,从而使调用者能将各个 Future 与其表示的 Callable 关联起来。当所有任务都执行完毕时,或者调用者线程被中断时,又或者超过指定时限时,invokeAll 将返回。当超过执行时限后,任何还未完成的任务都将被取消。当 invokeAll 返回后,每个任务要么正常的完成,要么被取消,而客户端代码可以调用 get 或 isCancelled 来判断具体的情况。
private class QuoteTask implements Callable<TravelQuote> {
private final TravelCompany company;
private final TravelInfo travelInfo;
...
public TravelQuote call() throws Exception {
return company.solicitQuote(travelInfo);
}
}
public List<TravelQuote> getRankedTravelQuotes(
TravelInfo travelInfo,
Set<TravelCompany> companies,
Comparator<TravelQuote> ranking,
long time, TimeUnit unit) throws InterruptedException {
List<QuoteTask> tasks = new ArrayList<QuoteTask>();
for (TravelCompany company : companies)
tasks.add(new QuoteTask(company, travelInfo));
List<Future<TravelQuote>> futures =
exec.invokeAll(tasks, time, unit);
List<TravelQuote> quotes =
new ArrayList<TravelQuote>(tasks.size());
Iterator<QuoteTask> taskIter = tasks.iterator();
for (Future<TravelQuote> f : futures) {
QuoteTask task = taskIter.next();
try {
quotes.add(f.get());
} catch (ExecutionException e) {
quotes.add(task.getFailureQuote(e.getCause()));
} catch (CancellationException e) {
quotes.add(task.getTimeoutQuote(e));
}
}
Collections.sort(quotes, ranking);
return quotes;
}
小结
通过围绕任务执行来设计应用程序,可以简化开发过程,并有助于实现并发。Executor 框架将任务提交与执行策略解耦开来,同时还支持不同类型的执行策略。当需要创建线程来执行任务时,可以考虑使用 Executor。要想在将应用程序分解为不同的任务时获得最大的好处,必须定义清晰的任务边界。某些应用程序中存在着比较明显的任务边界,而在其他一些程序中则需要进一步分析才能揭示出粒度更细的并行性。
3.1.7 - CH07-取消关闭
任务和线程的启动很容易。在大多数时候,我们都会让它们运行直到结束,或者让它们自行停止。然而,有时候我们希望提前结束任务或线程,或许是因为用户取消了操作,或者应用程序需要被快速关闭。
要使任务和线程能够安全、快速、可靠的停止下来,并不是一件容易的事。Java 没有提供任何机制来安全的终止线程。但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
这种协作式的方法是必要的,我们很少希望某个任务、线程或服务立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态。相反,在编写任务和服务时可以使用一种协作的方式:因为任务本身的代码比发出取消请求的代码更清除如何执行清除工作。
生命周期结束的问题会使任务、服务以及程序的设计和实现等过程变得复杂,而这个在程序设计中非常重要的要素却经常被忽略。一个在行为良好的软件与勉强运行的软件之间的最主要区别就是,行为良好的软件很完善的处理失败、关闭和取消等过程。本章将给出各种实现、取消、中断的机制,以及如何编写任务和服务,使它们能对取消请求做出响应。
7.1 任务取消
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以成为可取消的。取消某个操作的原因可能会是:
- 用户请求取消。 用户点击图形界面中的“取消”按钮,或者通过管理接口发出取消请求,例如 JMX。
- 有时限的操作。 例如,应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索的任务都将被取消。
- 错误。 网页爬虫程序搜索相关的页面,将页面或摘要数据保存到硬盘。当一个爬虫任务发生错误时,那么所有搜索任务都会取消,此时可能会记录它们的当前状态,以便稍后重新启动。
- 关闭。 当一个程序或服务关闭时,必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任务则可能取消。
在 Java 中没有一种安全的抢占式的方法来停止线程,因此也就没有安全的抢占式的方法来停止任务。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
其中一种协作机制能设置某个“已请求取消”标志,而任务将定期来查看该标志。如果设置了这个标志,那么任务将提前结束。程序清单 7-1 中就使用了这项技术,其中的 PrimeGenerator 持续的枚举素数,直到它被取消。cancel 方法将设置 cancelled 标志,并且主循环在搜索下一个素数之前会首先检查这个标志。(为了使该过程可靠的工作,标志 cancelled 必须为 volatile 类型。)
@ThreadSafe
public class PrimeGenerator implements Runnable {
@GuardedBy("this")
private final List<BigInteger> primes = new ArrayList<>();
private volatile boolean cancelled;
public void run() {
BigInteger p = BigInteger.ONE;
while(!cancelled) {
p = p.nextProbablePrime();
synchronized (this) {
primes.add(p);
}
}
}
public void cancel() { cancelled = true; }
public void synchronized List<BigInteger> get() {
return new ArrayList<>(primes);
}
}
程序清单 7-2 给出了这个类的使用示例,即让素数生成器运行 1 秒钟后取消。素数生成器通常并不会刚好在运行一秒钟后停止,因为在请求取消的时刻和 run 方法中循环执行下一次检查之间可能存在延迟。cancel 方法由 finally 块调用,从而确保即使在调用 sleep 时被中断也能取消素数生成器的执行。如果 cancel 没有被调用,那么搜索素数的线程将永远运行下去,不断消耗 CPU 的时钟周期,并使得 JVM 不能正常退出。
List<BigInteger> aSecondOfPrimes() throws InterruptedEx {
primeGenerator generator = new PrimeGenerator();
new Thread(generator).start();
try {
SECONDS.sleep(1);
} finally {
generator.cancel();
}
return generator.get();
}
一个可取消的任务必须拥有取消策略,在这个策略中将详细地定义取消操作的 How、When、What,即其他代码如何请求取消该任务,任务在何时检查是否已经请求了取消,以及在响应取消请求时应该执行哪些操作。
考虑现实世界中停止支付支票的示例。银行通常会规定如何提交一个停止支付的请求,在处理这些请求时需要作出哪些响应性保证,以及当支付终端后需要遵守哪些流程。这些流程和保证放在一起构成了支票支付的取消策略。
PrimeGenerator 使用了一种简单的取消策略:客户代码通过调用 cancel 来请求取消,PrimeGenerator 在每次搜索素数前首先检查是否存在取消请求,如果存在则退出。
7.1.1 中断
PrimeGenerator 中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如 BlockingQueue.put,那么可能会产生一个更严重的问题——任务永远不会检查取消标志,因此永远不会停止。
在程序清单 7-3 中的 BrokenPrimeProducer 就说明了这个问题。生产者线程生成素数,put 方法也会阻塞。当生产者在 put 方法中阻塞时,如果消费者希望取消生产任务,那么将发生什么情况?它可以调用 cancel 方法来设置 cancelled 标志,但此时生产者却永远不会检查这个标志,因为它无法从阻塞的 put 方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以 put 方法将一直保持阻塞状态)。
class BrokenPrimeProducer extends Thread {
private final BlockingQueue<BigInteger> queue;
private volatile boolean cancelled = false;
BrokenPrimeProducer(BlockingQueue<BigInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while (!cancelled)
queue.put(p = p.nextProbablePrime());
} catch (InterruptedException consumed) { }
}
public void cancel() { cancelled = true; }
}
void consumePrimes() throws InterruptedException {
BlockingQueue<BigInteger> primes = ...;
BrokenPrimeProducer producer =
new BrokenPrimeProducer(primes);
producer.start();
try {
while (needMorePrimes())
consume(primes.take());
} finally {
producer.cancel();
}
}
第五章曾提到,一些特殊的阻塞库的方法支持中断。线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他工作。
在 Java 的 API 或语言规范中,并没有将中断与任何取消语义关联起来,但实际上如果在取消之外的其他操作中使用中断,那么都是不合适的,并且很难支撑起更大的应用。
每个线程都有一个 boolean 类型的中断状态。当中断线程时,这个线程的中断状态将被设置为 true。在 Thread 中包含了中断线程以及查询线程中断状态的方法,如程序清单 7-4 所示。interrupt 方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。
public class Thread {
public void interrupt() {...}
public boolean isInterrupted() {...}
public static boolean interrupted() {...}
...
}
阻塞库方法,例如 Thread.sleep 和 Object.wait 等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出 InterruptedException,表示阻塞操作由于中断而提前结束。JVM 并不保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。
当线程在非阻塞状态下中断时,它的中断状态被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有粘性”——如果不触发中断异常,那么中断状态将一直保持,直到明确的清除中断状态。
调用 interrput 并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息。
对中断操作的正确理解是:它并不会真正的中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。(这些时刻也被称为取消点)。有些方法,例如 wait、sleep、join 等,将严格的处理这种请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用者代码对中断请求进行某种处理。设计糟糕的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求做出响应。
在使用静态的 interrupted 时应该小心,因为它会清除单签线程的中断状态。如果在调用 interrupted 时返回了 true,那么除非你想屏蔽这个中断,否则必须对它进行处理——可以抛出中断异常,或者通过再次调用 interrupt 来恢复中断状态,如程序清单 5-10 所示。
BrokenPromeProducer 说明了一些自定义的取消机制无法与可阻塞的库函数实现良好交互的原因。如果任务代码能够响应中断,那么可以使用中断作为取消机制,并且利用许多库中提供的中断支持。
通常,中断是实现取消的最合理方式。
BrokenPrimeProducer 中的问题可以很容易解决或简化:使用中断而不是 boolean 标志来请求取消,如程序清单 7-5 所示。在每次迭代循环中,有两个位置可以检测出中断:在阻塞的 put 方法调用中,以及在循环开始处查询中断中断状态时。由于调用了阻塞的 put 方法,因此这里并不需要进行显式的检测,但执行检测却会使 PrimeProducer 对中断具有更高的响应性,因为它是在启动寻找素数任务之前检测中断的,而不是在任务完成之后。如果可中断的阻塞方法的调用频率并不高,不足以获得足够的响应性,那么显式的检测中断状态能起到一定的帮助作用。
class PrimeProducer extends Thread {
private final BlockingQueue<BigIngeger> queue;
PrimeProducer(BlockingQueue<BitInteger> queue) {
this.queue = queue;
}
public void run() {
try {
BigInteger p = BigInteger.ONE;
while(!Thread.currentThread().isInterrupted())
queue.put(p=p.nexProbablePrime());
} catch(InterruptedException ignore) {
// 允许线程退出
}
}
public void cancel() { interrupt(); }
}
7.1.2 中断策略
正如任务中应该包含取消策略一样,线程同样应该包含中断策略。中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度响应中断。
最合理的中断策略是某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。此外还可以建立其他的中断策略,例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池,只能用于能够知道这些策略的任务中。
区分任务和线程对中断的反应是很重要的。一个中断请求可以有一个或多个接收者——中断线程池中的工作者线程,同时意味着“取消工作者任务”和“关闭工作者线程”。
任务不会在其自己拥有的线程中执行,而是在某个服务(如线程池)拥有的线程中执行。对于非线程持有者的代码来说(如对于线程池而言,任何在线程池以外实现的代码),应该小心的保存中断状态,这样拥有线程的代码才能对中断做出响应,即使“非所有者”代码也可以做出响应。(当你为一户人家打扫房间时,即使主人不在,也不应该把这段时间内收到的邮件扔掉,而应该把邮件收起来,等主人回来后再交给他们处理,尽管你可以阅读他们的杂志)。
这既是为什么大多数可阻塞的库函数只是抛出 InterruptedException 作为终端响应。他们永远不会在某个由自己拥有的线程中运行,因此他们为任务或库代码实现了最合理的取消策略:尽快推出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。
当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出 InterruptedException 或者表示已接收到中断请求。这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。
任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心的保存执行线程的中断状态。如果出了将中断异常传递给调用者外还需要执行其他操作,那么应该在捕获中断异常之后恢复中断状态:
Thread.currentThread().interrupt();
正如任务代码不应该对其执行所在的线程的中断策略做出假设,执行取消操作的代码也不应该对线程的中断策略做出假设。线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如关闭方法。
由于每个线程又有各自的中断策略,因此除非你知道中断对线程的含义,否在就不应该中断这个线程。
批评者层嘲笑 Java 的中断功能,因为它没有提供抢占式中断机制,而且还强迫开发人员必须处理中断异常。然而,通过推迟中断请求的处理,开发人员能够制定更灵活的中断策略,从而使应用程序在响应性和健壮性之间实现合理的平衡。
7.1.3 响应中断
在 5.4 节中,当调用可阻塞的阻塞函数式,例如 Thread.sleep 或 BlockingQueue.put 等,有两种实用策略可用于处理 InterruptedException:
- 传递异常(可能在执行某个特定于任务的清除操作之后),从而使你的方法也成为可中断的阻塞方法。
- 恢复中断状态,从而使调用栈的上层代码能够对其进行处理。
传递中断异常与将中断异常添加到 throws 子句中一样容易,如程序清单 7-6 中的 getNextTask 所示。
BlockingQueue<Task> queue;
...
public Task getNextTask() throws InterruptedException {
return queue.take();
}
如果不想或无法传递中断异常(或许通过 Runnable 来定义任务),那么需要寻找一另一种方式来保存中断请求。一种标准的方法是通过再次调用 interrupt 来恢复中断状态。你不能屏蔽 InterruptedException,例如 catch 块中捕获到异常却不做任何处理,除非在你的代码中实现了线程的中断策略。虽然 PrimeProducer 屏蔽了中断,但这是因为它已经知道线程将要结束,因此在调用栈中已经没有上层代码需要知道中断信息。由于大多数代码并不知道它们将在哪个线程上运行,因此应该保存中断状态。
只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。
对于一些不支持取消单仍可以调用阻塞方法的操作,他们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获中断异常时恢复状态,如程序清单 7-7 所示。如果过早的设置中断状态,就可能引起无限循环,因为大多数可中断的阻塞方法都会在入口检查中断状态,并且当返现该状态已被设置时会立即抛出 InterruptedException。(通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快的响应中断)。
public Task getNextTask(BlockingQueue<Task> queue) {
boolean interrupted = false;
try {
while(true) {
try {
return queue.take();
} catch(InterruptedException e) {
interrupted = true;
//重试
}
}
} finally {
if(interrupted)
Thread.currentThread().interrupt();
}
}
如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断。要选择合适的轮询频率,就需要在效率和响应性之间进行权衡。如果响应性要求较高,那么不应该调用那些执行时间较长并且不影响中断的方法,从而对可调用的库代码进行一些限制。
在取消过程中可能涉及中断状态之外的其他状态。中断可以用来获得线程的注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示。(当访问这些信息时,要确保使用同步)
例如,当一个由 ThreadPoolExecutor 拥有的工作线程检测到中断时,它会检查线程是否正在关闭。如果是,它会在结束前执行一些线程池清理工作,否则它可能创建一个新线程以将线程池恢复到合理的规模。
7.1.4 示例:计时运行
许多问题永远也无法解决(如枚举所有的素数),而某些问题,能很快得到答案,也可能永远得不到答案。在这些情况下,如果能够指定“最多花10分钟搜索答案”或者“枚举出在 10 分钟内能找到的答案”,那么将是非常有用的。
程序清单 7-2 中的 aSecondOfPrimes 方法将启动一个 PrimeGenerator,并在 1 秒钟后中断。尽管 PrimeGenerator 可能需要超过 1 秒钟的时间才能停止,但它最终会发现中断,然后停止,并使线程结束。在执行任务时的另一个方面是,你希望知道在任务执行过程中是否会抛出异常。如果 PrimeGenerator 在指定时限内抛出了一个未检异常,那么这个异常可能会被忽略,因为素数生成器在另一个独立的线程中运行,而这个线程并不会显式的处理异常。
在程序清单 7-8 中给出了在指定时间内运行一个任意的 Runnable 的示例。它在调用线程中运行任务,并安排了一个取消任务,在运行指定的时间间隔后中断它。这解决了从任务中抛出未检查异常的问题,因为该异常会被 timedRun 的调用者捕获。
private static final ScheduledExecutorService cancelExec = ...;
public static void timedRun(Runnable r, long timeout, TimeUnit unit) {
final Thread taskThread = Thread.currentThread();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}
}, timeout, unit);
r.run();
}
这是一种非常简单的方法,但却破坏了以下原则:在中断线程之前,应该了解它的中断策略。由于 TimeRun 可以从任意一个线程中调用,因此它无法找到这个调用线程的中断策略。如果任务在超时之前完成,那么中断 timedRun 所在线程的取消任务将在 tumedRun 返回到调用者之后启动。我们不知道在这种情况下将运行什么代码,但结果一定不是好的。(可以使用 schedule 返回的 ScheduleFuture 来取消这个取消任务以避免这种风险,这种做法虽然可行,但却非常复杂)
而且,如果任务不响应中断,那么 timedRun 会在任务结束时才返回,此时可能已经超过了指定的时限(或者尚未超过时限)。如果某个限时运行的服务没有在指定时间内返回,那么将对调用者带来负面影响。
在程序清单 7-9 中解决了 aSecondOfPrimes 的异常处理问题以及之前解决方案中的问题。执行任务的线程拥有自己的执行策略,即使任务不响应中断,限时运行的方法仍能返回到它的调用者。在启动任务线程之后,timedRun 将执行一个限时的 join 方法。在 join 返回后,它将检查任务中是否有异常抛出,如果有的话,则会在调用 timedRun 的线程中再次抛出该异常。由于 Throwable 将在两个线程之间共享,因此该变量被声明为 volatile 类型,从而确保线程安全的将其从任务线程中发布到 timedRun 线程。
public static void timedRun(
final Runnable r,
long timeout,
TimeUnit unit) throws InterruptedException {
class RethrowableTask implements Runnable {
private volatile Throwable t;
public void run() {
try { r.run(); }
catch (Throwable t) { this.t = t; }
}
void rethrow() {
if (t != null) throw launderThrowable(t);
}
}
RethrowableTask task = new RethrowableTask();
final Thread taskThread = new Thread(task);
taskThread.start();
cancelExec.schedule(new Runnable() {
public void run() {
taskThread.interrupt();
}, timeout, unit);
taskThread.join(unit.toMillis(timeout));
task.rethrow();
}
在这个示例代码中解决了前面示例中的问题,但由于它依赖于一个限时的 join,因此存在着 join 的不足:无法知道执行控制是因为线程正常退出而返回还是因为 join 超时而返回。
7.1.5 通过 Future 实现取消
我们已经使用了一种抽象机制来管理任务的生命周期、处理异常、实现取消,即 Future。通常,使用现有库中的类比自行编写更好,因此我们将继续使用 Future 和任务执行框架来构建 timedRun。
ExecutorService.submit 将返回一个 Future 来描述任务。Future 拥有一个 cancel 方法,该方法带有一个 boolean 类型的参数 mayInterruptIfRunning,表示取消操作是否成功。(这只是表示任务是否能够接收终端,而不是表示任务是否能检测并处理终端)。如果 mayInterruptIfRunning 为 true 并且任务当前正在某个线程中运行,那么这个线程能被中断,如果这个参数为 false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
除非你清除线程的中断策略,否则不要中断线程,那么在什么情况下调用 cancel 可以将参数指定为 true 呢?执行任务的线程是由标准的 Executor 创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准 Executor 中运行,并通过它们的 Future 来取消任务,那么可以设置 mayInterruptIfRunning。当尝试取消某个任务时,不宜直接中断线程池,因为你并不知道当中断请求时要将中断视为一个取消请求的另一个理由:可以通过任务的 Future 来取消它们。
程序清单 7-10 给出了另一个版本的 timedRun:将任务提交给一个 ExecutorService,并通过一个定时的 Future.get 来获得结果。如果 get 在返回时抛出了一个 TimeoutException,那么任务将通过它的 Future 来取消。(为了简化代码,这个版本的 timeRun 在 finally 块中将直接调用 Future.cancel,因为取消一个已经完成的任务不会带来任何影响)。如果任务在被取消前就抛出一个异常,那么该异常将被重新抛出以便由调用者来处理。在程序清单 7-10 中还给出了另一种良好的编程习惯:取消那些不再需要结果的任务。
public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
Future<?> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch(TimeoutExcetion e) {
// 接下来任务将被取消
} catch(ExecutionException e) {
// 如果在任务中抛出了异常,那么重新抛出该异常
throw launderThrowable(e.getCause());
} finally {
// 如果任务已经结束,执行取消操作不会带来任何影响
// 如果任务正在运行,那么将被中断
task.cancel(true);
}
}
当 Future.get 抛出中断或超时异常时,如果你知道不再需要结果,那么就可以调用 Future.cancel 来取消任务。
7.1.6 处理不可中断的阻塞
在 Java 库中,许多阻塞的方法都是通过提前返回或抛出中断异常来响应中断请求的,从而使开发人员更容易构建出能够响应取消请求的任务。然而,并非所有的可阻塞方法或者阻塞机制都能够响应中断:如果一个线程由于执行同步的 Socket IO 或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。
Java.io 包中的同步 Socket IO,在服务器应用中,最常见的 IO 形式就是对套接字进行读写,虽然 InputStream 和 OutputStream 中的 read 和 write 等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行 read 或 write 等方法而被阻塞的线程抛出一个 SocketException。
Java.io 包中的同步 IO,当中断一个正在 InterruptibleChannel 上等待的线程时,将抛出 ClosedByInterruptException 并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出该异常)。当关闭一个 InterruptibleChannel 时,将导致所有在链路操作上阻塞的线程都抛出 AsynchronousCloseException。大多数标准的 Channel 都实现了 InterruptibleChannel。
Selector 的异步 IO,如果一个线程在调用 Selector.select 方法时阻塞了,那么调用 close 或 wakeup 方法会使线程抛出 ClosedSelectorException 并提前返回。
获取某个锁,如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程任务它肯定会获得锁,所以不会理会中断请求。但是,在 Lock 类中提供了 lockInterruptibly 方法,该方法允许在等待一个锁的同时仍能响应中断,请参见第 13 章。
程序清单 7-11 的 ReaderThread 给出了如何封装非标准的取消操作。ReaderThread 管理了一个套接字连接,它采用同步方式从该套接字中读取数据,并将接收到的数据传递给 processBuffer。为了结束某个用户的连接或者关闭服务器,ReaderThread 改写了 interrupt 方法,使其既能处理标准的中断,也能关闭底层的套接字。因此,无论 ReaderThread 线程是在 read 方法中阻塞还是在某个可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。
public class ReaderThread extends Thread {
private final Socket socket;
private final InputStream in;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
public void interrupt() {
try {
socket.close();
}
catch(IOException ignored){}
finally {
super.interrupt();
}
}
public void run() {
try {
byte[] buf = new byte[BUFSZ];
while(true) {
int count = in.read(buf);
if(count < 0)
break;
else if(count >0)
pricessBuffer(buf, count);
}
} catch (IOException e) {
// 允许线程退出
}
}
}
7.1.7 采用 newTaskFor 来封装非标准的取消
我们可以通过 newTaskFor 方法来进一步优化 ReaderThread 中封装非标准取消的技术,这是 Java 6 在 ThreadPoolExecutor 中的新增功能。当把一个 Callable 提交给 ExecutorService 时,sunbmit 方法会返回一个 Future,我们可以通过这个 Future 来取消任务。newTaskFor 是一个工厂方法,它将创建 Future 来代表任务。newTaskFor 还能返回一个 RunnableFuture 接口,该接口扩展了 Future 和 Runnable(并由 FutureTask 实现)。
通过定制表示任务的 Future 可以改变 Future.cancel 的行为。例如,定制的取消代码可以实现日志记录或者收集取消操作的统计信息,以及取消一个写不响应中断的操作。通过改写 inturrupt 方法,ReaderThread 可以取消基于套接字的线程。同样,通过改写任务的 Future.cancel 方法可以实现类似的功能。
在程序清单 7-12 的 CancellableTask 中定义一个 CancellableTask 接口,该接口扩展了 Callable,并增加了一个 cancel 方法和一个 newTask 工厂方法来构造 RunnableFuture。cancellingExecutor 扩展了 ThreadPoolExecutor,并通过改写 newTaskFor 使得 CancellableTask 可以创建自己的 Future。
public interface CancellableTask<T> extends Callable<T> {
void cancel();
RunnableFuture<T> newTask();
}
@ThreadSafe
public class CancellingExecutor extends ThreadPoolExecutor {
...
protected<T> RunnableFuture<T> newTaskFor(Callable<T> callable) {
if (callable instanceof CancellableTask)
return ((CancellableTask<T>) callable).newTask();
else return super.newTaskFor(callable);
}
}
public abstract class SocketUsingTask<T>
implements CancellableTask<T> {
@GuardedBy("this") private Socket socket;
protected synchronized void setSocket(Socket s) {
socket = s;
}
public synchronized void cancel() {
try {
if (socket != null) socket.close();
} catch (IOException ignored) { }
}
public RunnableFuture<T> newTask() {
return new FutureTask<T>(this) {
public boolean cancel(
boolean mayInterruptIfRunning) {
try { SocketUsingTask.this.cancel(); }
finally {
return super.cancel(
mayInterruptIfRunning);
}
}
};
}
}
SocketUsingTask 实现了 CancellTask,并定义了 Future.cancel 来关闭套接字和调用 super.cancel。如果 SocketUsingTask 通过自己的 Future 来取消,那么底层的套接字将被关闭且线程将被中断。因此它提高了任务对取消操作的响应性:不仅能够在调用可中断方法的同时确保响应取消操作,而且还能调用可阻塞的套接字 IO 方法。
7.2 停止基于线程的服务
应用程序通常会创建拥有多个线程的服务,比如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期要长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于服务通过抢占式的方法来停止线程,因此它们需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程继续操控。例如,中断线程或者修改线程的优先级。在线程 API 中,并没有对线程所有权给出正式的定义:线程由 Thread 对象表示,并且像其他对象一样可以被自由共享。然而,线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。
与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法来关闭它自己以及他所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在 ExecutorService 中提供了 shutdown 和 shutdownNow 等方法。同样,在其他拥有线程的服务中也应该提供类似的关闭机制。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法存在的时间,那么久应该提供生命周期方法。
示例:日志服务
在大多数服务器应该程序中都会用到日志,例如,在代码中插入 pringln 语句就是一种简单的日志。像 PrintWriter 这样的字符流是线程安全的,因此这种简单的方法不需要显式的同步。然而,在 11.6 节中,我们将看到这种内联日志功能会给一些高容量的应用程序带来一定的性能开销。另外一种替代方法是通过调用 log 方法将日志消息放入某个队列中,并由其他线程来处理。
在程序清单 7-13 的 LogWriter 中给出了一个简单的日志服务示例,其中日志操作在单独的日志线程中执行。产生日志消息的线程并不会将消息直接写入输出流,而是由 LogWriter 通过 BlockingQueue 将消息提交给日志线程,并由日志线程写入。这是一种多生产者单消费者的设计方式:每个调用 log 的操作相当于一个生产者,而后台的日志线程相当于消费者。如果消费者的处理速度低于生产者的生产速度,那么 BlockingQueue 将阻塞生产者,直到日志线程有能力处理新的日志消息。
public class LogWriter {
private final BlockingQueue<String> queue;
private final LoggerThread logger;
public LogWriter(Writer writer) {
this.queue = new LinkedBlockingQueue<String>(CAPACITY);
this.logger = new LoggerThread(writer);
}
public void start() { logger.start(); }
public void log(String msg) throws InterruptedException {
queue.put(msg);
}
private class LoggerThread extends Thread {
private final PrintWriter writer;
...
public void run() {
try {
while (true)
writer.println(queue.take());
} catch(InterruptedException ignored) { }
finally { writer.close(); }
}
}
}
为了使像 LogWriter 这样的服务在软件产品中发挥实际的作用,还需要实现一种终止日志线程的方法,从而避免使 JVM 无法正常关闭。要停止日志线程是很容易的,因为他会反复调用 take,而 take 能响应中断。如果将日志线程修改为当捕获到 InterruptedException 时推出,那么只需中断日志线程就能停止服务。
然而,如果只是日志线程推出,那么还不是一种完备的关闭机制。这种直接关闭的做法会丢失那些正在等待被写入日志的信息,不仅如此,其他线程将在调用 log 时被阻塞,因为日志队列是满的,因此这些线程将无法解除阻塞状态。当取消一个生产者消费者操作时,需要同时取消生产者和消费者。在中断日志线程时会处理消费者,但在这个示例中,由于生产者并不是专门的线程,因此要取消它们非常困难。
另一种关闭 LogWriter 的方式是,设置某个“已请求关闭”标志,以避免进一步提交日志消息,如程序清单 7-14 所示。在收到关闭请求后,消费者会把队列中的所有消息写入日志,并解除所有在调用 log 时阻塞的生产者。然而,在这个方法中存在竟态条件,使得该方法并不可靠。log 的实现是一种“先判断再运行”的代码序列:生产者发现该服务还没有关闭,因此在关闭服务后仍然会将日志消费放入队列,这同样会使得生产者可能在调用 log 时阻塞并且无法解除阻塞状态。可以通过一些技巧来降低这种情况的发生概率(比如在宣布队列被清空之前,让消费者等待数秒钟),但这都没有解决问题的本质,即使很小的概率也可能导致程序故障。
public void log(String msg) throws InterruptedException {
if(!shutdownRequested)
queue.put(msg);
else
throw new IllegalStateException("logger is shutdown");
}
为 LogWriter 提供可靠关闭操作的方法是解决静态条件问题,因为要使日志消息的提交操作称为原子操作。然而,我们不希望在消息加入队列时去持有一把锁,因为 put 方法本身就可以阻塞。我们采用的方法是:通过原子方式来检查关闭请求,并且有条件的递增一个计数器来“保持”提交消息的权利,如程序清单 7-15 中的 LogService 所示。
public class LogService {
private final BlockingQueue<String> queue;
private final LoggerThread loggerThread;
private final PrintWriter writer;
@GuardedBy("this") private boolean isShutdown;
@GuardedBy("this") private int reservations;
public void start() { loggerThread.start(); }
public void stop() {
synchronized (this) { isShutdown = true; }
loggerThread.interrupt();
}
public void log(String msg) throws InterruptedException {
synchronized (this) {
if (isShutdown)
throw new IllegalStateException(...);
++reservations;
}
queue.put(msg);
}
private class LoggerThread extends Thread {
public void run() {
try {
while (true) {
try {
synchronized (this) {
if (isShutdown && reservations == 0) break;
}
String msg = queue.take();
synchronized (this) { --reservations; } writer.println(msg);
} catch (InterruptedException e) {
/* retry*/
}
}
} finally {
writer.close();
}
}
}
}
7.2.2 关闭 ExecutorService
在 6.2.4 节中,我们看到 ExecutorService 提供了两种关闭方法:使用 shutdown 正常关闭,以及使用 shutdownNow 强行关闭。在进行强行关闭时,shutdownNow 首先关闭正在执行的任务,然后返回所有尚未启动的任务清单。
这两种关闭的方式的差别在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束:而正常关闭虽然速度慢,但却更安全,因为 ExecutorService 会一直等到队列中的所有任务都执行完毕才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。
简单的程序可以直接在 main 函数中启动和关闭全局的 ExecutorService。而在复杂程序中,通常会将 ExecutorService 封装在某个更高级别的服务中,并且该服务能提供自己的生命周期方法,例如程序清单 7-16 中 LogService 的一种变化形式,它将管理线程的工作委托给一个 ExecutorService,而不是由其自行管理。通过封装 ExecutorService,可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它拥有的服务或线程的生命周期。
public class LogService {
private final ExecutorService exec = newSingleThreadExecutor();
...
public void start() {}
public void stop() throws InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(TIMOUT, UNIT);
} finally {
writer.close();
}
}
public void log(String msg) {
try {
exec.execute(new WriteTask(msg));
} catch(RejectedExecutionException ignored){}
}
}
7.2.3 “毒丸”对象
另一种关闭生产者——消费者服务的方式是使用“毒丸(Poison Pill)”对象:毒丸是指一个放在队列中的对象,其含义是“当遇到这个对象时,立即停止”。在 FIFO 队列中,毒丸对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交毒丸对象之前的所有工作都会被处理完毕,而生产者在提交了毒丸之后,将不会再生产任何工作。在程序清单 7-17、7-18、7-19 中给出了一个单生产者单消费者的桌面搜索示例,这个示例中使用了毒丸对象来关闭服务。
public class IndexingService {
private static final File POISON = new File("");
private final IndexerThread consumer =
new IndexerThread();
private final CrawlerThread producer =
new CrawlerThread();
private final BlockingQueue<File> queue;
private final FileFilter fileFilter;
private final File root;
class CrawlerThread extends Thread { /* Listing 7.18 */ }
class IndexerThread extends Thread { /* Listing 7.19 */ }
public void start() {
producer.start();
consumer.start();
}
public void stop() {
producer.interrupt();
}
public void awaitTermination() throws InterruptedException {
consumer.join();
}
}
public class CrawlerThread extends Thread {
public void run() {
try{
crawl(root);
} catch(InterruptedExcetion e) {/* fall through */}
finally {
while(true){
try{
queue.put(POISON);
break;
} catch(InterruptedException e1) {/* retry */}
}
}
}
private void cawal(File root) throws InterruptedException{
// ...
}
}
public class IndexerThread extends Thread {
public void run() {
try {
while(true){
File file = queue.take();
if(file == POISON)
break;
else
indexFile(file);
}
} catch(InterruptedException consumed) {}
}
}
只有生产者和消费者都已知的情况下,才可以使用毒丸对象。在 IndexingService 中采用的解决方案可以扩展到多个生产者:只需要从每个生产者都向队列中放入一个毒丸对象,并且消费者仅当在接收到 N 个毒丸对象时才停止。这种方法也可也扩展到多个消费者的情况,只需生产者将 N 个毒丸对象放入队列。然而,当生产者和消费者的数量较大时,这种方法将变得难以使用。只有在无界队列中,毒丸对象才能可靠的工作。
7.2.4 示例:只执行一次的服务
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的 Executor 来简化服务的生命周期管理,其中该 Executor 的生命周期是由这个方法来控制的。(在这种情况下,invokeAll 和 invokeAny 等方法通常会起较大的作用)
程序清单 7-20 中的 checkMail 方法能在多台主机上并行的检查新邮件。它创建一个私有的 Executor,并向每台主机提交一个任务。然后,当所有邮件检查任务都执行完成后,关闭 Executor 并等待结束。
boolean checkMail(Set<String> hosts, long timeout, TimeUnit unit) throws InterruptedException {
final AtomicBoolean hasNewMail = new AtomicBoolean(false);
try {
for(final String host : hosts)
exec.ececute(new Runnable() {
public void run() {
if(checkMail(host))
hasNewMail.set(true);
}
});
} finally {
exec.shutdown();
exec.awaitTermination(timeout, unit);
}
return hasNewMail.get();
}
7.2.5 shutdownNow 的局限性
当通过 shutdownNow 来强行关闭 ExecutorService 时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。
然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身执行某种检查。要知道哪些任务还没有完成,你不仅需要知道那些任务还没有开始,还需要知道当 Executor 关闭时哪些任务正在执行。
在程序清单 7-21 的 TrackingExecutor 中给出了如何在关闭过程中判断正在执行的任务。通过封装 ExecutorService 并使得 execute (类似的还有 submit 等)记录哪些任务是在关闭后取消的,TackingExecutor 可以找出哪些任务已经开始但还没有正常完成。在 Executor 结束后,getCancelledTasks 返回被取消的任务清单。要使这项技术能发挥作用,任务在返回时必须维持线程的中断状态,在所有设计良好的任务中都会实现这个功能。
public class TrackingExecutor extends AbstractExecutorService {
private final ExecutorService exec;
private final Set<Runnable> tasksCancelledAtShutdown =
Collections.synchronizedSer(new HashSet<>());
...
public List<Runnable> getCancelledTasks() {
if(!exec.isTerminated())
throw new IllegalStateException();
return new ArrayList<>(tasksCancelledAtShutdown);
}
public void execute(final Runnable runnable) {
exec.execute(new Runnable(){
public void run() {
try {
runnable.run();
} finally {
if(isShutdown() &&
Thread.currentThread().isInterrupted())
tasksCancelledAtShutdown.add(runnable);
}
}
});
}
}
在程序清单 7-22 的 WebCrawler 中给出了 TackingExecutor 的用法。网页爬虫的工作通常是无穷尽的,因此当爬虫程序必须关闭时,我们通常希望保存它的状态,以便稍后重新启动。CrawlerTask 提供了一个 getPage 方法,该方法能找出正在处理的页面。当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,多将记录它们的 URL,因此爬虫程序重新启动时,就可以将这些 URL 的页面抓取任务加入到任务队列中。
public abstract class WebCrawler {
private volatile TrackingExecutor exec;
@GuardedBy("this")
private final Set<URL> urlsToCrawl = new HashSet<URL>();
...
public synchronized void start() {
exec = new TrackingExecutor(Executors.newCachedThreadPool());
for (URL url : urlsToCrawl)
submitCrawlTask(url);
urlsToCrawl.clear();
}
public synchronized void stop()
throws InterruptedException {
try {
saveUncrawled(exec.shutdownNow());
if (exec.awaitTermination(TIMEOUT, UNIT))
saveUncrawled(exec.getCancelledTasks());
} finally {
exec = null;
}
}
protected abstract List<URL> processPage(URL url);
private void saveUncrawled(List<Runnable> uncrawled) {
for (Runnable task : uncrawled)
urlsToCrawl.add(((CrawlTask) task).getPage());
}
private void submitCrawlTask(URL u) {
exec.execute(new CrawlTask(u));
}
private class CrawlTask implements Runnable {
private final URL url;
...
public void run() {
for (URL link : processPage(url)) {
if (Thread.currentThread().isInterrupted())
return;
submitCrawlTask(link);
}
}
public URL getPage() { return url; }
}
}
在 TrackingExecutor 中存在一个不可避免的竟态条件,从而产生“误报”问题:一些被任务已经取消的任务实际上已经执行完成。这个问题的原因在于,在任务执行最后一条执行以及线程池将任务记录为“结束”的两个时刻之间,线程池可能被关闭。如果任务是幂等的,那么这不会存在问题,在网页爬虫程序中就是这种情况。否则,在应用程序中必须考虑这种风险,并对“误报”问题做好准备。
7.3 处理非正常的线程终止
当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出非常不同的栈追踪信息,这种情况是很容易理解的。然而,如果并发程序中的某个线程发生故障,那么通常并不会如此明显。在控制台中可能会输出栈追踪信息,但没有人会观察控制台。此外,当线程发生故障时,应用程序可能看起来仍然在工作,所以这个失败很可能被忽略。幸运的是,我们有可以检测并防止在程序中“遗漏”线程的方法。
导致线程提前死亡的最主要原因就是 RuntimeException。由于这些异常表示出现了某种编程错误或者其他不可修复的错误,因此它们通常不会被捕获。它们不会在调用栈中逐层传递,而是默认的在控制台中输出栈追踪信息,并终止线程。
线程非正常退出的后果可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。虽然在线程池中丢失一个线程可能会带来性能上的影响,但如果程序能在包含 50 个线程的线程池上运行良好,那么在包含 49 个线程的线程池上通常也能良好的运行。然而,如果在 GUI 程序中丢失了事件分派线程,那么造成的影响将非常显著——应用程序将停止处理事件并且 GUI 会因此失去响应。在第六章的 OutOfTime 中给出了由于遗留线程而造成的严重后果:Timer 表示的服务将永远无法使用。
任何代码都可能抛出一个 RuntimeException。每当调用另一个方法时,都要对它的行为保持怀疑,不要盲目的任务它一定会正常返回,或者一定会抛出在方法原型中声明的某个受检异常。对调用的代码约不熟悉,就越应该对其代码行为保持怀疑。
在任务处理线程(如线程池中的工作线程或 Swing 的事件派发线程等)的生命周期中,将通过某种抽象机制(如 Runnable)来调用许多未知的代码,我们应该对在这些线程中执行的代码能否表现出正确的行为保持怀疑。像 Swing 事件线程这样的服务可能只是因为某个编写不当的时间处理器抛出 NPE 而失败,这种情况是非常糟糕的。因此,这些线程应该在 try-catch 代码块中调用这些任务,这样就能捕获那些未检异常了,或者也可以使用 try-finally 代码块来确保框架能够知道线程非正常退出的情况,并作出正确的响应。在这种情况下,你或许会考虑捕获 RuntimeException,即当通过 Runnable 这样的抽象机制来调用未知的和不可信的代码时。
在程序清单 7-23 中给出了如何在线程池内部构建一个工作者线程。如果任务抛出了一个未检异常,那么它将终结线程,但首先会通知框架该线程已经终结。然后,框架可能会用新的线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的线程能够满足需要。ThreadPoolExecutor 和 Swing 都通过这项技术来确保行为糟糕的任务不会影响到后续执行的任务。当编写一个向线程池提交任务的工作者线程类时,或者调用不可信的外部代码时(如动态加载的插件),使用这些方法中的某一种可以避免某个编写的糟糕的任务或插件不会影响调用它的整个线程。
public void run() {
Throwable thrown = null;
try {
while(!isInterrupted()){
runTask(getTaskFromWorkQueue());
}
} catch (Throwable e) {
thrown = e;
} finally {
threadExited(this, thrown);
}
}
未捕获异常的处理
上节介绍了一种主动方法来解决未检异常。在 Thread API 中同样提供了 UncaughtExceptionHandler,它能检测出某个线程由于未捕获异常而终止的情况。这两种方法是互补的,通过将二者结合,就能有效防止线程泄露问题。
当一个线程由于未捕获异常而退出时,JVM 会把这个事件报告给应用程序提供的 UncaughtExceptionHandler 异常处理器。如果没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到 System.err。
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
异常处理器处理未捕获异常的方式,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中,如程序清单 7-25 所示。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或者执行其他修复或诊断等操作。
public class UEHLogger implements Thread.UncaughtExceptionHandler {
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SERVER,
"Thread terminated with exception: " + t.getname(), e);
}
}
在长时间运行的应用程序中,通常会为所有线程的未捕获异常指定同一个异常处理器,并且该处理器至少会将异常信息记录到日志中。
要为线程池中的所有线程设置一个异常处理器,需要为 ThreadPoolExecutor 的构造函数提供一个 ThreadFactory。(与所有线程操控一样,只有线程的所有者能够改变线程的异常处理器)。标准线程池允许当发生未捕获异常时结束线程,但由于使用了一个 try-finally 代码块来接收通知,因此当线程结束时,将有新的线程来代替它。如果没有提供捕获异常处理器或者其他的故障通知机制,那么任务将会悄悄失败,从而导致大面积的混乱,如果你希望在任务由于发生异常而失败时获得通知,并且执行一些特定于任务的恢复操作,那么可以将任务封装在能捕获异常的 Runnable 或 Callable 中,或者改写 ThreadPoolExecutor 的 afterExecute 方法。
令人困惑的是,只有通过 execute 提交的任务,才能将它抛出的异常交给异常处理器,而通过 submit 提交的任务,无论是抛出未检还是受检异常,都将被认为是任务返回状态的一部分。如果一个由 submit 提交的任务由于抛出了异常而结束,那么这个异常将被 Future.get 封装在 ExecutionException 中。
7.4 JVM 关闭
JVM 既可以正常关闭,也可以强行关闭。正常关闭的触发方式有多种,包括:当最后一个“正常(非守护)”线程结束时,或者当调用了 System.exit 时,或者通过其他特定于平台的方法关闭时(例如发送了 SIGINT 信号或键入 Ctrl+C)。虽然可以通过这些标准方法来正常关闭 JVM,但也可以通过调用 Runtime,halt 或者在操作系统中“杀死” JVM 进程(如发送 SIGKILL)来强行关闭 JVM。
关闭钩子
在正常关闭中,JVM 首先调用所有已注册的关闭钩子(ShutdownHook)。关闭钩子是通过 Runtime.addShutdownHook 注册的但尚未开始的线程。JVM 并不能保证关闭钩子的调用顺序。在关闭应用程序线程时,如果有(守护或非守护)线程仍在运行,那么这些(钩子)线程接下来将与关闭线程并发运行。当所有的关闭钩子都执行结束时,如果 runFinalizersOnExit 为 true,那么 JVM 将运行终结器,然后再停止。JVM 并不会停止或中断任何在关闭时仍在运行的应用程序线程。当 JVM 最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且 JVM 必须被强行关闭。当被强行关闭时,只是关闭 JVM,而不会运行关闭钩子。
关闭钩子应该确保线程安全:它们在访问共享数据时必须使用同步机制,并且小心的避免发生死锁,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态(如其他服务是否已经关闭、所有的正常线程是否已经执行完成)或 JVM 的关闭原因作出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子应该尽快退出,因为它们的运行会延迟 JVM 的结束时间,而用户可能希望尽快关闭 JVM。
关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件、清除无法由操作系统自行清除的资源。在程序清单 7-26 中给出了如何使用程序清单 7-16 中的 LogService 在其 start 方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。
public void start() {
Runtime.getRuntime.addShutdownHook(new Thread() {
public void run() {
try { LogService.this.stop();}
catch (InterruptedException ignored) {}
}
})
}
由于关闭钩子将并发执行,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。实现这种功能的一种方式是对所有服务使用同一个关闭钩子(而非为每个服务注册一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竟态条件或死锁。无论是否使用关闭钩子,都可以使用这项技术,通过将各个关闭操作串行执行而非并行执行,可以消除许多潜在的故障。当应用程序需要维护多个服务之间的显式依赖信息时,这项技术可以确保关闭操作按照正确的顺序执行。
7.4.2 守护线程
有时候你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍 JVM 的关闭。在这种情况下就需要使用守护线程(Daemon Thread)。
线程可分为两种:普通线程与守护线程。在 JVM 启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(如垃圾回收器及其他执行辅助工作的线程)。当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。
普通线程与守护线程的区别仅在于线程退出时发生的操作。当一个线程退出时,JVM 会检查其他正在运行的线程,如果这些线程都是守护线程,那么 JVM 会在正常退出操作。当 JVM 停止时,所有仍然存在的线程都将被抛弃——既不会执行 finally 代码块,也不会执行回卷栈,而是直接退出 JVM。
我们应尽可能少的使用守护线程——很少有操作能够在不进行清理的情况下被安全的抛弃。特别是,如果在守护线程中执行可能包含 IO 操作的任务,那么将是一种危险的行文。守护线程最后用于执行“内部任务”,例如周期性的从内存的缓存中移除逾期数据。
此外,守护线程不能用来替代应用程序管理程序中各个服务的生命周期。
7.4.3 终结器
当不再需要内存资源时,可以通过垃圾回收器来回收他们,但对于其他一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式的交还给操作系统。为了实现这个功能,垃圾回收器对那些定义了 finalize 方法的对象会进行特殊处理:在回收器释放它们后,调用它它们的 finalize 方法,从而保证一些持久化的方法被释放。
由于终结器可以在某个由 JVM 管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。终结器并不能保证它们将在何时运行甚至是否会运行,并且的复杂的终结器通常还会在对象上产生巨大的开销,要编写正确的终结器是非常困难的。在大多数情况下,通过使用 finally 代码块和显式 close 方法,能够比使用终结器更好的管理资源。唯一的例外请求在于:当需要管理对象,并且该对象的资源是通过本地方法获得的。基于这些原因以及其他一些原因,我们要尽量避免编写或使用包含终结器的类(除非是平台类库中的类)。
避免使用终结器。
小结
在任务、线程、服务已经应用程序等模块中的生命周期结束问题,可能会增加它们在设计和实现时的复杂性。Java 并没有提供某种抢占式的机制来取消操作或终结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这要依赖于如何构建取消操作的协议,以及能否遵循这些协议。通过使用 FutureTask 和 Executor 框架,可以帮助我们构建可以取消的任务和服务。
3.1.8 - CH08-线程池
第六种介绍了任务执行框架,他不仅简化任务与线程的生命周期管理,而且还提供一种简单灵活的方式将任务的提交与任务的执行策略解耦开来。第七章介绍了在实际应用程序中使用任务执行框架时出现的一些与服务生命周期管理相关的细节问题。本章将对线程池进行配置与调优的一些高级选项,并分析在使用任务执行框架时需要注意的各种危险,以及一些使用 Executor 的高级示例。
8.1 在任务与执行策略之间的隐性耦合
我们已经知道,Executor 框架可以将任务的提交与任务的执行策略解耦。就像许多对复杂过程的解耦操作那样,这种论断多少有些言过其实了。虽然 Executor 框架为指定和修改执行策略都提供了相当大的灵活性,但并非所有的任务都能适用所有的执行策略。有些类型的任务需要明确的执行策略,包括:
- 依赖性任务。大多数行为正确的任务都是独立的:它们不依赖于其他任务的执行时序、执行结果或其他效果。当在线程池中执行独立的任务时,可以随意的改变线程池的大小和配置,这些修改只会对执行性能产生影响。然而,如果提交给线程池的任务需要依赖其他的任务,那么久隐含的给执行策略带来的约束,此时必须小心的维持这些执行策略以避免产生活跃性问题。
- 使用线程封闭机制的任务。与线程池相比,单线程的 Executor 能够对并发性做出更强的承诺。它们能够确保任务不会并发的执行,使你能够放宽代码对线程安全的要求。对象可以封闭在任务线程中,使得在该线程中的任务在访问该对象时不需要同步,即使这些资源不是线程安全的也没问题。这种情形在任务与执行策略之间形成隐式的耦合——任务要求其执行所在的 Executor 是单线程的。如果将 Executor 从单线程环境改为线程池环境,那么将会失去线程安全性。
- 对响应时间敏感的任务。GUI 应用程序对于响应时间是敏感的:如果用户在点击按钮后需要很长延迟才能得到可见的反馈,那么他们会感到不满。如果将一个运行时间较长的任务提交到单线程的 Executor 中,或者将多个运行时间较长的任务提交到一个只包含少量线程的线程池中,那么将降低由该 Executor 管理的所有服务的响应性。
- 使用 ThreadLocal 的任务。ThreadLocal 使每个线程都可以拥有某个变量的一个私有“版本”。然而,只要条件允许,Executor 可以自由的重用这些线程。在标准的 Executor 实现中,当执行需求较低时将回收空闲线程,而当需求增加时将添加新的线程,并且如果从任务中抛出了一个未检异常,那么将用一个新的工作者线程来替代抛出异常的线程。只有当线程本地值的声明周期受限于任务的生命周期时,在线程池中使用 ThreadLocal 才有意义,而在线程池的线程中不应该使用 ThreadLocal 在任务之间传递值。
只有当任务都是同类型的并且是互相独立时,线程池的性能才能达到最佳。如果将运行时间较长的与运行时间较短的任务混合在一起,那么除非线程池很大,否则将可能造成“拥塞”。如果提交的任务依赖于其他任务,那么除非线程池无限大,否则将可能造成死锁。幸运的是,在基于网络的典型服务器应用程序中——网页服务器、邮件服务器、文件服务器,它们的请求通常都是同类型且相互独立的。
在一些任务中,需要拥有或排除某种特定的执行策略。如果某些任务依赖于其他的任务,那么会要求线程池足够大,从而确保它们的依赖任务不会被放入到线程池的等待队列中或被拒绝,而采用线程封闭机制的任务需要串行执行。通常将这些需求写入文档,将来的代码维护人员就不会由于使用了某种不合适的执行策略而破坏安全性和活跃性。
8.1.1 线程饥饿死锁
在线程池中,如果一个任务依赖于其他任务,则可能会产生死锁。在单线程 Executor 中,如果一个任务将另一个任务提交到同一个 Executor,并且等待这个被提交任务的执行结果,那么通常会引发死锁。第二个任务将停留在任务队列中,并等待第一个任务完成以释放线程,而第一个任务又无法完成,因为它在等待第二个任务的完成。在更大线程池中,如果所有正在执行的任务的线程都由于等待其他仍处于工作队列中的任务二阻塞,那么会发生同样的问题。这种现象被称为线程饥饿死锁,只要在线程池中的任务需要无限期的等待一些必须由线程池中其他任务才能提供的资源或条件,例如某个任务等待另一个任务的返回值或执行结果,那么除非线程池足够大,否则将发生线程饥饿死锁。
在程序清单 8-1 中的 ThreadDeadLock 中给出了线程饥饿死锁的示例。RenderPageTask 向 Executor 提交了两个任务来获取网页的页眉和页脚,绘制页面,等待获取页眉和页脚任务的结果,然后将页眉、页面、页脚组合起来并形成最终的页面。如果使用单线程的 Executor,那么 ThreadDeadLock 会经常发生死锁。同样,如果线程池不够大,那么当多个任务通过栅栏机制来彼此协调时,将导致线程饥饿死锁。
public class ThreadDeadLock {
ExecutorService exec = Executors.newSingleThreadExecutor();
public class RenderPageTask implements Callable<String> {
public String call() throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
// Will deadlock -- task waiting for result of subtask
return header.get() + page + footer.get();
}
}
}
每当提交了一个有依赖性的 Executor 任务时,要清楚的知道可能会出现线程“饥饿”死锁,因此需要在代码或配置 Executor 的配置文件中记录线程池的大小限制或配置限制。
除了在线程池大小上的显式限制外,还可能由于其他资源上的约束而存在一些隐式限制。如果应用程序使用一个包含 10 个连接的 JDBC 连接池,并且每个任务需要一个数据库连接,那么线程池就好像只有 10 个线程,因为当超过 10 个任务时,新的任务需要等待其他任务释放连接。
8.1.2 运行时间较长的任务
如果任务阻塞的时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。执行时间较长的任务不仅会造成线程池堵塞,甚至还会增加执行时间较短的任务的服务时间。如果线程池中线程的数量远小于在稳定状态下执行时间较长任务的数量,那么到最后所有的线程都会运行这些执行时间较长的任务,从而影响整体的响应性。
有一项技术可以缓解执行时间较长的任务的影响,即限定任务等待资源的时间,而不要无限制的等待。在平台类库的大多数可阻塞方法中,都同时定义了限时版本和无限时版本,例如 Thread.join、BlockingQueue.put、CountDownLatch.await、Selector.select 等。如果等待超时,那么可以把任务标记为失败,然后终止任务或者将任务重新放回队列以便随后执行。这样,无论任务的最终结果是否成功,这种方法都能确保任务总能执行下去,并将线程释放出来以执行一些能更快执行的任务。如果在线程池中总是充满了被阻塞的任务,那么也可能表明线程池的规模确实过小了。
8.2 设置线程池的大小
线程池的理想大小取决于被提交任务的类型以及所部署系统的特性。在代码中通常不会固定线程池的大小,而应该通过某种配置机制来提供,或者根据 Runtime.availableProcessors 来动态计算。
幸运的是,要设置线程池的大小并不困难,只需要避免过大或过小这两种极端情况。如果线程池过大,那么大量的线程将在相对很少的 CPU 和内存资源上发生竞争,这不仅会导致更高的内存占用,甚至可能会耗尽资源。如果线程池过小,那么将导致许多空闲的处理器无法执行工作,从而降低吞吐率。
要想正确的设置线程池的大小,必须分析计算环境、资源预算和任务的特性。在部署的系统中有多少个 CPU?多大的内存?任务是计算密集型还是 IO 密集型还是二者结合?它们是否需要像 JDBC 连接这样的稀缺资源?如果要执行不同类型的任务,并且它们之间的行为相差很大,那么应该考虑使用不同的线程池,从而使每个线程池可以根据各自的工作负载来调整。
对于计算密集型的任务,在拥有 N 个 CPU 的系统上,当线程池的大小为 N+1 时,通常能实现最优的利用率。(即使当计算密集型的线程偶尔由于缺页故障或其他原因而暂停(阻塞)时,这个“额外”的线程也能确保 CPU 的时钟周期不会被浪费)。对于包含 IO 草作者其他阻塞操作的任务,由于线程并不会一直执行,因此线程池的规模应该更大。要正确的设置线程池的大小,你必须估算出任务的等待时间和时间耗时的比值。这种估算不需要很精确,并且可以通过一些分析或监控工具来获得。你还可以通过另一种方法来调节线程池的带下:在某个基准负载下,分别设置不同大小的线程池来运行应用程序,并观察 CPU 利用率的水平。
给定如下定义:
要使处理器达到期望的使用率,线程池的最优大小等于:
可以通过 Runtime 来获得实际 CPU 数量:
int N_CPUS = Runtime.getRuntime().availableProcessors();
当然,CPU 周期并不是唯一影响线程池大小的资源,还包括内存、文件句柄、套接字句柄和数据库连接等。计算这些资源对线程池的约束条件则更加容易:计算每个任务对该资源的需求量,然后用该资源的可用总量除以每个任务的需求量,所得结果就是线程池大小的上限。
当任务需要某种通过资源池来管理的资源时,例如数据库连接,那么线程池和资源池的大小将会互相影响。如果每个任务都需要一个数据库连接,那么连接池的大小就限制的线程池的大小。同样,当线程池中的任务是数据库连接的唯一使用者时,那么线程池的大小又将限制连接池的大小。
8.3 配置 ThreadPoolExecutor
ThreadPoolExecutor 为一些 Executor 提供了基本的实现,这些 Executor 是由 Executors 中的 newCachedThreadPool、newFixedThreadPool、newScheduledThreadExecutor 等工厂方法返回的。ThreadPoolExecutor 是一个灵活稳定的线程池,支持进行各种定制。
如果默认的执行策略不能满足需要,那么可以通过 ThreadPoolExecutor 的构造函数来实例化一个对象,并根据自己的需要来定制,并且可以参考 Executors 的源代码来了解默认配置下的执行策略,然后再以这些执行策略为基础进行修改。ThreadPoolExecutor 定义了很多构造函数,在程序清单 8-2 中给出了最常见的形式。
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {...}
)
8.3.1 线程的创建与销毁
线程池的基本大小、最大大小以及存活时间等因素共同负责线程的创建与销毁。基本大小也就是线程池的目标大小,即在没有任务执行线程池的大小、并且只有在工作队列已满的情况下才会创建超出这个数量的线程。线程池的最大大小表示可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
通过调节线程池的基本大小和存活时间,可以帮助线程池回收空闲线程占优的资源,从而使得这些资源可以用于执行其他工作。(显然,这是一种折中:回收线程会产生额外的延迟,因为当需求增加时,必须创建新的线程来满足需求)
newFixedThreadPool 工厂方法将线程池的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。newCachedThreadPool 工厂方法将线程池的最大大小设置为 Integer.MAX_VALUE,而将基本大小设置为 0,并将超时设置为 1 分钟,这种方法创建出来的线程池可以被无限扩展,并且当需求降低时会自动收缩。其他形式的线程池可以通过显式的 ThreadPoolExecutor 构造函数来构造。
8.3.2 管理队列任务
在有限的线程池中会限制可以并发执行的任务数量。(单线程的 Executor 是一种值得注意的特例:它们能确保不会有任务并发执行,因为它们通过线程封闭来实现线程安全性。)
在 6.1.2 节中曾介绍过,如果无限制的创建线程,那么将导致不稳定性降低,并通过采用固定大小的线程池(而不是每收到一个请求就创建一个线程)来解决这个问题。然而,这个防范并不完整。在高负载的情况下,应用程序仍可能耗尽资源,只是出问题的概率比较小。如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将累积起来。在线程池中,这些请求会在一个由 Executor 管理的 Runnable 队列中等待,而不会线程那样去竞争 CPU 资源。通过一个 Runnable 和一个链表节点来表现一个等待中的任务,当然比使用线程来表示的开销低很多,但如果客户端提交给服务器的请求的速率超过了服务器的处理速率,那么仍可能会耗尽资源。
即使请求的平均到达率很稳定,也仍然会出现请求突增的情况。尽管队列有助于缓解任务的突增问题,但如果任务持续高速抵达,那么最终还是会抑制请求的到达率以避免耗尽内存。甚至在耗尽内存之前,响应性也随着任务队列的增长而变得越来越糟。
ThreadPoolExecutor 和 newSingleThreadExecutor 在默认情况下使用一个无界的 LinkedBlockingQueue。如果所有工作者线程都处于忙碌状态,那么任务将在队列中等候。如果任务持续快速到达,并且超过了线程池处理任务的速度,那么队列将会无限制的增长。
一种更稳妥的资源管理策略是使用有界队列,如 ArrayBlockingQueue、有界的 LinkedBlockingQueue、PriorityBlockingQueue。有界队列 有助于避免资源耗尽的情况发生,但它又带来了新的问题:当队列填满后,新抵达的任务怎么办?(有许多饱和策略可以解决这个问题,见 8.3.3)。在使用有界队列时,队列的大小与线程池的大小必须一起调节。如果线程池较小而队列较大,那么有助于减少内存占用量,降低 CPU 的使用率,同时还可以减少上下文切换,但付出的代价是可能会限制吞吐量。
对于非常大的或者无界的线程池,可以通过使用 SynchronousQueue 来避免任务排队,以及直接将任务从生产者移交给工作者线程。SynchronousQuque 不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入 SynchronousQuque 中,必须有一个线程正在等待着接收这个元素。如果没有正在等待的线程,并且线程池的当前大小小于最大值,那么 ThreadPoolExecutor 将创建一个新的线程,否则根据饱和策略,该任务将被拒绝。使用直接移交将更加高效,因为任务会直接移交给执行它的线程,而不是首先被放在队列中,然后由工作者线程从队列中提取该任务。只有当线程池是无界的或者可以拒绝任务时,SynchronousQuque 才有实际价值。在 newCachedThreadPool 工厂方法中使用了 SynchronousQuque。
当使用像 LinkedBlockingQueue 或 ArrayBlockingQueue 这样的 FIFO 队列时,任务的执行顺序与它们的到达顺序一致。如果想进一步控制任务的执行顺序,还可以使用 PriorityBlockingQueue,这个队列将根据优先级来安排任务。任务的优先级是通过自然顺序或 Comparator 来定义的。
对于 Executor,newCachedThreadPool 工厂方法是一种很好的默认选择,它能提供比固定大小的线程池更好的排队性能。当需要限制当前任务的数量以满足资源管理的需要时,那么可以选择固定大小的线程池,就像在接收网络客户请求的服务器应用程序中,如果不进行限制,那么很容易发生过载问题。
只有当任务互相独立时,为线程池或工作队列设置界限才是合理的。如果任务之间存在依赖性,那么有界的线程池或队列就可能导致线程饥饿死锁问题。此时应该使用无界的线程池,如 newCachedThreadPool。
8.3.3 饱和策略
当有界队列被填满后,饱和策略将开始发挥作用。ThreadPoolExecutor 的跑和策略可以通过调用 setRejectedExecutionHandler 来修改。(如果某个任务被提交到一个已被关闭的 Executor 时,也会用到饱和策略)。JDK 提供了几种不同的 RejectedExecutionHandler 实现,每种实现都包含有不同的饱和策略:AbortPolicy、CallRunsPolicy、DiscardPolicy、DiscardOldestPolicy。
“中止”策略是默认的饱和策略,该策略将抛出未检查的 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。当新提交的任务无法被保存到队列中等待执行时,“抛弃”策略会悄悄丢弃该任务。“抛弃最旧的”策略会抛弃下一个尚未执行但将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先级对垒,那么“抛弃最旧的”将会抛弃当前队列中优先级最高的任务,因此最好不要将“抛弃最旧”策略与优先级队列一起使用)。
“调用者运行”策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某感谢任务回退到调用者,从而降低新任务的流量。他不会在线程池的某个线程中执行新提交的任务,而是在一个调用了 execute 的线程中执行任务。我们可以将 WebServer 示例修改为使用有界队列和“调用者运行”饱和策略,当线程池中的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用 execute 时在主线程中执行。由于执行任务需要一定的时间,因此主线程至少在一段时间内都不能提交任何任务,从而使得工作者线程有时间来处理正在执行的任务。在这期间,主线程不会调用 accept,因此到达的请求会被保存在 TCP 层的队列中而不是在应用程序的队列中。如果持续过载,那么 TCP 层将最终会发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载时,这种过载请求会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到 TCP 层,最终到达客户端,导致服务器在高负载下实现一种平缓的性能降低。
当创建 Executor 时,可以选择饱和策略或者对执行策略进行修改。程序清单 8-3 给出了如何创建一个固定大小的线程池,同时使用“调用者运行”饱和策略。
ThreadPoolExecutor exeuctor = new ThreadPoolExecutor(
N_THREADS,
N_THREADS,
0L,
new LinkedBlockingQueue<Runnable>(CAPACITY)
);
exeuctor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy());
当工作队列被填满时,没有预定义的饱和策略来阻塞 execute。然而,通过使用 Semaphore 来限制任务的到达率,就可以实现这个功能。在程序清单 8-4 中给出了这种方法的实现。该方法使用一个无界对了(因为不能限制队列的大小和任务的到达率),并设置信号量的上界为线程池的大小加上可排队任务的数量,这是因为信号量需要控制正在执行的和等待执行的任务数量。
@ThreadSafe
public class BoundedExecutor {
private final Executor exec;
private fianl Semaphore semaphore;
public BoundedExecutor(Executor exec, int bound) {
this.exec = exec;
this.semaphore = new Semaphore(bound);
}
public void submitTask(final Runnable command)
throws InterruptedException {
semaphore.acquire();
try {
exec.execute(new Runnable() {
public void run() {
try { command.run(); }
finally {
semaphore.release();
}
}
});
} catch (RejectedExecutonException e) {
semaphore.release();
}
}
}
8.3.4 线程工厂
每当线程池需要创建一个线程时,都是线程工厂方法来完成的。默认的线程工厂方法都将创建一个新的、非守护线程,并且不包含特殊的配置信息。通过指定一个线程工厂方法,可以定制线程池的配置信息。在 ThreadFactory 中只定义了一个方法 newThread,每当线程池需要创建新的线程时都会调用该方法。
然而,在许多情况下都需要使用定制的线程工厂方法。例如,你希望为线程池中的线程指定一个 UncaughtExceptionHandler,或者实例化一个定制的 Thread 类用于执行调试信息的记录。你还可能希望修改线程的优先级(这通常并不是一个好主意)或者守护状态(同样不是一个好主意)。或许你只希望给线程取一个更有意义的名字,用来解释线程的转储信息和错误日志。
public interface ThreadFactory {
Thread newThread(Runnable r);
}
在程序清单 8-6 的 MyThreadFactory 中给出了一个自定义的线程工厂。它创建了一个新的 MyAppThread 实例,并将一个特定于线程池的名字传递给 MyAppThread 的构造函数,从而可以在线程转储和错误日志信息中区分来自不同线程池的线程。在应用程序和其他地方也可以使用 MyAppThread,以便所有线程都能使用它的调试功能。
public class MyThreadFactory implements ThreadFactory {
private final String poolName;
public MyThreadFactory(String poolName) {
this.poolName = poolName;
}
public Thread newThread(Runnable runnable) {
return new MyAppThread(runnable, poolName);
}
}
在 MyAppThread 中还可以定制其他行为,如程序清单 8-7 所示,包括:线程名字、设置自定义的 UncaughtExceptionHandler 以向 Logger 写入信息、维护一些统计信息(包括有多少线程被创建和销毁),以及在线程池被创建和终止时把调试信息写入日志。
public class MyAppThread extends Thread {
public static final String DEFAULT_NAME = "MyAppThread";
private static volatile boolean debugLifecycle = false;
private static fianl AtomicInteger created = new AtomicInteger(0);
private static final AtomicInteger alive = new AtomicInteger();
private static final Logger log = Logger.getAnonymousLogger();
public MyAppThread(Runnable r) {
this(r, DEFAULT_NAME);
}
public MyAppThread(Runnable runnable, String name) {
super(runnable, name + "-" + created.incrementAndGet());
setUncaughtExceptionHandler(
new Thread.UncaughtExceptionHandler() {
public void uncaughtException(Thread t, Throwable e) {
log.log(Level.SEVERE, "UNCAUGHT in thread " + t.getName(), e);
}
});
}
public void run() {
// Copy debug flag to ensure consistent value throughout.
boolean debug = debugLifecycle;
if (debug)
log.log(Level.FINE, "Created "+getName());
try {
alive.incrementAndGet();
super.run();
} finally {
alive.decrementAndGet();
if (debug)
log.log(Level.FINE, "Exiting "+getName());
}
}
public static int getThreadsCreated() {
return created.get();
}
public static int getThreadsAlive() {
return alive.get();
}
public static boolean getDebug() {
return debugLifecycle;
}
public static void setDebug(boolean b) {
debugLifecycle = b;
}
}
如果在应程序中需要利用安全策略来控制对某些代码库的访问权限,那么可以通过 Executor 中的 privilegedThreadFactory 工厂来定制自己的线程工厂。通过这种方式创建出来的线程,将与创建 privilegedThreadFactory 的线程拥有相同的访问权限、AccessControlContext 和 contextClassLoader。如果不使用 privilegedThreadFactory,线程池创建的线程将从在需要新线程时调用 execute 或 submit 的客户程序中继承权限,从而导致令人困惑的安全性异常。
8.3.5 在调用构造函数后再定制 ThreadPoolExecutor
在调用完 ThreadPoolExecutor 的构造函数之后,仍然可以通过设置函数来修改大多数传递给构造函数的参数。如果 Executor 是通过 Executors 中的某个工厂方法创建的,那么可以将结果的类型转换为 ThreadPoolExecutor 以访问器设置器,如程序清单 8-8 所示。
ExecutorService exec = Executors.newCachedThreadPool();
if (exec instanceof ThreadPoolExecutor)
((ThreadPoolExecutor) exec).setCorePoolSize(10);
else
throw new AssertionError("Oops, bad assumption");
哎 Executors 中包含一个 unconfigurableExecutorService 工厂方法,该方法对一个现有的 ExecutorService 进行包装,使其仅暴露出 ExecutorService 方法,因此不能对其进行配置。newSingleThreadExecutor 返回按这种方式封装的 ExecutorService,而不是最初的 ThreadPoolExecutor。虽然单线程的 Executor 实际上被实现为一个仅包含单个线程的线程池,但它同样确保了不会并发的执行任务。如果在代码中增加单线程 Executor 的线程池大小,那么将破坏它的执行策略语义。
你可以在自己的 Executor 中用这项技术来放置执行策略被修改。如果将 ExecutorService 暴露给不信任的代码,又不希望被非法修改,就可以通过 unconfigurableExecutorService 将其包装。
8.4 扩展 ThreadPoolExecutor
ThreadPoolExecutor 是可扩展的,它提供了几个可以在子类化中改写的方法:beforeExecute、afterExecute、terminated,这些方法可以用于扩展 ThreadPoolExecutor 的行为。
在执行任务的线程中将调用 beforeExecute 和 afterExecute 等方法,在这些方法中还可以添加日志、计时。监视或统计信息收集等功能。无论任务是从 run 中正常返回,还是抛出一个异常而返回,afterExecute 都会被调用。(让一个一任务在完成后带有一个 Error,那么久不会调用 afterExecute)。如果 beforeExecute 抛出一个 RuntimeException,那么任务将不会被执行,并且 afterExecute 也不会被调用。
在线程池完成关闭操作时调用 termianted,也就是在所有任务都已执行完成并且所有工作者线程都已经关闭后。terminated 可以用来释放 Executor 在其生命周期内分配的各种资源,此外还可以执行发送通知、记录日志或收集 finalize 统计信息等操作。
示例:给线程池添加统计信息
在程序清单 8-9 的 TimingThreadPool 中给出了一个自定义的线程池,它通过 beforeExecute、afterExecute 和 terminated 等方法来添加日志记录和统计信息收集。为了测量任务的运行时间,beforeExecute 必须记录开始时间并将其保存到一个 afterExecute 可以访问的地方。因为这些方法将在执行任务的线程中调用,因此 beforeExecute 可以将值保存到一个 ThreadLocal 变量中,然后由 afterExecute 来读取。在 TimingThreadPool 中使用了两个 AtomicLong 变量,分别用于记录已处理的任务数和总的处理时间,并通过 terminated 来输出包含平均时间的日志消息。
public class TimingThreadPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime
= new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
log.fine(String.format("Thread %s: start %s", t,r));
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format(
"Thread %s: end %s, time=%dns", t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
protected void terminated() {
try {
log.info(String.format(
"Terminated: avg time=%dns", totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
8.5 递归算法的并行化
我们对 6.3 节描绘绘制程序进行了一系列的改进以便不断发掘可利用的并行性。第一次是使程序完全串行执行,第二次虽然使用了两个线程,但仍然是串行的现在所有图像:在最后一次实现中将每个图像的下载操作视作一个独立任务,从而实现了更高的并发性。如果在循环体中包含了一些密集计算,或者需要执行可阻塞的 IO 操作,那么只要每次迭代是独立的,都可以对其进行并行化。
如果循环中的迭代操作都是独立的,并且不需要等待所有的迭代操作都完成再继续执行,那么就可以使用 Executor 将串行循环转化为并行循环,在程序清单 8-10 的 processSequentially 和 processInParallel 中给出了这种方法。
void processSequentially(List<Element> elements) {
for(Element e : elements)
process(e);
}
void processInParallel(Executor exec, List<Element> elements) {
for(final Element e : elements){
exec.execute(new Runnable() {
public void run() { process(e); }
});
}
}
调用 processInParallel 比调用 processSequentially 能能快的返回,因为 processInParallel 会在所有任务都进入了 Executor 的队列后就立即返回,而不会等待任务全部完成。如果需要提交一个任务集并等待它们完成,那么可以使用 ExecutorService.invokeAll,并且在所有任务都执行完成后调用 CompletionService 来获得结果,如第六章的 Render 所示。
当串行循环中的各个迭代操作之间彼此独立,并且每个迭代操作执行的工作量比管理一个新任务时带来的开销更多,那么这个串行循环就适合并行化。
在一些递归设计中同样可以采用循环并行化的方法。在递归算法中通常都会存在串行循环,而且这些循环可以按照程序清单 8-10 的方式进行并行化。一种简单的情况是:在每个迭代操作中都不需要来自后续递归迭代的结果。例如,程序清单 8-11 的 sequentialRecursive 用深度优先算法遍历一棵树,在每个节点上执行计算并将结果放入一个集合,而是为每个节点提交一个任务来完成计算。
public <T> void sequentialRecursive(
List<Node<T>> nodes,
Collection<T> results
) {
for(Node<T> n: nodes) {
results.add(n.compute());
sequentialRecursive(n.getChildren, results);
}
}
public <T> void parallelRecursive(
final Executor exec,
List<Node<T>> nodes,
final Collection<T> results
) {
for(final Node<T> n : nodes) {
exec.execute(new Runnable() {
public void run() {
results.add(n.compute());
}
});
parallelRecusive(exec, n.getChildren(), results);
}
}
当 parallelRecursive 返回时,树中的各个节点都已经访问过了(但是遍历过程仍然是串行的,只有 compute 调用才是并行执行的),并且每个节点的计算任务任务也已经放入 Executor 的工作队列。parallelRecursive 的调用者可以通过以下方式等待所有的结果:创建一个特定于遍历过过程的 Executor,并使用 shutdown 和 awaitTermination 等方法,如程序清单 8-12 所示。
public <T> Colleciton<T> getParallelResults(List<Node<T>> nodes) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
Queue<T> resultQueue = new ConcuurentLinkedQueue<>();
parallelRecursive(exec, nodes, resultQueue);
exec.shutdown();
exec.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
return resultQueue;
}
示例:谜题框架
这项技术的一种强大应用就是解决一些谜题,这些谜题都需要找出一系列的操作从初始化状态转换到目标状态,例如类似于“搬箱子”、“Hi-Q”、“四色方柱”和其他的棋牌谜题。
我们将“谜题”定义为:包含了一个初始位置,一个目标位置,以及用于判断是否是有效移动的规则集。规则集包含两个部分:计算从指定位置开始的所有合法移动,以及每次移动的结果位置。在程序清单 8-13 给出了表示谜题的抽象类,其中的类型参数 P 和 M 表示位置类和移动类。根据这个接口,我们可以写一个简单的串行求解程序,该程序子在谜题空间(Puzzle Space)中查找,直到找到一个解答或者找遍了整个空间都没有发现答案。
public interface Puzzle<P, M> {
P initialPosition();
boolean isGoal(P position);
Set<M> legalMoves(P position);
P move(P position, M move);
}
程序清单 8-14 中的 Node 代表通过一系列的移动到达的一个位置,其中保存了到达该位置的移动以及前一个 Node。只要沿着 Node 链接逐步回溯,就可以重新构建出到达当前位置的移动序列。
@Immutable
static class Node<P, M> {
final P pos;
final M move;
final Node<P,M> prev;
Node(P pos, M move, Node<P,M> prev) {...}
List<M> asMoveList() {
List<M> solution = new LinkedList<>();
for(Node<P,M> n = this; n.move != null; n = n.prev)
solution.add(0, n.move);
return solution;
}
}
在程序清单 8-15 的 SequentialPuzzleSolver 中给出了谜题框架的串行解决方法,它在谜题空间中执行一个深度优先搜索,当找到解答方案(不一定是最短的解决方案)后结束搜索。
public class SequentialPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final Set<P> seen = new HashSet<P>();
public SequentialPuzzleSolver(Puzzle<P, M> puzzle) {
this.puzzle = puzzle;
}
public List<M> solve() {
P pos = puzzle.initialPosition();
return search(new Node<P, M>(pos, null, null));
}
private List<M> search(Node<P, M> node) {
if (!seen.contains(node.pos)) {
seen.add(node.pos);
if (puzzle.isGoal(node.pos))
return node.asMoveList();
for (M move : puzzle.legalMoves(node.pos)) {
P pos = puzzle.move(node.pos, move);
Node<P, M> child = new Node<P, M>(pos, move, node);
List<M> result = search(child);
if (result != null)
return result;
}
}
return null;
}
static class Node<P, M> { /* Listing 8.14 */ }
}
通过修改解决方案以利用并发性,可以以并发方式来计算下一步移动以及目标条件,因为计算某次移动的过程在很大程度上与计算其他移动的过程是相互独立的。(之所以说“在很大的程度上”,是因为在各个任务之间会共享一些可变状态,例如已遍历位置的集合)。如果有多个处理器可用,那么这将减少寻找解决方案所花费的时间。
在程序清单 8-16 的 ConcurrentPuzzleSolver 中使用了一个内部类 SolverTask,这个类扩展了 Node 并实现了 Runnable。大多数工作都是在 run 方法中完成的:首先计算出下一步可能到达的所有位置,并去掉已经到达的位置,然后判断(这个任务或其他某个任务)是否已经成功完成,最后将尚未搜索过的位置交给 Executor。
public class ConcurrentPuzzleSolver<P, M> {
private final Puzzle<P, M> puzzle;
private final ExecutorService exec;
private final ConcurrentMap<P, Boolean> seen;
final ValueLatch<Node<P, M>> solution =
new ValueLatch<Node<P, M>>();
...
public List<M> solve() throws InterruptedException {
try {
P p = puzzle.initialPosition();
exec.execute(newTask(p, null, null));
// block until solution found
Node<P, M> solnNode = solution.getValue();
return (solnNode == null) ? null : solnNode.asMoveList();
} finally { exec.shutdown(); }
}
protected Runnable newTask(P p, M m, Node<P,M> n) {
return new SolverTask(p, m, n);
}
class SolverTask extends Node<P, M> implements Runnable {
...
public void run() {
if (solution.isSet() ||
seen.putIfAbsent(pos, true) != null)
return; // already solved or seen this position
if (puzzle.isGoal(pos))
solution.setValue(this);
else for (M m : puzzle.legalMoves(pos))
exec.execute( newTask(puzzle.move(pos, m), m, this));
}
}
}
为了避免无限循环,在串行版本中引入了一个 Set 对象,其中保存了之前已经搜索过的所有位置。在 ConcurrentPuzzleSovler 中使用了 ConcurrentHashMap 来实现相同的功能。这种做法不仅提供了线程安全性,还避免了在更新共享集合时存在的竟态条件,因为 putIfAbsent 只有在之前没有遍历过的某个位置才会通过原子方式添加到集合中。ConcurrentPuzzleSolver 使用线程池内部工作队列而不是调用栈来保存搜索的状态。
这种并发方法引入了一种新形式的限制去掉了一种原有的限制,新的限制在这个问题域中更合适。串行版本的程序执行深度优先搜索,因此搜索过程将受限于栈的大小。并发版本的程序执行广度优先搜索,因此不会受到栈大小的限制(但如果待搜索的或则已搜索的位置集合大小超过了可用的内存总量,那么仍可能耗尽内存)。
为了在找到某个解答后停止搜索,需要通过某种方法来检查是否有线程已经找到了一个解答。如果需要第一个找到的解答,那么嗨需要在其他任务都没有找到解答时更新答案。这些需求买哦是的是一种闭锁机制,具体的说,是一种包含结果的闭锁。通过使用第 14 章中的技术,可以很容易的构造出一个阻塞的并且可携带结果的闭锁,但更简单且更不容易出错的方式是使用现有库中的类,而不是使用底层的语言机制。在程序清单 8-17 的 ValueLatch 中使用 CountDownLatch 来实现所需的闭锁行为,并且使用多订机制来确保解答只会被设置一次。
@ThreadSafe public class ValueLatch<T> {
@GuardedBy("this") private T value = null;
private final CountDownLatch done = new CountDownLatch(1);
public boolean isSet() { return (done.getCount() == 0); }
public synchronized void setValue(T newValue) {
if (!isSet()) {
value = newValue;
done.countDown();
}
}
public T getValue() throws InterruptedException {
done.await();
synchronized (this) { return value; }
}
}
每个任务首先查询 solution 闭锁,找到一个解答就停止。而在此之前,主线程需要等待,ValueLatch 中的 getValue 将一直阻塞,直到有线程设置了这个值。ValueLatch 提供了一种方式来保存这个值,只有第一次调用才会设置它。调用者能够判断这个值是否已经被设置,以及阻塞并等待后它被设置。在第一次调用 setValue 时,将更新解答方案,并且 CountDownLatch 会递减,从 getValue 中释放主线程。
第一个找到解答的线程还会关闭 Executor,从而阻止接受新的任务。要避免处理 RejectedExecutionException,需要将拒绝执行处理器设置为“抛弃已提交的任务”。然后,所有未完成的任务最终将执行完成,并且在执行任何新任务时都会失败,从而使 Executor 结束。(如果任务运行时间过长,那么可以中断它们而不是等待它们完成)
如果不存在解答,那么 ConcurrentPuzzleSolver 就不能很好的处理这种情况:如果已经遍历的所有移动和位置都没有找到解答,那么在 getSolution 调用中将永远等待下去。当遍历了真个搜索空间时,串行版本的程序将结束,但要结束并发程序会更将困难。其中一个方法是:记录活动任务的数量,当该值为零时将解答设置为 null,如程序清单 8-18 所示。
public class PuzzleSolver<P,M>
extends ConcurrentPuzzleSolver<P,M> {
...
private final AtomicInteger taskCount =
new AtomicInteger(0);
protected Runnable newTask(P p, M m, Node<P,M> n) {
return new CountingSolverTask(p, m, n);
}
class CountingSolverTask extends SolverTask {
CountingSolverTask(P pos, M move, Node<P, M> prev) {
super(pos, move, prev);
taskCount.incrementAndGet();
}
public void run() {
try { super.run(); }
finally {
if (taskCount.decrementAndGet() == 0)
solution.setValue(null);
}
}
}
}
找到解答的时间可能比等待的时间更长,因此在解决器中需要包含几个结束条件。其中一个结束条件是时间限制,这很容易实现:在 ValueLatch 中实现一个限时的 getValue(其中将使用限时版本的 await),如果 getValue 超时,那么关闭 Executor 并声明一个失败。另一个结束条件是某种特定于谜题的标准,例如仅搜索特定数量的位置。此外,还可以提供一种取消机制,由用户来决定何时停止搜索。
总结
对于并发执行任务,Executor 框架是一种强大且灵活的框架。它提供了大量可调节的选项,例如创建线程和关闭线程的策略,处理队列任务的策略,处理过多任务的策略,并且提供了几个钩子方法来扩展它的行为。然而,与大多数功能强大的框架一样,其中有些设置参数并不能很好的工作,某些类型的任务需要特定的执行策略,而一些参数组合则可能产生奇怪的结果。
3.1.9 - CH09-GUI应用
如果用 Swing 编写过简单的图形用户界面应用程序,那么就应该知道 GUI 应用程序有其奇特的线程问题。为了维护安全性,一些特定的任务必须运行在 Swing 的事件线程中。然而,在事件线程中不应该执行时间较长的操作,以免用户界面失去响应。而且,由于 Swing 的数据结构不是线程安全的,因此必须将它们限制在事件线程中。
几乎所有的 GUI 工具包都被实现为单线程子系统,这意味着所有的 GUI 操作都被限制在单个线程中。如果你不打算编写一个单线程程序,那么就会有部分操作在一个应用程序线程中执行,而其他操作则在事件线程中执行。与其他线程错误一样,即使在这种操作分解中出现了错误,也会导致应用程序立即崩溃,而且程序将在一个难以确定的条件下表现出奇怪的行为。虽然 GUI 框架本身是单线程子系统,但应用程序可能不是单线程的,因此在编写 GUI 代码时仍然需要谨慎的考虑线程问题。
9.1 为什么 GUI 是单线程的
早期的 GUI 程序都是单线程的,并且 GUI 事件在“主事件循环”进行处理。当前的 GUI 框架则使用了一种略有不同的模型:在该模型中创建一个专门事件分发线程(EDT)来处理 GUI 事件。
单线程的 GUI 框架并不仅限于在 Java 中,在 Qt、NexiStep、MaxOS Cocoa、X Windows 以及其他环境中的 GUI 框架都是单线程的。许多人曾经尝试过编写多线程的 GUI 框架,但最终都由于竟态条件和死锁导致的稳定性问题而又重新回到单线程的事件队列模型:采用一个专门的线程从队列中抽取事件,并将它们转发到应用程序定义的事件处理器。(AWT 最初尝试在更大程度上支持多线程访问,而正是基于在 AWT 中得到的经验和教训,Swing 在实现时决定采用单线程模型)。
在多线程的 GUI 框架中更容易发生死锁问题,其部分原因在于,在输入事件的处理过程与 GUI 组件的面向对象模型之间会存在错误的交互。用户引发的动过将通过一种类似于“气泡上升”的方式从操作系统传递给应用程序——操作系统首先检测到一个鼠标点击,然后通过工具包将其转换为“鼠标点击”事件,该事件最终被转换为一个更高层事件(如“鼠标键被按下”事件)转发给应用程序的监听器。另一个方面,应用程序引发的工作有会以“气泡下沉”的方式从应用程序返回到操作系统。例如,在应用程序中引发修改某个组件背景色的请求,该请求将被转发给某个特定的组件类,并最终转发给操作系统进行绘制。因此,一方面这组操作将以完全相反的顺序来访问相同的 GUI 对象;另一方面,又要确保对象都是线程安全的,从而导致不一致的锁定顺序,并引发死锁。这种问题几乎在每次开发 GUI 包时都会重现。
另一个在多线程 GUI 框架中导致死锁的原因就是“模型——视图——控制(MVC)”这种设计模式的广泛应用。通过将用户的交互分解到模型、视图和控制等模块中,能极大的简化 GUI 应用程序的实现,但这却进一步增加了出现不一致锁定顺序的风险。“控制”模块将调用“模型”模块,而“模型”模块将发生的变化通知给“视图”模块。“控制”模块同样可以调用“视图”模块,并调用“模型”模块来查询模型的状态。这将再次导致不一致的锁定顺序并出现死锁。
Sun 公司的前副总裁 Graham Hamilton 在其博客中总结了这些问题,详细阐述了为什么多线程的 GUI 工具包会成为计算机科学史上的又一个“失败的梦想”。
不过,我相信你还是可以成功的编写出多线程的 GUI 工具包,只要做到:非常晋升的设计多线程 GUI 工具包,详尽无遗的公开工具包的锁定方法,以及你非常聪明,非常仔细,并且对工具包的整体结构有着全局理解。然而,如果在上述某个方面稍有偏差,那么即使程序在大多数时候都能正确运行,但在偶尔情况下仍会出现(死锁引起的)挂起或(竞争引起的)运行故障。只有那些深入参与工具包设计的人们才能够正确的使用这种多线程的 GUI 框架。
然而,我并不认为这些特性能够在商业产品中得到广泛应用。可能出现的情况是:大多数普通的开发者发现应用程序无法可靠的运行,而又找不到其中的原因。于是,这些开发者会感到非常不满,并诅咒这些无辜的工具包。
单线程的 GUI 框架通过线程封闭机制来实现线程安全性。所有 GUI 对象,包括可视化组件和数据模型等,都只能在事件线程中访问。当然,这只是将确保线程安全性的一部分工作交给应用程序的开发人员来负责,他们必须确保这些对象被正确的封闭在事件线程中。
9.1.1 串行事件处理
GUI 应用程序需要处理一些细粒度的事件,例如点击鼠标、按下键盘或定时器超时等。事件是另一种类型的任务,而 AWT 和 Swing 提供的事件处理机制在结构上也类似于 Executor。
因为只有单个线程来处理所有的 GUI 任务,因此会采用依次处理的方式——处理完一个任务后再开始下一个任务,在两个任务的处理之间不会重叠。清除了这一点,就可以更容易的编写任务代码,而无需担心其他任务会产生干扰。
串行任务处理的不利之处在于,如果某个任务的处理时间很长,那么其他任务必须等到该任务执行结束。如果这些任务的工作是响应用户输入或者提供可视化界面反馈,那么应该程序看似会失去响应。如果在事件线程中执行时间较长的任务,那么用户甚至无法点击“取消”按钮,因为在该任务完成之前,将无法调用“取消”按钮的监听器。因此,在事件线程中执行的任务必须尽快的把控制权交换给事件线程。要启动一些执行耗时较长的任务,例如对某个大型文档执行拼写检查,在文件系统中执行搜索,或者通过网络获取资源等,必须在另一个线程中执行这些任务,从而尽快的将控制权交还给事件线程。如果要在执行某个事件较长的任务时更新进度标识,或者在任务完成后提高一个可视化的反馈,那么需要再次执行事件线程中的代码。这会很快使程序变得更加复杂。
9.1.2 Swing 中的线程封闭机制
所有 Swing 组件和数据模型对象都被封闭在事件线程中,因此任何访问它们的代码都必须在事件线程中运行。GUI 对象并非通过同步来确保一致性,而是通过线程封闭机制。这种方法的好处在于,当访问表现对象时在事件线程中运行的任务无需担心同步问题,而坏处在于,无法从事件线程之外的线程中访问表现对象。
Swing 的单线程规则是:Swing 中的组件以及模型只能在这个事件分发线程中进行创建、修改、查询。
与所有的规则不同,这个规则也存在一些例外情况。Swing 中只有少数方法可以安全的从其他线程中调用,而在 Javadoc 中已经很清楚的说明了这些方法的线程安全性。单线程规则的其他一些例外情况包括:
- SwingUtilities.isEventDispatchThread,用于判断当前线程是否是事件线程。
- SwingUtilities.invokeLater,该方法可以将一个 Runnable 任务调度到事件线程中执行(可以从任意线程中调用)。
- SwingUtilities.invokeAndWait,该方法可以将一个 Runnable 任务调度到事件线程中执行,并阻塞当前线程直到任务完成(只能从非 GUI 线程中调用)。
- 所有将重绘请求或重生效请求插入队列的方法(可以从任意线程调用)。
- 所有添加或移除监听器的方法(这些方法可以在任意线程中调用,但监听器本身一定要在事件线程中调用)。
invokeLater 和 invokeAndWait 两个方法的作用酷似 Executor。事实上,用单线程的 Executor 来实现 SwingUtilities 中与线程相关的方法是很容易的,如程序清单 9-1 所示。这并非是 SwingUtilities 中的真实实现,因为 Swing 的出现时间要早于 Executor 框架,但如果现在来实现 Swing,或许应该采用这种实现方式。
public class SwingUtilities {
private static final ExecutorService exec =
Executors.newSingleThreadExecutor(new SwingThreadFactory());
private static volatile Thread swingThread;
private static class SwingThreadFactory implements ThreadFactory {
public Thread newThread(Runnable r) {
swingThread = new Thread(r);
return swingThread;
}
}
public static boolean isEventDispatchThread() {
return Thread.currentThread() == swingThread;
}
public static void invokeLater(Runnable task) {
exec.execute(task);
}
public static void invokeAndWait(Runnable task)
throws InterruptedException, InvocationTargetException {
Future f = exec.submit(task);
try { f.get(); }
catch (ExecutionException e) {
throw new InvocationTargetException(e);
}
}
}
可以将 Swing 的事件线程视为一个单线程的 Executor,它处理来自事件队列的任务。与线程池一样,有时候工作者线程会死亡并由另一个新线程来替代,但这一切要对任务透明。如果所有任务的执行时间都很短,或者任务调度的可预见性并不重要,又或者任务不能被并发执行,那么应该采用串行的和单线程的执行策略。
程序清单 9-2 中的 GuiExecutor 是一个 Executor,它将任务委托给 SwingUtilities 来执行。也可以使用其他的 GUI 框架来实现它,例如 SWT 提供的 Display.asyncExec 方法,它类似于 Swing 中的 invokeLater。
public class GuiExecutor extends AbstractExecutorService {
// Singletons have a private constructor and a public factory
private static final GuiExecutor instance = new GuiExecutor();
private GuiExecutor() { }
public static GuiExecutor instance() { return instance; }
public void execute(Runnable r) {
if (SwingUtilities.isEventDispatchThread())
r.run();
else SwingUtilities.invokeLater(r);
}
// Plus trivial implementations of lifecycle methods
}
9.2 短时间的 GUI 任务
在 GUI 应用中,事件在事件线程中产生,并通过“气泡上升”的方式来传递给应用程序提供的监听器,而监听器则根据收到的事件执行一些计算来修改表现对象。为了简便,短时间的任务可以把整个操作都放在事件线程中执行,而对于长时间的任务,则应该讲某些操作放到另一个线程中执行。
final Random random = new Random();
final JButton button = new JButton("change color");
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setBackground(new Color(random.nexInt()));
}
})
在这种情况下,表现对象封闭在事件线程中。程序清单 9-3 创建了一个按钮,它的颜色在被按下时会随机的变化。当用户点击按钮时,工具包将事件线程中的一个 ActionEvent 投递给所有已注册的 ActionListener,作为响应,ActionListener 将选择一个新的颜色,并将按钮的背景颜色设置为新颜色。这样,在 GUI 工具包中产生事件,然后发送到应用程序,而应用程序通过修改 GUI 来响应用户的动作。在这期间,执行控制始终不会离开事件线程,如图 9-1所示。
这个示例揭示了 GUI 应用程序和 GUI 工具包之间的主要交互。只要任务是短期的,并且只访问 GUI 对象(或者其他线程封闭的或线程安全的应用程序对象),那么就可以忽略与线程相关的问题,而在时间线程中可以执行任何操作都不会出问题。
图 9-2 给出了一个略微复杂的版本,其中使用了正式的数据模型,例如 TableModel 或 TreeModel。Swing 将大多数可视化组件都分为两个对象,即模型对象和视图对象。在模型对象中保存的是将被显示的数据,而在视图对象中则保存了控制显示方式的规则。模型对象可以通过引发事件来表示模型数据发生了变化,而视图对象则通过“订阅”来接收这些事件。当视图对象收到表示模型数据已经发生变化的事件,将向模型对象查询新的数据,并更新界面显示。因此,在一个修改表格内容的按钮监听器中,事件监听器将更新模型并调用其中一个 fireXxx 方法,这个方法会依次调用视图对象中表格模型监听器,从而更新视图的显示。同样,执行控制权仍然不会离开事件线程。(Swing 数据模型的 fireXxx 方法通常会直接调用模型监听器,而不会向线程队列中提交新的事件,因此 fireXxx 方法只能从事件线程中调用。)
9.3 长时间的 GUI 任务
如果所有任务的执行时间都较短(并且应用中不包含执行时间较长的非 GUI 部分),那么整个应用程序都可以在事件线程内部运行,并且完全不用关心线程。然而,在复杂的 GUI 应用中可能包含一些执行时间较长的任务,并且可能超过了用户可以等待的时间,例如拼写检查、后台编辑或者远程资源获取等。这些任务必须在另一个线程中运行,才能使得 GUI 在运行时保持高响应性。
Swing 使得在事件线程中运行任务很容易,但(在 Java 6 之前)并没有提供任何机制来帮助 GUI 任务执行其他线程中的代码。然而在这里并不需要借助 Swing:可以创建自己的 Executor 来执行长时间的任务。对于长时间的任务,可以使用缓存线程池。只要 GUI 应用程序很少会发起大量的长时间任务,因此即使线程池可以无限制的增长也不会有太大风险。
首先我们来看一个简单的任务,该任务不支持取消操作和进度指示,也不会在完成后更新 GUI,我们之后再将这些功能依次添加进来。在程序清单 9-4 中给出了一个与某个可视化组件绑定的监听器,它将一个长时间的任务提交给一个 Executor。尽管有两个层次的内部类,但通过这种方式使某个 GUI 任务启动另一个任务还是很简单的:在事件线程中调用 UI 动作监听器,然后将一个 Runnable 提交到线程池中执行。
ExecutorService backgroundExec =
Executors.newCachedThreadPool();
...
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
backgroundExec.execute(new Runnable() {
public void run() { doBigComputation(); }
});
}});
这个示例通过 “Fire and Forget” 方式将长时间运行的任务从事件线程中分类出来,这种方式可能不是非常有用。在执行完一个产时间的任务后,通常会产生某种可视化的反馈给用户。但你并不能从后台线程中访问这些表现对象,因此任务在完成时必须向事件线程提交另一个任务来更新用户界面。
程序清单 9-5 给出了如何实现这个功能的方式,但此时已经开始变得复杂了,即已经有了三层内部类。动作监听器首先使按钮无效,并设置一个标签表示正在进行某个计算,然后将一个任务提交给后台的 Executor。当任务完成时,它会在事件下滑才能中增加另一个任务,该任务将重新激活按钮并恢复文本标签文本。
button.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
button.setEnabled(false);
label.setText("busy");
backgroundExec.execute(new Runnable() {
public void run() {
try { doBigComputation(); }
finally {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
button.setEnabled(true);
label.setText("idle");
}
});
}
}
});
}
});
在按下按钮时触发的任务中包含 3 个连续的子任务,它们将在事件线程与后台线程之间交替运行。第一个子任务更新用户界面,表示一个长时间的操作已经开始,然后在后台线程中启动第二个子任务。当第二个子任务完成时,它把第三个子任务再次提交到事件线程中运行,第三个子任务也会更新用户界面来表示操作已经完成。在 GUI 应程序中,这种“线程接力”是处理长时间任务的典型用法。
9.3.1 取消
当某个任务在线程中运行了过长时间没有结束,用户可能希望取消它。你可以直接通过线程中断来实现取消操作,但是一种更简单的方式是使用 Future,专门用来管理可取消的任务。
如果调用 Future 的 cancel 方法,并将参数 mayInterruptIfRunning 设置为 true,那么这个 Future 可以中断正在执行任务的线程。如果你编写的任务能够正确响应中断,那么当它被取消时就可以提前返回。在程序清单 9-6 给出的任务中,将轮询线程的中断状态,并且在返现中断时提前返回。
Future<?> runningTask = null;
// thread-confined ...
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
if (runningTask != null) {
runningTask = backgroundExec.submit(new Runnable() {
public void run() {
while (moreWork()) {
if (Thread.currentThread().isInterrupted()) {
cleanUpPartialWork();
break;
}
doSomeWork();
}
}
});
}
}
}});
cancelButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
if (runningTask != null)
runningTask.cancel(true);
}});
由于 runningTask 被封闭在事件线程中,因此在对它进行设置会检查时不需要同步,并且“开始”按钮的监听器可以确保每次只有一个后台任务在运行。然而,当任务完成时最好能通知按钮监听器,例如说可以禁用“取消”按钮。我们将在下一节解决这个问题。
9.3.2 进度标识和完成标识
通过 Future 来表示一个长时间的任务,可以极大的简化取消操作的实现。在 FutureTask 中也有一个 done 方法同样有助于实现完成通知。当后台的 Callable 完成后,将调用 done。通过 done 方法在事件线程中触发一个完成任务,我们能够构造一个 BackgroundTask 类,这个类将提供一个在事件线程中调用 onCompletion 方法,如程序清单 9-7 所示。
abstract class BackgroundTask<V> implements Runnable, Future<V> {
private final FutureTask<V> computation = new Computation();
private class Computation extends FutureTask<V> {
public Computation() {
super(new Callable<V>() {
public V call() throws Exception {
return BackgroundTask.this.compute();
}
});
}
protected final void done() {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
V value = null;
Throwable thrown = null;
boolean cancelled = false;
try { value = get(); }
catch (ExecutionException e) {
thrown = e.getCause();
} catch (CancellationException e) {
cancelled = true;
} catch (InterruptedException consumed) {
} finally {
onCompletion(value, thrown, cancelled);
}
};
});
}
}
protected void setProgress(final int current, final int max) {
GuiExecutor.instance().execute(new Runnable() {
public void run() {
onProgress(current, max);
}
});
}
// Called in the background
thread protected abstract V compute() throws Exception;
// Called in the event thread
protected void onCompletion(V result, Throwable exception, boolean cancelled) { }
protected void onProgress(int current, int max) { }
// Other Future methods forwarded to computation
}
BackgroundTask 还支持进度标识。conpute 方法可以调用 setProgress 方法以数字形式来指示进度。因而在事件线程中调用 onProgress,从而更新用户界面以显示可视化的进度信息。
要想实现 BackgroundTask,只需要实现 compute 方法,该方法将在后台线程中调用。也可以改写 onCompletion 和 onProgress,这两个方法会在事件线程中调用。
基于 FutureTask 构造的 BackgroundTask 还能简化取消操作。Compute 不会检查线程的中断状态,而是调用 Future.isCancelled。程序清单 9-8 通过 BackgroundTask 重新实现了程序清单 9-6 中的示例程序。
public void runInBackground(final Runnable task) {
startButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
class CancelListener implements ActionListener {
BackgroundTask<?> task;
public void actionPerformed(ActionEvent event) {
if (task != null) task.cancel(true);
}
}
final CancelListener listener = new CancelListener();
listener.task = new BackgroundTask<Void>() {
public Void compute() {
while (moreWork() && !isCancelled())
doSomeWork();
return null;
}
public void onCompletion(boolean cancelled, String s, Throwable exception) {
cancelButton.removeActionListener(listener);
label.setText("done");
}
};
cancelButton.addActionListener(listener);
backgroundExec.execute(task);
}
});
}
9.3.3 SwingWorker
我们已经通过 FutureTask 和 Executor 构建了一个简单的框架,它会在后台线程中执行较长耗时的任务,因此不会影响 GUI 的响应性。在任何单线程的 GUI 框架中都可以使用这些技术,而不仅局限于 Swing。在 Swing 中,这类给出的许多特性是由 SwingWorker 类提供的,包括取消、完成通知、进度指示灯。在 “Swing Connection” 和 “The Java Tutorial” 等资料中介绍了不同版本的 SwingWorker,并在 Java 6 中包含了一个更新好的版本。
9.4 共享数据模型
Swing 的表现对象都被封装在事件线程中。在加单的 GUI 程序中,所有的可变状态都被保存在表现对象中,并且除了事件线程之外,唯一的线程就是主线程。要在这些程序中强制实施单线程规则是很容易的:不要从主线程中访问数据模型或表现组件。在一些更复杂的程序中,可能会使用其他线程对持久化的存储进行读写操作以免降低系统的响应性。
最简单的情况是,数据模型中的数据由用户来输入或由应用程序在启动时静态的从文件或其他数据源加载。在这种情况下,除了事件线程之外的任何线程都不可能访问到数据。但在某些情况下,表现模型对象只是一个数据源的视图对象。这时,当数据在应用程序中进出时,有多个线程可以访问这些数据。
例如,你可以使用一个树形空间来显示远程文件系统的内容。在显示树形控件之前,并不需要枚举真个文件系统——那样做会消耗大量的时间和内存。正确的做法是,当树节点被展开时才读取相应的内容。即使只枚举远程卷上的单个目录也可能花费很长的时间,因此你可以考虑在后台线程中执行枚举操作。当后台任务完成后,必须通过某种方式将数据填充到树形模型中。可以使用线程安全的树形模型来实现这个功能:通过 invokeLater 提交一个任务,将数据从后台任务中“推入”事件线程,或者让事件线程通过轮询来查看是否有数据可用。
9.4.1 线程安全的数据模型
只要阻塞操作不会过度影响响应性,那么多个线程操作同一份数据的问题都可以通过线程安全的数据模型来解决。如果数据模型支持细粒度的开发,那么事件线程和后台线程就能共享该数据模型,而不会发生响应性问题。例如,第五章的 DelegatingVehicleTracker 在底层使用了一个 ConcurrentHashMap 来提供高度并发的读写操作。这种方法的缺点在于,ConcurrentHashMap 无法提供一致的数据快照,而这可能是需求的一部分。线程安全的数据模型必须在更新模板时产生事件,这样视图才能在数据发生变化后进行更新。
有时候,在使用版本化数据模型时,例如 CopyOnWriteArrayList,可能要同时获得线程安全性,一致性以及良好的响应性。当获取一个“写实复制”容器的迭代时,这个迭代器将遍历真个容器。然而,只有在遍历操作远远多于修改操作时,“写时复制”容器才能提供更好的性能,例如在车辆追踪应用程序中就不适合采用这种方法。一些特定的数据结构或许可以避免这种限制,但要构建一个既能提供高效并发访问又能在旧数据无效后不再维护它们的数据结构却并不容易,因此只有其他方法都行不通后才应该考虑使用它。
9.4.2 分解数据模型
从 GUI 的角度看,Swing 的表格模型类,例如 TableModel 和 TreeModel,都是保存将要显示的数据的正式方法。然而,这些模型对象本身通常都是应用程序中其他对象的视图。如果在程序中既包含用于表示的数据模型,又包含应用程序特定的数据模型,那么这种应该程序就被称为一种分解模型设计。
在分解模型设计时,表现模型被封闭在事件线程汇总,而其他模型,即共享模型,是线程安全的,因此既可以由事件线程方法,也可以由应用程序线程访问。表现模型会注册共享模型的监听器,从而在更新时得到通知。然后,表示模型可以在共享模型中得到更新:通过将相关状态的快照嵌入到更新消息中,或者由表现模型在收到更新事件时直接从共享模型中获取数据。
快照这种方法虽然简单,但却存在着一些局限。当数据模型很小,更新频率不高,并且这两个模型的结构相似时,他可以工作良好。如果数据模型很大,或者更新频率极高,在分解模型包含的信息中有一方或者双方对另一方不可见,那么更高效的方式是发送增量更新信息而不是发送完整的快照。这种方法将共享模型上的更新操作序列化,并在事件线程中重现。增量更新的另一个好处是,细粒度的变化信息可以提高显示的视觉效果——如果只有一辆车移动,那么只需要更新发生变化的区域,而不用重绘整个显示图形。
如果一个数据模型必须被多个线程共享,而且由于阻塞、一致性或复杂度等原因而无法实现一个线程安全的模型时,可以考虑使用分解模型设计。
9.5 其他形式的单线程子系统
线程封闭不仅仅可以在 GUI 中使用,每当某个工具需要被实现为单线程子系统时,都可以使用这项技术。有时候,当程序员无法避免同步或死锁等问题时,也将不得不使用线程封闭。例如,一些原生库要求:所有对库的访问,甚至通过 System.loadLibiary 来加载库时,都必须在同一个线程中执行。
借鉴 GUI 框架中采用的方法,可以很容易创建一个专门的线程或一个单线程的 Executor 来访问那些库,并提供一个代理对象来拦截所有对线程封闭对象的调用,并将这些调用作为一个任务来提交给专门的线程。将 Future 和 newSingleThreadExecutor 一起使用,可以简化这项工作。在代理方法中可以调用 submit 方法提交任务,然后立即调用 Future.get 来等待结果。(如果在封闭线程的类中实现了一个接口,那么每次可以自动的让方法将一个 Callable 提交给后台线程并通过动态的代理来等待结果。)
小结
所有 GUI 框架基本上都实现为单线程的子系统,其中所有与表现相关的代码都作为任务在事件线程中运行。由于只有一个事件线程,因此运行时间较长的任务会降低 GUI 程序的响应性,所以应该放在后台线程中运行。在一些辅助类(例如 SwingWorker 以及在本章中构建的 BackgroundTask) 中提供了对取消、进度指示以及完成指示的支持,因此对于执行时间较长的任务来说,无论在任务中包含了 GUI 组件还是非 GUI 组件,在开发时都可以的到简化。
3.1.10 - CH10-活跃性危险
在安全性和活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度的使用锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能导致资源死锁。Java 应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的因素。本章将介绍一些导致活跃性故障的原因,以及如何避免它们。
10.1 死锁
经典的“哲学家就餐”问题很好的描述了死锁问题。5 个哲学家去吃中餐,坐在一张圆桌旁。他们有 5 根筷子(而不是 5 双),并且每两个人之间放着一根筷子。哲学家们时而思考、时而进餐。每个人都需要一双筷子才能吃东东,并在吃完后将筷子放回原处继续思考。有些筷子管理算法能够使每个人都能相对及时的吃到东西(例如一个饥饿的哲学家会尝试获取两根临近的筷子,但如果其中一跟正在被他人使用,那么他将放弃已经得到的那根筷子,并等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人都立即抓住自己左手边的筷子,然后等待右手边的筷子空出来,但同时又不放下已经拿到的筷子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
当一个线程永远的持有一把锁,并且其他线程都尝试获得这个锁时,那么他们将永远被阻塞。在线程 A 持有锁 L 并想获得锁 M 的同时,线程 B 持有锁 M 并尝试获得锁 L,那么这两个线程将永远的等待下去。这是最简单的死锁形式(即抱死),其中多个线程由于存在环路的锁依赖关系而永远等待下去。(把每个线程假想为有向图中的一个节点,图中每条边表示的关系是:“线程 A 等待线程 B 所占有的资源”。如果在图中形成了一条环路,那么久存在一个死锁)。
在数据库系统的设计中考虑了检测死锁以及从死锁恢复。在执行一个事务时可能需要同时获得多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死锁,但事实上这种情况并不常见。如果没有外部干涉,这些事务将永远等待下去(在某个事务中持有的锁可能在其他事务中也需要)。但数据库服务器不会让这种情况发生。当他检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索依赖循环),将选择牺牲者并放弃该事务。作为牺牲者的事务会释放它所只有的资源,从而使其他事务继续运行。应用程序可以重新执行被强制终止的事务,而这个事务现在可以成功完成,因为所有跟它存在资源竞争的事务都已经完成了。
JVM 在解决死锁问题方面并没有数据库服务那样强大。当一组 Java 线程发生死锁时,“游戏”将到此结束——这些线程将永远无法再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启,并希望不要再发生同样的事情。
与所有其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是最糟糕的时候——在高负载情况下。
10.1.1 锁顺序死锁
程序清单 10-1 中过的 LeftRightDeadlock 存在死锁风险。leftRight 和 rightLeft 这两个方法分别获得 left 锁和 right 锁。如果一个线程调用了 leftRight,而另一个线程调用了 rightLeft,并且这两个线程的操作是交错执行的,如图 10-1 所示,那么将发生死锁。
public class LeftRightDeadlock {
private fianl Object left = new Object();
private final Object right = new Object();
public void leftRight() {
synchronized(left) {
synchronized(right) {
doSomething();
}
}
}
public void rightLeft() {
synchronized(right) {
synchronized(left) {
doSomething();
}
}
}
}
在 LeftRightDeadlock 中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环加锁依赖性,因此也就不会产生死锁。如果每个需要锁 L 和锁 M 的线程都以相同的顺序来获得两个锁,那么就不会发生死锁了。
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
要想验证锁顺序的一致性,需要对程序中的加锁顺序进行全局分析。如果只是单独的分析每条获取多个锁的代码路径,那是不够的:leftRight 和 rightLeft 都采用了“合理的”方式来获得锁,它们只是不能互相兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。
10.1.2 动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。考虑程序清单 10-2 中看似无害的代码,它将资金从一个账户转入另一个账户。在开始转账之前,首先要获得这两个 Account 对象的锁,以确保通过原子方式来更新两个账户中的余额,同时又不破坏一些不变性条件,例如“账户的余额不能为负数”。
public void transferMoney(
Account fromAccount,
Account toAccount,
DollarAmount amount) throws InsufficientFundsException{
synchronized (fromAccount) {
synchronized(toAccount) {
if(fromAccount.getBalance().compareTo(amount) <0)
thorw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
在 transferMoney 中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给 transferMoney 的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调用 transferMoney,其中一个线程从 X 向 Y 转账,另一个线程从 Y 向 X 转账,那么就会发生死锁:
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);
如果执行时序不当,那么 A 可能获得 myAccount 的锁并等待 yourAccount 的锁,然后 B 此时持有 yourAccount 的锁,并正在等待 myAccount 的锁。
这种死锁可以采用程序清单 10-1 中的方法来检查——查看是否存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决该问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺讯来获得锁。
在制定锁的顺序时,可以使用 System.identityHashCode 方法,该方法将返回由 Object.hashCode 返回的值。程序清单 10-3 给出了一另一个版本的 transferMoney,在该版本中使用了 System.identityHashCode 来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的可能性。
private static final Object tieLock = new Object();
public void transferMoney(
final Account fromAcct,
final Account toAcct,
final DollarAmount amount
) throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
在极少情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛”锁。在获得两个 Account 锁之前,首先获得这个“加时赛”锁,从而每次保证只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致的使用这种机制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类似于在整个程序中只有一个锁的情况),但由于 System.identityHashCode 中出现散列冲突的频率非常低,因此这项技术以最小的代价,换来的最大的安全性。
如果在 Account 中包含一个唯一的、不可变的,并具备可比性的键值,比如账号,那么要指定锁的顺序就更加容易了:通过键值将对象排序,因而不再需要使用“加时赛”锁了。
你或许认为我夸大了死锁的风险,因为所被持有的时间通常很短暂,然而在真实系统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天肯可能要处理数十亿次获取锁——释放锁的操作。只要在这数十亿次操作中出现一次错误,就可能导致程序发生死锁,并且即使使应用程序通过了压力测试也不可能找出所有潜在的死锁。在程序清单 10-4 中的 DemonstrateDeadlock 在大多数系统下都会很快发生死锁。
public class DemonstrateDeadlock {
private static final int NUM_THREADS = 20;
private static final int NUM_ACCOUNTS = 5;
private static final int NUM_ITERATIONS = 1000000;
public static void main(String[] args) {
final Random rnd = new Random();
final Account[] accounts = new Account[NUM_ACCOUNTS];
for (int i = 0; i < accounts.length; i++)
accounts[i] = new Account();
class TransferThread extends Thread {
public void run() {
for (int i=0; i<NUM_ITERATIONS; i++) {
int fromAcct = rnd.nextInt(NUM_ACCOUNTS);
int toAcct = rnd.nextInt(NUM_ACCOUNTS);
DollarAmount amount =
new DollarAmount(rnd.nextInt(1000));
transferMoney(accounts[fromAcct], accounts[toAcct], amount);
}
}
}
for (int i = 0; i < NUM_THREADS; i++)
new TransferThread().start();
}
}
10.1.3 在协作对象之间发生死锁
某些获取多个锁的操作并不像在 LeftRightDeadlock 或 transferMoney 中那么明显,这两个锁并不一定必须在同一个方法中获取。考虑程序清单 10-5 中两个互相协作的类,在出租车调度系统中可能会用到它们。Taxi 代表一个出租车对象,包含位置和目的地两个属性,Dispatcher 代表一个出租车车队。
// Warning: deadlock-prone!
class Taxi {
@GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
}
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
尽管没有任何方法会显式的获取连个锁,但 setLocation 和 getImage 等方法的调用者都会获得两个锁。如果一个线程在收到 GPS 接收器的更新事件时调用 setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。如果已经到达,它会通知 Dispatcher:它需要一个新的目的地。因为 setLocation 和 notifyAvailable 都是同步方法,因此调用 setLocation 的线程将首先获取 Taxi 的锁,然后获取 Dispatcher 的锁。同样,调用 getImage 的线程将首先获取 Dispatcher 锁,然后在获取一个 Taxi 锁(每次获取一个)。这与 LeftRightDeadlock 中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。
在 LeftRightDeadlock 或 transferMoney 中,要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法即可。然而要在 Taxi 和 Dispatcher 中查找死锁则比较困难:如果在持有锁的情况中调用某个外部方法,那么就需要警惕死锁。
如果在持有锁调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能或获取其他锁(从而可能产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。
10.1.4 开放调用
当然,Taxi 和 Dispatcher 并不知道它们将要陷入死锁,况且它们本来就不应该知道。方法调用相当于一种抽象屏障,因而你无需了解在被调方法内执行的操作。但也正是由于不知道在被调方法内部执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开放调用的类通常表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更容易编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析那些没有使用封装的程序容易的多。同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能的使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获取锁。
可以很容易的将程序清单 10-5 中的 Taxi 和 Dispatcher 修改为使用开放调用,从而消除发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如程序清单 10-6 所示。通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块),那么就会导致程序清单 10-5 中的问题。(此外,收缩同步代码块的保护范围还可以提高伸缩性,在 11.4.1 节中给出了如何确定同步代码块大小的方法。)
@ThreadSafe class Taxi {
@GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher;
...
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) { boolean reachedDestination; synchronized (this) { this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination)
dispatcher.notifyAvailable(this);
}
}
@ThreadSafe
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis; @GuardedBy("this") private final Set<Taxi> availableTaxis;
...
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public Image getImage() {
Set<Taxi> copy;
synchronized (this) {
copy = new HashSet<Taxi>(taxis);
}
Image image = new Image();
for (Taxi t : copy)
image.drawMarker(t.getLocation());
return image;
}
}
在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
有时候,再重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变得不再原子。在许多情况下,是某个操作时区原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调用程序这两出租车已经准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。在其他情况中,虽然去掉原子性可能会出现一些值得注意的结果,但这种语义变化让然是可以接受的。在容易产生死锁的版本中,getImage 会生成某个时刻下的整个车队位置的完整快照,而在重新改写的版本中,getImage 将获得每辆出租车不同时刻的位置。
然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。例如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。这个问题的解决方法是,在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会视图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态,因此,这项技术依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。
10.1.5 资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现当资源为空时的阻塞行为。如果一个任务需要同时连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程 A 可能与持有数据库 D1 的连接、并等待与数据库 D2 的连接,而线程 B 则持有与 D2 的连接并等待与 D1 的连接。(资源池越大,出现这种情况的可能性也就越小。如果每个资源池都有 N 个连接,那么发生死锁时不仅需要 N 个循环等待的线程,而且还需要大量不恰当的执行顺序)。
另一种基于资源的死锁形式是线程饥饿死锁。8.1.1 节给出了这种危害的一个示例:一个任务提交另一个任务,并等待被提交的任务在单线程的 Executor 中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个 Executor 中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能结合使用。
10.2 死锁的避免与诊断
如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获得多个锁,那么在设计的时候必须考虑锁的顺序:尽量减少潜在的加锁交互次数,将获取锁时需要遵循的协议写入正式的文档并始终遵循这些协议。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方将会获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能的使用开放调用,这能极大的简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。
10.2.1 支持定时的锁
还有一项技术用于检测死锁和从死锁中恢复,即显式使用 Lock 类中的定时 tryLock 功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁可以指定一个超时时限,在等待超过该时限后 tryLock 会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权,(在程序清单 13-3 中给出了 transferMoney 的另一种实现,其中使用了一种轮询的 tryLock 消除了死锁发生的可能性)
当定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误的进入了无限循环,还可能是某个操作的执行时间远远超过了你的预期。然而,至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式来重新启动计算,而不是关闭整个进程。
即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效的应对死锁问题。如果获取锁时超时,那可可以释放该锁,然后后退并在一段时间后再次尝试,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法将其释放)。
10.2.2 通过线程转储信息来分析死锁
虽然防止死锁的主要责任在于你自己,但 JVM 仍然通过线程转储来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每隔线程持有了哪些锁,在哪些栈帧中获得这些所,以及被阻塞的线程正在等待哪个锁。在生成线程转储之前,JVM 将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中设计哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。
要在 UNIX 平台上触发线程转储操作,可以通过向 JVM 的进程发送 SIGQUIT 信号(kill -3
),或者在 UNIX 平台中按下 Ctrl-\
,在 Windows 平台中按下 Ctrl-Break
键。在许多 IDE 中都可以请求线程转储。
如果使用显式的 Lock 类而不是内部锁,那么 Java 5 并不支持与 Lock 相关的转储信息,在线程转储中不会出现显式的 Lock。虽然 Java 6 中包含对显式 Lock 的线程转储和死锁检测等支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的 Lock 只与获得它的线程相关联。
程序清单 10-7 给出了一个 J2EE 应用中获取的部分线程转储信息。在导致死锁的故障中包括 3 个组件:一个 J2EE 应用程序、一个 J2EE 容器、一个 JDBC 驱动程序,分别由不同的生产商提供。这三个组件都是商业产品,并经过了大量的测试,但每个组件都存在一个错误,并且这个错误只有当他们进行交互时才会显现出来,并导致服务器出现一个严重的故障。
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x080f0cdc (a MumbleDBConnection),
which is held by "ApplicationServerThread" "ApplicationServerThread":
waiting to lock monitor 0x080f0ed4 (a
MumbleDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MumbleDBConnection.remove_statement
- waiting to lock <0x650f7f30> (a MumbleDBConnection)
at MumbleDBStatement.close
- locked <0x6024ffb0> (a MumbleDBCallableStatement)
...
"ApplicationServerThread":
at MumbleDBCallableStatement.sendBatch
- waiting to lock <0x6024ffb0> (a MumbleDBCallableStatement)
at MumbleDBConnection.commit
- locked <0x650f7f30> (a MumbleDBConnection)
...
我们只给出了与查找死锁相关的部分线程转储信息。当诊断死锁时,JVM 可以帮我们做许多工作——哪些死锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接地给其他下城带来了不利的影响。其中一个线程持有 MumbleDBConnection 上的锁,并等待获得 MumbleDBCallableStatement 上的锁,而另一个线程则持有 MumbleDBCallableStatement 上的锁,并等待 MumbleDBConnection 上的锁。
在这里使用的 JDBC 驱动程序中明显存在一个锁顺序问题:不同的调用链通过 JDBC 驱动程序以不同的顺序获得多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程视图同时使用同一个 JDBC 连接。这并不是应用程序的设计初衷——开发任务惊讶的发现同一个 Connection 被两个线程并发使用。在 JDBC 规范中并没有要求 Connection 必须是线程安全的,以及 Connection 通常被封装在单个线程中使用,而在这里就采用了这种假设。这个生产商视图提供一个线程安全的 JDBC 驱动,因此在驱动程序代码内部对多个 JDBC 对象施加了同步机制。然而,生产商却没有考虑锁的顺序,因而驱动程序很容易发生死锁,而正是由于这个存在死锁风险的驱动程序与错误共享 Connection 的应用程序发生了交互,才使得这个问题暴露出来。因为单个错误并不会产生死锁,只有这两个错误同时发生时才会出现,即使它们分别进行了大量测试。
10.3 其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号、活锁等。
10.3.1 饥饿
当线程无法访问它所需要的资源而不能继续执行时,就发生了饥饿。引发饥饿的崔常见资源就是 CPU 时钟周期。如果在 Java 应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(如无限训话、无限制的等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
在 Thread API 中定义的线程优先级只是作为线程调度的参考。在 Thread API 中定义了 10 个优先级,JVM 根据需要将他们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的 Java 优先级可能被映射到同一个优先级上,而在另一个操作系统中则可能被映射到另一个不同的优先级上。在某些操作系统中,如果优先级的数量少于 10 个,那么有多个 Java 优先级会被映射到同一个优先级上。
操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出 Java 语言规范的需求范围。在大多数 Java 程序中,所有线程都具有相同的优先级 Thread.NORMA_PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。
通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将于平台相关,并且会导致发生饥饿问题的风险。你经常能发现某个程序会在一些奇怪的地方抵用 Thread.sleep 或 Thread.yield,这是因为该程序视图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。
Thread.yied (以及 Thread.sleep) 的语义都是未定义的。JVM 既可以将它们实现为空操作,也可以将它们视为线程调度的参考。尤其是,在 UNIX 系统中并不要求它们拥有 sleep 的语义——将当前线程放在与该优先级对应的运行队列的末尾,并将执行权交给拥有相同优先级的其他线程,尽管有些 JVM 是按照这种方式实现 Thread.yied 方法的。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数应用程序中,都可以使用默认的线程优先级。
10.3.2 糟糕的响应性
除饥饿意外的另一个问题是糟糕的响应性,如果在 GUI 应用程序中使用了后台线程,那么这种问题是很常见的。在第九章中开发了一个框架,并发运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但 CPU 密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争 CPU 时钟周期。在这种情况下就可以发挥线程优先级的作用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。
10.3.3 活锁
活锁是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断的重复执行相同的操作,而且总是会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某个特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递给存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列的开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message)。虽然处理消息的贤臣并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误的将不可修复的错误作为可修复的错误。
当多个相互协作的线程都对彼此进行响应而修改各自的状态,并使得任何一个线程都无法继续执行,就发生了活锁。这就像两个过于礼貌的人在半路上面对面的相遇:他们彼此都让出对方的路,然后又在另一条路上相遇了。因此它们就这样反复的避让下去。
要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器都尝试使用相同的载波来发送数据包,那么这些数据包将会冲突。这两台机器都检查到了冲突,并都在稍后再次发送。如果二者都选择了在 1 秒钟之后重试,那么它们会再次冲突,如此往复,因而即使有大量闲置的带宽,也无法发出数据包。为了避免这种情况发生,需要让他们分别等待一段随机的时间。(以太网定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥堵而反复失败的风险)。在并发应用中,通过等待随机长度的时间和回退可以有效的避免活锁的发生。
小结
活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了终止应用之外没有其他任何机制可以帮助从这种故障中恢复。最常见的活跃性故障就是锁顺序死锁。在设计时应避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的代码路径,也更容易发现有问题的代码路径。
3.1.11 - CH11-性能与伸缩
线程主要的目的是提高程序的运行性能。线程可以使程序更加充分的发挥系统的可处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
本章将开始介绍各种分析、监测以及提升并发程序性能的技术。然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,虽然某些技术的初衷是提升性能,但事实上却与最初的目标背道而驰,或者又带来了其他新的性能问题。虽然我们希望获得更好的性能——提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行的更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。
11.1 对性能的思考
提升性能意味着用更少的资源做更多的事。“资源”的含义很广。对于一个给定的操作,通常会缺乏某种特定的资源,例如 CPU 时钟周期、内存、网络带宽、IO 带宽、数据库请求、磁盘空间等其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如,CPU 密集型、数据库密集型等。
尽管使用线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度的使用线程,那么这些开销甚至会超过由于提升吞吐量、响应性或计算能力带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。
要想通过并发来获得更好的性能,需要努力做好两件事情:更有效的利用现有处理资源,以及在出现新的处理资源时使程序尽可能的利用这些新资源。从性能监视的视角来看,CPU 需要尽可能保持忙碌状态。(当然,这并不意味着将 CPU 时钟周期浪费在一些无用的计算上,而是执行一些有用的工作)。如果程序是计算密集型的,那么可以通过增加处理器来提升性能。因为如果程序无法使现有的处理器处于忙碌的状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有 CPU 都保持忙碌状态。
11.1.1 性能与可伸缩性
应用的性能可以通过多个指标来衡量,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的“运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的“处理能力”,即在计算资源一定的情况下,能完成多少工作。
可伸缩性指的是:当增加计算资源时(如 CPU、内存、存储容量或 IO 带宽),程序的吞吐量或者处理能力能获得相应的提升。
在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用事件复杂度 O(n^2) 算法来代替复杂度为 O(n log n) 的算法。在进行可伸缩性调优时,其目的是设法将问题进行并行化,从而能利用更多的计算资源来完成更多的任务。
我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层、持久化层都是彼此独立的,并且可能由不同的系统来处理,这很好的说明了提高可伸缩性通常会造成性能损失的原因。如果把表现层、业务逻辑层和持久化层都融合到单个应用程序汇总,那么在处理第一个工作单元时,其性能肯定要高出将应用分为多个层次并将不同层次划分到多个系统时的性能。这种单一的应用程序避免了在不同层次之间传递任务时存在的网络延迟,同时也不需要将计算过程分解到不同的抽象层次,因此能减少很多开销(如任务排队、线程协调、数据复制)。
然而,当这种单一的系统到达自身处理能力的极限时,会遇到一个严重的问题:要进一步提升它的处理能力将非常困难。因此,我们通常会接受每个工作单元执行更长的时间或消耗更多的计算资源,以换取应用程序在增加更多资源的情况下处理更高的负载。
对于服务器应用程序来说,“多少”这个方面——可伸缩性、吞吐量、生产量,往往比“多快”这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条,并奇怪程序究竟在执行哪些操作)。本章将重点介绍可伸缩性而不是单线程程序的性能。
11.1.2 评估各种性能权衡因素
在几乎所有的工程决策中都会涉及某些形式的权衡。在建设桥梁时,使用更粗的钢筋可以提高桥的负载能力和安全性,但同时也会提高建造成本。尽管在软件工程的决策中通常不会涉及资金以及人身安全,但在做出正确的权衡时通常会缺少相应的信息。例如,“快速排序”算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序”实际上更搞笑。如果要实现一个高效的排序算法,那么就需要知道被处理的数据集的大小,还有衡量优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中的排序算法的开发人员通常无法知道这些需求信息。这就是大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确的需求。
避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它运行的还不够快。
当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用量以降低服务时间),也会通过增加开销来换取安全性。安全性并不一定就是指对人身安全的威胁,例如桥梁设计的示例。很多性能优化措施都是以牺牲可读性或可维护性为代价——代码越聪明越晦涩,它们又会带来更高的错误风险,因为通常越快的算法就越复杂。(如果你无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析)。
在大多数性能决策中都包含多个变量,并且非常依赖于运行环境。在使某个方案比其他方案更快之前,首先问自己一些问题:
- “更快”的含义是什么?
- 该方法在什么条件写运行的更快?在低负载还是高负载的情况下?多数据集还是小数据集?能否通过测试结果来验证你的答案?
- 这些条件在运行环境中的发生概率?能否通过测试结果来验证你的答案?
- 在其他不同条件的环境中是否能使用这里的代码?
- 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?
在进行任何与性能相关的决策时,都应该考虑哪些问题,本书只介绍并发性方面的内容。我们为什么要推荐这种保守的方法?对性能的提升可能是并发错误的最大来源。有人认为同步机制太慢,因而采用一些看似聪明实则危险的方法来减少同步的使用(比如 16.2.4 节中讨论的双重检查锁),这也通常作为不遵守同步规则的一个常见借口。然而,由于并发错误是最难追踪和消除的错误,因此对于任何可能会引入这类错误的措施,都需要谨慎实施。
更糟糕的是,虽然你的初衷可能是用安全性来换取性能,但最终可能什么也得不到。特别是,当提到并发时,许多开发人员对于哪些地方存在性能问题,哪种方法的运行速度更快,以及哪种方法的可伸缩性更好,往往会存在错误的直觉。因此,在对性能进行调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实的配置和负载均衡等环境。在对性能调优后,你需要再次测量以验证是否达到了预期的性能提升目标。在许多优化措施中带来的安全性和可维护性等风险非常高。如果不是必须的话,你通常不想付出这样的代价,如果无法从这些措施中获得性能提升,那么你肯定不希望付出这种代价。
以测试为基准,不要猜测。
市场上有一些成熟的分析工具可以用于评估性能以及找出性能瓶颈,但你不需要花费太多的资金来找出程序的功能。比如免费的 perfbar 应用可以给出 CPU 的忙碌程度信息,而我们通常的目标是使 CPU 保持忙碌状态,因此这个功能可以有效的评估是否需要进行性能调优或者已经实现的调优效果如何。
11.2 Amdahl 定律
在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼工人越多,那么就能越快的完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也无法增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效的使用这种潜在的并行能力。
大多数并发程序都与农业耕作有着形似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上就能够实现最高加速比,这个取值取决于程序中可并行组件与串行组件所占的比重。假定 F 是必须被串行的部分,那么根据 Amdahl 定律,在包含 N 个处理器的机器中,最高的加速比为:
当 N 趋近于无穷大时,最大的加速比趋近于 1/F。因此,如果程序有 50% 的计算需要串行执行,那么最高的加速比只能是 2(而无论有多少个线程可用);如果程序中有 10% 的计算需要串行执行,那么最高的加速比接近于 10。Amdahl 定律还量化了串行化的效率开销。在拥有 10 个处理器的系统中,如果程序中有 10% 的部分需要串行执行,那么最高加速比为 5.3(53%的使用率),在拥有 100 个处理器的系统中,加速比可以达到 9.2(9%的使用率)。即使拥有无限多个 CPU,加速比也不可能为 10。
图 11-1 给出了处理器利用率在不同串行比例以及处理器数量的情况下的变化曲率。(利用率定义为:加速比除以处理器的数量)。随着处理器数量的增加,可以明显的看到,即使串行部分所占的比例很小,也会极大的限制当增加计算资源时能够提升的吞吐率。
第六章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。然而,要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务的串行部分。
假设应用程序中 N 个线程正在执行程序清单 11-1 中的 doWork,这些线程从一个共享的工作队列中取出任务并进行处理,而且这里的任务都不依赖于任何其他的执行结果或影响。暂时先不考虑任务是如何进入这个队列的,如果增加处理器,那么应用程序的性能是否会相应的发生变化?初看上去,这个程序似乎能完全并行化:各个任务之间不会互相等待,因此处理器越多,能够并发处理的任务也就越多。然而,在这个过程中包含了一个串行的部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出一个任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。
public class WorkerThread extends Thread {
private final BlockingQueue<Runnable> queue;
public WorkerThread(BlockingQueue<Runnable> queue) { this.queue = queue;
}
public void run() {
while (true) {
try {
Runnable task = queue.take();
task.run();
} catch (InterruptedException e) {
break; /* Allow thread to exit */
}
}
}
}
单个任务的处理时间不仅包括执行任务 Runnable 的时间,还包括从共享队列中取出任务的时间。如果使用 LinkedBlockingQueue 作为工作队列,那么出列操作被阻塞的可能性将小于使用同步 LinkedList 发生阻塞的可能性,因为 BlockingQueue 使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会为程序引入一个串行的部分。
这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或产生某种副作用——如果不会,那么可以将它们作为无用代码删除掉。由于 Runnable 没有提供明确的结果处理过程,因此这些任务一定会产生某种副作用,例如将它们的结果写入日志或保存到某个数据结构。通常,日志文件和结果容器都会由多个工作者线程共享,因此这也是串行的一部分。如果所有线程都将各自的计算结果保存到自行维护的数据结构中,并且在所有任务都执行完成后在合并所有结果,那么这种合并操作也是一个串行部分。
在所有并发程序中都包含一些串行部分。
11.2.1 示例:在各种框架中隐藏的串行部分
要想知道串行部分是如何隐藏在应用程序的框架中的,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩线变化来推断串行部分中的差异。图 11-2 给出了一个简单的应用程序,其中多个线程反复从一个共享 Queue 中取出元素进行处理,这与程序清单 11-1 很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么他将一个新元素放入队列,因而其他线程在下一次访问时不会出现没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。
图 11-2 的曲线对两个线程安全的 Queue 的吞吐率进行了对比:其中一个是采用 synchronizedList 封装的 LinkedList,另一个是 ConcurrentLinkedQueue。这些测试在 8 路 Sparc V880 系统上运行,操作系统为 Solaris。尽管每次运行都表示相同的“工作量”,但我们可以看到,只需要改变队列的实现方式,就能对伸缩性产生明显的影响。
ConcurrentLinkedQueue 的吞吐量不断提升,直到达到处理器的数量上限,之后将基本保持不变。另一方面,当线程数量小于 3 时,同步 LinkedList 的吞吐量也会有某种程度的提升,但是之后会由于同步开销而骤然下跌。当线程数量达到 4 个或 5 个时,竞争将非常激烈,甚至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
吞吐量的差异主要来源于两个队列中不同比例的串行部分。同步 LinkedList 采用单个锁来保护整个队列的状态,并且在 offer 和 remover 等方法的调用期间都将持有该锁。ConcurrentLinkedQueue 使用了一种更加复杂的非阻塞队列算法,该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入和删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作才需要串行执行。
11.2.2 Amdahl 定律的应用
如果能准确估算出串行部分所占的比例,那么 Amdahl 定律就能量化增加计算资源时的加锁比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下该定律仍然是有用的。
因为我们的思维通常会受周围环境的影响,因此很多人都习惯的认为在多处理器系统中会包含 2 个或 4 个处理器,甚至更多,因为这种技术在近年来被广泛使用。但随着多个 CPU 逐渐成为主流,系统可能拥有数百个或者数千个处理器。一些在 4 路系统中看似具有可伸缩性的算法,却可能含有一个隐藏的可伸缩性瓶颈,只是还没有遇到而已。
在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,在 11.4.2 节或 11.4.3 节中介绍了两种降低所粒度的技术:锁分解(将一个锁分解为两个锁)和锁分段(将一个锁分解为多个锁)。当通过 Amdahl 定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,应用锁分解就够了)。
11.3 线程引入的开销
单线程程序即不存在线程调度、也不存在同步开销、而且也不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过引入线程的开销。
11.3.1 上下文切换
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于 CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使得其他线程能够使用 CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的上下文设置为当前上下文。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。应用程序、操作系统以及 JVM 都使用一组相同的 CPU。在 JVM 和操作系统的代码中消耗越多的 CPU 时钟周期,应用程序的可用 CPU 时钟周期就越少。但上下文切换的开销并不只是包含 JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不再当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM 通常会将这个线程挂起,并允许它被交换出去。如果线程频繁发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞 IO、等待获取发生竞争的锁、在条件变量上等待),与 CPU 密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减少上下文切换,详见第 15 章)。
上下文切换的开销会随着平台的不同而变化,然而按照经验来看:在大多数同样的处理器中,上下文切换的开销相当于 5000~10000 个时钟周期,也就是几微秒。
UNIX 系统中的 vmstat 命令和 Windows 系统的 perfmon 工具都能包括上下文切换的次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过 10%),那么通常表示调度活动发生得很频繁,这很可能是由 IO 或竞争锁导致的阻塞引起的。
11.3.2 内存同步
同步操作的性能开销包括多个方面。在 synchronized 和 volatile 提供的可见性保证中可能会使用一些特殊指令,即内存屏障。内存屏障可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存屏障可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存屏障中,大多数操作都不能被重排序。
在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized 机制针对无竞争的同步进行了优化(volatile 通常是非竞争的),而在编写本书时,一个快速通道(Fast-Path)的非竞争同步将消耗 20~250 个时钟周期。虽然非竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且会使你(或后续开发人员)经历非常痛苦的除错过程。
现代的 JVM 能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁的对象只能由当前线程访问,那么 JVM 就可以通过优化来去掉这个加锁操作,因为另一个线程无法与当前线程在这个锁上发生同步。比如,JVM 通常都会去掉程序清单 11-2 中的加锁操作。
synchronized (new Object()) {
// some operations
}
一些更加完备的 JVM 会通过逃逸分析来找出不会被发布到堆的对象引用(因此这个对象是线程本地的)。在程序清单 11-3 的 getStoogeNames 中,对 List 的唯一引用就是局部变量 stooges,并且所有封闭在栈中的变量都会自动称为线程本地变量。在 getStoogeNames 的执行过程中,至少会将 Vector 上的锁获取、释放 4 次,每次调用 add 或 toString 时都会执行一次。然而,一个智能的运行时编译器通常会分析这些调用,从而使 stooges 及其内部状态不会益处,因此可以去掉这 4 次对锁的获取操作。
public String getStoogeNames() {
List<String> stooges = new Vector<String>();
stooges.add("Moe");
stooges.add("Larry");
stooges.add("Curly");
return stooges.toString();
}
即使不进行逃逸分析,编译器也可以执行锁粒度粗化操作,即将临近的同步代码块用同一个锁合并起来。在 getStoogeNames 中,如果 JVM 进行锁粗粒度化,那么可能会把 3 个 add 操作和 1 个 toString 调用合并为单个加解锁操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而实现更进一步的优化。
不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且 JVM 还能进行额外的优化进一步降低或消除开销。因此,我们应该将优化重点放到那些发生锁竞争的地方。
某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。
11.3.3 阻塞
非竞争同步可以完全在 JVM 中进行处理(而不涉及操作系统),而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞的行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断的尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待的时间很短,则适合采用自旋等待的方式,而如果等待时间很长,则适合采用线程挂起的方式。有些 JVM 将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数 JVM 在等待锁时都只是将线程挂起。
当线程无法获取某个锁或者由于在某个条件等待或在 IO 操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致锁阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程)。
11.4 减少锁的竞争
我们已经看到,串行操作会降低可伸缩线,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
有三种方式可以降低锁的竞争程度:
- 减少持有锁的时间。
- 降低对锁的请求频率。
- 使用带有协调机制的独占锁,这些机制支持更高的并发性。
11.4.1 缩小锁的范围——“快进快出”
降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如 IO 操作。
我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性,例如在第二章中介绍的 SynchronizedFactorizer 的示例。如果某个操作持有锁的时间超过 2 毫秒并且所有操作都需要这个锁,那么无论拥有多个个空闲的处理器,吞吐量也不会超过每秒 500 个操作。如果将这个锁的持有时间降低为 1 毫秒,那么能够将这个锁对应的吞吐量提高到每秒 1000 个操作。
程序清单 11-4 给出了一个示例,其中锁被持有过长的时间。userLocationMatches 方法在一个 Map 对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个 userLocationMatches 方法都是用了 synchronized 来修饰,但只有 Map.get 这个调用才真正需要锁。
@ThreadSafe public class AttributeStore {
@GuardedBy("this")
private final Map<String, String> attributes =
new HashMap<String, String>();
public synchronized boolean userLocationMatches(
String name, String regexp) {
String key = "users." + name + ".location";
String location = attributes.get(key);
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
在程序清单 11-5 的 BetterAttributeStore 中重新编写了 AttributeStore,从而大大降低了锁的持有时间。第一个步骤是构建 Map 中与用户位置相关联的键值,这是一个字符串,形式为 user.name.location。这个步骤还包括实例化一个 StringBuilder 对象,向其添加几个字符串,并将结果转化为一个 String 对象。在获得了位置之后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中不需要访问共享状态,因此在执行时不需要持有锁。通过 BetterAttributeStore 中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。
@ThreadSafe public class BetterAttributeStore {
@GuardedBy("this")
private final Map<String, String> attributes =
new HashMap<String, String>();
public boolean userLocationMatches(String name, String regexp) {
String key = "users." + name + ".location";
String location; synchronized (this) {
location = attributes.get(key);
}
if (location == null)
return false;
else
return Pattern.matches(regexp, location);
}
}
通过缩小 userLocationMatches 方法中所的作用范围,能极大的减少在持有锁时需要执行的指令数量。根据 Amdahl 定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
由于 AttributeStore 中只有一个状态变量 attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能。通过使用线程安全的 Map 来代替 attributes,AttributeStore 可以将确保线程安全性为任务委托给顶层的线程安全容器来实现,这样就无需在 AttributeStore 中采用显式锁的同步,缩小在访问 Map 期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(比如在访问 attributes 之前忘记获得对应的锁)。
尽管缩小同步代码块能够提高可伸缩性,但同步代码块也不能过小——一些采用原子方式执行的操作(如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要获得一定的开销,当把一个同步块分解为多个同步块代码块时(在确保正确性的情况下),反而会对性能提升产生负面的影响。在分解同步代码时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作作为同步代码块中移出时,才应该考虑同步代码块的大小。
11.4.2 减小锁的粒度
另一种减少锁持有时间的方式是降低线程请求锁的频率(从而减少发生竞争的可能性)。这可以通过锁分解和锁分段技术来实现,在这些技术中将采用多个互相独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个变量来保护的情况。这些技术能减少锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,发生死锁的风险也越高。
设想一下,如果在整个应用中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效的降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁仅保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
在程序清单 11-6 的 ServerStatus 中给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用对应的 add 和 remove 方法来更新 ServerStatus 对象。这两种类型的信息是完全独立的,ServerStatus 甚至可以被分解为两个类,同时确保不会丢失功能。
@ThreadSafe public class ServerStatus {
@GuardedBy("this")
public final Set<String> users;
@GuardedBy("this")
public final Set<String> queries;
...
public synchronized void addUser(String u) {
users.add(u);
}
public synchronized void addQuery(String q) {
queries.add(q);
}
public synchronized void removeUser(String u) {
users.remove(u);
}
public synchronized void removeQuery(String q) {
queries.remove(q);
}
}
在代码中不是用 ServerStatus 锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,如程序清单 11-7 所示。在对锁进行分解后,每个新的粒度锁上的访问量将比最初的访问量少。(通过将用户状态和查询状态委托给一个线程安全的 Set,而不是使用显式的同步,能隐含的对锁进行分解,因为每个 Set 都会使用一个不同的锁来保护其状态。)
@ThreadSafe public class ServerStatus {
@GuardedBy("users") public final Set<String> users;
@GuardedBy("queries") public final Set<String> queries;
...
public void addUser(String u) {
synchronized (users) {
users.add(u);
}
}
public void addQuery(String q) {
synchronized (queries) {
queries.add(q);
}
}
// remove methods similarly refactored to use split locks
}
如果在锁上存在适中而非激烈的竞争时,通过将一个锁分解为两个锁,能最大限度的提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效的提高性能和可伸缩性。
11.4.3 锁分段
把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分性能,但是在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。在 ServerStatus 类的锁分解实例中,并不能进一步多锁进行分解。
在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在 ConcurrentHashMap 的实现中使用了一个包含 16 的锁的数组,每个锁保护所有散列通的 1/16,其中第 N 个散列桶由第 (N mod 16) 个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的 1/16。这是这项技术使得 ConcurrentHashMap 能够支持多达 16 个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的 16 个。)
锁分段的一个劣势在于:与采用单个锁来实现的独占性相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要对整个容器加锁,例如当 ConcurrentHashMap 需要扩展容器映射范围时,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段分段锁集合中所有的锁。
在程序清单 11-8 的 StripedMap 中给出了基于散列的 Map 实现,其中使用了锁分段技术。它拥有 N_LOCKS 个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如 get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如 clear 方法的实现。
@ThreadSafe public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node { ... }
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
...
}
11.4.4 避免热点域
锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会互相干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量 X 和 Y,并且线程 A 想要访问 X,而线程 B 想要访问 Y(这类似于在 ServerStatus 中,一个线程调用 addUser,而另一个线程调用 addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
当每个操作都访问多个变量时,锁的粒度将很难降低。这是在性能和可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)”,而这些热点域往往会限制可伸缩性。
当实现 HashMap 时,你需要考虑如何在 size 方法中计算 Map 中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在 put 和 remove 等方法中略微增加了一些开销,以确保计数器是最新的值,但这将 size 方法的开销从 O(n) 降低到了 O(1)。
在单线程或采用完全同步的实现中,使用一个独立的就计数能很好的提高类似 size 和 isEmpty 这些方法的执行速度,但却导致更加难以提升实现的可伸缩性,因为每个修改 Map 的操作都需要跟新这个计数器。即使使用锁分段即使来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存 size 操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
为了避免该问题,ConcurrentHashMap 中的 size 将对每个分段进行枚举并将每个分段中的元素数量增加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap 为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。
11.4.5 一些替代独占锁的方法
第三种降低竞争锁的影响的技术就是放弃独占所,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。
ReadWriteLock 实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果有多个读取操作都不会修改共享资源,那么这些读操作可以同时访问该共享资源,但在执行写入操作时必须以独占的方式来获取锁,对于读取操作占多数的数据结构,ReadWriteLock 能够提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不可变性可以完全不需要加锁操作。
源自变量提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列生成器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(比如 CAS)。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来代替它们提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)
11.4.6 检测 CPU 的利用率
当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如 UNIX 系统上的 vastat 和 mpstat,或者 Windows 系统的 perfmon,都能给出处理器的忙碌状态。
如果所有 CPU 的利用率并不均匀(有些 CPU 在忙碌的运行,而其他 CPU 却并非如此)。那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表名大多数计算都是由一小组线程完成的,并且应用程序没有利用上其他的处理器。
如果 CPU 没有得到充分的利用,那么需要找出其中的原因,通常有以下几种原因:
负载不充足。测试的程序中可能没有足够的负载,因而可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试的系统。
IO 密集。可以通过 iostat 或 perfmon 来判断某个应用程序是否是磁盘 IO 密集型的,或者通过监控网络的通信流量级别来判断它是否需要高带宽。
外部限制。如果应用程序依赖于外部服务,例如数据库或者 Web 服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部的结果时需要的时间。
锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monitor…”。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程正在等待获取它,因此将在线程转储中频繁的出现。
如果应用程序正在使 CPU 保持忙碌的状态,那么可以使用检测工具来判断是否能通过增加额外的 CPU 来提升程序的性能。如果一个程序只有 4 个线程,那么可以充分利用一个 4 路系统的计算能力,但当移植到 8 路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的处理器。(可以通过重新配置程序将负载分配给更多的线程,例如调整线程池的大小)。在 vmstat 命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的 CPU)的线程数量。如果 CPU 的利用率很高,并且总会有可运行的线程在等待 CPU,那么当增加更多的处理器时,程序的性能可能会得到提升。
11.4.7 向对象池说“不”
在 JVM 早期的版本中,对象分配和垃圾回收等操作的执行速度非常慢,但在后续的版本中,这些操作的性能得到了很大的提升。事实上,现在 Java 的分配操作已经比 C 语言的 malloc 调用更快:在 HotSpot 1.4.x 和 5.0 中,“new Object”的代码大约包含 10 条机器指令。
为了解决“缓慢的”对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能够被循环使用,而不是由垃圾收集器回收并在需要时再重新分配。在单线程程序中,尽管对象池技术能降低垃圾收集器的开销,但对于高开销对象以外的其他对象来说,仍然存在性能缺失(对于轻量级和中量级的对象来说,这种损失更为严重)。
在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么久需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩的性能瓶颈。(即使是一个非竞争同步,所导致的开销也会被分配一个对象的开销大)。虽然这看上去是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途,但对于性能优化来说,用途是有限的。
通常,对象分配操作的开销比同步的开销更低。
11.5 示例:比较 Map 的性能
在单线程环境下,ConcurrentHashMap 的性能比同步的 HashMap 的性能略好一些,但在并发环境中则要好的多。在 ConcurrentHashMap 的实现中假设,大多数常用的操作都是获取某个已经存在的值,因此它对各种 get 操作进行了优化从而提高性能和并发性。
在同步 Map 的实现中,可伸缩性的最主要阻碍在于整个 Map 中只有一个锁,因此每次只有一个线程能能够访问这个 Map。不同的是,ConcurrentHashMap 对于大多数读操作都不会加锁,并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术。因此,多个线程能并发的访问这个 Map 而不会发生阻塞。
图 11-3 给出了几种 Map 实现在可伸缩性上的差异:ConcurrentHashMap、ConcurrentSkipListMap,以及通过 synchronizedMap 来包装的 HashMap 和 TreeMap。前两种 Map 是线程安全的,而后两个 Map 则通过同步封装器来确保线程安全性。每次运行时,将有 N 个线程并发的执行一个紧凑的循环:选择一个随机的键值,并尝试获取与这个键值相对应的值。如果不存在相应的值,那么将这个值增加到 Map 的概率为 p=0.6,如果存在相应的值,那么删除这个值的概率为 p=0.02。这个测试在 8路 Sparc V880 系统上运行,基于 Java 6 环境,并且在图中给出了将 ConcurrentHashMap 归一化为单个线程时的吞吐量。(并发容器与同步容器在可伸缩性上的差异比在 Java 5.0 中更明显)。
ConcurrentHashMap 和 ConcurrentSkipListMap 的数据显示,它们在线程数量增加时能表现出更好的可伸缩性,并且吞吐量会随着线程的增加而增加。虽然图 11-3 中的线程数量并不大,但与普通的应用程序相比,这个测试程序在每个线程上生成更多的竞争,因为它除了向 Map 施加压力以外几乎没有执行任何其他操作,而实际的应用程序通常会在每次迭代中进行一些线程本地工作。
同步容器的数量并非越多越好。单线程情况下的性能与 ConcurrentHashMap 的性能基本相当,但当负载情况由于非竞争性转变为竞争性时——这里是两个线程,同步容器的性能将变得非常糟糕。在伸缩性收到锁竞争限制的代码中,这种情况很常见。只要竞争程度不高,那么每个操作消耗的时间基本上即使实执行工作的时间,并且吞吐量会因为线程数的增加而增加。当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。
11.6 减少上下文切换的开销
在许多人物中都包含一些可能被阻塞的操作。当任务在运行和阻塞这连个状态之间转换时,就相当于一次上下文切换。在服务器应用中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量,我们将对两种日志方法的调度行为进行分析。
在大多数日志框架中都是简单的对 println 进行包装,但需要记录某个消息时,只需要将其写入日志文件。在第七章中的 LogWriter 中给出了另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程。从开发人员的角度来看,这两种方法基本上是相同的。但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素,例如上下文切换的开销等。
日志操作的服务时间包括与 IO 流类相关的计算时间,如果 IO 操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移出并直到 IO 操作结束,这将比实际阻塞的时间更长。当 IO 操作结束时,可能有其他线程正在执行它们的带哦度时间片,并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,那么还可能在输出流上的锁上发生竞争,这种情况的结果与阻塞 IO 的情况一样——线程被阻塞并等待锁,然后被线程调度器调度出去。在这种日志操作中包含了 IO 操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。
请求服务的时间不应该过长,主要有以下原因。首先,服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更多的时间。但更重要的是,服务是将越长,也就意味着存在越多的锁竞争。11.4.1 节中的“快进快出”原则告诉我们,锁被持有的时间应该尽可能的短,因为锁的持有时间越长,那么在这个锁上发生竞争的可能性就越大。如果一个线程由于等待 IO 操作完成而被阻塞,同时它还持有一个锁,那么在这期间很可能会有另一个线程想要获得这个锁。如果在大多数的锁获取操作上不存在竞争,那么并发系统就能执行得更好,因为在锁获取操作上发生竞争时将导致更多的上下文切换。在代码中造成的上下文切换次数越多,吞吐量就越低。
通过将 IO 操作从处理请求的线程分离出来,可以缩短处理请求的平均服务时间。调用 log 方法的线程将不会再因为等待输出流的锁或者 IO 完成而被阻塞,它们只需要将消息放入队列,然后就返回到各自的任务中。另一方面,虽然在消息队列上可能发生竞争,但 put 操作相对于记录日志的 IO 操作(可能需要执行系统调用)是一种更为轻量级的操作,因此在实际使用中发生阻塞的概率更小(只要队列未满)。由于发出日志请求的线程现在被阻塞的概率降低,因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包含 IO 操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。
从某种意义上讲,我们只是将工作分散开来,并将 IO 操作移到了另一个用户感知不到开销的线程上(这本身就已经获得了成功)。通过把所有记录日志的 IO 转移到一个线程,还消除了输出流上的竞争,因去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。
通过把 IO 操作从处理请求的线程转移到一个专门的线程,类似于两种不同救火方案之间的差异:第一种方案是所有人排成一队,通过传递水桶来救火;第二种方案是每个人都拿着一个水桶去救火。在第二种方案中,每个人都可能在水源点和着火点上存在很大的竞争(结果导致了只能将更少的水传递到着火点),此外救火的效率也更低,因为每个人都在不停的切换模式(装水、跑步、倒水、跑步…)。在第一种方案中,水不断的从水源传递到燃烧的建筑物,人么付出更少的体力却传递了更多的水,并且每个人从头到尾只需要做一项工作。正如中断会干扰人们的工作并降低效率一样,阻塞和上下文切换同样会干扰线程的正常执行。
小结
由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多的将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl 定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码的比例。因为 Java 程序中串行执行的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升伸缩性:减少锁的持有时间、降低锁的粒度、以及采用非独占的锁或者非阻塞锁来代替独占锁。
3.1.12 - CH12-测试
在编写并发程序时,可以采用与串行程序相同的设计原则和设计模式。二者的差异在于,并发程序存在一定程度的不确定性,而在串行程序中不存在这个问题。这种不确定性将增加不同交互模式及故障模式的数量,因此在设计并发程序时必须对这些模式进行分析。
同样,在测试开发程序时,将会使用并扩展很多在测试串行程序时用到的方法。在测试串行程序正确性与性能方面所采用的技术,同样可以用于测试并发程序,但对于并发程序而言,可能出错的地方要远比串行程序多。要在测试中将这些故障暴露出来,就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间。
并发测试大致分为两类,即安全性测试与活跃性测试。在第一章,我们将安全性定义为“不发生任何错误的行为”,而降活跃性定义为“某个良好的行为终究会发生”。
在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。例如,假设有一个链表,在它每次被修改时将其大小缓存下来,那其中一项安全性测试就是比较在缓存中保存的大小值与链表中实际元素的数目是否相等。这种测试在单线程程序中很简单,因为在测试时链表的内容不会发生变化。但在并发程序中,这种测试将可能由于竞争而失败,除非能将访问计数器的操作和统计元素数据的操作合并为单个原子操作。要实现这一点,可以对链表加锁以实现独占访问,然后采用链表中提供的某种“原子快照”功能,或者在某些“测试点”上采用原子方式来判断不变性条件或者执行测试代码。
在本书中,我们曾通过执行时序图来说明“错误的”交互操作,这些操作将在未被正确构造的类中导致各种故障,而测试程序将努力在足够大的状态空间中查找这些地方。然而,测试代码同样会对执行时序或同步操作带来影响,这些影响可能会掩盖一些本可以暴露的错误。
测试活跃性本身也存在问题。活跃性测试包括进展测试和无进展测试两个方面,这些都是很难量化的——如何验证某个方法是被阻塞了。而不是运行缓慢?同样,如何测试某个算法不会发生死锁?要等待多久才能宣告发生了故障?
与活跃性测试相关的是性能测试。性能可以通过多个方面来衡量,包括:
- 吞吐量。指一组并发任务中已经成任务所占的比例。
- 响应性。指请求从发出到完成之间的时间,也称延迟。
- 可伸缩性。指在增加更多资源的情况下(如 CPU),吞吐量(或者缓解短缺)的提升情况。
12.1 正确性测试
在为某个并发类设计单元测试时,首先需要执行与测试串行代码类时相同的分析——找出需要检查的不变性条件和后验条件,而在剩下的时间里,当编写测试时将不断发现新的规范。
为了进一步说明,接下来我们将构建一组测试用例来测试一个有界缓存。程序清单 12-1 给出了 BoundedBuffer 的实现,其中使用 Semaphore 来实现缓存的有界属性和阻塞行文。
@ThreadSafe public class BoundedBuffer<E> {
private final Semaphore availableItems, availableSpaces;
@GuardedBy("this")
private final E[] items;
@GuardedBy("this")
private int putPosition = 0, takePosition = 0;
public BoundedBuffer(int capacity) {
availableItems = new Semaphore(0);
availableSpaces = new Semaphore(capacity);
items = (E[]) new Object[capacity];
}
public boolean isEmpty() {
return availableItems.availablePermits() == 0;
}
public boolean isFull() {
return availableSpaces.availablePermits() == 0;
}
public void put(E x) throws InterruptedException {
availableSpaces.acquire();
doInsert(x);
availableItems.release();
}
public E take() throws InterruptedException {
availableItems.acquire();
E item = doExtract();
availableSpaces.release();
return item;
}
private synchronized void doInsert(E x) {
int i = putPosition;
items[i] = x;
putPosition = (++i == items.length)? 0 : i;
}
private synchronized E doExtract() {
int i = takePosition;
E x = items[i];
items[i] = null;
takePosition = (++i == items.length)? 0 : i;
return x;
}
}
BoundedBuffer 实现了一个固定长度的队列,其中定义了可阻塞的 put 和 take 方法,并通过两个计数信号量进行控制。信号量 availableItems 表示可以从缓存中删除的元素个数,它的初始值为 0(因为缓存的初始状态为空)。同样,信号量 availableSpaces 表示可以插入到缓存的元素个数,它的初始值等于缓存的大小。
take 操作首先请求从 availableItems 中获得一个许可(Permit)。如果缓存不为空,那么这个请求会立即成功,否则请求将被阻塞直到缓存不再为空。在获得一个许可后,take 方法将删除缓存中的下一个元素,并返回一个许可到 availableSpaces 信号量。put 方法的执行属顺序则刚好相反,因此无论是从 put 方法还是从 take 方法中退出,这两个信号量计数值的和都会等于缓存的大小。(在实际情况中,如果需要一个有界缓存,应该直接使用 ArrayBlockingQueue 或 LinkedBlockingQueue,而不是自己编写,但这里用于说明如何对添加和删除等方法进行控制的技术,在其他数据结构中也同样适用。)
12.1.1 基本的单元测试
BoundedBuffer 的最基本单元测试类似于在串行上下文中执行的测试。首先创建一个有界缓存,然后调用它的各个方法,并验证它的后验条件和不变性条件。我们很快会想到一些不变性条件:新建立的缓存应该是空的,而不是满的。另一个略显复杂的安全测试是,将 N 个元素插入到容量为 N 的缓存中(整个过程应该可以成功且不会阻塞),然后测试缓存是否已经填满(不为空)。程序清单 12-2 给出了这些属性的 JUnit 测试方法。
class BoundedBufferTest extends TestCase {
void testIsEmptyWhenConstructed() {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
assertTrue(bb.isEmpty());
assertFalse(bb.isFull());
}
void testIsFullAfterPuts() throws InterruptedException {
BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
for (int i = 0; i < 10; i++)
bb.put(i);
assertTrue(bb.isFull());
assertFalse(bb.isEmpty());
}
}
这些简单的测试方法都是串行的。在测试集中包含一组串行测试通常是有益的,因为他们有助于在开始分析竞争之前就找出与并发性无关的问题。
12.1.2 测试阻塞操作
在测试并发的基本属性时,需要引入多线程。大多数测试框架并不能很好的支持并发性测试:它们很少会包含相应的工具来创建线程或监视线程,以确保它们不会意外结束。如果在某个测试用例创建的辅助线程中发现了一个错误,那么框架通常无法得知与这个线程相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递会主测试线程,从而能够将对应的信息报告出来。
在 JUC 的一致性测试中,一定要将故障与特定的测试明确的关联起来。因此 JSR 166 转件组创建了一个基类,其中定义了一些方法可以将 tearDown 期间传递或报告失败信息,并遵循一个约定:每个测试必须等待它所创建的全部线程结束后才能完成。你不需要考虑这么深入,关键的需求在于,能否通过这些测试,以及是否在某个地方报告了失败信息以便于诊断问题。
如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。
在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功的阻塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式是使用中断——在一个单独的线程中启动一个阻塞操作,等到线程阻塞后再中断它,然后宣告阻塞操作成功。当然,这要求阻塞方法通过提前返回或者抛出中断异常来响应中断。
“等待并直到线程阻塞后”这句话说起来简单,做起来难。实际上,你必须估计执行这些指令可能需要多长时间,并且等待的时间会更长。如果估计的时间不准确(在这种情况下,你会看到伪测试失败),那么应该增大这个值。
程序清单 12-3 给出了一种测试阻塞操作的方法。这种方法会创建一个“获取线程”,该线程将尝试从缓存中获取一个元素。如果 take 方法成功,那么表示测试失败。执行测试的线程启动“获取”线程,等待一段时间,然后中断该线程。如果“获取”线程正确的在 take 方法中阻塞,那么将抛出中断异常,而捕获到该异常的 catch 块将把这个异常视为测试成功,并让线程退出。然后,主测线程会尝试与“获取”线程合并,通过调用 Thread.isAlive 来验证 join 方法是否返回成功,如果“获取”线程可以响应中断,那么 join 能很快完成。
void testTakeBlocksWhenEmpty() {
final BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
Thread taker = new Thread() {
public void run() {
try {
int unused = bb.take();
fail(); // if we get here, it's an error
} catch (InterruptedException success) { }
}
};
try {
taker.start();
Thread.sleep(LOCKUP_DETECT_TIMEOUT);
taker.interrupt();
taker.join(LOCKUP_DETECT_TIMEOUT);
assertFalse(taker.isAlive());
} catch (Exception unexpected) {
fail();
}
}
如果 take 操作由于某种意料之外的原因停滞了,那么支持限时的 join 方法能确保测试最终完成。该测试验证了 take 方法的多种属性——不仅能阻塞,而且在中断后还能抛出中断异常。在这种情况下,最好是对 Thread 进行子类化而不是使用线程池中的 Runnable,即通过 join 来正确的结束测试。当主线程将一个元素放入队列后,“获取”线程应该解除阻塞状态,要测试这种行为,可以使用相同的方法。
开发人员会尝试使用 Thread.getState 来验证线程能否在一个条件等待上阻塞,但这种方法并不可靠。被阻塞线程并不需要进入 WAITING 或 TIMED_WAITING 等状态,因此 JVM 可以选择通过自旋锁等待来实现阻塞。类似的,由于在 Object.wait 或 Condition.await 等方法上存在伪唤醒,因此,即使一个线程等待的条件尚未成真,也可能从 WAITING 或 TIMED_WAITING 等待状态临时性的转换到 RUNNABLE 状态。即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。Thread.getState 的返回结果不能用于并发控制,它将限制测试的有效性——其主要的作用要是作为调试信息。
12.1.3 安全性测试
程序清单 12-2、12-3 的测试用例验证了有界缓存的一些重要属性,但它们却无法发现由于数据竞争而引发的错误。要想测试一个并发类在不可预测的并发访问情况下能否正确执行,需要创建多个线程来分别执行 put 和 take 操作,并在执行一段时间后判断在测试中是否会出现问题。
如果要构造一些测试来发现并发类中的安全性错误,那么这实际上是一个“先有蛋还是先有鸡”的问题:测试程序自身就是并发程序。要开发一个良好的并发测试程序,或许比开发这些要被测试的类更加困难。
在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码认为的限制并发性。理想的情况是,在测试属性中不需要任何同步机制。
要测试在生产消费模式中使用的类,一种有效的方法就是检查被放入队列中的、和从队列中取出的各个元素。这种方法的一种简单实现是,当元素被插入到队列时,同时将其插入到一个“影子”列表,当从队列中删除该元素时,同时也从“影子”列表中删除,然后在测试程序运行完成以后判断“影子”列表是否为空。然而,这种方法可能会干扰测试线程的调度,因为在修改“影子”列表时需要同步,并可能会阻塞。
一种更好的方法是,通过一个对顺序敏感的校验和计算函数来计算所有入列元素以及出列元素的校验和,并进行比较。如果二者相等,那么测试就是成功的。如果只有一个生产者将元素放入缓存,同时也只有一个消费者从中取出元素,那么这种方法能发挥最大的作用,因为它不仅能测试出是否取出了正确的元素,而且还能测试出元素被取出的顺序是否正确。
如果要将这种方法扩展到多生产者——多消费者的情况,就需要一个对元素的入列、出列顺序不敏感的校验和函数,从而在测试程序运行完成以后,可以将多个校验和以不同的顺序组合起来。如果不是这样,多个线程就需要访问同一个共享的校验和变量,因此就需要同步,这将成为一个并发瓶颈或者破坏被测代码的执行时序。(任何具备可交换性的操作,例如加法或 XOR,都符合这些需求)。
要确保测试程序正确的测试所有要点,就一定不能让编译器可以预先猜到校验和的值。使用连续的整数作为测试数据并不是一个好办法,因为得到的结果是相同的,而一个智能的编译器通常可以预先计算出这个结果。
要避免这种问题,应该采用随机方式生成的测试数据,但如果选择了一种不合适的随机数生成器,那么会对许多其他的测试造成影响。由于大多数随机数生成器都是线程安全的,并且会代码额外的同步开销,因此在随机数生成过程中,可能会在这些类与执行时序之间产生耦合关系。如果每个线程都拥有各自的生成器,那么生成器就无需在意线程安全性。
与其使用一个通用的随机数生成器,还不如使用一些简单的伪随机函数。你并不需要某种高质量的随机性,而只需要确保在不同的测试运行中都有不同的数字。在程序清单 12-4 的 xorShift 函数是最符合这个需求的随机函数之一。该函数基于 hashCode 和 nanoTime 来生成随机数,所得的结果是不可预测的,而且基本上每次运行都会不同。
static int xorShift(int y) {
y ^= (y << 6);
y ^= (y >>> 21);
y ^= (y << 7);
return y;
}
在程序清单 12-5 和程序清单 12-6 的 PutTakeTest 中启动了 N 个生产者线程来生成元素并把它们插入到队列,同时还启动了 N 个消费者线程从队列中取出元素。当元素进出队列时,每个线程都会更新对这些元素计算得到的校验和,每个线程都拥有一个校验和,并在测试结束后将它们合并,从而在测试缓存时就不会引入过多的同步或竞争。
public class PutTakeTest {
private static final ExecutorService pool = Executors.newCachedThreadPool();
private final AtomicInteger putSum = new AtomicInteger(0);
private final AtomicInteger takeSum = new AtomicInteger(0);
private final CyclicBarrier barrier;
private final BoundedBuffer<Integer> bb;
private final int nTrials, nPairs;
public static void main(String[] args) {
new PutTakeTest(10, 10, 100000).test(); // sample parameters
pool.shutdown();
}
PutTakeTest(int capacity, int npairs, int ntrials) {
this.bb = new BoundedBuffer<Integer>(capacity);
this.nTrials = ntrials; this.nPairs = npairs;
this.barrier = new CyclicBarrier(npairs* 2 + 1);
}
void test() {
try {
for (int i = 0; i < nPairs; i++) {
pool.execute(new Producer());
pool.execute(new Consumer());
}
barrier.await(); // wait for all threads to be ready
barrier.await(); // wait for all threads to finish
assertEquals(putSum.get(), takeSum.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
class Producer implements Runnable { /* Listing 12.6*/ }
class Consumer implements Runnable { /* Listing 12.6 */ }
}
/* inner classes of PutTakeTest (Listing 12.5) */
class Producer implements Runnable {
public void run() {
try {
int seed = (this.hashCode() ^ (int)System.nanoTime());
int sum = 0;
barrier.await();
for (int i = nTrials; i > 0; --i) {
bb.put(seed);
sum += seed;
seed = xorShift(seed);
}
putSum.getAndAdd(sum);
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
class Consumer implements Runnable {
public void run() {
try {
barrier.await();
int sum = 0;
for (int i = nTrials; i > 0; --i) {
sum += bb.take();
}
takeSum.getAndAdd(sum);
barrier.await();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
根据系统平台的不同,创建线程与启动线程等操作可能需要较大的开销。如果线程的执行时间很短,并且在循环中启动了大量的这种线程,那么最坏的情况就是,这些线程将会串行执行而不是并发执行。即使在一些不太糟糕的情况下,第一个线程仍然比其他线程具有“领先优势”。因此这可能无法获得预想预想中的交替执行:第一个线程先运行一段时间,然后前两个线程会并发的执行一段时间,只有到了最后,所有线程才会一起并发执行。(在线程结束运行时也存在同样的问题:首先启动的线程将提前完成)。
在 5.5.1 中接收了一项可以缓解该问题的技术,即使用两个 CountDownLatch,一个作为开始阀门,一个作为结束阀门。使用 CyclicBarrier 也可以获得同样的效果:在初始化 CyclicBarrier 时将计数值指定为工作者线程的数量再加 1,并在运行开始和结束时,使工作者线程和测试线程都在这个栅栏处等待。这能确保所有线程在开始执行任何工作之前,都首先执行到同一个位置。PutTakeTest 使用这项技术来协调工作者现货出呢个的启动和停止,从而产生更多的并发交替操作。我们仍然无法确保调度器不会采用串行方式来执行每个线程,但只要这些线程的执行时间足够长,就能降低调度机制对结果的不利影响。
PutTakeTest 使用了一个确定性的结束条件,从而在判断测试何时完成时就不需要在线程之间执行额外的协调。test 方法将启动相同数量的生产者和消费者线程,它们将分别插入和取出相同数量的元素,因此添加和删除的总数相同。
想 PutTakeTest 这种测试能很好的发现安全性问题。例如,在实现由限号量控制的缓存时,一个常见的错误就是在执行插入和取出的代码中忘记实现互斥行为(可以使用 synchronized 或 ReentrantLock)。如果在 PutTakeTest 使用的 BoundedBuffer 中忘记将 doInsert 和 doExtract 声明为 synchronized,那么在运行 PutTakeTest 时就会立即失败。通过多个线程来运行 PutTakeTest,并且使这些线程在不同系统上的不同容量的缓存上迭代数百万次,是我们能进一步确定在 put 和 take 方法中不存在数据破坏问题。
这些测试应该放在多处理器系统上运行,从而进一步测试更多形式的交替运行。然而,CPU 的数量越多并不一定会使测试更加高效。要最大程度的检测出一些对执行时序敏感的数据竞争,那么测试中的线程数量应该多于 CPU 数量,这样在任意时刻都会有一些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可测试性。
有一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在测试代码中出现了一个错误并抛出了一个异常,那么这个测试将永远不会停止。最常见的解决方法是:让测试框架放弃那个没有在规定时间内完成的测试,具体要等待多长时间,则要凭借经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够的时长而造成的。(这个问题并不仅限于对并发类的测试,在串行测试中也必须区分长时间的运行和死循环)。
12.1.4 资源管理测试
到目前为止,所有的测试都侧重于类与其设计规范的一致程度——在类中应该实现规范中定义的功能。测试的另一个方面就是要判断类中是否没有做它应该做的事情,例如资源泄露。对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄露不仅会妨碍垃圾回收期回收内存(或者线程、文件句柄、套接字、数据库连接或其他有限资源),而且还会导致资源耗尽甚至应用程序失败。
对于像 BoundedBuffer 这样的类来说,资源管理的问题尤为重要。之所以有限制缓存的带下,其原因就是要防止由于资源耗尽而导致应用程序发生故障,例如生产者的速度远远高于消费者的处理速度。通过对缓存进行限制,将使得生产力过剩的生产者被阻塞,因为它们就不会继续创建更多的工作来消耗越来越多的内存以及其他资源。
通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易的测试出对内存的不合理占用,许多商业和开源的堆分析工具都支持这种功能。在程序清单 12-7 的 testLeak 方法中包含了一些堆分析工具用于抓取堆的快照,这将强制执行一次垃圾回收,然后记录堆大小和内存用量的信息。
class Big {
double[] data = new double[100000];
}
void testLeak() throws InterruptedException {
BoundedBuffer<Big> bb = new BoundedBuffer<Big>(CAPACITY);
int heapSize1 = /* snapshot heap */ ;
for (int i = 0; i < CAPACITY; i++)
bb.put(new Big());
for (int i = 0; i < CAPACITY; i++)
bb.take();
int heapSize2 = /* snapshot heap */ ;
assertTrue(Math.abs(heapSize1-heapSize2) < THRESHOLD);
}
testLeak 方法将多个大型对象插入到一个有界缓存中,然后再将他们移除。第二个堆快照中的内存用量应该与第一个堆快照中的内存用量基本相同。然而,doExtract 如果忘记将返回元素的引用置为空(intems[i]=null
),那么在两次快中中报告的内存用量将明显不同。(这是为数不多的集中需要显式的将变量置空的情况之一。在大多数情况下,这种做法不仅不会带来帮助,甚至还会带来负面作用)。
12.1.5 使用回调
在构造测试案例时,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。例如,在 ThreadPoolExecutor 中将调用任务的 Runnable 和 ThreadFactory。
在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时不创建,以及当需要回收空闲线程时执行回收操作等。要构造一个全面的测试方案是很困难的,但其中许多方面的测试都可以单独进行。
通过使用自定义的线程工厂,可以对线程的创建过程进行控制。在程序清单 12-8 的 TestingThreadFactory 中将记录已创建的线程数量。这样,在测试过程中,测试方案可以验证已创建线程的数量。我们还可以对 TestingThreadFactory 进行扩展,使其返回一个自定义的 Thread,并且该对象可以记录自己在何时结束,从而在测试方案中验证线程在被回收时是否与执行策略一致。
class TestingThreadFactory implements ThreadFactory {
public final AtomicInteger numCreated = new AtomicInteger();
private final ThreadFactory factory =
Executors.defaultThreadFactory();
public Thread newThread(Runnable r) {
numCreated.incrementAndGet();
return factory.newThread(r);
}
}
如果线程池的基本大小小于最大值,那么线程池会根据执行需求做对应的增长。当把一些运行时间较长的任务提交给线程池时,线程池中的任务数量在长时间内都不会变化,这就可以进行一些判断,例如测试线程池是否能按照预期的方式扩展,如程序清单 12-9 所示。
public void testPoolExpansion() throws InterruptedException {
int MAX_SIZE = 10;
ExecutorService exec = Executors.newFixedThreadPool(MAX_SIZE);
for (int i = 0; i < 10* MAX_SIZE; i++)
exec.execute(new Runnable() {
public void run() {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
for (int i = 0;i < 20 && threadFactory.numCreated.get() < MAX_SIZE;i++)
Thread.sleep(100);
assertEquals(threadFactory.numCreated.get(), MAX_SIZE);
exec.shutdownNow();
}
12.1.6 产生更多的交替操作
由于并发代码中的大多数错误都是一些低概率事件,因此在测试并发错误时需要反复的执行多次,但有些方法可以提高发现这些错误的概率。在前面提高过,在多处理器系统上,如果处理器数量少于活动线程的数量,那么与单处理器系统或者包含多处理器的系统相比,将能产生更多的交替行为。同样,如果在不同的处理器数量、操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定运行环境中才会出现的问题。
有一种有用的方法可以提高交替操作的数量,以便能够更有效的探索程序的状态空间:在访问共享状态的操作中,使用 Thread.yield 将产生更多的上下文切换。(该方法的有效性与具体的平台有关,因为 JVM 可以将 Thread.yield 实现为一个空操作。如果使用一个睡眠时间较短的 sleep,那么虽然更慢,但却更可靠)。程序清单 12-10 中的方法在两个账户之间执行转账操作,在两次更新操作之间,像“所有账户的总和应等于零”这样的一些不变性条件可能会被破坏。当代码在访问状态时没有使用足够的同步,将存在一些对执行时序敏感的错误,通过在某个操作的执行过程中调用 yield 方法,可以将这些错误暴露出来。这种方法需要在测试中添加一些调用并且在正式产品中删除这些调用,这将给开发人员带来不便,通过使用面向切面编程(AOP)工具,可以降低这种不便性。
public synchronized void transferCredits(Account from, Account to, int amount) {
from.setBalance(from.getBalance() - amount);
if (random.nextInt(1000) > THRESHOLD)
Thread.yield();
to.setBalance(to.getBalance() + amount);
}
12.2 性能测试
性能测试通常是功能测试的延伸。事实上,在性能测试中应该包含一些基本的功能测试,从而确保不会对错误代码进行性能测试。
虽然在性能测试与功能测试之间肯定会存在重叠之处,但它们的目标是不同的。性能测试将衡量典型测试用例中的端到端性能。通常,要活的一组合理的使用场景并不容易,理想情况下,在测试中应该反映出被测试对象在应用程序中的实际用法。
在某些情况下,也存在某种显而易见的测试场景。在生产者-消费者设计中通常都会用到有界缓存,因此显然需要测试生产者向消费者提供数据时的吞吐量。对 PutTakeTest 进行扩展,使其成对针对该应用场景的性能测试。
性能测试的第二个目标是根据经验值来调整各个不同的限值,比如线程数量、缓存容量等。这些限值可能依赖具体平台的特性(如处理器类型、处理器的步进级别、CPU 数量、内存大小等),因此需要进行动态的配置,而我们通常需要合理的选择这些值,从而使程序能够在更多的系统上良好的运行。
12.2.1 在 PutTakeTest 中增加计时功能
之前对 PutTakeTest 的主要扩展计时测量运行一次需要的时间。现在,我们不测量单个操作的时间,而是实现一种更精确的测量方式:记录整个运行过程的时间,然后除以总操作的次数,从而得到每次操作的运行时间。之前使用了 CyclicBarrier 来启动和结束工作者线程,因此可以对其进行扩展:使用一个栅栏动作来测量启动和技术时间,如程序清单 12-11 所示。
this.timer = new BarrierTimer();
this.barrier = new CyclicBarrier(npairs * 2 + 1, timer);
public class BarrierTimer implements Runnable {
private boolean started; private long startTime, endTime;
public synchronized void run() {
long t = System.nanoTime();
if (!started) {
started = true;
startTime = t;
}
else endTime = t;
}
public synchronized void clear() {
started = false;
}
public synchronized long getTime() {
return endTime - startTime;
}
}
我们可以将栅栏的初始化过程修改为使用这种栅栏动作,即使用能接收栅栏动作的 CyclicBarrier 构造函数。
在修改后的 test 方法中使用了基于栅栏的计时器,如程序清单 12-12 所示。
public void test() {
try {
timer.clear();
for (int i = 0; i < nPairs; i++) {
pool.execute(new Producer());
pool.execute(new Consumer());
}
barrier.await();
barrier.await();
long nsPerItem = timer.getTime() / (nPairs* (long)nTrials);
System.out.print("Throughput: " + nsPerItem + " ns/item");
assertEquals(putSum.get(), takeSum.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
我们可以从 TimedPutTakeTest 的运行中学到一些东西。第一,生产者-消费者模式在不同参数组合下的吞吐率。第二,有界缓存在不同线程数量下的可伸缩性。第三,如果选择缓存的大小。要回答这些问题,需要对不同的参数组合以进行测试,因此我们需要一个主测试程序,如程序清单 12-13 所示。
public static void main(String[] args) throws Exception {
int tpt = 100000; // trials per thread
for (int cap = 1; cap <= 1000; cap*= 10) {
System.out.println("Capacity: " + cap);
for (int pairs = 1; pairs <= 128; pairs*= 2) {
TimedPutTakeTest t =
new TimedPutTakeTest(cap, pairs, tpt);
System.out.print("Pairs: " + pairs + "\t");
t.test();
System.out.print("\t");
Thread.sleep(1000);
t.test();
System.out.println();
Thread.sleep(1000);
}
}
pool.shutdown();
}
图 12-1 给出在 4 路机器上的一些测试结果,缓存容量分别为 1、10、100、1000。我们可以看到,当缓存大小为 1 时,吞吐率非常糟糕,这是因为每个线程在阻塞并等待另一个线程之前,所取得的进展是非常有限的。当把缓存大小提高到 10 时,吞吐率得到了几大提高:但在超过 10 之后,所得到的收益又开始降低。
初看起来可能会感到困惑:当增加更多的贤臣时,性能却略有下降。其中的原因很难中数据中看出来,但可以在运行测试时使用 CPU 性能工具:虽然有许多的线程,但却没有足够的计算量,并且大部分时间都消耗在线程的阻塞和解除阻塞等操作上。线程有足够多的 CPU 空闲时钟周期来做相同的事情,因此不会过多的降低性能。
然而,要谨慎对待从上面的数据中得出的结论,即在使用有界缓存的生产消费程序中总是可以添加更多的线程。这个测试在模拟应该程序时忽略了许多实际的因素,例如生产者几乎不需要任何工作就可以生成一个元素并将其放入队列,如果工作者线程需要通过执行一些复杂的操作来生产和获取各个元素条目(通常就是这种情况),那么之前那种 CPU 空闲状态将消失,并且由于线程过多而导致的影响将变得非常明显。这个测试的主要目的是,测量生产者和消费者在通过有界缓存传递数据时,哪些约束条件将对整体吞吐量产生影响。
12.2.2 多种算法的比较
虽然 BoundedBuffer 是一种非常合理的实现,并且他的性能还不错,但还是没有 ArrayBlockingQueue 或 LinkedBlockingQueue 那样好(这也解释了为什么这种缓存算法没有被选入标准库)。JUC 中的算法已经通过类似的测试进行了调优,其性能也已经达到了我们已知的最佳状态。此外,这些算法还能提供更多的功能。BoundedBuffer 运行效率不高的主要原因是:在 put 和 get 方法中都包含多个可能发生竞争的操作,比如获取一个信号量、获取一个锁、释放信号量等。而在其他实现中,可能发生竞争的位置将少很多。
图 12-2 给出了一个在双核超线程机器上对这三个类的吞吐量的测试结果,在测试中使用了一个包含 256 个元素的缓存,以及相应版本的 TimedPutTakeTest。测试结果表明,LinkedBlockingQueue 的可伸缩性要高于 ArrayBlockingQueue。初看起来,这个结果有些奇怪:链表队列在每次插入元素时,都必须分配一个链表节点对象,这似乎比基于数组的队列执行了更多的工作。然而,虽然它拥有更好的内存分配和 GC 等开销,但与基于数组的队列相比,链表队列的 put 和 take 等方法支持并发性更高的访问,因为一些优化后的链表队列算法能够将队列头结点的更新操作与尾节点的更新操作分离开来。由于内存分配操作通常是线程本地的,因此如果算法能够通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常具有更高的可伸缩性。(这种情况再次证明了,基于传统性能调优的直觉与提升可伸缩性的实际需求是背道而驰的)。
响应性衡量
到目前为止,我们的重点是对吞吐量的测试,这通常是并发程序中最重要的性能指标。但有时候,我们还需要知道某个动作经过长时间才能执行完成,这时就好测量服务时间的变化情况。而且,如果能获得更小的服务时间变动性,那么更长的平均服务时间是有意义的,“可预测性”同样是一个非常有价值的性能特征。通过测量变动性,使我们能回答一些关于服务质量的问题,如“操作在 100 毫秒内成功执行的百分比是多少?”
通过表示任务完成时间的直方图,最能看出服务时间的变动。服务时间变动的测量比平均值的测量要略困难一些——除了总共完成时间外,还要记录每个任务的完成时间。因为计数器的粒度通常是测量任务时间的一个主要因素(任务的执行时间可能小于或接近于最小“定时器计时单位”,这将影响测量结果的精确性),为了避免测量过程中的认为影响,我们可以测量一组 put 和 take 方法的运行时间。
图 12-3 给出在不同 TimedPutTakeTest 中每个任务的完成时间,其中使用了一个大小为 1000 的缓存,有 256 个并发任务,并且每个任务都将使用非公平的信号量(隐蔽栅栏,Shaded Bars)和公平信号量(开发栅栏,Open Bars)来迭代这 1000 个元素。非公平信号量完成时间的变动范围为 104 到 8714 毫秒,相差超过 80 倍。通过在同步控制中实现为更高的公平性,可以缩小这种变动范围,通过在 BoundedBuffer 中将信号量初始化为公平模式,可以很容易实现这个功能。如图 12-3 所示,这种方法能成功的降低变动性(现在的变动范围为 38194 到 38207 毫秒),然而,该方法会极大的降低吞吐量。(如果在运行时间较长的测试中执行更多种任务,那么吞吐量的下降程度可能更大。)
前面讨论过,如果缓存过小,那么将导致非常多的上下文切换次数,这即使是在非公平模式中也会导致很低的吞吐量,因此在几乎每个操作上都会执行上下文切换。为了说明非公平性开销主要是由于线程阻塞而造成的,我们可以将缓存大小设置为 1,然后重新运行这个测试,从而可以看出此时非公平信号量与公平信号量的执行性能基本相当。如图 12-4 所示,这种情况下公平性并不会使平均完成时间变长,或者使变动性变小。
因此,除非线程由于密集的同步需求而被持续的阻塞,否则非公平的信号量通常能实现更好的吞吐量,而公平的信号量则实现更低的变动性。因为这些结果之间的差异非常大,所以 Semaphore 要求客户选择针对哪一个特性进行优化。
12.3 避免性能测试的陷阱
理论上,编写性能测试程序是很容易的——找出一个典型应用场景,比那些一段程序并多次执行这种场景,同时统计程序的运行时间。但在实际情况中,你必须提防多种编码陷阱,它们会使性能测试变得毫无意义。
12.3.1 垃圾回收
垃圾回收的执行时机是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。如果测试程序执行了 N 次迭代都没有触发垃圾回收操作,但在第 N+1 次迭代时触发了垃圾回收操作,那么即使运行次数相差不大,仍可能在最终测试的每次迭代时间上带来很大的(却是虚假的)影响。
有两种策略可以防止垃圾回收操作对测试结果产生偏差。第一种策略是,确保垃圾回收操作在测试运行的整个期间都不会执行(可以在启动 JVM 时指定 -vervose:gc
来判断是否执行了垃圾回收操作)。第二种策略是,确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。通常第二种策略更好,它要求更长的测试时间,斌跟那个更有可能反映实际环境下的性能。
在大多数采用生产者消费者设计的应用程序中,都会执行一定数量的内存分配与垃圾回收等操作——生产者分配新对象,然后被消费者使用并丢弃。如果将有界缓存测试运行足够长的时间,那么将引发多次垃圾回收,从而得到更精确的结果。
12.3.2 动态编译
与静态编译语言(如 C/C++)相比,编写动态编译语言(如 Java)的性能基准测试要困难的多。在 HotSpot JVM (以及其他现代 JVM)中将字节码的解释与动态编辑结合起来使用。当某个类第一次没加载时,JVM 会通过解释字节码的方式来执行它。在某个时刻,如果一个方法被运行的次数足够多,那么动态编译器会将它编译为机器代码,当编译完成之后,代码的执行方式将从即使执行变成直接执行。
这种编译的执行时机是无法预测的。只有在所有代码都编译完成以后,才应该统计测试的运行时间。测量采用解释执行的速度是没有意义的,因为大多数程序在运行足够长时间后,所有频繁执行的代码路径都会被编译。如果编译器可以在测试期间运行,那么将在两个方面对测试结果带来偏差:在编译过程中将消耗 CPU 资源,并且,如果在测量的代码中包含解释执行的代码,又包含编译执行的代码,那么通过测试这种混合代码得到的性能指标没有太大意义。图 12-5 给出了动态编译在测试结果上带来的偏差。这 3 条时间线表示执行了相同次数的迭代:时间线 A 表示所有代码都采用解释执行,时间线 B 表示在运行过程中间开始转向编译执行,而时间线 C 表示从较早时刻就尅是采用编译执行。编译执行的开始时刻会对每次操作的运行时间产生极大的影响。
基于各种原因,代码还可能被反编译(退回到解释执行)以及重新编译,比如加载了一个会使编译假设无效的类,或者在收集了足够的分析信息后,决定采用不同的优化措施来重新编译某条代码路径。
有种方式可以放置动态编译对测试结果产生偏差,即使使程序运行足够长的时间(至少数分钟),这样编译过程以及解释执行都只是总运行时间的很小一部分。另一种方法是使代码预先运行一段时间并且不测试这段时间内的代码性能,这样在开始计时前代码就已经被完全编译了。在 HotSpot 中,如果在运行时使用命令行选项 -xx:+PrintCompilation
,那么当动态编译运行时将输出一条信息,你可以通过这条信息来验证动态编译是在测试运行前将机执行,而不是在运行过程中执行。
通过在同一个 JVM 中将相同的测试运行多次,可以验证测试方法的有效性。第一组结果应该作为“预先执行”的结果而丢弃,如果在剩下的结果中仍然存在不一致的地方,那么就需要进一步对测试进行分析,从而找出结果不可重复的原因。
JVM 会使用不同的后台线程来执行辅助任务。当在单次运行中测试多个不相关的计算密集型操作时,一种好的做法是在不同操作的测试之间插入显式的暂停,从而使 JVM 能够与后台任务保持步调一致,同时将被测试任务的干扰将至最低。(然而,当测量多个相关操作时,例如将相同测试运行多次,如果按照这种方式来排除 JVM 后台任务,那么可能会得出不真实的结果)。
12.3.3 对代码路径不真实采样
运行时编译器根据收集到的信息对已编译的代码进行优化。JVM 可以与执行过程特定的信息来生成更优的代码,这意味着在编译某个程序的方法 M 时生成的代码,将可能与编译另一个不同程序中的方法 M 时生成的代码不同。在某些情况下,JVM 可能会基于一些只是临时有效的假设进行优化,并在这些假设失效时丢弃已编译的代码。
因此,测试程序不仅要大致判断某个典型应用程序的使用模式,还需要尽量覆盖在该应用程序中将要指定的代码路劲的集合。否则,动态编译器可能会针对一个单线程测试程序进行一些专门优化,但只要在真实的应用程序中略微包含一些并行,都是使这些优化不复存在。因此,即使你只是想测试单线程的性能,也应该将单线程的性能与多线程的性能测试结合在一起。(在 TimedPutTakeTest 中不会出现这个问题,因为即使在最小的测试用例中都使用了两个线程。)
12.3.4 不真实的竞争程度
并发的应用程序可以交替执行两种不同类型的工作:访问共享数据(例如从共享工作对垒中取出下一个任务)以及执行线程本地的计算(如,执行任务,并假设任务本身不会访问共享数据)。根据两种不同类型工作的相关程度,在应用程序中出现不同程度的竞争,并发现出不同的性能与可伸缩性。
如果有 N 个线程从共享对垒中获取任务并执行,并且这些任务都是计算密集型的且运行时间较长(但不会频繁的访问共享数据),那么在这种情况下几乎不存在竞争,吞吐量仅受限于 CPU 资源的可用性。然而,如果任务的生命周期很短,俺么在工作队列上将会存在验证的竞争,此时的吞吐量将受限于同步的开销。
要获得有实际意义的结果,在并发测试中应该尽量模拟典型应用程序中的线程本地计算量以及并发协调开销。如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不准确的。在 11.5 节看到过,对于基于锁的类,比如同步 Map 实现,在访问锁时是否存在高度的竞争将会对吞吐量产生巨大的影响。本节的测试除了不断访问 Map 之外没有执行其他操作,因此,虽然又两个线程,但在所有对 Map 的访问操作中都存在竞争。然而,如果应用程序在每次访问共享数据结构时执行大量的线程本地计算,那么可以极大的降低竞争程度并提供更好的性能。
从这个角度来看,TimedPutTakeTest 对于某些应用程序来说不是一种好模式。由于工作者线程没有执行太多的工作,因此吞吐量将主要受限于线程之间的协调开销,并且对所有通过有界缓存的生产者和消费者之间交换数据的应用程序来说,并不都是这种情况。
12.3.5 无用代码的消除
在编写优秀的基准测试程序时,一个需要面对的挑战是:优化编译器能找出并消除那些不会对输出结果产生任何影响的无用代码。由于基准测试通常不会执行任何计算,因此它们很容易在编译器的优化过程中被消除。在大多数情况下,编译器从程序中删除无用代码都是一种优化措施,但对于基准测试程序来说却是一个大问题,因为这将使得被测试的内容变得更少。如果幸运的话,编译器将删除整个程序中无用的代码,从而得到一份明显虚假的测试数据。但如果不幸运的话,编译器在消除无用代码后将提高程序的执行速度,从而是你做出错误的结论。
对于静态编译语言中的基准测试,编译器在消除无用代码时也存在问题,但要检测出编译器是否消除了测试基准是很容易的,因为可以通过机器码来发现是否缺失了部分程序。但在动态编译语言中,要获得这种信息则更加困难。
在 HotSpot 中,许多基准测试在 “-server” 模式下都能比在 “-client” 模式下运行的更好,这不仅是因为 “-server” 模式下的编译器能产生更有效的代码,而且这种模式更易于通过优化消除无用代码。然而,对于将执行一定操作的代码来说,无用代码消除优化却不会去掉它们。在多处理器系统上,无论在正式产品还是测试版本中,都应该选择 “-server” 模式而不是 “-client” 模式——只是在测试程序时必须保证它们不会受到无用代码消除带来的影响。
要编写有效的性能测试程序,就需要告诉编译器不要将基准测试当做无用代码优化掉,这就要求在程序中对每个计算结果都要通过某种方式使用,这种方式不需要同步或者大量的计算。
在 PutTakeTest 中,我们计算了在队列中被添加和删除的所有元素的校验和,但如果在程序中没有用到这个校验和,那么计算校验和的操作仍有可能被优化掉。幸好我们需要通过校验和来验证算法的正确性,然而你也可以通过输出这个值来确保它被用到。但是,你需要避免在运行测试时执行 IO 操作,以避免运行时间的测试结果产生偏差。
有一个简单的技巧可以避免运算被优化掉而又不引入过高的开销:即计算某个派生对象中域的散列值,并将它与一个任意值进行比较,比如 System.nanoTime 的当前值,如果二者碰巧相等,那么就输出一个无用且可以被忽略的消息:
if(foo.x.hashCode() == System.nanoTime())
System.out.print(" ");
这个比较操作很少很成功,即使成功了,它的唯一作用就是在输出中插入一个无害的空字符。(在 print 方法中把输出结果缓存起来,并直到调用 println 才真正执行输出操作,因此即使 hashCode 和 System.nanoTime 的返回值碰巧相等,也不会真正的执行 IO 操作)。
不仅每个计算结果都应该被使用,而且还应该是不可预测的。否则,一个智能的动态优化编译器将用预先计算的结果来代替计算过程。虽然在 PutTakeTest 的构造过程中解决了这个问题,但如果测试程序的输入参数为静态数据,那么都会受到这种优化措施的影响。
12.4 其他的测试方法
虽然我们希望一个测试程序能够“找出所有的错误”,但这是一个不切实际的目标。NASA 在测试中投入的资源比任何商业集团投入的都要多,但他们生产的代码仍然是存在缺陷的。在一些复杂的程序中,即使再多的测试也无法找出所有的错误。
测试的目标不是更多的发现错误,而是提高代码能按照预期方式工作的可信度。由于找出所有的错误是不现实的,所以质量保证(QA)目标应该是在给定的测试资源下实现最高的可信度。到目前为止,我们介绍了如何构造有限的单元测试和性能测试。在构建并发类能否表现出正确行为的可信度时,测试是一种非常重要的首选,但并不是唯一可用的 QA 方法。
还有其他一些 QA 方法,他们在找出某些类型的错误时非常有效,而在找出其他类型的错误时则相对低效。通过使用一些补充的测试方法,比如代码审查和静态分析等,可以获得比在使用任何单一方法更多的可信度。
12.4.1 代码审查
正如单元测试和压力测试在查找并发错误时是非常有效和重要的手段,多人参与的代码审查通常是不可替代的。虽然你可以在设计测试方法时使其能最大限度的发现安全性错误,以及反复的运行这些测试,但同样应该需要有代码编写者之外的其他人来仔细的审查并发代码。即使并发专家也有犯错的时候,花一定的时间由其他人来审查代码总是物有所值的。并发专家能够比大多数测试程序更有效的发现一些微秒的竞争问题。(此外,一些平台问题,比如 JVM 的实现细节或处理器的内存模型等,都会屏蔽一些只有在特定硬件或软件配置下才会出现的错误)。代码审查还有其他的好处,它不仅能发现错误,通常还能提高描述实现细节的注释质量,因此可以降低后期维护的成本和风险。
12.4.2 静态分析工具
在编写本书时,一些静态分析工具正在迅速的称为正式测试和代码审查的有效补充。静态代码分析是指在进行分析时不需要运行代码,而代码审查工具可以分类类中是否存在一些常见错误模式。在一些静态分析工具(如 FindBugs)中包含了许多错误模式检查器,能检查出多种常见的编码错误,其中许多错误都很容易在测试或代码审查中遗漏。
静态分析工具能生成一个告警列表,其中包含的警告信息必须通过手工方式进行检查,从而确定这些警告是否表示真正的错误。曾经有一些工具(如 lint)会产生很多伪警告信息,使得开发人员望而却步,但现在的一些工具已经在这方面有所改进,并且产生的伪警告很少。虽然静态分析工具仍然显得有些原始(尤其在它们与开发工具和开发生命周期的集成过程中),但却足以成为对测试过程的一种有效补充。
在编写本书时,FindBugs 包含的检查器中可以发现以下与并发相关的错误模式,而且一直在不断的增加新的检查器:
不一致的同步。许多对象遵循的同步策略是,使用对象的内置锁来保护所有的变量。如果某个域被频繁的访问,但并不是在每次访问时都持有相同的锁,那么这就可能表示没有一致的遵循这个策略。
分析工具必须对同步策略进行猜测,因为在 Java 类中并没有正式的同步规范。将来,如果 @GuardedBy
注解可以被标准化,那么核查工具就能解析这些注解,而无需猜测变量与锁之间的关系,从而提高分析质量。
调用 Thread.run。在 Thread 中实现了 Runnable,因此包含了一个 run 方法。然而,如果直接调用 Thread.run,那么通常是错误的,而应该调用 Thread.start。
未被释放的锁。与内置锁不同的是,执行控制流在退出显式锁的作用域时,通常不会自动释放它们。标准的做法是在一个 finally 块中释放显式锁,否则,当发生 Execution 事件时,锁仍然处于未被释放的状态。
空的同步块。虽然在 Java 内存模型中,空同步块具有一定的语义,但它们总是被不正确的使用,无论开发人员尝试通过空同步块来解决哪种问题,通常都存在一些更好的替代方案。
双重检查锁。双检锁所是一种错误的习惯用法,其初衷是为了降低延迟初始化过程中的同步开销,该用法在读取一个共享的可变域时缺少正确的同步。
在构造函数中启动线程。如果在构造函数中启动线程,那么将可能带来子类化问题,同时还会导致 this 引用从构造函数中溢出。
通知错误。notify 和 notifyAll 方法都表示,某个对象的可变状态可能以某个方式发生了变化,并且这种方式将在相关条件队列上被阻塞的线程恢复执行。只有在与条件队列相关的状态发生改变后,才应该调用这些方法。如果在一个同步块中条用了 notify 或 notifyAll,但没有修改任何状态,那么就可能出错。
条件等待中的错误。当在一个条件队列上等待时,Object.wait 和 Condition.await 方法应该在检查了状态谓词之后,在某个循环中调用,同时需要持有正确的锁。如果在调用 Object.wait 和 Condition.await 方法时没有持有锁,或者不在某个循环中,或者没有检查某些状态谓词,那么通常都是一个错误。
对 Lock 和 Condition 的无用。将 Lock 作为同步块来使用通常是一种错误的用法,正如调用 Condition.wait 而不是调用 await(后者能够通过测试被发现,因此在第一次调用它的将抛出 IllegalMonitorStateException)。
在休眠或等待的同时持有一个锁。如果在调用 Thread.sleep 时持有一个锁,那么将导致其他线程在很长一段时间内无法执行,因此可能导致严重的活跃性问题。如果在调用 Object.wait 或 Condition.await 时持有两个锁,那么也可能导致同样的问题。
自旋循环。如果在代码中除了通过自旋(忙于等待)来检查某个域的值以外不做任何事情,那么将浪费 CPU 时钟周期,并且如果这个域不是 volatile 类型,那么将无法保证这种自旋过程能结束。当等待某个状态转换发生时,闭锁或条件等待通常是一种更好的技术。
12.4.3 面向方面的测试技术
在编写本书时,面向方面编程(AOP)技术在并发领域的应用是非常有限的,因为大多数主流的 AOP 工具还不能支持在同步位置处的“切入点”。然而,AOP 还可以用来确保不变型条件不被破坏,获取与同步策略的某些方面保持一致。例如,在(Laddad, 2003)中给出了一个示例,其中使用了一个切面将所有对非线程安全的 Swing 方法调用都封装在一个断言中,该断言确保这个调用是在事件线程中执行的。由于不需要修改代码,因此该技术很容易使用,并且可以发现一些复杂的发布错误和线程封闭错误。
12.4.4 分析与检测工具
大多数商业分析工具都支持线程。这些工具在功能与执行效率上存在差异,但通常都能给出队程序内部的详细信息(虽然分析工具通常采用侵入式实现,因此可能对程序的执行时序和行为产生极大的影响)。大多数分析工作通常还为每个线程提供了一个时间线显示,并且用颜色来区分不同的线程状态(可运行、由于等待某个锁而阻塞、由于等待 IO 操作而阻塞等等)。从这些显示信息中可以看出程序对可用 CPU 资源的利用率,以及当程序表现糟糕时,该从何处查找原因。(许多分析工具还生成能够找出哪些锁导致了竞争,但在实际情况中,这些功能与人们期望的加锁行为分析能力之间存在一定的差距)。
内置的 JMX 代理同样提供了一些有限的功能来监测线程的行为。在 ThreadInfo 类中包含了线程的当前状态,并且当线程被阻塞时,它还会包含发生阻塞所在的锁或者条件对了。如果启动了“线程竞争监测”功能(在默认情况下为了不影响性能会被关闭),那么在 ThreadInfo 中还会包括线程由于等待一个锁或者通知而被阻塞的次数,以及等待的累积时间。
小结
要测试并发程序的正确性可能非常困难,因为并发程序的许多故障模式都是一些低概率事件,它们对于执行时序、负载情况以及其他难以重现的条件都非常敏感。而且,在测试程序中还会引入额外的同步或执行时序限制,这些因素都将掩盖被测试代码中的一些并发问题。要测试并发程序的性能同样非常困难,与使用静态编译语言(C/C++)编写的程序相比,用 Java 编写的的程序在测试起来会更加困难,因为动态编译、垃圾回收以及自动优化等操作都会影响与时间相关的测试结果。
要想尽可能的发现潜在的错误以及避免它们在正式正式产品中暴露出来,我们需要将传统的测试技术与代码审查和自动化分析工具结合起来,每项技术都可以找出其他方式忽略的问题。
3.1.13 - CH13-显式锁
在 Java 5.0 之前,在协调对共享对象访问时仅能使用 synchronized 和 volatile 机制。Java 5.0 增加了一种新的机制:ReentrantLock。与之前提到的机制相反,ReentrantLock 并非一种替代内置锁的方法,而是当内置锁机制不适用时,作为一种可供选择的高级功能。
13.1 Lock 与 ReentrantLock
在程序清单 13-1 给出的 Lock 接口中定义了一组抽象的加锁操作。有内置加锁机制不同的是,Lock 提供了一种无条件的、可轮询的、定时的、可中断的加锁操作,所有加锁和解锁的方法都是显式的。在 Lock 的实现中必须提供与内置锁相同的内存可见性语义,但在加锁语义、调度算法、顺序保证以及性能特性等方面可以有所不同。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。在获取 ReentrantLock 时,有着与进入同步代码块相同的内存语义,在释放 ReentrantLock 时,有着与退出同步代码块相同的内存语义。此外,与 synchronized 一样,ReentrantLock 还提供了可重入的加锁语义。ReentrantLock 支持在 Lock 接口中定义的所有加锁模式,并且与 synchronized 相比,它还为处理锁的不可用性问题提供更高的灵活性。
为什么要创造一种与内置锁如此类似的新加锁机制?在大多数情况下,内置锁能很好的工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限的等待下去。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用 synchronized 的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
程序清单 13-2 给出了 Lock 接口的标准使用形式。这种形式比使用内置锁复杂一些:必须在 finally 块中释放锁。否则,如果在被保护的代码中抛出了异常,那么这个锁永远都无法释放。当使用加锁时,还必须考虑更多的 try-catch 或 try-finally 代码块。(当使用某种形式的加锁时,包括内置锁,都应该考虑在出现异常时的情况。)
Lock lock = new ReentrantLock();
...
lock.lock();
try {
// update object state
// catch exceptions and restore invariants if necessary
} finally {
lock.unlock();
}
如果没有使用 finally 块来释放 Lock,那么就相当于启动了一个定时炸弹。当“炸弹爆炸”时,将会很难追踪到最初发生错误的位置,因为没有记录应该释放锁的位置和时间。这就是 ReentrantLock 无法完全替代 synchronized 的原因:它更加危险,因为当程序的执行控制离开被保护的代码时,不会自动清除锁。虽然在 finally 块中释放锁并不困难,但可能会被忘记。
13.1.1 轮询锁与定时锁
可定时的与可轮询的加锁模式是由 tryLock 方法实现的,与无条件的加锁模式相比,它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序,而放置死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时的与可轮询的锁提供了另一种选择:避免死锁的发生。
如果不能获得所有需要的锁,那么可以使用可定时的或可轮询的加锁方式,从而使你重新获得控制权,这会释放已经获得的锁,然后尝试重新获取所有锁(或者至少会将这次失败记录到日志,并采取其他措施)。程序清单 13-3 给出了另一种方法来解决 10.1.2 节中动态顺序死锁的问题:使用 tryLock 来获取两个锁,如果不能同时获得,那么就会退并重新尝试。在休眠时间中包括固定部分和随机部分,从而降低发生活锁的可能性。如果在指定时间内不能获得所有需要的锁,那么 transferMoney 将返回一个失败状态,从而使该操作平缓的失败。
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
while (true) {
if (fromAcct.lock.tryLock()) {
try {
if (toAcct.lock.tryLock()) {
try {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
fromAcct.lock.unlock();
}
}
if (System.nanoTime() < stopTime)
return false;
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
在实现具有时间限制的操作时,定时锁同样非常有用。挡在带有时间限制的操作中调用了一个阻塞方法时,它能根据剩余时间来提供一个时限。如果操作不能在指定的时间内给出结果,那么就会使程序提前结束。当使用内置锁时,在开始请求锁后,这个操作将无法取消,因此内置锁很难实现带有时间限制的操作。
在程序清单 6-17 的旅游门户网站示例中,为询价的每个汽车租赁公司都创建了一个独立的任务。询价操作包含某种基于网络的请求机制,例如 Web 服务请求。但在询价操作中同样可能需要实现对紧缺资源的独占访问,例如通过向公司的直连通信线路。
9.5 节中介绍了确保对资源进行串行访问的方法:一个单线程的 Executor。另一种方法是使用一个独占锁来保护对资源的访问。程序清单 13-4 视图在 Lock 保护的共享通信线路上发送一条消息,如果不能在指定时间内完成,代码就会失败。定时的 tryLock 能够在这种带有时间限制的操作中实现独占加锁行为。
public boolean trySendOnSharedLine(String message,
long timeout,
TimeUnit unit)
throws InterruptedException { long nanosToLock = unit.toNanos(timeout) - estimatedNanosToSend(message);
if (!lock.tryLock(nanosToLock, NANOSECONDS))
return false;
try {
return sendOnSharedLine(message);
} finally {
lock.unlock();
}
}
13.1.2 可中断的加锁操作
正如定时加锁操作能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。7.1.6 节给出了集中不能响应中断的机制,例如请求内置锁。这些不可中断的阻塞机制将使得实现可取消的任务变得复杂。lockInterruptibly 方法能够在获得锁的同时保持对中断的响应,并且由于它包含在 Lock 中,因此无需创建其他类型的不可中断阻塞机制。
可中断的锁获取操作的标准结构比普通的锁线程获取操作略微复杂一些,因为需要两个 try 块。(如果在可中断的锁获取操作中抛出了 InterruptedException,那么可以使用标准的 try-finally 加锁模式。)在程序清单 13-5 中使用了 lockInterruptily 来实现程序清单 13-4 中的 sendOnSharedLine,以便在一个可取消的任务中调用它。定时的 tryLock 同样能响应中断,因此当需要实现一个定时的和可中断的锁获取操作时,可以使用 tryLock 方法。
public boolean sendOnSharedLine(String message)
throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException { ... }
13.1.3 非块结构的加锁
在内置锁中,锁的获取和释放等操作都是基于代码块的——释放锁的操作总是与获取锁的操作处于同一个代码块,而不考虑控制权如何退出该代码块。自动的锁释放操作简化了对程序的分析,避免了可能的编码错误,但有时候需要更灵活的加锁规则。
在第 11 章中,我们看到了通过降低锁的粒度可以提供代码的可伸缩性。锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。我们可以通过采用类似的原则来降低链表中所的粒度,即为每个链表节点使用一个独立的锁,使不同的线程能独立的对链表的不同部分进行操作。每个节点的锁将保护链接指针以及在该节点中存储的数据,因此当遍历或修改链表时,我们必须持有该节点上的这个锁,直到获得了下一个节点的锁,只有这样,才能是方法前一个节点上的锁。在 CPJ 2.5.1.4
中介绍了使用这项技术的一个示例,并称之为连锁式加锁(Hand-Over-Hand Locking)或者耦合锁(Lock Coupling)。
13.2 性能考虑因素
当把 ReentrantLock 添加到 Java 5.0 时,它能比内置锁提供更好的竞争性能。对于同步原语来说,竞争性是可伸缩性的关键要素:如果有越多的资源被耗费在锁的管理和调度上,那么应用程序得到的资源就越少。锁的实现方式越好,将需要越少的系统调用和上下文切换,并且在共享内存总线上的内存同步通信量也越少,而一些耗时的操作将占用应用程序的计算资源。
Java 6 使用了改进后的算法来管理内置锁,与在 ReentrantLock 中使用的算法类似,该算法有效的提供了可伸缩性。图 13-1 给出了在 Java 5 和 Java 6 版本中,内置锁与 ReentrantLock 之间的性能差异,测试程序的运行环境是 4 路的 Opteron 系统,操作系统为 Solaris。图中的曲线表示在某个 JVM 版本中 ReentrantLock 相对于内置锁的“加速比”。在 Java 5 中,ReentrantLock 能提供更高的吞吐量,但在 Java 6 中,二者非常接近。这里使用了与 11.5 节相同的测试程序,这次比较的是通过一个 HashMap 在由内置锁保护以及由 ReentrantLock 保护的情况下的吞吐量。
在 Java 5.0 中,当从单线程(无竞争)变化到多线程时,内置锁的性能急剧下降,而 ReentrantLock 的性能下降则更为平缓,因为它具有更好的可伸缩性。但在 Java 6 中,情况就完全不同了,内置锁的性能不会犹豫竞争而急剧下降,并且两者的可伸缩性也基本相当。
图 13-1 的曲线图告诉我们,像 “X 比 Y 更快”这样的表述大多是短暂的。性能和可伸缩性对于具体平台等因素较为敏感,例如 CPU、处理器数量、缓存带下以及 JVM 特性等,所有这些因素都可能会随着时间而发生变化。
性能是一个不断变化的指标。如果在昨天的测试基准中发现 X 比 Y 要快,那么在今天就可能已经过时了。
13.3 公平性
在 ReentrantLock 的构造函数中提供了两种公平性选择:创建一个非公平的锁或一个公平的锁。在公平的锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平的锁上,则允许“插队”:当一个线程请求非公平的锁时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在 Semaphore 中同样可以选择采用公平或非公平的获取顺序)。非公平的 ReentrantLock 并不提倡插队行为,但无法防止某个线程在合适的时候进行插队。在公平的锁中,如果有另一个线程持有这个锁或者有其他线程在队列中等待这个锁,那么新发出请求的线程将被放入队列中。在非公平的锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中。
我们为什么不希望所有的锁都是公平的?毕竟,公平是一种好的行为,而不公平是一种不好的行为,对不对?当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大的降低性能。在实际情况中,统计上的公平性保证——确保被阻塞的线程能最终获得锁,通常已经够用了,并且实际开销也小的多。有些算法依赖于公平的排队算法以确保他们的正确性,但这些算法并不常见。在大多数情况下,非公平锁的性能要高于公平锁的性能。
图 13-2 给出了 Map 的性能测试,并比较由公平的以及非公平的 ReentrantLock 包装的 HashMap 的性能,测试程序在一个 4 路的 Opteron 系统上运行,操作系统为 Solaris,在绘制结果曲线时采用了对数缩放比例。从图中可以看出,公平性把性能指标降低了约两个数量级。不必要的话,不要为公平性付出代价。
在竞争激烈的情况下,非公平锁的性能高于公平锁的性能的一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。假设线程 A 持有一个锁,并且线程 B 请求这个锁。由于该锁已经被线程 A 持有,因此线程 B 被挂起。当 A 释放时,B 将被唤醒,因此会再次尝试获取锁。与此同时,如果 C 也在请求该锁,那么 C 可能会在 B 被唤醒之前获得、使用以及释放这个锁。这样的情况是一种双赢的局面:B 获得锁的时刻并没有被推迟,C 更早的获得了锁,并且吞吐量也获得了提升。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。在这些情况下,插队带来的吞吐量提升(但锁处于可用状态时,线程却还处于被唤醒的过程中)则可能不会出现。
与默认的 ReentrantLock 一样,内置锁并不会提供确定的公平性保证,但在大多情况下,在锁实现统计上的公平性保证已经足够了。Java 语言规范并没有要求 JVM 以公平的方式来实现内置锁,而在各种 JVM 中也没有这么做。ReentrantLock 并没有进一步降低锁的公平性,而只是使一些已经存在的内容更明显。
在 synchronized 和 ReentrantLock 之间选择
ReentrantLock 在加锁和内存上提供的语义与内置锁相同,此外还提供了一些其他功能,包括定时的锁等待、可中断的锁等待、公平性,以及实现非块结构的加锁。ReentrantLock 在性能上似乎优于内置锁,其中在 Java 6 中略有胜出,而在 Java 5.0 中则是远远胜出。那么为什么不放弃 synchronized,并在所有新的并发代码中都使用 ReentrantLock?事实上有些作者已经建议这么做了,将 synchronized 作为一种“遗留”结构,但这会将好事情变坏。
与显式锁相比,内置锁仍然具有很大的优势。内置锁为许多开发人员所熟悉,并且间接紧凑,而且在很多现有的程序中都使用了内置锁——如果将这两种机制混用,那么不仅会容易令人迷惑,也容易发生错误。ReentrantLock 的危险性比同步机制要高,如果忘记在 finally 块中调用 unlock,那么虽然代码表面上能继续正常运行,但实际上已经买下了一颗定时炸弹,并很有可能伤及其他代码。仅当内置锁不能满足需求时,才可以考虑使用 ReentrantLock。
在一些内置锁无法满足需求的情况下,ReentrantLock 可以作为一种高级工具。当需要一些高级功能时才应该使用 ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用内置锁。
在 Java 5.0 中,内置锁与 ReentrantLock 相比还有一个优点:在线程转储中能够给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。JVM 并不知道哪些线程持有 ReentrantLock,因此咋调试使用 ReentrantLock 的线程问题时,将起不到帮助作用。Java 6 解决了这个问题,它提供了一个管理和调试接口,锁可以通过该接口进行注册,从而与 ReentrantLock 相关的加锁信息就能出现在线程转储中,并通过其他的管理接口和调试接口来访问。与 synchronized 相比,这些调试消息是一种重要的优势,即便它们大部分都是临时性消息,线程转储中的加锁能够给很多程序员的带来帮助。ReentrantLock 的非块结构特性仍然意味着,获取锁的操作不能与特定的帧栈关联起来,而内置锁却可以。
未来可能会提升 synchronized 而不是 ReentrantLock 的性能。因为 synchronized 是 JVM 内置的属性,它能执行一些优化,例如对线程封闭的锁对象的锁执行消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能性不大。除非将来需要在 Java 5 上部署应用程序,并且在该平台上确实需要 ReentrantLock 包含的伸缩性优势,否则就性能方面来说,应该选择内置锁而不是 ReentrantLock。
13.5 读写锁
ReentrantLock 实现了一种标准的互斥锁:每次最多有一个线程能持有 ReentrantLock。但对于维护数据的完整性来说,互斥通常是一种过于强硬的加锁规则,因此也就不必要的限制了并发性。互斥是一种保守的加锁策略,虽然可以避免“写写”冲突和“读写”冲突,但同时也避免了“读读”冲突。在许多情况下,数据结构上的操作大多是“读”操作——虽然他们也是可变的并且在某些情况下会被修改,但其中大多数访问操作都是读操作。此时,如果能够放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。只要每个线程都能确保读取到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会出现问题。在这种情况下就可以使用读写锁:一个资源可以被多个读操作同时访问,或者被一个写操作独占访问,但读写不能同时进行。
在程序清单 13-6 的 ReadWritLock 中暴露了两个 Lock 对象,其中一个用于读操作,另一个用于写操作。要读取由 ReadWriteLock 保护的数据,必须首先获得读取锁。尽管这两个锁看上去彼此独立,但读锁和写锁这是整个读写锁的不同视图。
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
在读写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。与 Lock 一样,ReadWriteLock 可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有所不同。
读写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。在实际情况中,对于在多处理器系统上被频繁读取的数据结构,读写锁能够提高性能。而在其他情况下,读写锁的性能比独占锁的性能要略差一些,这是因为他们的复杂性很高。如果要判断在某种情况下使用读写锁是否会带来性能提升,最好对程序进行分析。由于 ReadWriteLock 使用 Lock 来实现锁的读写部分,因此如果分析结果表明读写锁没有提高性能,那么可以很容易的将读写锁换位独占锁。
在读锁和写锁之间的交互可以采用多种实现方式。ReadWriteLock 中的一些可选实现包括:
释放优先。当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程、写线程、还是最先发出请求的线程?
读线程插队。如果锁由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,呢么将提高并发性,但却可能造成写线程发生饥饿问题。
重入性。读锁和写锁是否是可重入的?
降级。如果一个线程持有写锁,那么它能否在不释放该锁的情况下获得读锁?这可能会使得写锁被降级为读锁,同时不允许其他写线程修改被保护的资源。
升级。读锁能否有限于其他正在等待的读、写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。(如果两个读线程视图同时升级为写入锁,那么二者都不会释放读取所)。
ReentrantReadWriteLock 为这两种锁都提供了可重入的加锁语义。与 ReentrantLock 类似,ReentrantReadWriteLock 在构造时可以选择公平性。在公平的锁时等待时间最长的线程将优先获得锁。如果这个锁由度线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读锁,直到写线程使用完并释放了写锁。在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线程升级为写线程是不可以的(这样做会导致死锁)。
与 ReentrantLock 类似的是:ReentrantReadWriteLock 中的写锁是独占的,并且只能由获得该锁的线程来释放。在 Java 5 中,读锁的行为更类似于一个 Semaphore 而不是锁,它只维护活跃的读线程的数量,而不考虑它们的标识。在 Java 6 中修改了这个行为:将会记录哪些线程已经获得了读锁。
当锁的持有时间较长且大部分操作都不会修改被保护的资源时,那么读写锁能提高并发性。在程序清单 13-7 的 ReadWriteMap 中使用了 ReentrantReadWriteLock 来包装 Map,从而使它能在读线程之间被安全的共享,并且仍然能够避免“读写”或“写写”冲突。在实现中,ConcurrentHashMap 的性能已经很好了,因此如果只是需要一个并发的基于散列的映射,那么就可以使用 ConcurrentHashMap 来代替这种方法,但如果需要对另一种 Map 实现(如 LinkedHashMap)提供并发性更高的访问,那么可以使用这种技术。
public class ReadWriteMap<K,V> {
private final Map<K,V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K,V> map) {
this.map = map;
}
public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// Do the same for remove(), putAll(), clear()
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// Do the same for other read-only Map methods
}
图 13-3 给出了分别用 ReentrantLock 和 ReadWriteLock 来封装 ArrayList 的吞吐量比较,测试程序在 4 路的 Opteron 系统上运行,操作系统为 Solaris。这里使用的测试程序与本书使用的 Map 性能测试基本类似——每个操作都随机选择一个值并在容器中查找该值,并且只有少量的操作会修改这个容器中的内容。
小结
与内置锁相比,显式 Lock 提供了一些扩展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列行有着更好的控制。但 ReentrantLock 不能完全替代内置锁,只有在内置锁无法满足需求时,才应该使用它。
读写锁允许多个读线程并发的访问被保护的对象,当访问以读取操作为主的数据结构时,它能提高程序的可伸缩性。
3.1.14 - CH14-自定义扩展
类库中包含了很多存在状态依赖的类,比如 FutureTask、Semaphore、BlockingQueue 等。在这些类的操作中有着基于状态的前提条件,比如,不能从一个空队列中删除元素,或者获取一个尚未结束的任务的计算结果,在这些操作可以执行之前,必须等到对了进入“非空”状态,或者任务进入“已完成”状态。
创建状态依赖类的最简单方式通常是在类库中现有的状态依赖类的基础上进行构建。比如,在第 8 章的 ValueLatch 中就采用了这种方法,其中使用了 CountDownLatch 来提供所需的阻塞行为。但如果类库中没有提供所需的功能,那么还可以使用 Java 语言和类库提供的底层机制来构造自己的同步机制,包括内置的条件队列、显式的 Condition 对象以及 AbstractQueuedSynchronizer 框架。本章将介绍实现状态依赖性的各种选择,以及在使用平台提供的状态依赖性机制时需要遵守的各项规则。
14.1 状态依赖性的管理
在单线程程序中调用一个方法时,如果某个基于状态的前置条件未得到满足(比如“连接池必须为空”),那么这个条件将永远无法为真。因此,在编写串行程序中的类时,要使得这些类在他们的前提条件未满足时就失败。但在并发程序中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在几条指令之前还是空的,但稍后却被填充,因为另一个线程可能会返回一个元素到资源池。对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下也不会失败,但通常有一种更好的选择,即等待前提提交转变为真。
依赖状态的操作可以一直阻塞到可以继续执行,这比使它们先失败再实现起来要更为方便且不易出错。内置的条件对了可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。我们将在 14.2 节介绍条件队列的详细内容,但为了突出高效的条件等待机制的价值,我们将首先介绍如何通过轮询与休眠等方式来(勉强的)解决状态依赖问题。
可阻塞的状态依赖操作的形式如程序清单 14-1 所示。这种加锁模式有些不同寻常,因为锁是在操作的执行过程中被释放并重新获取的。构成前提条件的状态标量必须由对象的锁来保护,从而使它们在检查前提条件的同时保持不变。如果前提条件尚未满足,就必须释放锁,以便其他线程可以修改对象的状态,否则,前提条件就永远无法被转变为真。在再次检查前提条件之前,又必须重新获得锁。
void blockingAction() throws InterruptedException {
acquire lock on object state
while (precondition does not hold) {
release lock
wait until precondition might hold
optionally fail if interrupted or timeout expires
reacquire lock
}
perform action
}
在生成消费设计中经常会使用像 ArrayBlockingQueue 这样的有界缓存。在有界缓存提供的 put 和 take 操作中都包含一个前提条件:不能从空缓存中获取元素,也不能讲元素放入已满的缓存中。当前提条件未满足时,依赖状态的操作可以抛出一个异常或者返回一个错误状态(使其成为调用者的一个问题),也可以保持阻塞直到对象进入正确的状态。
接下来介绍有界缓存的几种实现,其中将采用不同的方法来处理前提条件失败的问题。在每种实现中都扩展了程序清单 14-2 中的 BaseBoundedBuffer,在这个类中实现了一个基于数组的循环缓存,其中各个缓存状态变量均由缓存的内置锁来保护。它还提供了同步的 doPut 和 doTake 方法,并在子类中通过这些方法来实现 put 和 take 操作,底层的状态将对子类隐藏。
@ThreadSafe public abstract class BaseBoundedBuffer<V> {
@GuardedBy("this") private final V[] buf;
@GuardedBy("this") private int tail;
@GuardedBy("this") private int head;
@GuardedBy("this") private int count;
protected BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
}
protected synchronized final void doPut(V v) {
buf[tail] = v;
if (++tail == buf.length)
tail = 0;
++count;
}
protected synchronized final V doTake() {
V v = buf[head];
buf[head] = null;
if (++head == buf.length)
head = 0;
--count;
return v;
}
public synchronized final boolean isFull() {
return count == buf.length;
}
public synchronized final boolean isEmpty() {
return count == 0;
}
}
14.1.1 示例:将前提条件的失败传递给调用者
程序清单 14-3 的 GrumpyBoundedBuffer 是第一个简单的有界缓存实现。put 和 take 方法都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法都进行了同步以确保实现对缓存状态的独占访问,因为这两个方法在访问缓存时都采用“先检查再运行”的逻辑策略。
@ThreadSafe public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public GrumpyBoundedBuffer(int size) { super(size); }
public synchronized void put(V v) throws BufferFullException {
if (isFull())
throw new BufferFullException();
doPut(v);
}
public synchronized V take() throws BufferEmptyException {
if (isEmpty())
throw new BufferEmptyException();
return doTake();
}
}
尽管这种方法实现起来很简单,但使用起来却并非如此。异常应该用于真正发生异常条件的场景。“缓存已满”并不不是有界缓存的一个异常条件,就像“红灯”并不表示交通信号灯出现了异常。在实现缓存时得到的简化(直接抛出异常,由使调用者管理状态依赖性)并不能抵消在使用时存在的复杂性,因为现在调用者必须做好捕获异常的准备,并且在每次缓存操作时都需要重试。程序清单 14-4 给出了对 take 的调用——并不是很漂亮,尤其是当程序中很多地方都要调用 put 和 take 方法时。
while(true) {
try {
V item = buffer.take();
// 对 item 执行一些操作
break;
} catch(BufferEmptyException e) {
Thread.sleep(SLEEP_GRANULARITY);
}
}
这种方法的一种变化形式是,当缓存处于一种错误的状态时返回一个错误值。这是一种改进,因为并没有放弃异常机制,抛出的异常意味着“对不起,请再试一次”。但这种方法并没有解决根本问题:调用者需要自行处理前置条件失败的情况。
程序清单 14-4 中的客户端代码并非实现重试的唯一方式。调用者可以不用进入休眠状态,而直接重新调用 take 方法,这种方式被称为忙等待或自旋等待。如果缓存的状态在很长一段时间内都不会发生变化,那么使用这种方式将会消耗大量的 CPU 时间。但是,调用者也可以进入休眠状态来避免消耗过多的 CPU 时间,但如果缓存的状态在刚调用完 sleep 就立即发生了变化,那么将不必要的休眠一段时间。因此,客户端代码必须在二者之间进行选择:要么容忍自旋导致的 CPU 时钟周期浪费,要么容忍由于休眠而导致的低响应性。(除了忙等待与休眠之外,还有一种选择是调用 Thread.yield,这相当于给调度器一个提示:现在需要让出一定的 CPU 时间给别的线程运行。假设正在等待另一个线程执行工作,那么如果选择让出处理器而不是消耗完整个 CPU 调度时间片,那么可以使整体的执行过程变快。)
14.1.2 示例:通过轮询与休眠来实现简单的阻塞
程序清单 14-5 中的 SleepyBoundedBuffer 尝试通过 put 和 take 方法来实现一种简单的“轮询与休眠”重试机制,从而使调用者无需在每次调用时都实现重试逻辑。如果缓存为空,那么 take 将休眠直到另一个线程向缓存中放入数据;如果缓存是满的,那么 put 将休眠直到另一个线程从缓存中取出一些数据,以便有空间容纳新的数据。这种方法将前置条件的管理操作封装了起来,并简化了对缓存的作用——这正是朝着正确的改进方向迈进了一步。
@ThreadSafe
public class SleepyBoundedBuffer<V> extends BaseBoundedBuffer<V> {
public SleepyBoundedBuffer(int size) { super(size); }
public void put(V v) throws InterruptedException {
while (true) {
synchronized (this) {
if (!isFull()) {
doPut(v);
return;
}
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
public V take() throws InterruptedException {
while (true) {
synchronized (this) {
if (!isEmpty())
return doTake();
}
Thread.sleep(SLEEP_GRANULARITY);
}
}
}
SleepyBoundedBuffer 的实现远比之前的实现要复杂。缓存代码必须在持有缓存锁的时候才能测试相应的状态条件,因为表示状态条件的变量是由缓存锁保护的。如果检查失败,那么当前执行的线程首先释放锁并休眠一段时间,从而使其他线程能够访问缓存。当线程醒来时,它将重新请求锁并再次尝试执行操作,因而线程将反复的在休眠以及测试条件等过程之间进行切换,直到可以执行操作为止。
从调用者的角度来看,这种方法能很好的运行,如果某个操作可以执行,那么就立即执行,否则就阻塞,调用者无需处理失败和重试。要选择合适的休眠时间间隔,就需要在响应性与 CPU 使用率之间进行权衡。休眠的间隔越小,响应性就越高,但消耗的 CPU 资源也越高。图 14-1 给出了休眠间隔对响应性的影响:在缓存中出现可用空间的时刻与线程醒来并再次执行检查的时刻之间可能存在延迟。
SleepyBoundedBuffer 给调用者提出了一个新的需求:处理中断异常。当一个方法由于等待某个条件为真而阻塞时,需要提供一种取消机制。与大多数具备良好行为的阻塞库方法一样,SleepyBoundedBuffer 通过中断来支持取消,如果该方法被中断,那么将提前返回并抛出中断异常。
这种通过轮询与休眠来实现阻塞操作的过程需要付出大量的努力。如果存在某种挂起线程的方法,并且这种方法能够确保当某个条件为真时线程会立即醒来,那么将极大的简化实现工作。这正是条件队列实现的功能。
14.1.3 条件队列
条件队列就好像烤面包机中通知“面包已烤好”的铃声。如果你注意听着铃声,那么当面包烤好后可以立即得到通知,然后放下手头的事情(或者先把手头的事情做完,例如先看完报纸)开始品尝面包。如果没有听见铃声(可能出去拿报纸了),那么会错过通知消息,但回到厨房时还可以观察烤面包机的状态,如果已经烤好,那么就取出面包,如果尚未烤好,就再次开始留意铃声。
“条件队列”这个名字的来源是:它使得一组线程(称为等待线程集合)能够通过某种方式来等待特定的条件变为真。传统队列的元素是一个个数据,与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。
正如每个 Java 对象都可以作为一个锁,每个对象同样可以作为一个条件队列,并且 Object 中的 wait、notify、notifyAll 方法就构成了内部条件队列的 API。对象的内置锁与其内部条件队列是相互关联的,要调用对象 X 中条件队列的任何一个方法,必须持有对象 X 上的锁。这是因为“等待由状态构成的条件”与“维护状态一致性”这两种机制必须紧密的被绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能修改状态时,才能从条件等待中释放另一个线程。
Object.wait 会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并修改对象的状态。当被挂起的线程醒来时,它将在返回之前重新获取锁。从直观上来理解,调用 wait 意味着“我要去休息了”,但当发生特定的事情时唤醒为,而调用通知方法就意味着“特定的事情发生了”。
在程序清单 14-6 中的 BoundedBuffer 使用了 wait 和 notifyAll 来实现一个有界缓存。这比使用“休眠”的有界缓存更加简单,并且更加高效(当缓存状态没有发生变化时,线程醒来的次数将更少),响应性也更高(当发生特定状态变化时将立即醒来)。这是一个较大的改进,但要注意:与使用“休眠”的有界缓存相比,条件队列并没有改变原来的语义。它只是在多个方面进行了优化:CPU 效率、上下文切换开销和响应性等。如果某个功能无法通过“轮询与休眠”来实现,那么使用条件队列也无法实现,但条件队列使得在表达和管理状态依赖性时更加简单和高效。
@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V> {
// CONDITION PREDICATE: not-full (!isFull())
// CONDITION PREDICATE: not-empty (!isEmpty())
public BoundedBuffer(int size) { super(size); }
// BLOCKS-UNTIL: not-full
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
doPut(v);
notifyAll();
}
// BLOCKS-UNTIL: not-empty
public synchronized V take() throws InterruptedException {
while (isEmpty())
wait();
V v = doTake();
notifyAll();
return v;
}
}
最终,BoundedBuffer 变得足够好了,不仅简单易用,而且实现了明确的状态依赖性管理。在产品的正式版本中还应该包括限时版本的 put 和 take,这样当阻塞操作不能在预期的时间内完成时,可以因超时而返回。通过使用定时版本的 Object.wait,可以很容易实现这些方法。
14.2 使用条件队列
条件队列使构建高效及高可响应性的状态依赖类变得更容易,但同时也很容易被误用。虽然很多规则都能确保正确的使用条件队列,但在编译器或系统平台上却并没有强制要求遵循这些规则。(这也是为什么要尽量基于 LinkedBlockingQueue、Latch、Semaphore 和 FutureTask 等类来构造程序的原因之一,如果能避免使用条件队列,那么实现起来将容易很多。)
14.2.1 条件谓词
要想正确的使用条件队列,关键是找出对象在哪个条件谓词上等待。条件谓词将在等待与通知等过程中引起很多困惑,因为在 API 中没有对条件谓词进行实例化的方法,并且在 Java 语言规范或 JVM 实现中也没有任何信息可以确保正确的使用它们。事实上,在 Java 语言规范或 Javadoc 中根本没有直接提到过它。但如果没有条件谓词,条件等待机制将无法发挥作用。
条件谓词是使某个操作成为状态依赖操作的前提条件。在有界缓存中,只有当缓存不为空时,take 方法才能执行,否则必须等待。对 take 方法来说,它的条件谓词就是“缓存不为空”,take 方法必须在执行之前必须首先测试条件谓词。同样,put 方法的条件谓词是“缓存不满”。条件谓词是由类中各个状态变量构成的表达式。BaseBoundedBuffer 在测试“缓存不为空”时将把 count 与 0 进行比较,在测试“缓存不满”时将 count 与缓存的大小进行比较。
将与条件队列相关联的条件谓词以及在这些条件谓词上等待的操作都写入文档。
在条件等待中存在一种重要的三元关系:加锁、wait 方法、和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前必须先持有这个锁。锁对象与条件队列对象(即调用 wait 和 notify 等方法所在的对象)必须是同一个对象。
在 BoundedBuffer 中,缓存的状态由缓存锁保护,并且缓存对象被用作条件队列。take 方法将获取请求缓存锁,然后对条件谓词(缓存非空)进行测试。如果缓存非空,那么它会移除一个原色,之所以能这么做,是因为 take 此时仍然持有保护缓存状态的锁。
如果条件谓词不为真(缓存为空),那么 take 方法必须等待直到另一个线程在缓存中放入一个对象。take 将在缓存的内置条件队列上条用 wait 方法,这需要持有条件队列对象上的锁。这是一种严谨的设计,因为 take 方法已经持有在测试条件谓词时(并且如果条件谓词为真,那么在同一个原子操作中修改缓存的状态)需要的锁。wait 方法将释放锁,阻塞当前线程,并等待直到超时,然后线程被中断或者通过一个通知被唤醒。在唤醒进程后,wait 在返回前还要重新获取锁。当线程从 wait 方法中被唤醒时,它在重新请求锁时不具有任何特殊的优先级,而要与任何其他尝试进入同步代码块的线程一起正常的在锁上进行竞争。
每一次 wait 调用都会隐式的与特定的条件谓词关联起来。当调用某个特定条件谓词的 wait 时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
14.2.2 过早唤醒
虽然在锁、条件谓词和条件队列之间的三元关系并不复杂,但 wait 方法的返回并不一定意味着线程正在等待的条件谓词已经变味真了。
内置条件队列可以与多个条件谓词一起使用。当一个线程由于调用 notifyAll 而醒来时,并不意味着该线程正在等待的条件谓词已经为真了。(这就像烤面包机和咖啡机公用一个铃声,而响铃后,你必须检查是哪个设备发出的铃声)。另外,wait 方法还可以“假装”返回,而不是由于某个线程条用了 notify。
当执行控制重新进入调用 wait 的代码时,它已经重新获取与跳进队列相关的锁。现在条件谓词是不是已经为真了呢?或许,在发出通知的线程调用 notifyAll 时,条件谓词可能已经变为真,但在重新获取锁时将再次变为假。在线程被唤醒到 wait 重新获取锁的这段时间内,可能有其他线程已经获取过这个锁,并修改了对象的状态。或者,条件谓词从调用 wait 起根本就没有变为真。你并不知道另一个线程为什么会调用 notifyAll 或 notify,也许是因为与同一个条件队列相关的另一个条件谓词变为了真。“一个条件队列与多个条件谓词相关”是一种很常见的情况——在 BoundedBuffer 中使用的条件队列与“非满”和“非空”两个条件谓词相关。
基于所有这些原因,每当线程从 wait 中醒来时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败)。由于线程在条件谓词不为真的情况下也可以反复的醒来,因此必须在一个循环中调用 wait,并在每次迭代中都测试条件谓词。程序清单 14-7 给出了条件等待的标准形式。
void stateDependentMethod() throws InterruptedException {
// condition predicate must be guarded by lock
synchronized(lock) {
while (!conditionPredicate())
lock.wait();
// object is now in desired state
}
}
当使用条件等待时(如 Object.wait 或 Condition.await):
- 通常都有一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
- 在调用 wait 之前测试条件谓词,并且从 wait 中返回时再次进行测试。
- 在一个循环中调用 wait。
- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
- 当调用 wait、notify、notifyAll 方法时,一定要持有与条件队列相关的锁。
- 在检查条件谓词之后又以及开始执行相应的操作之前,不要释放锁。
14.2.3 丢失的信号
第 10 章曾经介讨论过活跃性故障,比如死锁和活锁。另一种形式的活跃性故障是丢失的信号。指的是:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在,线程将等待一个已经发出的事件。这就好比在启动了烤面包机之后出去拿报纸,当你在屋外时烤面包机的铃声响了,但你没有听到,因此还会坐在厨房的桌子前等待烤面包机的铃声。你可能会等待很长时间。通知并不像你涂在面包上的果酱,它没有“黏附性”。如果线程 A 通知了一个条件队列,而线程 B 随后在这个条件队列上等待,那么线程 B 将不会立即醒来,而是需要另一个通知来唤醒它。像上述程序清单中警示之类的编码错误(比如,没有在调用 wait 之前检测条件谓词)就会导致信号的丢失。如果按照程序清单 14-7 的方式来设计条件等待,那么就不会发生信号丢失事件。
14.2.4 通知
到目前为止,我们介绍了条件等待的前一半内容:等待。另一半内容则是通知。在有界缓存中,如果缓存为空,那么在调用 take 时将阻塞。在缓存变为非空时,为了使 take 解除阻塞,必须确保在每条使缓存变为非空的代码路径中发出一个通知。在 BoundedBuffer 中,只有一条代码路径,即在 put 方法之后。因此,put 在成功的将一个元素添加到缓存后,将调用 notifyAll。同样,take 在移除一个元素之后将调用 notifyAll,向任何正在等待“非满”条件的线程发出通知:缓存现在有可用的空间了。
每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
在条件队列 API 中有两个发出通知的方法,即 notifyAll 和 notify。无论调用哪一个,都必须持有与条件队列对象相关联的锁。在调用 notify 时,JVM 会从这个条件对了上等待的多个线程中选择一个来唤醒,而调用 notifyAll 则会唤醒所有在这个条件队列上等待的线程。由于在调用 notify 和 notifyAll 时必须持有条件队列对象的锁,而如果这些等待中的线程此时不能重新获得锁,那么无法从 wait 返回,因此发出通知的线程应该尽快的释放锁,从而确保正在等待的线程尽可能快的解除阻塞。
由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用 notify 而不是 notifyAll,那么将是一种危险的动作,因为单一的通知很容易导致类似信号丢失的问题。
在 BoundedBuffer 中很好的说明了为什么在大多数情况下应该优先使用 notifyAll 而不是单个的 notify。这里的条件队列用于两个不同的条件谓词:“非空”和“非满”。假设线程 A 在条件队列上等待条件谓词 PA,同时线程 B 在同一个条件队列上等待条件谓词 PB。现在,假设 PB 变为真,并且线程 C 执行了一个 notify:JVM 将从它拥有的众多线程中选择一个并唤醒。如果选择了线程 A,那么 A 将被唤醒,并且看到 PA 尚未变为真,因此将继续等待。同时,线程 B 本可以开始执行,却没有被唤醒。这并不是严格意义上的“丢失信号”,而更像是一种“被劫持的”信号,但导致的问题是相同的:线程正在等待一个已经(或者本应该)发生过的信号。
只有同时满足以下两个条件时,才能用单一的 notify 而不是 notifyAll:
- 所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,并且每个线程在从 wait 返回后将执行相同的操作。
- 单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
BoundedBuffer 满足“单进单出”的条件,但不满足“所有等待线程的类型都相同”,因此正在等待的线程可能是在等待“非满”,也可能是在等待“非空”。例如第 5 章的 TestHarness 中使用的“开始阀门”闭锁(单个事件释放一组线程)并不满足“单进单出”的需求,因为这个“开始阀门”将使得多个线程开始执行。
由于大多数类并不满足这些需求,因此普遍认可的做法是优先使用 notifyAll 而不是 notify。虽然 notifyAll 可能比 notify 更低效,但却更容易确保类的行为是正确的。
有些开发人员并不赞同这种“普遍认可的做法”。当只有一个线程可以执行时,如果使用 notifyAll,那么将是低效的,这种低效情况带来的影响有时候很小,但有时候却非常大。如果有 10 个线程在一个条件队列上等待,那么调用 notifyAll 将唤醒每个线程,并使得他们在锁上发生竞争。然后,他们中的大多数或者全部又都回到休眠状态。因而,在每个线程执行一个事件的同时,将出现大量的上下文切换操作以及发生竞争的加锁操作。(最坏的情况是,在使用 notifyAll 时将导致 O(n^2)次唤醒操作,而实际上只需要 n 次唤醒操作就足够了)。这是“性能考虑因素与安全考虑因素互相矛盾”的另一种情况。
在 BoundedBuffer 的 put 和 take 方法中采用的通知机制是保守的:每当将一个对象放入缓存或者从缓存中移走一个对象时,就执行一次通知。我们可以对其进行优化:首先,仅当缓存从空变为非空,或者从满变为非满时,才需要释放一个线程。并且,仅当 put 和 take 影响到这些状态转换时,才发出通知。这也被称为“条件通知”。虽然“条件通知”可以提升性能,但却很难正确的实现(而且还会使子类的实现变得复杂),因此在使用时应当谨慎。程序清单 14-8 给出了如何在 BoundedBuffer.put 中使用“条件通知”。
public synchronized void put(V v) throws InterruptedException {
while (isFull())
wait();
boolean wasEmpty = isEmpty();
doPut(v);
if (wasEmpty)
notifyAll();
}
单次通知和条件通知都属于优化措施。通常,在使用这些优化措施时,应该遵循“首先使程序正确的运行,然后再使其运行的更快”这个原则。如果不正确的使用这些优化措施,那么很容易在程序中引入奇怪的活跃性故障。
14.2.5 示例:阀门类
在第 5 章的 TestHarness 中使用的“开始阀门闭锁”在初始化时指定的参数为 1,从而创建了一个二元闭锁:它只有两种状态,即初始状态和结束状态。闭锁能阻止线程通过开始阀门,并直到阀门被打开,此时所有的线程都可以通过该阀门。虽然闭锁机制通常能满足需求,但在某些情况下存在一些缺陷:按照这种方式构造的阀门在打开后无法重新关闭。
通过使用条件等待,可以很容易的实现一个可重新打开关闭的 TreadGate 类,如程序清单 14-9 所示。ThreadGate 可以打开和关闭阀门,并提供一个 await 方法,该方法能一直阻塞直到阀门被打开。在 open 方法中使用 notifyAll,这是因为这个类的语义不满足单次通知的“单进单出”测试。
@ThreadSafe public class ThreadGate {
// CONDITION-PREDICATE: opened-since(n) (isOpen || generation>n)
@GuardedBy("this") private boolean isOpen;
@GuardedBy("this") private int generation;
public synchronized void close() {
isOpen = false;
}
public synchronized void open() {
++generation;
isOpen = true;
notifyAll();
}
// BLOCKS-UNTIL: opened-since(generation on entry)
public synchronized void await() throws InterruptedException {
int arrivalGeneration = generation;
while (!isOpen && arrivalGeneration == generation)
wait();
}
}
在 wait 中使用的条件谓词比测试 isOpen 复杂的多。这种条件谓词是必须的,因为如果当阀门打开时有 N 个线程正在等待它,那么这些线程都应该被允许执行。然而,如果阀门在打开后又非常快速的关闭了,并且 await 方法只能检查 isOpen,那么所有的线程都可能无法释放:当所有线程收到通知时,将重新请求锁并退出 wait,而此时的阀门可能已经再次关闭了。因此,在 ThreadGate 中使用了一个更复杂的条件谓词:每次阀门关闭时,递增一个 “Generation” 计数器,如果阀门现在是打开的,或者阀门自从该线程到达后就一直是打开的,那么线程就可以通过 wait。
由于 ThreadGate 只支持等待打开阀门,因此只有在 open 中执行通知。要想既支持“等待打开”又支持“等待关闭”,那么 ThreadGate 必须在 open 和 close 中都进行通知。这很好的说明了为什么在维护状态依赖的类时是非常困难的——当增加一个新的状态依赖操作时,可能需要多多条修改对象的代码路径进行调整,才能正确的执行通知。
14.2.6 子类的安全问题
在使用条件通知或单次通知时,一些约束条件使的子类化过程变得更加复杂。要想支持子类化,那么在设计时就需要保证:如果在实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。
对于状态依赖的类,要么将其等待和通知协议完全向子类公开并写入正式文档,要么完全阻止子类参与到等待和通知等过程中。(这是对“要么围绕着继承来设计和子类化,要么禁止使用继承”这条规则的一种扩展)。当设计一个可以被继承的状态依赖类时,至少需要公开条件队列和锁,并将条件谓词和同步策略写入文档。此外,还可能需要公开一些底层的状态变量。(最糟糕的情况是,一个状态依赖的类虽然将其状态向子类公开,但却没有将相应的等待和通知等协议写入文档,这就类似于虽然公开了它的状态变量,但却没有将其不变性写入文档一样。)
另外一种选择是完全禁止子类化,比如将类声明为 final 类型,或者将条件队列、锁和状态变量等都隐藏依赖,使子类无法看到。否则,如果子类破坏了在基类中使用 notify 的方式,那么基类就需要修复这种破坏。考虑一个无界的可阻塞栈,当栈为空时,pop 操作将其阻塞,但 push 操作通常可以执行。这就满足了使用单次通知的需求。如果在这个类中使用了单次通知,并且在其中一个子类中添加了一个阻塞的“弹出连续两个元素”方法,那么就会出现两种类型的等待线程:等待弹出一个元素的线程和等待弹出连个元素的线程。但如果基类将条件队列公开出来,并且将使用该条件队列的协议也写入文档,那么子类就可以将 push 方法改写为执行 notifyAll,而重新确保安全性。
14.2.7 封装条件队列
通常,我们应该把条件队列封装起来,因而消除了使用条件队列的类,就不能在其他地方访问它。否则,调用者会自以为理解类在等待和通知上使用的协议,并且采用一种违背设计的方式来使用条件队列。(除非条件队列对象对于你无法控制的代码来说是不可访问的,否则就不可能要求在单次通知中的所有等待线程都是同一类型的。如果外部代码错误的在条件对了上等待,那么可能通知协议,并导致一个“被劫持的”信号)。
不幸的是,这条建议——将条件队列对象封装起来,与线程安全类的最常见设计模式并不一致,在这种模式中建议使用对象的内置锁来保护对象自身的状态。在 BoundedBuffer 中给出了这种常见的模式,即缓存对象自身即为锁、又是条件队列。然而,可以很容易将 BoundedBuffer 重新设计为使用私有的锁对象和条件队列,唯一的不同之处在于,新的 BoundedBuffer 不再支持任何形式的客户端加锁。
14.2.8 入口协议与出口协议
Wellings 通过“入口协议和出口协议”来描述 wait 和 notify 方法的正确使用。对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。入口协议就是该操作的条件谓词,出口协议则包括:检查被该操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。
在 AbstractQueuedSynchronizer (JUC 中大多数依赖状态的类都是基于这个类构建的)中使用出口协议。这个类并不是由同步器类执行自己的通知,而是要求同步器方法返回一个值来表示该类的操作是否已经解除了一个或多个等待线程的阻塞状态。这种明确的 API 调用需求使得难以“忘记”在某些状态转换发生时通知。
14.3 显式的 Condition 对象
第 13 章曾经介绍过,在某些情况下,当内置锁过于灵活时,可以使用显式锁。正如 Lock 是一种广义的内置锁,Condition 也是一种广义的内置条件队列。
public interface Condition {
void await() throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
long awaitNanos(long nanosTimeout) throws InterruptedException;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
内置条件队列存在一些缺陷。每个内置锁都只能有一个相关联的条件队列,因为在像 BoundedBuffer 这种类中,多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都是的无法满足在使用 notifyAll 时所有等待线程为同一类型的需求。如果想要编写一个带有多个条件谓词的并发对象,或者想获得出列条件队列可见性之外的更多控制权,就可以使用显式的 Lock 和 Condition 而不是内置锁和条件队列,这是一种更灵活的选择。
一个 Condition 和一个 Lock 关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个 Condition,可以在相关联的 Lock 上调用 Lock.newCondition 方法。正如 Lock 比内置加锁提供了更为丰富的功能,Condition 同样比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待,以及公平或非公平的队列操作。
与内置条件队列不同的是,对于每个 Lock,可以有任意数量的 Condition 对象。Condition 对象继承了相关 Lock 对象的公平性,对于公平的锁,线程会依照 FIFO 顺序从 Condition.await 中释放。
特别注意:在 Condition 对象中,与 wait、notify、notifyAll 方法对应的分别是 await、singal、signalAll。但是,Condition 继承了 Object,因而它也拥有 wait 和 notify 方法。一定要确保使用正确的方法——await 和 signal。
程序清单 14-11 给出了有界缓存的另一种实现,即使用两个 Condition,分别为 notFull 和 notEmpty,用于表示“非满”与“非空”两个条件谓词。当缓存为空时,take 将阻塞并等待 notEmpty,此时 put 向 notEmpty 发送信号,可以解除任何在 take 中阻塞的线程。
@ThreadSafe public class ConditionBoundedBuffer<T> {
protected final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: notFull (count < items.length)
private final Condition notFull = lock.newCondition();
// CONDITION PREDICATE: notEmpty (count > 0)
private final Condition notEmpty = lock.newCondition();
@GuardedBy("lock")
private final T[] items = (T[]) new Object[BUFFER_SIZE];
@GuardedBy("lock")
private int tail, head, count;
// BLOCKS-UNTIL: notFull
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[tail] = x;
if (++tail == items.length)
tail = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: notEmpty
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
T x = items[head];
items[head] = null;
if (++head == items.length)
head = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
ConditionBoundedBuffer 的行为和 BoundedBuffer 相同,但他对条件队列的使用方式更易理解——在分析使用了多个 Condition 的类时,比分析一个使用单一内部队列加上多个条件谓词的类简单的多。通过将连个条件谓词分开并放到两个等待线程集中,Condition 使其更容易满足单次通知的需求。signal 比 signalAll 更高效,它能极大的减少在每次缓存操作中发生的上下文切换与锁请求次数。
与内置锁和条件队列一样,当使用显式的 Lock 和 Condition 时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须由 Lock 来保护,并且在检查条件谓词以及调用 await 和 signal 时,必须持有 Lock 对象。
在使用显式的 Condition 和内置条件对了之间进行选择时,与在 ReentrantLock 和 synchronized 之间进行选择是一样的:如果需要一些高级功能,例如使用公平的队列操作或者在每个锁上对应多个等待线程集,那么应该优先使用 Condition 而非内置条件队列。
14.4 Synchronizer 剖析
在 ReentrantLock 和 Semaphore 这两个接口之间存在许多共同点。这两个类都可以用作一个阀门,即每次只允许一定数量的线程通过,并当在线程到达阀门时,可以通过(在调用 lock 或 acquire 时成功返回),也可以等待(在调用 lock 或 acquire 时阻塞),还可以取消(在调用 tryLock 或 tryAcquire 时返回 false,表示在指定的时间内锁是不可用的或者无法获得许可)。而且,这两个接口都支持可中断的、不可中断的以及限时的获取操作,并且也都支持等待线程执行公平或非公平的队列操作。
列出了这种共性后,你或许会认为 Semaphore 是基于 ReentrantLock 实现的,或者认为 ReentrantLock 实际上是带有一个许可的 Semaphore。这些实现方式都是可行的,一个很常见的练习就是,证明可以通过锁来实现计数信号量(如程序清单 14-12 中的 SemaphoreOnLock 所示),以及可以通过计数信号量来实现锁。
// Not really how java.util.concurrent.Semaphore is implemented @ThreadSafe
public class SemaphoreOnLock {
private final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: permitsAvailable (permits > 0)
private final Condition permitsAvailable = lock.newCondition();
@GuardedBy("lock") private int permits;
SemaphoreOnLock(int initialPermits) {
lock.lock();
try {
permits = initialPermits;
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: permitsAvailable
public void acquire() throws InterruptedException {
lock.lock();
try {
while (permits <= 0)
permitsAvailable.await();
--permits;
} finally {
lock.unlock();
}
}
public void release() {
lock.lock();
try {
++permits;
permitsAvailable.signal();
} finally {
lock.unlock();
}
}
}
事实上,它们在实现时都基于共同的基类,即 AQS,这个类也是其他许多同步类的基类。AQS 是一个用于构建锁和同步器的框架,许多同步容器都可以通过 AQS 很容易并且高效的构造出来。不仅 ReentrantLock 和 Semaphore 是基于 AQS 构建的,还包括 CountDownLatch、ReentrantReadWriteLock、SynchronousQueue 和 FutureTask。
AQS 解决了在实现同步器时涉及的大量细节问题,例如等待线程采用 FIFO 队列操作书序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。
基于 AQS 来构建同步器能带来很多好处。它不仅能极大的减少实现工作,而且也不必处理在多个位置上发生的竞争问题(这是在没有使用 AQS 来构建同步器时的情况)。在 SemaphoreOnLock 中,获取许可的操作可能在两个时刻阻塞——当锁保护信号量状态时,或者当许可不可用时。在基于 AQS 构建的同步容器中,只可能在一个时刻发生阻塞,从而降低上下文切换的开销,并提高吞吐量。在设计 AQS 时充分考虑了可伸缩性,因此 JUC 中所有基于 AQS 构建的同步器都能获得这种优势。
14.5 AQS:AbstractQueuedSynchronizer
大多数开发者都不会直接使用 AQS,标准同步器的集合能够满足绝大多数的需求。但如果能了解标准同步器类的实现方式,那么对于理解它们的工作原理将会非常有帮助。
在基于 AQS 构建的同步容器中,最基本的操作包括各种形式的获取和释放操作。获取操作是一种状态依赖操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。在使用 CountDownLatch 时,“获取”操作意味着“等待直到闭锁到达结束状态”,而在使用 FutureTask 时,则意味着“等待直到任务已经完成”。“释放”并不是一个可阻塞的操作,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS 负责管理同步容器类中的状态,它管理了一个整数状态信息,可以通过 getState、setState 以及 compareAndSwap 等 protected 方法来进行操作。这个整数可以用于表示任务状态。比如,ReentrantLock 用它来表示所有者线程已经重复获取锁的次数,Semaphore 用它来表示剩余的许可数量,FutureTask 用它来表示任务的状态(尚未开始、正在运行、已完成、已取消)。在同步容器中还可以自行管理一些额外的状态变量,比如,ReentrantLock 保存了锁的当前所有者信息,这样就能区分某个操作是重入的还是竞争的。
程序清单 14-13 给出了 AQS 中获取和释放操作的形式。根据同步器的不同,获取操作可以是一种独占操作(如 ReentrantLock),也可以是一种非独占操作(如 Semaphore 和 CountDownLatch)。一个获取操作包含两个部分。首先,同步器判断当前状态十分允许获取操作,如果是,则允许线程执行,否则获取操作将阻塞或失败。这种判断是由同步器语义来决定的。例如,对于所来说,如果它没有被某个线程持有,那么就能被成功的获取,而对于闭锁来说,如果它处于结束状态,那么也能被成功的获取。
boolean acquire() throws InterruptedException {
while (state does not permit acquire) {
if (blocking acquisition requested) {
enqueue current thread if not already queued
block current thread
}
else return failure
}
possibly update synchronization state
dequeue thread if it was queued
return success
}
void release() {
update synchronization state
if (new state may permit a blocked thread to acquire)
unblock one or more queued threads
}
其次,就是更新同步器的状态,获取同步器的某个线程可能会对其他线程能够也获取该同步器造成影响。比如,当获取一个锁后,锁的状态将从“未被持有”变成“已被持有”,而从 Semaphore 中获得一个许可后,将把剩余许可的数量减去 1。然而,当一个线程获取闭锁时,并不会影响其他线程能否获取它,因此获取闭锁的操作不会改变闭锁的状态。
如果某个同步器支持独占的获取操作,那么需要实现一些保护方法,包括 tryAcquire、tryRelease 和 isHeldExclusively 等,而对于支持共享获取的同步器,则应该实现 tryAcquireShared 和 tryReleaseShared 等方法。AQS 中的 accuire、acquireShared、release 和 releaseShared 等方法都将调用这些方法在子类中带有前缀 try- 的版本来判断某个操作是否能够执行。在同步器的子类中,可以根据其获取操作和释放操作的语义,使用 getState、setState 以及 compareAndSetState 来检查和更新状态,并通过返回的状态值来告知基类“获取”和“释放”同步器的操作是否成功。例如,如果 tryAcquireShared 返回一个值,那么表示获取操作失败,返回零值表示同步器通过独占方式被获取,返回正值则表示同步器通过非独占方式被获取。对于 tryRelease 和 tryReleaseShared 方法来说,如果释放操作使得所有在获取同步器时被阻塞的线程恢复执行,那么这两个方法应该返回 true。
为了使支持条件队列的锁(如 ReentrantLock)实现起来更简单,AQS 还提供了一些机制来构造与同步器相关联的条件变量。
一个简单的闭锁
程序清单 14-14 中的 OneSlotLatch 是一个使用 AQS 实现的二元闭锁。它包括两个公有方法:await 和 signal,分别对应获取操作和释放操作。起初,闭锁是关闭的,任何调用 await 的线程都将阻塞并直到闭锁被打开。当通过调用 signal 打开闭锁时,所有等待中的线程都将被释放,并且后续到达闭锁的线程也被允许执行。
@ThreadSafe
public class OneShotLatch {
private final Sync sync = new Sync();
public void signal() {
sync.releaseShared(0);
}
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(0);
}
private class Sync extends AbstractQueuedSynchronizer {
protected int tryAcquireShared(int ignored) {
// Succeed if latch is open (state == 1), else fail
return (getState() == 1) ? 1 : -1;
}
protected boolean tryReleaseShared(int ignored) {
setState(1);
// Latch is now open
return true;
// Other threads may now be able to acquire
}
}
}
在 OneShotLatch 中,AQS 状态用来表示闭锁状态——关闭(0)或者打开(1)。await 方法调用 AQS 的 acquireSharedInterruptibly,然后接着调用 OneShotLatch 中的 tryAcquireShared 方法。在 tryAcquireShared 的实现中必须返回一个值来表示该获取操作能否执行。如果之间已经打开了闭锁,那么 tryAcquireShared 将返回成功并允许线程通过,否则就会返回一个表示获取操作失败的值。acquireSharedInterruptibly 方法在处理失败的方式,是把这个线程放入等待线程队列中。类似的,signal 将调用 releaseShared,接下来又会调用 tryReleaseShared。在 tryReleaseShared 中将无条件的将闭锁的状态设置为打开,(通过返回值)表示该同步器处于完全释放的状态。因而 AQS 让所有等待中的线程都尝试重新请求该同步器,并且由于 tryAcquireShared 将返回成功,因此现在的请求操作将成功。
OneShotLatch 是一个功能全面的、可用的、性能较好的同步器,并且仅使用了大约 20 多行代码就实现了。当然,它缺少了一些有用的特性,比如限时的请求操作或检查闭锁状态的操作,但这些功能实现起来同样简单,因为 AQS 提供了限时版本的获取方法,以及一些在常见检查中使用的辅助方法。
OneShotLatch 也可以通过扩展 AQS 来实现,而不是将一些功能委托给 AQS,但这种做法并不合理,原因有很多。这样做将破坏 OneShotLatch 接口(只有两个方法)的简洁性,并且虽然 AQS 的公共方法不允许调用者破坏闭锁的状态,但调用者仍可以很容易的误用它们。JUC 中的所有同步器类都没有直接扩展 AQS,而是都将它们的相应功能委托给私有的 AQS 子类来实现。
14.6 JUC 同步类中的 AQS
JUC 中的很多课阻塞类,如 ReentrantLock、Semaphore、ReentrantReadWriteLock、CountDownLatch、SynchronousQueue 和 FutureTask 等,都是基于 AQS 构建的。我们快速的浏览一下每个类是如何使用 AQS 的,不需要过于深入了解细节。
14.6.1 ReentrantLock
ReentrantLock 仅支持独占方式的获取操作,因此它实现了 tryAcquire、tryRelease 和 isHeledExclusively,程序清单 14-15 给出了非公平版本的 tryAcquire。ReentrantLock 将同步状态用于保存加锁操作的次数,并且还维护了一个 owner 变量来保存当前所有者线程的标示符,只有在当前线程刚刚获取到锁,或者正要释放锁的时候,才会修改这个变量。在 tryRelease 中检查 owner 域,从而确保当前线程在执行 unlock 操作之前确实已经获得了锁:在 tryAcquire 中将使用这个域来区分获取操作是重入的还是竞争的。
protected boolean tryAcquire(int ignored) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, 1)) {
owner = current;
return true;
}
}
else if (current == owner) {
setState(c+1);
return true;
}
return false;
}
当一个线程尝试获取锁时,tryAcquire 将首先检查所的状态。如果锁未被持有,那么它将尝试更新锁的状态以表示锁已经被持有。由于状态可能在检查后被立即修改,因此 tryAcquire 使用 compareAndSetState 来原子的更新状态,表示这个锁已经被占有,并确保状态在最后一次检查以后就没有被修改过。(请参考 15.3 节中对 compareAndSet 的描述)。如果锁状态表示已经被持有,并且如果当前线程是锁的拥有者,那么获取计数会被递增,如果当前线程不是锁的拥有者,那么获取操作将失败。
ReentrantLock 还利用了 AQS 对多个条件变量和多个等待线程集的内置支持。Lock.newCondition 将返回一个新的 ConditionObject 实例,这是 AQS 的一个内部类。
14.6.2 Semaphore 与 CountDownLatch
Semaphore 将 AQS 的同步状态用于保存当前可用许可的数量。tryAcquireShared 方法(见程序清单 14-16)首先计算剩余许可的数量,如果没有足够的许可,那么会翻译个值表示获取操作失败。如果还有剩余的许可,那么 tryAcquireShared 会通过 compareAndSetState 以原子方式来降低许可的计数。如果这个操作成功(这意味着许可的计数自从上一次读取后就没有被修改过),那么将返回一个值来表示获取操作成功。在返回值中包含了表示其他共享获取操作能否成功的信息,如果成功,那么其他等待的线程同样会解除阻塞。
protected int tryAcquireShared(int acquires) {
while (true) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 || compareAndSetState(available, remaining))
return remaining;
}
}
protected boolean tryReleaseShared(int releases) {
while (true) {
int p = getState();
if (compareAndSetState(p, p + releases))
return true;
}
}
当没有足够的许可,或者当 tryAcquireShared 可以通过原子方式来更新许可的计数以响应获取操作时,while 循环将终止。虽然对 compareAndSetState 的调用可能由于与另一个线程发生竞争而失败(请参考 15.3 节),使其重新尝试,但在经过了一定次数的重试操作之后,在两个结束条件中有一个会变为真。同样,tryReleaseShare 将增加许可计数,这可能会截除等待中线程的阻塞状态,并且不断的重试直到操作成功。tryReleaseShared 的返回值表示在这次释放操作中解除了其他线程的阻塞。
CountDownLatch 使用 AQS 的方式与 Semaphore 很相似:在同步状态中保存的是当前的计数值。countDown 方法将调用 release,从而导致计数值递减,并且当计数值为零时解除所有等待线程的阻塞。await 调用 acquire,当计数器为 0 时,acquire 将立即返回,否则将阻塞。
14.6.3 FutureTask
咋一看,FutureTask 甚至不像一个容器,但 Future.get 的语义非常类似于闭锁的语义——如果发生了某件事(由 FutureTask 表示的任务执行完成或被取消),那么线程就可以恢复执行,否则这些线程将停留在队列中直到该事件发生。
在 FutureTask 中,AQS 同步状态被用来保存任务的状态,如正在运行、已完成或已取消。FutureTask 还维护一写额外的状态变量,用来保存计算结果或抛出的异常。此外,它还为了一个引用,指向正在执行计算任务的线程(如果当该线程还处于运行状态时),因而如果任务取消,该线程就会被中断。
14.6.4 ReentrantReadWriteLock
ReadWriteLock 接口表示存在两个锁:一个读锁一个写锁,但在基于 AQS 实现的 ReentrantReadWriteLock 中,单个 AQS 子类将同时管理读取加锁和写入加锁。ReentrantReadWriteLock 使用了一个 16 位的状态来表示写入锁的计数,并且使用了另一个 16 位的状态来表示读取锁的计数。在读取锁上操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的获取方法与释放方法。
AQS 在内部维护一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。在 ReentrantReadWriteLock 中,当锁可用时,如果位于对了头部的线程执行写入操作,那么线程会得到该锁,如果位于队列头部的线程执行的是获取访问,那么队列在第一个写入线程之前的所有线程都将获得这个锁。
小结
要实现一个依赖状态的类——如果没有满足依赖状态的前提条件,那么这个类的方法必须阻塞,那么最好的方式是基于现有的类库来构建,比如 Semaphore、BlockingQueue 或 CountDownLatch。如第八章的 ValueLatch 所示。然而,有时候现有类库不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显式的 Condition 对象或者 AQS 来构建自己的同步器。内置条件队列与内置锁是紧密绑定在一起的,这是因为管理状态依赖性的机制必须与确保状态一致性的机制关联起来。同样,显式的 Condition 与显式的 Lock 也是紧密的绑定在一起的,并且与内置条件队列相比,还提供了一个扩展的功能集,包括每个锁对于多个等待线程集,可中断或不可中断的条件等待,公平或非公平的队列操作,以及基于时限的等待。
3.1.15 - CH15-原子与非阻塞同步
在 JUC 包的许多类中,如 Semaphore 和 ConcurrentLinkedQueue,都提供了比 synchronized 机制更高的性能和可伸缩性。本章将介绍这种性能提升的主要来源:原子变量和非阻塞同步机制。
近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令(如 CAS)代替锁来确保数据在并发访问中的一致性。非阻塞算法被广泛的用于在操作系统和 JVM 中实现线程/进程调度机制、垃圾回收机制、锁和其他并发数据结构。
与基于锁的方法相比,非阻塞算法在设计和实现上都要复杂的多,但它们在可伸缩性和活跃性上却拥有巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大的减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。在基于锁的算法中,如果一个线程在休眠或自旋的同时持有一个锁,那么其他线程都无法执行下去,而非阻塞算法不会受到单个线程失败的影响。从 Java 5.0 开始,可以使用原子变量类(如 AtomicInteger)来构建高效的非阻塞算法。
即使原子变量没有用于非阻塞算法的开发,他们也可以被用作一种“更好的 volatile 变量”。原子变量提供了与 volatile 变量相同的内存语义,此处还支持原子更新操作,从而使它们更加适用于实现计数器、序列发生器和统计数据收集等,同时还能比基于锁的方法提供更高的可伸缩性。
15.1 锁的劣势
通过使用一致的锁协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都能采用独占的方式来访问这些变量,并且对变量的任何修改对后续获得这个锁的其他线程都是可见的。
现代的许多 JVM 都给非竞争的加解锁操作进行极大的优化,但如果有多个线程同时加锁,那么 JVM 就需要借助操作系统的能力。如果出现了这种情况,那么一些线程将被挂起并且在稍后恢复运行。当线程恢复执行时,必须等待其他线程执行完成它们的时间片以后,才能被调度执行。在挂起和恢复线程等过程中存在很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含细粒度的操作(如同步容器类,在其大多数情况中仅包含了少量操作),那么当在锁上存在着激烈的竞争时,调度开销将大大超出工作开销。
与锁相比,volatile 变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。然而,volatile 变量同样存在一些局限:虽然它提供了相似的可见性保证,但不能用于构建原子的复合操作。因此,在一个变量依赖其他的变量时,或者当变量的新值依赖于旧值时,就无法使用 volatile 变量。这都限制了 volatile 变量的使用范围,因此他们不能用来实现一些常见的工具,如计数器或互斥体(mutex)。
比如,虽然自增操作看起来像是一个原子操作,但事实上却包含了 3 个独立的操作——获取变量的值、将该值加 1、写入新值到变量。为了确保更新操作不会丢失,整个的读-改-写操作都必须是原子的。到目前为止,我们实现这种原子操作的唯一途径就是使用锁定的方式,如第二章的 Counter 所示。
Counter 是线程安全的,并且在没有竞争的情况下运行良好。但在竞争的情况下,其性能会由于上下文切换的开销和调度延迟而降低。如果锁的持有时间非常短,俺么挡在不恰当的时间请求锁时,使线程休眠将付出很高的代价。
锁定还存在一些其他缺点。当一个线程正在等待锁时,他不能做任何其他事情。如果一个线程在持有锁的情况下被延迟执行(如发生内存缺页、调度延迟等),那么所有需要该锁的线程都将无法继续执行。如果被阻塞的线程的优先级很高,而持有锁的线程优先级较低,那么这将是一个严重的问题——也被称为优先级反转。即使高优先级的线程可抢先执行,但仍然需要等待锁被释放,从而导致它的优先级会降至低优先级线程的级别。如果持有锁的线程被永久的阻塞(如出现了无限循环、死锁、活锁或其他活跃性故障),所有等待该锁的线程都将永远无法继续执行。
即使忽略这种风险,锁定方式对于细粒度的操作(如递增计数器)来说仍然是一种高开销的机制。在管理线程之间的竞争时应该有一种粒度更细的技术,类似于 volatile 变量的机制,同时还要支持原子的更新操作。幸运的是,在现代的处理器中提供了这种机制。
15.2 硬件对并发的支持
独占锁是一项悲观技术——它假设最坏的情况(如果不锁门,那么捣蛋鬼就会闯入并搞破坏),并且只有在确保其他线程不会干扰(通过获取正确的锁)的情况下才能执行下去。
对于细粒度的操作,还有另外一种更高效的方法,也是一种乐观的方法,通过这种方法可以在不发生干扰的情况下完成更新操作。这种方法需要借助冲突检测机制来判断在更新过程中是否存在来自其他线程的干扰,如果存在,该操作将失败,但可以选择是否重试。这种乐观方式就好像一句谚语:“原谅比准许更易获得”,其中“更易”在这里相当于“更高效”。
在针对多处理器操作而设计的处理器中提供了一些特殊指令,用于管理共享数据的并发访问。在早期的处理器中支持原子的测试并设置(TestAndSet)、获取并递增(FetchAndIncrement)、交换(Swap)等指令,这些指令足以实现各种互斥体,而这些互斥体又可以实现一些更复杂的并发对象。现在,几乎所有的现代处理器中都包含了某种形式的原子读-该-写指令,如比较并交换(CAS)、关联加载/条件存储(LoadLinked/StoreConditional)。操作系统和 JVM 使用这些指令来实现锁和并发数据结构,但在 Java 5.0 之前,在 Java 类中还不能直接使用这些指令。
CAS
在大多数处理架构中采用的方法是实现一个 CAS 指令。CAS 包含 3 个操作数——需要读写的内存位置 V、进行比较的值 A、拟写入的新值 B。当且仅当 V 的值等于 A 时,CAS 才会通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作。无论 V 的值是否等于 A,都将返回 V 原有的值。(这种变化形式被称为“比较并设置”,无论操作是否成功都将放回)。CAS 的含义是:“我认为 V 的值应该是 A,如果是,那么将 V 的值更新为 B,否则不修改,并告诉我 V 的实际值是什么”。CAS 是一种乐观技术,它希望能成功的执行更新操作,并且如果有另一个线程在最近一次检查后更新了该变量,那么 CAS 能检测到这个错误。程序清单 15-1 中的 SimulatedCAS 说明了 CAS 语义(并非实现和性能)。
@ThreadSafe public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程更够成功更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同,当获取锁失败时线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。有一个线程在竞争 CAS 时失败不会阻塞,因此它可以决定是否进行重试,或者执行一些恢复动作,也或者不执行任何操作。这种灵活性就大大减少了与锁相关的活跃性风险(尽管在一些不常见的情况下仍然存在活锁风险——见 10.3.3 节)。
CAS 的典型使用模式是:首先从 V 中读取值 A,并根据 A 值计算新值 B,然后通过 CAS 以原子方式将 V 中的值由 A 变成 B(只要在这期间没有任何线程将 V 的值修改为其他值)。由于 CAS 能检测到来自其他线程的干扰,因此即使不适用锁也能实现原子的读-该-写操作序列。
15.2.2 非阻塞计数器
程序清单 15-2 中的 CasCounter 使用 CAS 实现了一个线程安全的计数器。递增操作采用了标准形式——读取旧值,根据旧值计算出新值(+1),并使用 CAS 来设置新值。如果 CAS 失败,那么该操作将立即重试。通常,返回重试是一种合理的策略,但在一些竞争很激烈的情况下,更好的方式是在重试之前首先等待一段时间或回退,从而避免造成活锁问题。
@ThreadSafe public class CasCounter {
private SimulatedCAS value;
public int getValue() {
return value.get();
}
public int increment() {
int v;
do {
v = value.get();
} while (v != value.compareAndSwap(v, v + 1));
return v + 1;
}
}
CasCounter 不会阻塞,但如果其他线程同时更新计数器,那么会多次执行重试操作。(在实际情况中,如果仅需要一个计数器或序列生成器,那么可以直接使用 AtomicInteger 或 AtomicLong,它们能提供原子的递增方法和其他一些算术方法)。
咋一看,基于 CAS 的计数器似乎比基于锁的计数器在性能上会更差一些,因为它需要执行更多的操作和更复杂的控制流,并且还依赖于看似复杂的 CAS 操作。但实际上,当竞争程度不高时,基于 CAS 的计数器在性能上远远超过基于锁的计数器,而在没有竞争时甚至更高。如果要快速获取无竞争的锁,那么至少需要一次 CAS 操作再加上与其他锁相关的操作,因此基于所的计数器即使在最好的情况下也会比基于 CAS 的计数器在一般情况下能执行更多的操作。由于 CAS 在大多数情况下都能成功执行(假设竞争程度不高),因此硬件能够正确的越策 while 循环中的分支,从而把复杂控制逻辑的开销将至最低。
虽然 Java 语言的锁定语法比较简单,但 JVM 和操作在管理锁时需要完成的工作却并不简单。在实现锁定时需要遍历 JVM 中一条非常复杂的代码路径,并可能导致操作系统级的锁定、线程挂起、上下文切换等操作。在最好的情况下,在锁定时至少需要一次 CAS,因此虽然在使用锁时没有用到 CAS,但实际上也无法节约任何执行开销。另一方面,在程序内部执行 CAS 时不需要执行 JVM 代码、系统调用或线程调度操作。在应用级看起来越长的代码路径,如果加上 JVM 和操作系统中的代码调用,那么事实上却变得更短。CAS 的主要缺点是,它将使调用者处理竞争问题(通过重试、回退、放弃),而在锁中能自动处理竞争问题(线程在获得锁之前将一直阻塞)。
CAS 的性能会随着处理器数量的不同而变化很大。在单 CPU 系统中,CAS 通常只需要很少的时钟周期,因为不需要处理器之间的同步。在编写本书时,非竞争的 CAS 在多 CPU 系统中需要 10 到 150 个时钟周期的开销。CAS 的执行性能不仅在不同的体系架构之间变化很大,甚至在相同处理器的不同版本之间也会发生变化。生产厂商迫于竞争的压力,在接下来的几年内还会继续提高 CAS 的性能。一个很有效的经验法则是:在大多数处理器上,在无竞争的加解锁“快速代码路径”上的开销,大约是 CAS 开销的两倍。
15.2.3 JVM 对 CAS 的支持
那么,Java 代码如何确保处理器执行 CAS 操作呢?在 Java 5.0 之前,如果不编写明确的代码,那么就无法执行 CAS。在 Java 5.0 中引入了底层的支持,在 int、long 和对象的引用类型上都公开了 CAS 操作,并且 JVM 把他们编译为底层硬件提供的最有效方法。在支持 CAS 的平台上,运行时再把这些方法编译为对应的(多条)机器指令。在最坏的情况下,如果不支持 CAS 指令,那么 JVM 将使用自旋锁。在原子类变量(AtomicXxx)中使用了这些底层的 JVM 支持为数字类型和引用类型提供了一种高效的 CAS 操作,而在 JUC 中的大多数类在实现时则直接或间接的引用了这些原子变量类。
15.3 原子变量类
原子变量比锁的粒度更细、更加轻量级,这对于在处理器系统上实现高性能并发代码来说是非常关键的。原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况(假设算法能够基于这种细粒度来实现)。更新原子变量的快速(非竞争)路径不会比获取锁的路径慢,并且通常会更快,而它的慢速路径肯定比锁的慢速路径要快,因为它不需要挂起或重新调度线程。在使用基于原子变量而非锁的算法中,线程在执行时更不易出现延迟,并且如果遇到竞争,也更容易恢复过来。
原子变量类相当于一种泛化的 volatile 变量,能够支持原子的和有条件的读-该-写操作。AtomicInteger 表示一种 int 类型的值,并提供了 get 和 set 方法,这些 volatile 类型的 int 变量在读取和写入上有着相同的内存语义。它还提供了一个原子的 compareAndSet 方法(如果该方法执行成功,那么将实现与读取/写入一个 volatile 变量相同的内存效果),以及原子的添加、递增和递减等方法。AtomicInteger 表面上非常像一个扩展的 Counter 类,但在发生竞争的情况下能够提供更高的可伸缩性,因为它直接利用了硬件对并发的支持。
共有 12 个原子变量类,可分为 4 组:标量类(Scalar)、更新器类、数组类、符合变量类。最常用的原子变量就是标量类:AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference。所有这些类都支持 CAS,此外,AtomicInteger 和 AtomicLong 还支持算术运算。(要想模拟其他基本类型的原子变量,可以将 short 或 byte 等类型与 int 类型进行转换,以及使用 floatToIntBits 或 doubleToLongBits 来转换浮点数)。
原子数组类(仅支持 Integer、Long、Reference 版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了 volatile 类型的访问语义,这是普通数组所不具备的特性——volatile 类型的数组仅在数组引用上具有 volatile 语义,而在其元素上则没有。
尽管原子标量类扩展了 Number 类,但并没有扩展一些基本类型的包装类,例如 Integer 或 Long。事实上,它们也不能进行扩展:基本类型的包装类是不可修改的,而原子变量类是可修改的。在原子变量类中同样没有定义 hashCode 和 equals 方法,每个实例都是不同的。与其他可变对象相同,它们也不宜用作基于散列的容器中的键值。
15.3.1 原子变量是一种更好的 volatile
在 3.4.2 节中,我们使用了一个指向不可变对象的 volatile 引用来原子的更新多个状态变量。这个示例依赖于“先检查再运行”,但在这种特殊的情况下,竞争是无害的,因为我们并不关心是否会遇到偶尔的丢失更新操作。而在大多数情况下,这种“先检查再运行”不会是无害的,并且可能会破坏数据的一致性。例如,在第四章中的 NumberRange 既不能使用指向不可变对象的 volatile 引用来安全的实现上界和下界,也不能使用原子的整数来保存这两个边界。由于有一个不变性条件限制了两个数值,并且它们无法在同时更新时还维持该不变性条件,因此如果在数值范围类中使用 volatile 引用或多个原子整数,那么将出现不安全的“先检查再运行”操作序列。
可以将 OneValueCache 中的技术与原子引用结合起来,并通过对指向不可变对象(其中保存了上界和下界)的引用进行原子更新以避免竟态条件。在程序清单 15-3 的 CasNumgerRange 中使用了 AtomicReference 和 IntPair 来保存状态,并通过使用 compareAndSet,使它在更新上界或下界时能避免 NumberRange 的竟态条件。
public class CasNumberRange {
@Immutable private static class IntPair {
final int lower; // Invariant: lower <= upper
final int upper;
...
}
private final AtomicReference<IntPair> values =
new AtomicReference<IntPair>(new IntPair(0, 0));
public int getLower() {
return values.get().lower;
}
public int getUpper() {
return values.get().upper;
}
public void setLower(int i) {
while (true) {
IntPair oldv = values.get();
if (i > oldv.upper)
throw new IllegalArgumentException( "Can't set lower to " + i + " > upper");
IntPair newv = new IntPair(i, oldv.upper);
if (values.compareAndSet(oldv, newv))
return;
}
}
// similarly for setUpper
}
15.3.2 性能比较:锁与原子变量
为了说明锁和原子变量之间的可伸缩性差异,我们构造了一个基准测试,其中将比较伪随机数生成器(PRNG)的集中不同实现。在 PRNG 中,当生产下一个随机数时需要用到上一个数字,所以在 PRNG 中必须记录上一个数值并将其作为状态的一部分。
程序清单 15-4 和程序清单 15-5 给出了线程安全的 PRNG 的两种实现,一种使用 ReentrantLock,另一种使用 AtomicInteger。测试程序将返回调用它们,在每次迭代中将生成一个伪随机数(在此过程中将读取并修改共享的 seed 状态),并执行一些仅在线程本地数据上执行的“繁忙”迭代,这种方式模拟了一些典型操作,以及一些在共享状态以及线程本地状态上的操作。
@ThreadSafe
public class ReentrantLockPseudoRandom extends PseudoRandom {
private final Lock lock = new ReentrantLock(false);
private int seed;
ReentrantLockPseudoRandom(int seed) {
this.seed = seed;
}
public int nextInt(int n) {
lock.lock();
try {
int s = seed;
seed = calculateNext(s);
int remainder = s % n;
return remainder > 0 ? remainder : remainder + n;
} finally {
lock.unlock();
}
}
}
@ThreadSafe
public class AtomicPseudoRandom extends PseudoRandom {
private AtomicInteger seed;
AtomicPseudoRandom(int seed) {
this.seed = new AtomicInteger(seed);
}
public int nextInt(int n) {
while (true) {
int s = seed.get();
int nextSeed = calculateNext(s);
if (seed.compareAndSet(s, nextSeed)) {
int remainder = s % n;
return remainder > 0 ? remainder : remainder + n;
}
}
}
}
图 15-1 和图 15-2 给出了在每次迭代中工作量较低以及适中情况下的吞吐量。如果线程本地的计算量较少,那么在锁和原子变量上的竞争将非常激烈,如果线程本地的计算量较多,那么在锁和原子变量上的竞争会降低,因为线程访问锁和原子变量的频率将会降低。
从这些图中可以看出,在高度竞争的情况下,锁的性能将超过原子变量的性能,但是在更真实的竞争情况下,原子变量的性能则会超过锁的性能。这是因为锁在发生竞争时会挂起线程,从而降低了 CPU 的使用率和共享内存总线上的同步通信量。(这类似于在生产者消费者设计中的可阻塞生产者,它能降低消费者上的工作负担,使消费者的处理速度赶上生产者的处理速度)。另一方面,如果使用原子变量,那么发出调用的类负责对竞争进行管理。与大多数基于 CAS 的算法一样,AtomicPseudoRandom 在遇到竞争时会立即重试,这通常是一种正确的做法,但在激烈竞争的环境下却导致了更多的竞争。
在批评 AtomicPseudoRandom 写得太糟糕或者原子变量比锁更糟糕之前,应该意识到图 15-1 中竞争级别过高而有些不切实际:任何一个真实的程序都不会除了竞争锁或原子变量,其他什么工作都不做。在实际情况中,原子变量在可伸缩性上要高于锁,因为在应对常见的竞争程度时,原子变量的效率会更高。
锁与原子变量在不同竞争程度上的性能差异很好的说明了各自的优势和劣势。在中低程度的竞争下,原子变量能够提供更好的可伸缩性,而在高轻度的竞争下,锁能够更有效的避免竞争。(在单 CPU 系统中,基于 CAS 算法在性能上同样会超过基于所的算法,因为 CAS 在单 CPU 的系统上通常能执行成功,只有在偶然情况下,线程才会在执行读-改-写的操作过程中被其他线程抢占执行)。
在图 15-1 和图 15-2 中都包含了第三条曲线,它是一个使用 ThreadLocal 来保存 PRNG 状态的 RseudoRandom。这种实现方法改变了类的行为,即每个线程都只能看到自己私有的伪随机数序列,而不是所有线程共享同一个随机数序列,这说明了,如果能够避免使用共享状态,那么开销将会更小。我们可以通过提供处理竞争的效率来提高可伸缩性,但只有完全消除竞争,才能实现真正的可伸缩性。
15.4 非阻塞算法
在基于所的算法中可能会发生各种活跃性故障。如果线程在持有锁时由于阻塞 IO、内存缺页、或其他延迟而导致推迟执行,那么很可能所有线程都不能继续执行下去。如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁(lock-free)算法。如果在算法中仅将 CAS 用于协调线程之间的操作,并且能够正确的实现,那么它既是一种非阻塞算法,又是一种无锁算法。无竞争的 CAS 通常都能执行成功,并且如果有多个线程竞争同一个 CAS,那么总会有一个线程在竞争中胜出并执行下去。在非阻塞算法中通常不会出现死锁和优先级反转问题(但可能会出现饥饿和活锁问题,因为在算法中会反复的出现重试)。到目前为止,我们已经看到了一个非阻塞算法:CasCounter。在许多常见的数据结构中都可以使用非阻塞算法,包括栈、队列、优先队列、以及散列表等,而要设计一些新的这种数据结构,最好还是由专家们来完成。
15.4.1 非阻塞的栈
在实现相同功能的前提下,非阻塞算法通常比基于锁的算法更为复杂。创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。在链式容器类中,有时候无需将状态转换操作表示为对节点链接的修改,也无需使用 AtomicReference 来表示每个必须采用原子操作来更新链接。
栈是最简单的链式数据结构:每个元素仅指向一个元素,并且每个元素也只被一个元素引用。在程序清单 15-6 的 ConcurrentStack 中给出了如何通过原子引用来构建栈的示例。栈是由 Node 元素构成的一个链表,其中栈顶作为根节点,并且在每个元素中都包含了一个值以及指向下一个元素的链接。put 方法创建一个新的节点,该节点的 next 域指向当前的栈顶,然后使用 CAS 把这个新节点放入栈顶。如果在开始插入节点时,位于栈顶的节点没有发生变化,那么 CAS 就会成功,如果栈顶节点发生了变化(比如由于其他线程在本线程开始之前插入或移除了元素),那么 CAS 将会失败,而 push 方法会根据栈的当前状态来更新节点,并且再次尝试。无论哪种情况,在 CAS 执行完成后,栈仍会处于一致的状态。
@ThreadSafe
public class ConcurrentStack <E> {
AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node <E> {
public final E item; public Node<E> next;
public Node(E item) { this.item = item; }
}
}
在 CasCounter 和 ConcurrentStack 中说明了非阻塞算法的所有特性:某项工作的完成具有不确定性,必须重新执行。在 ConcurrentStack 中,当构造表示新元素的 Node 时,我们系统当把这个新节点压入到栈时,其 next 引用的值仍然是正确的,同时也准备好在发生竞争时的清下重新尝试。
在像 ConcurrentStack 这样的非阻塞算法中都能确保线程安全性,因为 compareAndSet 像锁定机制一样,技能提高原子性,又能提高可见性。当一个线程需要改变栈的状态时,将调用 compareAndSet,这个方法与写入 volatile 变量一样有着相同的内存效果。当线程检查站的状态时,将在同一个 AtomicReference 上调用 get 方法,该方法与读取 volatile 变量有着相同的内存效果。因此,一个线程执行的任何修改结构都可以安全的发布给其他正在查看状态的线程。并且,这个栈是通过 compareAndSet 来修改的,因此将采用原子操作来更新 top 的引用,或者在发现存在其他线程干扰的情况下,修改操作将失败。
15.4.2 非阻塞链表
到目前为止,我们已经看到了两个非阻塞算法,计数器和栈,它们很好的说明了 CAS 的基本使用状态:在更新某个值时存在不确定性,以及在更新失败时重新尝试。构建非阻塞算法的技巧在于:将执行原子修改的范围缩小到单个变量上。这在计数器中很容易实现,在栈中也很简单,但对于一些更复杂的数据结构来说,如队列、散列表、树,这也要复杂的多。
连接队列比栈更为复杂,因为他必须支持对头结点和尾节点的快速访问。因此,它需要单独维护头指针和尾指针。有两个指针指向位于尾部的节点:当前最后一个元素的 next 指针,以及尾节点。当成功的插入一个新元素时,这两个指针都需要采用原子操作来更新。初看起来,这个操作无法通过原子变量来实现。在更新这两个指针时需要不同的 CAS 操作,并且如果第一个 CAS 成功,但第二个 CAS 失败,那么队列将处于不一致的状态。并且,即使这两个 CAS 都成功了,那么在执行这两个 CAS 之间,让可能有另一个线程会访问队列。因此,在为链表队列构建非阻塞算法时,需要考虑到这两种情况。
我们需要使用一种技巧。第一个技巧是,即使在一个包含多个步骤的更新过程中,也要确保数据结构总是处于一致的状态。这样,当线程 B 到达时,如果发现线程 A 正在执行更新,那么线程 B 就可以知道有一个操作已经部分完成,并且不能立即开始自己的更新操作。然后,B 可以等待(通过反复检查队列的状态)并直到 A 完成更新,从而使两个线程不会互相干扰。
虽然这种方法能够使不同的线程“轮流”访问数据结构,并且不会造成破坏,但如果一个线程在更新操作中失败了,那么其他线程都无法再访问队列。要使得该算法成为一个非阻塞算法,必须确保当一个线程失败时不会妨碍其他线程继续执行下去。因此,第二个技巧在于,如果当 B 到达时发现 A 正在修改数据结构,那么在数据结构中应该有足够多的信息,使得 B 能够完成 A 的更新操作。如果 B “帮助” A 完成了更新操作,那么 B 就可以执行自己的操作,而不用等待 A 的操作完成。当 A 恢复后再试图完成其操作时,会发现 B 已经替他完成了。
在程序清单 15-7 中的 LinkedQueue 中给出了 Michael-Scott 提出的非阻塞链接队列算法中的插入部分,在 ConcurrentLinkedQueue 中使用的正式该算法。在许多队列算法中,空队列通常都包含一个“哨兵节点”或者“哑节点”,并且头节点和尾节点在初始化时都指向该哨兵节点。尾节点通常要么指向哨兵节点(如果队列为空),即队列的最后一个元素;要么指向(当有操作正在进行更新时)指向倒数第二个元素。图 15-3 给出了一个处于正常状态(或者说稳定状态)的包含两个元素的队列。
@ThreadSafe
public class LinkedQueue <E> {
private static class Node <E> {
final E item;
final AtomicReference<Node<E>> next;
public Node(E item, Node<E> next) {
this.item = item;
this.next = new AtomicReference<Node<E>>(next);
}
}
private final Node<E> dummy = new Node<E>(null, null);
private final AtomicReference<Node<E>> head =
new AtomicReference<Node<E>>(dummy);
private final AtomicReference<Node<E>> tail =
new AtomicReference<Node<E>>(dummy);
public boolean put(E item) {
Node<E> newNode = new Node<E>(item, null);
while (true) {
Node<E> curTail = tail.get();
Node<E> tailNext = curTail.next.get();
if (curTail == tail.get()) {
if (tailNext != null) {
// Queue in intermediate state, advance tail
tail.compareAndSet(curTail, tailNext);
} else {
// In quiescent state, try inserting new node
if (curTail.next.compareAndSet(null, newNode)) {
// Insertion succeeded, try advancing tail
tail.compareAndSet(curTail, newNode);
return true;
}
}
}
}
}
}
当插入一个新的元素时,需要更新两个指针。首先更新当前最后一个元素的 nex 指针,将新节点连接到列表队尾,然后更新尾节点,将其指向这个新元素。在这两个操作之间,队列处于一种中间状态,如图 15-4 所示。在第二次更新完成后,队列将再次处于稳定状态,如图 15-5 所示。
实现这两个技巧时的关键点在于:当队列处于稳定状态时,尾节点的 next 域将为空,如果队列处于中间状态,呢么 tail.next 将为非空。因此,任何线程队能够通过检查 tail.next 来获取队列的当前状态。而且,当队列处于中间状态时,可以通过将尾节点向前移动一个节点,从而结束其他线程正在执行的插入元素操作,并使得队列恢复为稳定状态。
LinkedQueue.put 方法在插入新元素之前,将首先检查队列是否处于中间状态(步骤 A)。如果是,那么有另一个线程正在插入元素(在步骤 C 和 D 之间)。此时当前线程不会等待其他线程执行完成,而是帮助它完成操作,并将尾节点向前推进一个节点(步骤 B)。然后,它将重新恢复执行这种检查,以免另一个线程已经开始插入新元素,并继续推进尾节点,知道它发现队列处于稳定状态之后,才会开始执行自己的插入操作。
由于步骤 C 中的 CAS 将把新节点链接到队列尾部,因此如果两个线程同时插入元素,那么这个 CAS 将失败。在这样的情况下,并不会造成破坏:不会发生任何变化,并且当前的线程只需要重新读取尾节点并再次重试。如果步骤 C 成功了,那么插入操作将生效,第二个 CAS(步骤 D)被认为是一个“清理操作”,因为它既可以由执行插入操作的线程来执行,也可以由其他任何线程来执行。如果步骤 D 失败了,那么执行插入操作的线程将返回,而不是重新执行 CAS,因为不再需要重试——另一个线程已经在步骤 B 中完成了这个工作。这种方式能够工作,因为在任何线程尝试将一个新节点插入到队列之前,都会首先通过检查 tail.next 是否为空来判断是否需要执行清理工作。如果是,它首先会推进尾节点(可能需要执行多次),知道队列处于稳定状态。
15.4.3 原子的域更新器
程序清单 15-7 说明了在 ConcurrentLinkedQueue 中使用的算法,但在实际的实现中略有区别。在 ConcurrentLinkedQueue 中没有使用原子引用来表示每个 Node,而是使用普通的 volatile 类型应用,并通过基于反射的 AtomicReferenceFieldUpdater 来进行更新,如程序清单 15-8 所示。
private class Node<E> {
private final E item; private volatile Node<E> next;
public Node(E item) {
this.item = item;
}
}
private static AtomicReferenceFieldUpdater<Node, Node> nextUpdater =
AtomicReferenceFieldUpdater.newUpdater( Node.class, Node.class, "next");
原子的类更新器类表示现有的 volatile 域的一种基于反射的视图,从而能够在已有的 volatile 域上使用 CAS。在更新器类中没有构造函数,要创建一个更新器对象,可以调用 newUpdater 工厂方法,并指定类和域的名字。域更新器类没有与某个特定的实例关联在一起,因而可以更新目标类的任意实例中的指定域。更新器提供的原子性保证比普通原子类更弱一些,因为无法保证底层的域不被直接修改——compareAndSet 以及其他算法方法只能确保其他使用原子域更新器方法的线程的原子性。
在 ConcurrentLinkedQueue 中,使用 nextUpdater 的 compareAndSet 方法来更新 Node 的 next 域。这个方法有点繁琐,但完全是为了提升性能。对于一些频繁分配并且生命周期很短的对象,如队列的链接节点,如果能去掉每个 Node 的 AtomicReference 创建过程,那么将极大的降低插入操作的开销。然而,几乎在所有情况下,普通原子变量的性能都很不错,只有在很少的情况下才需要使用原子的域更新器。(如果在执行原子更新的同时还需要维持现有类的串行化形式,那么原子的域更新器将非常有用)。
15.4.4 ABA 问题
ABA 问题是一种异常现象:如果在算法中的节点可以被循环使用,那么在使用 CAS 指令时就可能出现这种问题(主要在没有垃圾回收机制的环境中)。在 CAS 操作中将判断 “V 的值是否仍然为 A?”,并且如果是的话就继续执行更新操作。在大多数情况下,包括本章给出的示例,这种判断是完全足够的。然而,有时候还是需要知道“自从上次看到 V 的值为 A 以来,这个值是否发生过变化?”。在某些算法中,如果 V 的值首先由 A 变为 B,再由 B 变为 A,那么仍然被认为是发生了变化,并需要重新执行算法中的某些步骤。
如果在算法中采用自己的方式来管理节点对象的内存,那么可能出现 ABA 问题。在这种情况下,即使链表的头结点仍然指向之前观察到的节点,那么也不足以说明链表的内容没有发生改变。如果通过垃圾回收器来管理链表节点仍然无法避免 ABA 问题,那么还有一个相对简单的解决方案:不是更新某个引用的值,而是更新两个值,包括一个引用和一个版本号。即使这个值由 A 变为 B,然后又变为 A,版本号也将是不同的。AtomicStampedReference(以及 AtomicMarkableReference)支持在两个变量上执行原子的条件更新。AtomicStampedReference 将更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免 ABA 问题。类似的,AtomicMarkableReference 将更新一个“对象引用-布尔值”二元组,在某些算法中将通过这种二元组使节点保存在链表中同时又将其标记为“已删除的节点”。
小结
非阻塞算法通过底层的并发原语(如 CAS 而不是锁)来维持线程安全性。这些底层的原语通过原子变量类向外公开,这些类也用作一种“更好的 volatile 变量”,从而为整数和对象引用提供原子的更新操作。
非阻塞算法在设计和实现时非常困难,但通常能够提供更高的可伸缩性,并能更好的防止活跃性故障的发生。在 JVM 从一个版本升级到下一个版本的过程中,并发性能的主要提升都来自于(在 JVM 内部以及平台类库中)对非阻塞算法的使用。
3.1.16 - CH16-内存模型
本书中,我们尽可能的避开了 Java 内存模型(JMM)的底层细节,而将重点放在一些高层次的设计问题,如安全发布、同步策略的规范以及一致性等。它们的安全性都来自于 JMM,并且当你理解了这些机制的工作原理之后,就能更容易的使用它们。本章将介绍 JMM 的底层需求以及它提供的保证,此外还将介绍在本书给出的一些高层设计原则背后的原理。
16.1 什么是内存模型,为什么需要它
假设一个线程为变量 aVariable 赋值:
aVariable = 3;
内存模型需要解决一个问题:“在什么条件下,读取 aVariable 的线程将看到它的值为 3?”。这听起来似乎是一个愚蠢的问题,但如果缺少同步,那么将会发生很多因素使得线程无法立即甚至永远看到另一个线程的操作结果。在编译器中生成的指令顺序,可以与源码中的顺序不同,此外编译器还会把变量保存在寄存器而非内存中:处理器可以采用乱序执行或并行等方式来执行指令;缓存可能会改变将写入变量提交到主存的次序;而且,保存在处理器本地缓存中的值,对于其他处理器是不可见的。这些因素都会使得一个线程无法看到变量的最新值,并且会导致其他线程中的内存操作似乎在乱序执行——如果没有使用正确的同步。
在单线程环境中,我们无法看到所有这些底层技术,它们除了提高程序的执行速度外,不会产生太大的影响。Java 语言规范要求 JVM 在线程中维护一种类似串行的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。这确实是一件好事情,因为在最近几年中,计算性能的提升在很大程度上要归功于这些重排序措施。当然,时钟频率的提升同样提升了性能,此外还有不断提升的并行性——采用流水线的超标量执行单元、动态指令调度、猜测执行以及完备的多级缓存。随着处理器变得越来越强大,编译器也不断地在改进:通过对指令重排序来实现执行优化,以及使用成熟的全局寄存器分配算法。由于时钟频率越来越难以提高,因此许多处理器制造厂商都开始转而生产多核处理器,因为能够提高的只有硬件的并行性。
在多线程环境中,维护程序的串行性将导致很大的性能开销。对于开发应用程序中的线程来说,他们在大部分时间里都执行各自的任务,因此在线程之间的协调操作只会降低应用程序的运行速度,而不会带来任何好处。只有当多个线程要共享数据时,才必须协调它们之间的操作,并且 JVM 依赖程序通过通过操作来找出这些协调操作将会在何时发生。
JMM 规定了 JVM 必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。JMM 在设计是就在可预测性和程序的易于开发性之间进行了权衡,从而在各种主流的处理器体系架构上能够实现高性能的 JVM。如果你不了解现代处理器和编译器中使用的程序性能优化措施,那么在刚刚接触 JMM 的某些方面时会感到困惑。
16.1.1 平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期的与主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最低的保证,即允许不同的处理器在任意时刻从一个存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至还包括应用程序)需要弥合这种在硬件能力与线程安全需求之间的差异。
要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息都是不需要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。在架构定义的内存模型中将告诉应用程序可以从内存系统中获取怎样的保证,此外还定义了一些特殊的指令(称为内存屏障或屏障),当需要共享数据时,这些指令就能实现额外的存储协调保证。为了使 Java 开发在人员无需关心不同架构上内存模型之间的差异,Java 还提供了自己的内存模型,并且 JVM 通过在适当的位置上插入内存屏障来屏蔽在 JMM 与底层平台内存模型之间的差异。
程序执行一段简单的假设:想象在程序中只存在唯一的操作执行顺序,而不考虑这些操作在何种处理器上执行,并且在每次读取变量时,都能获得在执行时序中(在任何处理器)最近一次写入该变量的值。这种乐观的模型就被称为串行一致性。软件开发人员经常会错误的假设存在串行一致性,但在任何一款现代多处理器架构中都不会提供这种串行一致性,JMM 也是如此。冯诺依曼模型中这种经典串行计算模型,只能近似描述现代多处理器的行为。
在现代支持共享内存的多处理器(和编译器)中,当跨线程共享数据时,会出现一些奇怪的情况,除非通过使用内存屏障来防止这些情况的发生。幸运的是,Java 程序不需要指定内存屏障的位置,而只需要正确的使用同步来找出何时将访问共享状态。
16.1.2 重排序
在第二章中介绍竟态条件和原子性故障时,我们使用了交互图来说明:在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM 还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
在程序清单 16-1 的 PossibleReording 中说明了,在么有正确同步的情况下,即使要推断最简单的并发程序的行为也是很困难的。很容易想象 PossibleReording 是如何输出(1,0)或(0,1)或(1,1)的:线程 A 可以在线程 B 开始之前就执行完成,线程 B 也可以在线程 A 开始之前执行完成,或者二者的操作交替执行。但奇怪的是,PossibleReording 还可以输出 (0,0)。由于每个线程中的各个操作之间不存在数据流依赖性,因此这些操作可以乱序执行。(即使这些操作按顺序执行,但在将缓存刷新到主内存的不同时序中也可能出现这种情况,从线程 B 的角度看,线程 A 中的复制操作可能以相反的次序执行)。图 16-1 给出了一种可能由重排序导致的交替执行方式,这种情况恰好会输出 (0,0)。
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
other.start();
one.join();
other.join();
System.out.println("( "+ x + "," + y + ")");
}
}
PossibleReording 是一个简单的程序,但要列举出它所有可能的结果却非常困难。内存级重排序会使程序的行为变得不可预测。如果没有使用同步,那么推断出执行顺序将是非常困难的,而要确保在程序中正确的使用同步确实非常容易的。同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏 JMM 提供的可见性保证。
16.1.3 JMM 简介
JMM 是通过各种操作来定义的,包括对变量的读写操作、监视器的加解锁操作、线程的启动和合并操作。JMM 为程序中所有的操作定义了一个偏序关系,称之为 HappensBefore。要想保证执行操作 B 的线程能够看到操作 A 的结果(无论 A 和 B 是否在同一个线程中执行),那么在 A 和 B 之间必须满足 HappensBefore 关系。如果两个操作之间缺乏 HappensBefore 关系,那么 JVM 可以对它们进行任意的重排序。
偏序关系 π 是集合上的一种关系,具有反对称、自反和传递性,但对于任意两个元素 x、y 来说,并不需要一定满足 x π y 或 y π x 的关系。我们每天都在使用偏序关系来表达喜好,如我们可以使用更喜欢寿司而不是三明治,可以更喜欢莫扎特而不是马勒,但我们不必在三明治和莫扎特之间做出明确的喜好选择。
当一个变量被多个线程读取并且至少被一个线程写入时,如果在读取操作和写入操作之间没有依照 HappensBefore 来排序,那么就会产生数据竞争问题。在正确同步的程序中不存在数据竞争,并会表现出串行一致性,这意味着程序中的所有操作都会安装一种固定的和全局的顺序执行。
HappensBefore 规则包括:
- 程序顺序规则。如果程序中操作 A 在操作 B 之前,那么在线程中 A 操作将在 B 操作之间执行。
- 监视器锁规则。在监视器上的解锁操作必须在同一个监视器锁上的加锁操作之前执行。
- volatile 变量规则。对 volatile 变量的写入操作必须在对该变量的读取操作之前执行。
- 程序启动规则。在线程上对 Thread.start 的调用必须在该线程中执行任何操作之前执行。
- 线程结束规则。线程中任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 时返回 false。
- 中断规则。当一个线程在另一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用之前执行(或者抛出中断异常,或者调用 isInterrupted 和 interrupted)。
- 终结器规则。对象的构造函数必须在启动该对象的终结器之间执行完成。
- 传递性。如果操作 A 在操作 B 之前执行,并且操作 B 在操作 C 之前执行,那么操作 A 必须在操作 C 之前执行。
虽然这些操作只满足偏序关系,但同步操作,如锁的获取与释放操作,以及 volatile 变量的读写操作,都满足全序关系。因此,在描述 HappensBefore 关系时,就可以使用“后续的加锁操作”和“后续的 volatile 变量读取操作”等表述语句。
图 16-2 给出了当两个线程使用同一个锁进行同步时,在它们之间的 HappensBefore 关系。在线程 A 内部的所有操作都按照他们在源程序中的先后顺序来排序,在线程 B 内部的操作也是如此。由于 A 释放了锁 M,并且 B 随后获得了锁 M,因此 A 中所有在释放锁之前的操作,也就位于 B 请求锁之后的所有操作之前。如果这两个线程在不同的锁上进行同步,那么就不能推断出它们之间的动作关系,因为这两个线程的操作之间并不存在 HappensBefore 关系。
16.1.4 借助同步
由于 HappensBefore 的排序功能很强大,因此有时候可以“借助”现有同步机制的可见性属性。这需要将 HappensBefore 的程序顺序规则与其他某个顺序规则(通常是监视器锁或 volatile 变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。这是一项高级技术,并且只有当需要最大限度的提升某些类(如 ReentrantLock)的性能时,才应该使用这种技术。
在 FutureTask 的保护方法 AQS 中说明了如何使用这种借助技术。AQS 维护一个表示同步器状态的整数,FutureTask 用这个整数来保存任务的状态:正在运行、已完成、已取消。但 FutureTask 还维护了其他一些变量,如计数结果。当一个线程调用 set 来保存结果并且另一个线程调用 get 来获取该结果时,这两个线程最好按照 HappensBefore 进行排序,这可以通过将执行结果的引用声明为 volatile 类型来实现,但利用现有的同步机制可以更容易地实现相同的功能。
FutureTask 在设计时能够确保,在调用 tryAcquireShared 之前总能成功的调用 tryReleaseShared。tryReleaseShared 会写入一个 volatile 类型的变量,而 tryAcquireShared 将读取该变量。程序清单 16-2 给出了 innerSet 和 innerGet 等方法,在保存和获取 result 时将调用这些方法。由于 innerSet 将在调用 releaseShared (这又将调用 tryReleaseShared)之前写入 result,并且 innerGet 将在调用 acquireShared (这又将调用 tryReleaseShared)之后读取 result,因此将程序顺序规则与 volatile 变量规则结合在一起,就可以确保 innerSet 中的写入操作在 innerGet 中的读取操作之前执行。
// Inner class of FutureTask
private final class Sync extends AbstractQueuedSynchronizer {
private static final int RUNNING = 1, RAN = 2, CANCELLED = 4;
private V result; private Exception exception;
void innerSet(V v) {
while (true) {
int s = getState();
if (ranOrCancelled(s))
return;
if (compareAndSetState(s, RAN))
break;
}
result = v;
releaseShared(0);
done();
}
V innerGet() throws InterruptedException, ExecutionException {
acquireSharedInterruptibly(0);
if (getState() == CANCELLED)
throw new CancellationException();
if (exception != null)
throw new ExecutionException(exception);
return result;
}
}
之所以将这项技术成为“借助”,是因为它使用了一种现有的 HappensBefore 顺序来确保对象 X 的可见性,而不是专门为了发布 X 而创建一种 HappensBefore 顺序。
在 FutureTask 中使用的借助技术很容易出错,因此要谨慎使用。但在某些情况下,这种借助技术是非常合理的。如,当某个类在其规范中规定他的各个方法之间必须遵循一种 HappensBefore 关系,基于 BlockingQueue 实现的安全发布就是一种“借助”。如果一个线程将对象置入队列并且另一个线程随后获取这个对象,那么这就是一种安全发布,因为在 BlockingQueue 的实现中包含有足够的内部同步来确保入列操作在出列操作之前执行。
在类中提供的其他 HappensBefore 排序包括:
- 将一个元素放入一个线程安全容器的操作将在另一个线程从该容器中获得这个元素的操作之前执行。
- 在 CountDownLatch 上的倒数操作将在线程从闭锁上的 await 方法中返回之前执行。
- 在释放 Semaphore 许可的操作将在从该 Semaphore 上获得一个许可之前执行。
- Future 表示的任务的所有操作将在从 Future.get 中返回之前执行。
- 向 Executor 提交一个 Runnable 或 Callable 的操作将在任务开始执行之前执行。
- 一个线程到达 CyclicBarrier 或 Exchanger 的操作将在其他到达该栅栏或交换点的线程被释放之前执行。如果 CyclicBarrier 使用一个栅栏操作,那么到达栅栏的操作将在栅栏操作之前执行,而栅栏操作又会在线程从栅栏中释放之前执行。
16.2 发布
第三章介绍了如何安全的或者不正确的发布一个对象。对于其中介绍的各种安全技术,他们的安全性都来自于 JMM 提供的保证,而造成不正确发布的真正原因,就是在“发布一个共享对象”与“另一个线程访问该对象”之间缺少一种 HappensBefore 排序。
16.2.1 不安全的发布
当缺少 HappensBefore 关系时,就能出现重排序问题,这就解释了为什么在么有充分同步的情况下发布一个对象会导致另一个线程看到一个只被部分构造的对象。在初始化一个新的对象时需要写入多个变量,即新对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享对象引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将于对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造的对象。
错误的延迟初始化将导致不正确的发布,如程序清单 16-3 所示。初看起来,在程序中存在的问题只有在 2.2.2 中介绍的竟态条件问题。在某些特定条件下,例如当 Resource 的所有实例都相同时,你或许会忽略这些问题(以及在多次创建 Resource 实例时存在低效率问题)。然而,即使不考虑这些问题,UnsafeLazyInitialization 仍然是不安全的,因为另一个线程可能看到对部分构造的 Resource 实例的引用。
@NotThreadSafe
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getInstance() {
if (resource == null)
resource = new Resource(); // unsafe publication
return resource;
}
}
假设线程 A 是一个调用 getInstance 的线程。它将看到 resource 为 null,并且初始化一个新的 Resource,然后将 resource 设置为执行这个新实例。当线程 B 随后调用 getInstance,它可能看到 resource 的值为非空,因此使用这个已经构造好的 Resource。最初看不出任何问题,但线程 A 写入 resource 的操作与线程 B 读取 resource 的操作之间不存在 HappensBefore 关系。在发布对象时存在数据竞争问题,因此 B 并不一定看到 Resource 的正确状态。
当新分配一个 Resource 时,Resource 的构造函数将把新实例中的各个域由默认值(由 Object 构造函数写入的)修改为他们的初始值。由于在两个线程中都没有使用同步,因此线程 B 看到的线程 A 中的操作顺序,可能与线程 A 执行这些操作时的顺序并不相同。因此,即使线程 A 初始化 Resource 实例之后再将 resource 设置为指向他,线程 B 仍可能看到对 resource 的写入操作将在对 Resource 各个域的写入操作之前发生。因此,线程 B 就可能看到一个被部分构造的 Resource 实例,该实例肯呢个处于无效的状态,并在随后该实例的状态可能出现无法预料的变化。
除了不改变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
16.2.2 安全的发布
第三章中介绍的安全发布常用模式可以确保被发布对象对于其他对象是可见的,因为他们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。如果线程 A 将 X 放入 BlockingQueue (并且随后没有线程修改它),线程 B 从队列中获取 X,那么可以确保 B 看到的 X 与 A 放入的 X 相同。这是因为在 BlockingQueue 的实现中有足够的内部同步确保了 put 方法在 take 方法之前执行。同样,通过使用一个由锁保护共享变量或者使用共享的 volatile 类型变量,也可以确保对该变量的读取操作和写入操作按照 HappensBefore 关系来排序。
事实上,HappensBefore 比安全发布提供了更强的可见性与顺序保证。如果将 X 从 A 安全的发布到 B,那么这种安全发布可以保证 X 状态的可见性,但无法保证 A 访问的其他变量的状态可见性。然而,如果 A 将 X 置入队列的操作在线程 B 从队列中获取 X 的操作之间执行,那么 B 不仅能看到 A 留下的 X 的状态,(假设线程 A 或其他线程都没有对 X 再次进行修改),而且还能看到 A 在移交 X 之前所做的任何操作。
既然 JMM 已经提供了这种更强大的 HappensBefore 关系,那么为什么还要介绍 @GuardedBy
和安全发布呢?与内存写入操作的可见性相比,从转移对象的所有权以及对象公布等角度来看,它们更符合大多数程序的设计。HappensBefore 排序是在内存访问级别上的操作,它是一种“并发级汇编语言”,而安全发布的运行级别更接近程序设计。
16.2.3 安全初始化模式
有时候,我们需要推迟一些高开销的对象的初始化操作,并且只有当使用这些对象时才对其进行初始化,但我们也看到了在无用延迟初始化时导致的问题。在程序清单 16-4 中,通过将 getResource 方法声明为 synchronized,可以修复 UnsafeLazyInitialization 中的问题。由于 getInstance 的代码路径很短(只包括一个判断遇见和一个预测分支),因此如果 getInstance 没有被多个线程频繁调用,那么在 SafeLazyInitialization 上不会存在激烈的竞争,从而提供令人满意的性能。
@ThreadSafe
public class SafeLazyInitialization {
private static Resource resource;
public synchronized static Resource getInstance() {
if (resource == null)
resource = new Resource();
return resource;
}
}
在初始化中采用了特殊的方式来处理静态域(或者在静态初始化块中初始化的值),并提供了额外的线程安全性保证。静态初始化器是由 JVM 在类的初始化阶段执行的,即在类被加载后并且被线程使用之前。由于 JVM 将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保该类已被加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步机制来确保随后的修改操作是可见的,以及避免数据被破坏。
如程序清单 16-5 所示,通过使用提前初始化,避免了在每次调用 SafeLazyInitialization 中的 getInstance 时所产生的同步开销。通过将这项技术和 JVM 的延迟加载机制结合起来,可以形成一种延迟初始化技术,从而在常见的代码路径中避免使用同步。在程序清单 16-6 的“延迟初始化占位类模式”中使用了一个专门的类来初始化 Resource。JVM 将推迟 ResourceHolder 的初始化操作,直到开始使用这个类时才初始化,并且由于通过一个静态初始化来创建 Resource,因此不需要额外的同步。当任何一个线程第一次调用 getResource 时,都会使 ResourceHolder 被加载和被初始化,此时静态初始化器将执行 Resource 的初始化操作。
@ThreadSafe public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() { return resource; }
}
@ThreadSafe public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceHolder.resource ;
}
}
16.2.4 双重检查加锁
在任何一本介绍并发的书中都会讨论声名狼藉的双重检查加锁(DCL),如程序清单 16-7 所示。在早期的 JVM 中,同步(甚至是无竞争的同步)都存在着巨大的同步开销。因此,人们先出了许多“聪明的(或者至少看上去聪明)”技巧来降低同步的影响,有些技巧很好,但有些技巧却不然,甚至是糟糕的,DSL 就属于糟糕的一类。
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (DoubleCheckedLocking.class) {
if (resource == null)
resource = new Resource();
}
}
return resource;
}
}
由于早期的 JVM 在性能上存在一些有待优化的地方,因此延迟初始化经常被用来避免不必要的高开销操作,或者用来降低程序的启动时间。在编写正确的延迟初始化方法中需要使用同步。但在当时,同步不仅执行速度慢,并且重要的是,开发人员还没有完全理解同步的含义:虽然人们能很好的理解了“独占性”,却没有很好的理解“可见性”的含义。
DCL 声称能实现两全其美——在常见的代码路径上的延迟初始化中不存在同步开销。它的工作原理是,首先检查是否在没有同步的情况下需要初始化,如果 resource 引用不为空,那么就直接使用它。否则,就进行同步并再次检查 Resource 是否已被初始化,从而保证只有一个线程对其共享的 Resource 执行初始化。在常见的代码路径中——获取一个已构造完成的 Resource 引用时并没有使用同步。这就是问题所在:在 16.2.1 中介绍过,线程能够看到一个仅被部分构造的 Resource。
DCL 的真正问题在于:当在没有同步的情况下读取一个共享对象时,可能发生的最糟糕情况就是看到一个失效值(在这个例子中是一个空值),此时 DCL 方法将通过在持有锁的情况下再次尝试来避免这种风险。然而,实际情况远比这种情况要糟糕——线程可能看到引用的当前值,但对象的状态值却是失效的,这意味着线程可以看到对象处于失效或错误的状态。
在 JMM 的后续版本(5.0 及以上)中,如果把 resource 声明为 volatile,那么就能启用 DCL,并且这种技术对性能的影响很小,因为 volatile 变量读取操作的性能通常只是略高于非 volatile 变量读取操作的性能。然而,DCL 的这种用法已经被广泛得废弃了——促使该模式出现的驱动力(无竞争同步的执行速度很慢每、JVM 启动时很慢)已经不复存在,因而他不是一种高效的优化措施。延迟初始化占位类模式能带来同样的优势,并且更易理解。
16.3 初始化过程中的安全性
如果能够确保初始化过程的安全性,那么就可以使的被正确构造的不可变对象在没有同步的情况下也能安全的在多个线程之间共享,而不管他们是如何发布的,甚至通过某种数据竞争来发布。(这意味着,如果 Resource 是不可变的,那么 UnsafeLazyInitialization 实际上是线程安全的)。
如果不能确保初始化的安全性,那么当在发布或线程中没有使用同步时,一些本因为不可变对象(如 String)的值将会发生改变。安全性架构依赖于 String 的不可变性,如果缺少了初始化安全性,那么可能导致一个安全漏洞,从而使恶意代码绕过安全检查。
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象各个 final 域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个 fianl 域到达的任意变量(如某个 final 数组中的元素,或者由一个 final 域引用的 HashMap 的内容)将同样对于其他线程是可见的。
对于含有 final 域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造过程之前。当构造函数完成时,沟站函数对 final 域的所有写入操作,以及对通过这些域可以到达的任何变量的写入操作,都将被“冻结”,并且任何获得该对象引用的线程都至少能确保看到被冻结的值。对于通过 fianl 域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。
初始化安全性意味着,程序清单 16-8 的 SafeStates 可以安全的发布,即便通过不安全的延迟初始化,或者在没有同步的情况下将 SafeStates 的引用放到一个工友的静态域,或者没有使用同步以及依赖于非线程安全的 HashSet。
@ThreadSafe
public class SafeStates {
private final Map<String, String> states;
public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
...
states.put("wyoming", "WY"); }
public String getAbbreviation(String s) {
return states.get(s);
}
}
然而,许多对 SafeStates 的细微修改可能会破坏它的线程安全性。如果 states 不是 final 类型,或者存在除构造函数以外的其他方法能修改 states,那么初始化安全性将无法确保在缺少同步的情况下安全的访问 SafeStates。如果在 SafeStates 中还有其他的非 final 域,那么其他线程任然可能看到这些域上的不正确的值。这也导致了对象在构造过程中逸出,从而使初始化安全性的保证失效。
初始化安全性只能保证通过 final 域可达的值从构造过程完成时开始的可见性。对于通过非 final 域科大的值,或者在构成过程完成后可能改变的值,必须采用同步来确保可见性。
小结
Java 内存模型说明了某个内存操作在哪些情况下对于其他线程是可见的。其中包括确保这些操作是按照一种 HappensBefore 的偏序关系进行排序,而这种关旭是基于内存操作和同步操作等级别来定义的。如果缺少充足的同步,那么在当线程访问共享数据时,会发生一些非常奇怪的问题。然而,如果使用第二、三章介绍的更高级规则,如 @GuardedBy
和安全发布,那么即使不考虑 HappensBefore 的底层细节,也能确保线程安全性。
3.2 - Java 并发模式
3.3 - 深入理解并行
3.3.1 - CH01-关于本书
- Is Parallel Programming Hard, And If So, What Can You Do About It?
- 中文版:深入理解并行编程
- 作者:Paul E. McKenney
- 译者:谢宝友、鲁阳
- 中文版基于 2015.x,对照英文版本 2017.11.22a。
- 本书的英文版以长期维护的开源形式免费供读者阅读,且每个版本都存在变更,大到整书的组织结构、小到示例代码。
- 因此建议想要基于本书开展实践的同学直接阅读英文版。
本书专注于基于 基于共享内存的并行编程 ,重点放在软件栈底层的软件,比如操作系统内核、并行的数据库管理系统、底层系统库等。
本书包含一些广泛应用且使用频繁的设计技巧,而非一些适用范围有限的最佳算法。
内容简介:
- CH01:关于本书。
- CH02:并行编程概览。
- CH03:介绍共享内存并行硬件。因为,在不了解底层硬件的情况下很难编写出正确的并行代码。
- CH04:为常用的、基于共享内存的并行编程原语提供了一个简要的概览。
- CH05:深入介绍了并行领域中可能最简单的一个问题——计数。
- CH06:介绍了一些设计层的方法,用于解决 CH05 中遇到的问题。
- CH07:锁。
- CH08:数据所有权。
- CH09:延期处理机制——引用计数、危险指针、顺序锁、RCU。
- CH10:将前面介绍的技术应用到哈希表。
- CH11:各种形式的并行代码校验手段。
- CH12:形式验证。
- CH13:通过示例的形式介绍了一系列中等规模的并行编程问题。
- CH14:高级同步方法,如无锁同步、并行实时计算。
- CH15:关于内存序的高级主题。
- CH16:一些实践建议。
- CH17:并行编程的未来方向,包括共享内存并行系统设计、软件和硬件事务内存、函数式并行编程。
- 附录-C:着重介绍了内存屏障的原理与实践。
本文作者 Paul 是 Linux 内核大神,40 年开发经验。 全书干货居多,作者的介绍非常细致,很多高级主题让我这个新手感到震撼,值得反复阅读。 这里为了学习理解、加深记忆,标注、摘抄、整理了中文版、英文版中的内容,仅供个人学习、交流、查阅。 写书、译书不易,感谢作者、译者的辛勤汗水,请支持正版。
3.3.2 - CH02-简介
导致并行编程困难的历史原因
困难的分类:
- 并行系统在历史上的高价格与稀缺性。
- 研究者和从业人员对并行系统的经验欠缺。
- 缺少公开的并行代码。
- 并行编程缺少被广泛了解的工程准则。
- 相对于处理本身,通信的代价高昂,即使在紧凑的 共享内存系统 中也是如此。
目前这些问题的现状:
- 基于摩尔定律,并行系统的价格降低。
- 研究者和从业人员开始广泛接触并行系统。
- 大量开源的并行软件项目出现。
- 开发者社区形成,这些开发者知道产品级的并行代码需要什么样的准则。
- 通信、处理代价高昂的问题依然存在,而光的有限速度及原子特性会限制该领域的进展,但方法总是有的。
并行编程的目标
性能
大多并行编程的尝试都是为了提升性能。摩尔定律仍然在晶体管密度方面有效,但在单线程性能方面已经不再有效,因此 对性能的关注点从硬件转移到了并行软件。这意味着先编写单线程代码,再通过升级 CPU 来提升性能已不再可行。因此首先要考虑的是性能而不是扩展性。
即使拥有多个 CPU,也不必全部都用起来。并行编程主要是为了性能优化,但这只是众多优化措施中的一种。 如果程序够快则无需优化或并行化,亦或是基于单线程方式的优化。如果需要基于并行的方式进行优化,则需要与最好的串行算法进行比较,已确定并行化的必要性。
生产率
硬件的价格已远低于软件的开发与维护成本。仅仅高效的使用硬件已经不再足够,高效的利用开发者已经变得同等重要。
通用性
要想减少开发并行程序的高昂成本,一种方式是 让程序尽量通用。如果其他影响因素一样,通用的软件能获得更多用户从而摊薄成本。但通用性会带来更大的性能损失和生产率损失。如下是一些典型的并行开发环境。
- C/C++ 锁与线程:包含 POSIX 线程(pthreads)、Windows 线程以及众多系统内核环境。性能优秀、通用性良好,但生产率低。
- Java:生产率比 C/C++ 要高,虽然性能不断进步但仍低于 C/C++。
- MPI:该消息传递接口向大量的科学和技术计算提供能力,提供了无与伦比的性能和扩展性。虽然通用但主要面向科学计算。生产率低于 C/C++。
- OpenMP:该编译指令集用于并行循环,因此用于特定任务从而限制了其性能。比 MPI、C/C++ 要简单。
- SQL:结构化编程语言 SQL 仅用于数据库查询,性能出色、生产率优秀。
同时满足性能、生产率、通用性要求的并行编程环境仍不存在,因此必须在三者之间进行权衡。 越往上层,生成率越重要;越往下层,性能和通用性越重要。大量的开发工作消耗在上层,必须提高通用性以降低成本;下层的性能损失很难在上层得到恢复。越往上层,采用额外的硬件比采用额外的开发者更划算。
本书面向底层开发,因此主要关心 性能和通用性。
并行编程的替代方案
并行编程只是提升性能的方案之一,以下是一些其他流行的方案:
- 运行多个串行应用实例:
- 会增加内存消耗、CPU 指令周期浪费在重复计算中间结果上,也会增加数据复制操作。
- 利用现有的并行软件构建应用:
- 通常会牺牲性能,至少会逊色于精心构造的并行程序,但可以显著降低开发难度。
- 对串行应用进行逻辑优化:
- 来自并行计算的速度提升与 CPU 个数大约成正比,而对软件逻辑进行的优化可能会带来指数级的性能提升。
- 不同程序的性能瓶颈不同。
复杂的原因
并行编程需要双向交互:人告诉计算机要做什么;人还需要通过结果的性能和扩展性来评价程序。
我们所要考虑的并行程序开发者的任务,对于串行程序开发者来说是完全不需要的。我们将这些任务分为 4 类:
分割任务
合理的对任务进行分割以提升并行度,可以极大提升性能和扩展性,但是也会增加复杂性。 比如,分割任务可能会让全局错误处理和事件处理更复杂,并行程序可能需要一些相当复杂的同步措施来安全的处理这些全局事件。总的来说,每个任务分割都需要一些交互,如果某个线程不存在任何交互,那它的执行对任务本身也就不产生任何影响。但是交互也就意味着额外的、可能降低性能的开销。
如果同时执行太多线程,CPU 缓存将会溢出,引起过高的缓存未命中,降低性能。
运行程序并发执行会大量增加程序的状态集,导致程序难以理解和调试,降低生产率。
并行访问控制
单线程的线性程序对程序的所有资源都有访问权,主要是内存数据结构,但也可能是 CPU、内存、高速缓存、IO 设备、计算加速器、文件等。并行访问控制的问题有:
- 访问特定的资源是否受限于资源的位置。 比如本地或远程、显式或隐式、赋值或消息传递等。
- 线程如何协调对资源的访问。 这种协调由不同的并行语言或环境通过大量同步机制实现,如:消息传递、加锁、事务、引用计数、显式计时、共享原子变量、数据所有权等。因此要面对死锁、活锁、事务回滚等问题。
资源分割与复制
最有效的并行算法和系统都善于对资源进行并行化,所以并行编程的编写最好从分割写密集型资源和复制经常访问的读密集型资源开始。 这里所说的访问频繁的数据,可能是计算机系统、海量存储设备、NUMA节点、CPU、页面、Cache Line、同步原语实例、代码临界区等等多个层次。
与硬件交互
开发者需要根据目标硬件的高速缓存分布、系统的拓扑结果或者内部互联协议来对应用进行量体裁衣。
组合使用
最好的实践会将上述 4 种类型的基础性任务组合应用。比如,数据并行方案首先把数据分割以减少组件内的交互需求,然后分割相应的代码,最后对数据分区并与线程映射,以便提升吞吐、减少线程内交互。
3.3.3 - CH03-硬件特性
本章主要关注 共享内存系统中的同步和通信开销,仅涉及一些共享内存并行硬件设计的初级知识。
概述
人们容易认为 CPU 的性能就像在一条干净的赛道上赛跑,但事实上更像是一个障碍赛训练场。
流水线 CPU
在 20 世纪 80 年代,典型的微处理器在处理一条指令之前,至少需要取值、解码和执行这三个时钟周期来完成当前指令。到 90 年代之后,CPU 可以同时处理多条指令,通过一条很长的流水线来控制 CPU 内部的指令流。
带有长流水线的 CPU 要想达到最佳性能,需要程序给出高度可预测的控制流。如果程序代码执行的是紧凑循环,那么这种程序就能提供 可预测的控制流,此时 CPU 可以正确预测出在大多数情况下代码循环结束后的分支走向。在这种程序中,流水线可以一直保持在满状态,CPU 高速运行。
如果程序中带有很多循环,且循环计数都比较小,或者面向对象的程序中带有很多虚方法,每个虚方法都可以引用不同的对象实例,而这些对象实例都实现了一些频繁被调用的成员函数,此时 CPU 很难或者完全不可能预测某个分支的走向。这样一来,CPU 要么等待控制流进行到足以知道分支走向的方向,要么干脆猜测,但常常出错。这时流水线会被排空,CPU 需要等待流水线被新指令填充,这将大幅降低 CPU 的性能。
- 分支预测的原理?
内存引用
在 20 世纪 80 年代,微处理器从内存中读取一个值的时间一般比执行一条指令的时间短,即指令执行慢于内存 IO。在 2006 年,同样是读取内存中的一个值的时间,微处理器可以执行上百条甚至千条指令。这源于摩尔定律对 CPU 性能的提升,以及内存容量的增长。
虽然现代微型计算机上的大型缓存极大减少了内存访问延迟,但是只有高度可预测的数据访问模式才能发挥缓存的最大效用。因此对内存的引用也就造成了对 CPU 性能的严重影响。
原子操作
原子操作本身的概念在某种意义上与 CPU 流水线上一次执行多条指令的操作产生了冲突。而现代 CPU 使用了很多手段让这些操作看起来是原子的,即使这些指令实际上并非原子。比如标出所有包含原子操作所需数据的流水线,确保 CPU 在执行原子操作时,所有这些流水线都属于正在执行原子操作的 CPU,并且只有在这些流水线仍归该 CPU 所有时才推进原子操作的执行。这样一来,因为所有数据都只属于该 CPU,即使 CPU 流水线可以同时执行多条指令,其他 CPU 也无法干扰此 CPU 的原子操作执行。但这种方式要求流水线必须能够被延迟或冲刷,这样才能执行让原子操作过程正确完成的一系列操作。
非原子操作则与之相反,CPU 可以从流水线中按照数据出现的顺序读取并把结果放入缓冲区,无需等待流水线的归属切换。
虽然 CPU 设计者已经开始优化原子操作的开销,但原子指令仍频繁对 CPU 性能造成影响。
内存屏障
原子操作通常只用于数据的单个元素,由于许多并行算法都需要在更新多个元素时保证正确的执行顺序,因此大多数 CPU 都提供了内存屏障。
spin_lock(&mylock);
a = a +1;
spin_unlock(&mylock);
像这样一个基于锁的临界区中,锁操作必须包含隐式或显式的内存屏障。内存屏障可以防止 CPU 为了提升性能而进行乱序执行,因此内存屏障也一定会影响性能。
高速缓存未命中
现代 CPU 使用大容量的高速缓存来降低由于低速的内存访问带来的性能惩罚。但是,CPU 高速缓存事实上对多 CPU 间频繁访问的变量起到了反面效果。因为当某个 CPU 想去改变变量的值时,极有可能该变量的值刚被其他 CPU 修改过。这时,变量存在于其他 CPU 的高速缓存中,这将导致代价高昂的高速缓存为命中。
IO 操作
缓存未命中可被视为 CPU 之间的 IO 操作,也是代价最小的 IO 操作之一。IO 操作涉及网络、大容量存储,或者人类本身(人机交互 IO)。IO 操作对性能的影响也远远大于前面所有提到的所有影响因素。
这也是共享内存并行计算和分布式系统式的并行编程的其中一个不同点:共享内存式并行编程的程序一般不会处理比缓存未命中更糟的情况,而分布式并行编程的程序则会遭遇网络通信延迟。因此,通信的开销占实际执行任务的比率是一项关键的设计参数。
开销
硬件体系结构
这是一个 8 核计算机概要图:每个芯片上有 2 个核,每个核带有自己的高速缓存,每个芯片内还带有一个互联模块,使芯片内的两个核可以互相通信,图中央的系统互联模块可以让 4 个芯片之间互相通信,并且与主存进行连接。
数据以缓存行(cache line)为单位在系统中传输,缓存行对应内存中一个 2 的乘方大小的字节,大小通常为 32 到 256 之间。当 CPU 从内存中读取一个变量到它的寄存器中时,必须首先将该变量的缓存行读取到 CPU 高速缓存;CPU 寄存器中的一个值存储到内存时,不仅需要将包含了该值的缓存行写入 CPU 高速缓存,还必须确保其他 CPU 没有该缓存行的复制。
比如,如果 CPU0 在对一个变量执行"比较并交换(CAS)“操作,而该变量所在的缓存行存储在 CPU7 的高速缓存中。下面是将要发送的事件序列:
- CPU0 检查本地高速缓存,没有找到缓存行。
- 请求被转发到 CPU0 和 CPU1 的互联模块,检查 CPU1 的高速缓存,没有找到缓存行。
- 请求被转发到系统互联模块,检查其他三个芯片,得知缓存行被 CPU6 和 CPU7 所在的芯片持有。
- 请求被转发到 CPU6 和 CPU7 的互联模块,检查这两个 CPU 的高速缓存,在 CPU7 的高速缓存中找到缓存行。
- CPU7 将缓存行发送到自己所属的互联模块,并且刷新掉自己高速缓存中的缓存行。
- CPU6 和 CPU7 所在芯片的互联模块将缓存行发送给系统互联模块。
- 系统互联模块将缓存行发送给 CPU0 和 CPU1 所在芯片的互联模块。
- CPU0 和 CPU1 所在芯片的互联模块将缓存行发送给 CPU0 的高速缓存。
- CPU0 现在可以对高速缓存中的变量执行 CAS 操作。
操作开销
上图是各种同步机制相与 CPU 周期时间的比率。(4-CPU 1.8 GHz AMD Opteron 844 System)
软件设计的启示
并行算法必须将每个线程设计成尽可能独立运行的线程。越少使用线程间的同步通信手段,比如原子操作、锁或其他消息传递方法,应用程序的性能和扩展性就会越好。想要达到优秀的并行性和扩展性,就需要在并行算法和实现中挣扎,小心的选择数据结构和算法,尽量使用现有的并行软件和环境,或者将并行问题转换为已经拥有并行解决方案的问题。
- 好消息是多核系统变得廉价且可靠。
- 另一个好消息是,现在很多同步操作的开销正变得越来越小。
- 坏消息是 高速缓存未命中的开销仍然很高,特别是在大型系统上。 本书剩余部分则会讨论如何解决该问题。
3.3.4 - CH04-并行工具
本章主要介绍一些并行编程领域的基本工具,主要是类 Linux 系统上可以供应用程序使用的工具。
脚本语言
Shell 脚本提供了一种简单有效的并行化:
1 compute_it 1 > compute_it.1.out &
2 compute_it 2 > compute_it.2.out &
3 wait
4 cat compute_it.1.out
5 cat compute_it.2.out
第 1、2 行分别启动了两个实例,通过 &
符号使这两个程序在后台运行,并分别将程序的输出重定向到一个文件。第 3 行等待两个实例执行完毕,第 4、5 行显示程序的输出。
另外,例如 make
脚本语言提供了一个 -j
选项来指定编译过程中同时执行多少个并行任务,make -j4
则表示同时执行 4 个并行编译过程。
既然基于脚本的并行编程这么简单,为什么还需要其他工具呢?
POSIX 多进程
POSIX 进程的创建与销毁
进程通过 fork()
原语创建,通过 kill()
原语销毁,也可以通过 exit()
原语实现自我销毁。执行 fork()
原语的进程被称为新创建进程的父进程,父进程可以功能通过 wait()
原语等待子进程执行完毕。
1 pid = fork();
2 if (pid == 0) {
3 / * child * /
4 } else if (pid < 0) {
5 / * parent, upon error * /
6 perror("fork");
7 exit(-1);
8 } else {
9 / * parent, pid == child ID * /
10 }
fork()
的返回值表示了其执行状态,即上面片段中的 pid。
1 void waitall(void)
2 {
3 int pid;
4 int status;
5
6 for (;;) {
7 pid = wait(&status);
8 if (pid == -1) {
9 if (errno == ECHILD)
10 break;
11 perror("wait");
12 exit(-1);
13 }
14 }
15 }
父进程使用 wait()
原语来等待子进程时,wait()
只能等待一个子进程。我们将 wait()
原语封装成一个 waitall()
函数,该函数与 shell 中的 wait
意义一样:for(;;)
将会一直循环,每次循环等待一个子进程,阻塞直到该子进程退出,并返回子进程的进程 ID 号,如果该进程号为 -1,则表示 wait()
无法等待子进程执行完毕。如果检查错误码为 ECHILD
则表示没有其他子进程了,这时退出循环。
wait()
原语的复杂性在于,父进程与子进程之间不共享内存,而最细粒度的并行化需要共享内存,这时则要比不共享内存式的并行化复杂很多。
这种 fork-waitall
的形式被称为 fork-join。
POSIX 线程创建与销毁
在一个已有的进程中创建线程,需要调用 pthread_create()
原语,它的第一个参数指向 pthread_t
类型的指针,第二个 NULL 参数是一个可选的指向 pthread_attr_t
结构的指针,第三个参数是新线程要调用的函数(下面的例子中是 mythread()
),最后一个 NULL 参数是传递给 mythread()
的参数。
1 int x = 0;
2
3 void * mythread(void * arg)
4 {
5 x = 1;
6 printf("Child process set x=1\n");
7 return NULL;
8 }
9
10 int main(int argc, char * argv[])
11 {
12 pthread_t tid;
13 void * vp;
14
15 if (pthread_create(&tid, NULL,
16 mythread, NULL) != 0) {
17 perror("pthread_create");
18 exit(-1);
19 }
20 if (pthread_join(tid, &vp) != 0) {
21 perror("pthread_join");
22 exit(-1);
23 }
24 printf("Parent process sees x=%d\n", x);
25 return 0;
26 }
- 第 7 行中,
mythread
直接选择了返回,也可以使用pthread_exist()
结束。 - 第 20 行的
pthread_join()
原语是对 fork-join 中wait()
的模仿,它一直阻塞到 tid 变量指向的线程返回。线程的返回值要么是传给pthread_exit()
的返回值,要么是线程调用函数的返回值,这取决于线程退出的方式。
上面代码的执行结果为:
Child process set x=1
Parent process sees x=1
上面的代码中小心构造了一次只有一个线程为变量赋值的场景。任何一个线程为某变量赋值而另一线程读取变量值的场景,都会产生数据竞争(data-race)。因此我们需要一些手段来安全的并发读取数据,即下面的加锁原语。
POSIX 锁
POSIX 规范支持开发者使用 POSIX 锁来避免数据竞争。POSIX 锁包括几个原语,其中最基础的是 pthread_mutex_lock()
和 pthread_mutex_unlock()
。这些原语将会操作 pthread_mutex_t
类型的锁。该锁的静态声明和初始化由 PTHREAD_MUTEX_INITIALIZER
完成,或者由 pthread_mutex_init()
来动态分配并初始化。
因为这些加锁、解锁原语是互相排斥的,所以一次只能有一个线程在一个特定的时刻持有一把特定的锁。比如,如果两个线程尝试同时获取一把锁,那么其中一个线程会首先获准持有该锁,另一线程只能等待第一个线程释放该锁。
POSIX 读写锁
POSIX API 提供了一种读写锁,用 pthread_rwlock_t
类型来表示,与 pthread_mutex_t
的初始化方式类似。pthread_rwlock_wrlock()
获取写锁,pthread_rwlock_rdlock()
获取读锁,pthread_rwlock_unlock()
用于释放锁。
读写锁是专门为大多数读的情况设计的。该锁能够提供比互斥锁更多的扩展性,因为互斥锁从定义上已经限制了任意时刻只能有一个线程持有锁,而读写锁运行任意多的线程同时持有读锁。
读写锁的可扩展性不甚理想,尤其是临界区较小时。为什么读锁的获取这么慢呢?应该是由于所有想获取读锁的线程都要更新 pthread_rwlock_t
的数据结构,因此一旦 128 个线程同时尝试获取读写锁的读锁时,那么这些线程必须逐个更新读锁中的 pthread_rwlock_t
结构。最幸运的线程几乎立即就获得了读锁,而最倒霉的线程则必须在前 127 的线程完成对该结构的更新后再能获得读锁。而增加 CPU 则会让性能变得更糟。
但是在临界区较大,比如开发者进行高延迟的文件或网络 IO 操纵时,读写锁仍然值得使用。
原子操作(GCC)
读写锁在临界区最小时开销最大,因此需要其他手段来保护极其短小的临界区。GCC 编译器提供了许多附加的原子操作:
- 返回参数原值
__sync_fetch_and_sub()
__sync_fetch_and_or()
__sync_fetch_and_and()
__sync_fetch_and_xor()
__sync_fetch_and_nand()
- 返回变量新值
__sync_add_and_fetch()
__sync_sub_and_fetch()
__sync_or_ and_fetch()
__sync_and_and_fetch()
__sync_xor_and_fetch()
__sync_nand_ and_fetch()
经典的比较并交换(CAS)是由一对原语 __sync_bool_compare_and_swap()
和 __sync_val_compare_and_swap()
提供的,当变量的原值与指定的参数值相等时,这两个原语会自动将新值写到指定变量。第一个原语在操作成功时返回 1,或在变量原值不等于指定值时返回 0;第二个原语在变量值等于指定的参数值时返回变量的原值,表示操作成功。任何对单一变量进行的原子操作都可以用 CAS 方式实现,上述两个原语是通用的,虽然第一个原语在适用的场景中效率更高。CAS 操作通常作为其他原子操作的基础。
__sync_synchronize()
原语是一个内存屏障,它限制编译器和 CPU 对指令乱序执行的优化。有些时候只限制编译器对指令的优化就够了,CPU 的优化可以保留,这时则需要 barrier()
原语。有时只需要让编译器不优化某个内存访问就够了,这时可以使用 ACCESS_ONCE()
原语。后两个原语并非由 GCC 直接提供,可以按如下方式实现:
#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
#define barrier() __asm____volatile__("": : :"memory")
POSIX 的操作的替代选择
线程操作、加解锁原语、原子操作的出现早于各种标准委员会,因此这些操作存在多种变体。直接使用汇编实现这些操作也十分常见,不仅因为历史原因,还可以在某些特定场景下获得更好的性能。
如何选择
基于经验法则,应该在能够胜任工作的工具中选择最简单的一个。
- 尽量保持串行。
- Shell 脚本。
- C 中的 fork-join。
- POSIX 线程库原语。
- 第几章将要介绍的原语。
除此之外,不要忘记除了共享内存多线程执行之外,还可以选择进程间通信和消息传递。
3.3.5 - CH05-计数
计数概念的简单性让我们在探索并发中的基本问题时,无需被繁复的数据结构或复杂的同步原语干扰,因此可以作为并行编程的极佳切入对象。
并发计数并不简单
1 long counter = 0;
2
3 void inc_count(void)
4 {
5 counter++;
6 }
7
8 long read_count(void)
9 {
10 return counter;
11 }
- 1,声明一个计数器
- 5,将计数器加 1
- 10,读取计数器的值
当计数器不停读取计数但又几乎不增加计数时,计算性能非常好。但存在计数丢失。精确计数的最简单方式是使用原子操作:
1 atomic_t counter = ATOMIC_INIT(0);
2
3 void inc_count(void)
4 {
5 atomic_inc(&counter);
6 }
7
8 long read_count(void)
9 {
10 return atomic_read(&counter);
11 }
- 1,声明一个原子计数器
- 5,将计数器原子加 1
- 10,原子读取计数器的值
以上都是原子操作,因此非常精确,单线程时速度是非原子方式的 1/6,两个线程时速度是非原子方式的 1/10,即原子计数的性能随着 CPU 和线程数的增加而下降。
下图以 CPU 视角展示了原子操作带来的性能损耗,为了让每个 CPU 得到机会来增加一个全局变量,包含变量的缓存行需要在所有 CPU 间传播,沿下图中箭头所示的方向。这种传播相当耗时,从而导致了上图中糟糕的性能。
统计计数器
常见的统计计数器场景中,计数器更新频繁但很少被读,或者甚至完全不读。
设计
统计计数器一般以每个线程一个计数器的方式实现(或者在内核运行时以每个 CPU 一个),所以每个线程仅更更新自己的计数。而总的计数值就是所有线程计数值的和。
基于数组的实现
分配一个数组,数组每个元素对应一个线程(假设数组已经按缓存行对其并且被填充,以防止出现假共享)。
该数组可以用一个“每线程”原语来表示:
1 DEFINE_PER_THREAD(long, counter);
2
3 void inc_count(void)
4 {
5 __get_thread_var(counter)++;
6 }
7
8 long read_count(void)
9 {
10 int t;
11 long sum = 0;
12
13 for_each_thread(t)
14 sum += per_thread(counter, t);
15 return sum;
16 }
- 1,定义了一个数组,包含一套类型为 long 的每线程计数器 counter。
- 3~6,增加计数的函数,使用
__get_thread_var()
原语来定位当前运行线程对应 counter 数组的元素。因为该元素仅能由对应的线程修改,因此使用非原子自增即可。 - 8~16,读取总计数的函数,使用
for_each_thread()
原语遍历当前运行的所有线程,使用per_thread()
原语获取指定线程的计数。因为硬件可以原子地存取正确对齐的 long 型数据,并且 GCC 充分利用了这一点,所以使用非原子读取操作即可。
该方法随着 inc_count()
函数的更新者线程增加而线性扩展,原因是每个 CPU 可以快速的增加自己线程的变量值,不再需要代价高昂的、跨越整个计算机系统的通信,如下图所示:
但这种在“更新端”扩展极佳的方式在存在大量线程时会给“读取端”带来极大代价。接下来将介绍另一种方式,能在保留更新端扩展性的同时,减少读取端产生的代价。
最终结果一致的实现
一种保留更新端扩展性的同时又能提升读取端性能的方式是:削弱一致性要求。
前面介绍的计数算法要求保证返回的值在 read_count()
执行前一刻的理想计数值和 read_count()
执行完毕时的理想计数值之间。最终一致性方式提供了弱一些的保证:当不调用 inc_count()
时,调用 read_count()
最终会返回正确的值。
我们维护一个全局计数来利用”最终结果一致性“。但是因为写者只操作自己线程的每线程计数,我们需要一个单独的线程负责将每线程计数的计数值传递给全局计数,而读者仅需访问全局计数值。如果写者正在更新计数,读者读取的全局计数值将不是最新的,不过一旦写者更新完毕,全局计数最终会回归正确的值。
1 DEFINE_PER_THREAD(unsigned long, counter);
2 unsigned long global_count;
3 int stopflag;
4
5 void inc_count(void)
6 {
7 ACCESS_ONCE(__get_thread_var(counter))++;
8 }
9
10 unsigned long read_count(void)
11 {
12 return ACCESS_ONCE(global_count);
13 }
14
15 void * eventual(void * arg)
16 {
17 int t;
18 int sum;
19
20 while (stopflag < 3) {
21 sum = 0;
22 for_each_thread(t)
23 sum += ACCESS_ONCE(per_thread(counter, t));
24 ACCESS_ONCE(global_count) = sum;
25 poll(NULL, 0, 1);
26 if (stopflag) {
27 smp_mb();
28 stopflag++;
29 }
30 }
31 return NULL;
32 }
33
34 void count_init(void)
35 {
36 thread_id_t tid;
37
38 if (pthread_create(&tid, NULL, eventual, NULL)) {
39 perror("count_init:pthread_create");
40 exit(-1);
41 }
42 }
43
44 void count_cleanup(void)
45 {
46 stopflag = 1;
47 while (stopflag < 3)
48 poll(NULL, 0, 1);
49 smp_mb();
50 }
- 1~2,定义了跟踪计数值的没线程变量和全局变量。
- 3,定义了 stopflag,用于控制程序结束。
- 5~8,增加计数函数
- 10~13,读取计数函数
- 34~42,
count_init()
函数创建了位于 15~32 行的eventual()
线程,该线程将遍历所有线程,对每个线程的本地计算 counter 进行累加,将结果放入 global_count。eventual
线程在每次循环之间等待 1ms(随便选择的值)。 - 44~50,
count_cleanup()
函数用来控制程序结束。
本方法在提供极快的读取端计数性能的同时,仍然保持线性的更新端计数性能曲线。但也带来额额外的开销,即 eventual
线程。
基于每线程变量的实现
GCC 提供了一个用于每线程存储的 _thread
存储类。下面使用该类来实现统计计数器,该实现不仅能扩展,而且相对于简单的非原子自增来说几乎没有性能损失。
1 long __thread counter = 0;
2 long * counterp[NR_THREADS] = { NULL };
3 long finalcount = 0;
4 DEFINE_SPINLOCK(final_mutex);
5
6 void inc_count(void)
7 {
8 counter++;
9 }
10
11 long read_count(void)
12 {
13 int t;
14 long sum;
15
16 spin_lock(&final_mutex);
17 sum = finalcount;
18 for_each_thread(t)
19 if (counterp[t] != NULL)
20 sum += * counterp[t];
21 spin_unlock(&final_mutex);
22 return sum;
23 }
24
25 void count_register_thread(void)
26 {
27 int idx = smp_thread_id();
28
29 spin_lock(&final_mutex);
30 counterp[idx] = &counter;
31 spin_unlock(&final_mutex);
32 }
33
34 void count_unregister_thread(int nthreadsexpected)
35 {
36 int idx = smp_thread_id();
37
38 spin_lock(&final_mutex);
39 finalcount += counter;
40 counterp[idx] = NULL;
41 spin_unlock(&final_mutex);
42 }
- 1~4,定义所需变量,
counter
是每线程计数变量,counterp[]
数组允许线程访问彼此的计数,finalcount
在各个线程退出时将计数值累加到综合,final_mutex
协调累加计数总和值的线程和退出的线程。 - 更新者调用
inc_count()
函数,见 6~9 行。 - 写者调用
read_count()
函数,首先在 16 行获取与正在退出线程互斥的锁,21 行释放锁。17 行初始化已退出线程的每线程计数总和,18~20 将还在运行的线程的每线程计数累加进总和,最后,22 行返回总和。 - 25~32,
count_register_thread()
函数,每个线程在访问自己的计数前都要调用它,将本线程对应countp[]
数组中的元素指向线程的每线程变量 counter。 - 34~42,
count_unregister_thread()
函数,每个之前调用过count_register_thread()
函数的线程在退出时都要调用该函数。38 行获取锁,41 行释放锁,因此排除了有线程正在调用read_count()
同时又有线程调用count_unregister_thread()
函数的情况。39 行将本线程的每线程计数添加到全局 finalcount 中,然后将countp[]
数组的对应元素设置为 NULL。随后read_count()
调用可以在全局变量 finalcount 里找到已退出线程的计数值,并且顺序访问countp[]
数组时可以跳过已退出线程,从而获得正确的总计数结果。
该方式让更新者的性能几乎和非原子计数一样,并且也能线性扩展。另外,并发的读者竞争一个全局锁,因此性能不佳,扩展性差。但是这不是统计计数器要面对的问题,因为统计计数器总是在增加计数,很少读取计数。
近似上限计数器
另一种计数的场景是上限检查,比如需要维护一个已分配数据结构数目的计数器,来防止分配超过一个上限。我们假设这些结构的生命周期很短,数目也极少能超出上限。对近似值上限来说,偶尔超出少许是可以接受的。
设计
一种可能的实现是将近似总数值(10000)平均分配给每个线程,然后每个线程一个固定个数的资源池。假如有 100 个线程,每个线程管理一个有 100 个结构的资源池。这种方式简单,在有些情况下有效,但是无法处理一种常见情况:某个结构由一个结构创建,但由另一个线程释放。一方面,如果线程释放一个结构就得一分的话,那么一直在分配结构的线程很快就会分配光资源池,而一直在释放结构的线程积攒了大量分数却无法使用。另一方面,如果每个被释放的结构都能让分配它的 CPU 加一分,CPU 就需要操纵其他 CPU 的计数,这会带来昂贵的原子操作或其他跨线程通信手段。
因此,在很多重要的情况下我们不能讲计数问题完全分割。对于上限计数,我们可以采用一种分割计数方法的变体,部分地分割计数。比如在四个线程中,每个线程拥有一份每线程变量 counter,但同时每个线程也持有一份每线程的最大值 countermax。
如果某个线程需要增加其 counter,可是此时 counter 等于 countermax,这时该如何处理呢?此时可以把此线程 counter 值的一半转移给 globalcount,然后在增加 counter。举个例子,加入某线程的 counter 和 countermax 都为 10,我们可以执行如下操作:
- 获取全局锁
- 给 globalcount 增加 5
- 当前线程的 couter 减少 5,以抵消全局的增加
- 释放全局锁
- 递增当前线程的 counter,编程 6
虽然该操作中需要全局锁,但是该锁只有在每 5 次增加操作后才获取一次,从而降低了竞争程度,如果我们增大了 countermax 的值,竞争程度还会进一步降低。但是增大 countermax 值的副作用是 globalcount 精确度的降低。假设一台 4 CPU 系统,此时 countermax 值为 10,global 和真实计数值的误差最高可达 40,如果把 countermax 增加到 100,那么 globalcount 和真实计数值的误差可达 400。
因此问题成了我们到底有多在意 globalcount 和真实计数值的偏差。真实计数值由 globalcount 和所有每线程 counter 相加得出。误差取决于真实计数值和计数上限的差值有多大,差值越大,countermax 就越不容易超过 globalcountmax 的上限。这就代表着任何一个线程的 countermax 变量可以根据当前的差值计算取值。当离上限还比较远时,可以给每线程变量 countermax 赋值一个较大的数,这样对性能和扩展性都有好处。当靠近上限时,可以给这些 countermax 赋值一个比较小的数,这样可以降低超过统计上限 globalcountmax 的风险,从而减少误差。
这种设计就是一个并行快速路径的例子,这是一种重要的设计模式,适用于下面的情况:在多数情况下没有线程间的通信和交互开销,对偶尔进行的跨线程通信又使用了静心设计的(但是开销仍然很大的)全局算法。
精确上限计数器
一种实现精确计数的方式是允许线程放弃自己的计数,另一种是采用原子操作。当然,原子操作会减慢快速路径。
原子上限计数
如果想要一个线程减少另一个线程上的计数,需要自动的操作两个线程的 counter 和 countermax 变量。通常的做法是将这两个变量合并成一个变量,比如一个 32 位的变量,高 16 位代表 counter,低 16 位代表 countermax。
这种方式运行计数一直增长直到上限,但是也带来了快速路径上原子操作的开销,让快速路径明显变慢了。虽然在某些场合这种变慢是允许的,但是仍然值得我们去探索让读取端性能更好的算法。而使用信号处理函数从其他线程窃取计数就是一种算法。因为信号处理函数可以运行在收到信号线程的上下文,所以就不需要原子操作了。
Signal-Theft 上限计数
虽然每线程状态只由对应线程修改,但是信号处理函数仍然有必要进行同步。
上图中的状态机展示了这种同步机制。Signal-Threft 状态机从”空闲“状态开始,当 add_count()
和 sub_count()
发现线程的本地计数和全局计数之和已经不足以容纳请求的大小时,对应的慢速路径将每线程的 threft 状态设置为”请求”(除非线程没有计数值,这样它就直接转换为“准备完毕”)。只有在慢速路径获得 gblcnt_mutex_lock 之后,才允许从“空闲”状态转换为其他状态。然后慢速路径向每个线程发送一个信号,对应的信号处理函数检查本地线程的 threft 和 counting 状态。如果 threft 状态不为“请求”,则信号处理函数就不能改变其状态,只能直接返回。而 threft 状态为“请求”时,如果设置了 counting 变量,表名当前线程正处于快速路径,信号处理函数将 threft 状态设置为“确认”,而不是“准备完毕”。
如果 threft 状态为“确认”,那么只有快速路径才有权改变 threft 的状态。当快速路径完成时,会将 threft 状态设置为“准备完毕”。
一旦慢速路径发现某个线程的 threft 状态为“准备完毕”,这时慢速路径有权窃取此线程的计数。然后慢速路径将线程的 threft 状态设置为“空闲”。
在一般笔记本电脑上,使用 signal-threft 的实现比原子操作的实现快两倍。由于原子操作的相对缓慢,signal-threft 实现在 Pentium-4 处理器上比原子操作好的多,但是后来,老式的 8086 对称处理器系统在原子操作实现的路径深度更短,原子操作的性能也随之提升。可是,更新端的性能提升是以读取端的高昂开销为代价的,POSIX 信号不是没有开销的。如果考虑最终的性能,则需要在实际部署应用的系统上测试这两种方式。
特殊场合的并行计数
即便如此,如果计数的值总是在 0 附近变动,精确计数就没什么用了,正如统计对 IO 设备的访问计数一样。如果我们并不关心当前有多少计数,这种统计值总是在 0 附近变动的计数开销很大。比如在可移除 IO 设备的访问计数问题,除非有人想移除设备,否则访问次数完全不重要,而移除设备这种情况本身又很少见。
一种简单的解决办法是,为计数增加一个很大的“偏差值”(比如 10 亿),确保计数的值远离 0,让计数可以有效工作。当有人想拔出设备时,计数又减去偏差值。计数最后几次的增长将是非常低效的,但是对之前的所有计数去可以全速运行。
虽然带偏差的计数有用且有效,但这只是可插拔 IO 设备访问计数问题的部分解决办法。当尝试移除设备时,我们不仅需要当前精确的 IO 访问计数,还需要从现在开始阻止未来的访问请求。一种方式是在更新计数时使用读写锁的读锁,在读取计数时使用同一把读写锁的写锁。
并行计数讨论
本章展示了传统计数原语会遇见的问题:可靠性、性能、扩展性。C 语言的 ++ 操作符不能在多线程代码中保证函数的可靠性,对单个变量的原子操作性能不好,可扩展性也差。
并行计数性能
统计计数算法性能:
算法 | 写延迟 | 延迟(1核) | 读延迟(32核) |
---|---|---|---|
数组快速通道 | 11.5ns | 308ns | 409ns |
最终一致 | 11.6ns | 1ns | 1ns |
每线程变量 | 6.3ns | 389ns | 51,200ns |
RCU | 5.7ns | 354ns | 501ns |
上限计数算法性能:
算法 | 是否精确 | 写延迟 | 读延迟(1核) | 读延迟(32核) |
---|---|---|---|---|
每线程变量-1 | 否 | 3.6ns | 375ns | 50,700ns |
每线程变量-2 | 否 | 11.7ns | 369ns | 51,000ns |
原子方式 | 是 | 51.4ns | 427ns | 49,400ns |
信号方式 | 是 | 10.2ns | 370ns | 54,000ns |
并行计算的专门化
上述算法仅在各自的问题领域性能出色,这可以说是并行计算的一个主要问题。毕竟 C 语言的 ++ 操作符在所有单线程程序中性能都不错,不仅仅是个别领域。
我们提到的问题不仅是并行性,更是扩展性。我们提到的问题也不专属于算术问题,假设你还要存储和查询数据库,是不是还会用 ASCII 文件、XML、关系型数据库、链表、紧凑数组、B 树、基树或其他什么数据结构和环境来存取数据,这取决于你需要做什么、做多快、数据集有多大。
同样,如果需要计数,合适的方案取决于统计的数有多大、有多少个 CPU 并发操纵计数、如何使用计数,以及需要的性能和可扩展性程度。
总结
本章的例子显示,分割是提升可扩展性和性能的重要工具。计数有时可以被完全分割,或者被部分分割。
- 分割能够提升性能和可扩展性。
- 部分分割,也就是仅分割主要情况的代码路径,性能也很出色。
- 部分分割可以应用在代码上,但是也可以应用在时间空间上。
- 读取端的代码路径应该保持只读,对共享内存的“伪同步写”严重降低性能和扩展性。
- 经过审慎思考的延迟处理能够提升性能和扩展性。
- 并行性能和扩展性通常是跷跷板的两端,达到某种程度后,对代码的优化反而会降低另一方的表现。
- 对性能和可扩展性的不同需求及其他很多因素,会影响算法、数据结构的设计。
3.3.6 - CH06-分割同步设计
本章将描述如何设计能够更好利用多核优势的软件。编写并行软件时最重要的考虑是如何进行分割。正确的分割问题能够让解决方案简单、扩展性好且高性能,而不恰当的分割问题则会产生缓慢且复杂的解决方案。“设计”这个词非常重要:对你来说,应该是分割问题第一、编码第二。顺序颠倒会让你产生极大的挫败感,同时导致软件低劣的性能和扩展性。
分割练习
哲学家就餐
该问题指的是,桌子周围坐着 5 位哲学家,而桌子上每两个哲学家之间有一根叉子,因此是 5 个哲学家 5 根叉子。每个哲学家只能用他左手和右手旁的叉子用餐,一旦开始用餐则不吃到心满意足是不会停下的。
我们的目标就是构建一种算法来阻止饥饿。一种饥饿的场景是所有哲学家都去拿左手边的叉子。因为他们在吃饱前不会放下叉子,并且他们还需要第二把叉子才能开始用餐,所以所有哲学家都会挨饿。注意,让至少一位哲学家就餐并不是我们的目标,即使让个别哲学家挨饿也是要避免的。
Dijkstra 的解决方法是使用一个全局信号量。假设通信延迟忽略不计,这种方法十分完美。因此,近来的解决办法是像下图一样为叉子编号。每个哲学家都先拿他盘子周围编号最小的叉子,然后再拿编号最高的叉子。这样坐在图中最上方的哲学家会先拿起他左手边的叉子,然后是右手边的叉子,而其他哲学家则先拿起右手边的叉子。因为有两个哲学家试着会去拿叉子 1,而只有一位会成功,所以只有 4 位哲学家抢 5 把叉子。至少 4 位中的一位肯定能够拿到两把叉子,这样就能开始就餐了。
这种为资源编号并按照编号顺序获取资源的通用技术经常在被用在防止死锁上。 但是很容易就能想象出来一个事件序列来产生这种效果:虽然大家都在挨饿,但是一次只有一个哲学家就餐。
- P2 拿起叉子 1,阻止 P1 拿起叉子 1。
- P3 拿起叉子 2。
- P4 拿起叉子 3。
- P5 拿起叉子 4。
- P5 拿起叉子 5,开始就餐。
- P5 放下叉子 4 和 5。
- P4 拿起叉子 4,开始就餐。
简单来说,该算法会导致每次仅有一个哲学家能够就餐,即使 5 个哲学家都在挨饿,但事实上此时有足够的叉子供两名哲学家同时就餐。
上图是另一种解决方式,里面只有 4 位哲学家,而不是 5 位,这样可以更好的说明分割技术。最上方和最右边的哲学家合用一个叉子,而最下面和最左面的哲学家合用一个叉子。如果所有哲学家同时感觉饿了,至少有两位能够同时就餐。另外如图所示,现在叉子可以捆绑成一对,这样同时拿起或放下,就简化了获取和释放锁的算法。
这是水平化分割的一个例子,或者叫数据并行化,这么叫是因为哲学家之间没有依赖关系。在数处理型的系统中,“数据并行化”是指一种类型的数据只会被多个同类型软件组件中的一个处理。
双端队列
双端队列是一种元素可以从两端插入和删除的数据结构。这里将展示一种分割设计策略,能实现合理且简单的解决方案。
右手锁与左手锁
右手锁与左手锁是一种看起来很直接的办法,为左手端的入列操作添加一个左手锁,为右手端的出列操作添加一个右手锁。但是这种办法的问题是当队列中的元素不足 4 个时,两个锁的返回会发生重叠。这种重叠是由于移动任何一个元素不仅只影响元素本身,还要影响它左边和右边相邻的元素。这种范围在图中被涂上了淹死,蓝色表示左手锁的范围,红色表示右手锁的范围,紫色表示重叠的范围。虽然创建这样一种算法是可能的,但是至少要小心着五种特殊情况,尤其是在队列另一端的并发活动会让队列随时可能从一种特殊情况转变为另外一种特殊情况的场景。所以最好考虑其他解决方案。
复合双端队列
上图是一种强制确保锁的范围不会发生冲突的方法。两个单独的双端队列串联在一起,每个队列用自己的锁保护。这意味着数据偶尔会从双端队列的一列跑到另一列。此时必须同时持有两把锁。为避免死锁,可以使用一种简单的锁层级关系,比如,在获取右手锁前先获取左手锁。这比在同一列上同时使用两把锁要简单的多,因为我们可以无条件的让左边的入列元素进入左手队列,右边的入列元素进入右手队列。主要的复杂度来源于从空队列中出列,这种情况下必须做到如下几点:
- 如果持有右手锁,释放并获取左手锁,重新检查队列释放仍然为空。
- 获取右手锁。
- 重新平衡跨越两个队列的元素。
- 移除指定的元素。
- 释放两把锁。
代码实现也并不复杂,再平衡操作可能会将某个元素在两个队列之间来回移动,这不仅浪费时间,而且想要获得最佳性能,还需针对工作负荷不断微调。
哈希双端队列
哈希永远是分割一个数据结构的最简单有效的方法。可以根据元素在队列中的位置为每个元素分配一个序号,然后以此对双端队列进行哈希,这样第一个从左边进入空队列的元素编号为 0,第一个从右边进入空队列的元素编号为 1。其他从左边进入只有一个元素的队列的元素编号依次递减(-1,-2,-3…),而其他从右边进入只有一个元素的队列的元素编号依次递增(2,3,4…)。关键是,实际上并不用真正为元素编号,元素的序号暗含它们在队列中的位置中。
然后,我们用一个锁保护左手下标,用另外一个锁保护右手下标,再各用一个锁保护对应的哈希链表。上图展示了 4 个哈希链表的数据结构。注意锁的范围没有重叠,为了避免死锁,只在获取链表锁之前获取下标锁,每种类型的锁(下标或链表),一次获取从不超过一个。
每个哈希链表都是一个双端队列,在这个例子中,每个链表拥有四分之一的队列元素。上图最上面部分是 R1 元素从右边入队后的状态,右手的下标增加,用来引用哈希链表 2。上图第二部分是又有 3 个元素从右手入队。正如你所见,下标回到了它们初始的状态,但是每个哈希队列现在都是非空的。上图第三部分是另外三个元素从左边入队,而另外一个元素从右边入队后的状态。
从上图第三部分的状态可以看出,左出队操作将会返回元素 L-2,并让左手下边指向哈希链 2,此时该链表只剩下 R2。这种状态下,并发的左入队和右入队操作可能会导致锁竞争,但这种锁竞争发生的可能性可以通过使用更大的哈希表来降低。
上图展示了 12 个元素如何组成一个有 4 个并行哈希桶的双端队列。每个持有单锁的双端队列拥有整个并行双端队列的四分之一。
双端队列讨论
复合式实现在某种程度上要比哈希式实现复杂,但是仍然属于比较简单的。当然,更加智能的再平衡机制可以非常复杂,但是和软件实现相比,这里使用的软件再平衡机制已经很不错了,这个方法甚至不比使用硬件辅助算法的实现差多少。不过,从这种机制中我们最好也只能获得 2 倍的扩展能力,因为最多只有两个线程并发的持有出列的锁。这个局限同样适用于使用非足额同步方法的算法,比如 Michael 的使用 CAS 的出队算法。
事实上,正如 Dice 等人所说,非同步的单线程双端队列实现性能非常好,比任何他们研究过的并行实现都搞很多。因此,不管哪种实现,由于队列的严格先入先出特性,关键点都在于共享队列中出队或出队的巨大开销。
更近一步,对于严格先入先出的队列,只有在线性化点不对调用者可见时,队列才是严格先入先出。事实上,在事前的例子中“线性化点”都隐藏在带锁的临界区内。而这些队列在单独的指令开始时,并不保证先入先出。这表明对于并发程序来说,严格先入先出的特性并没有那么有价值。实际上 Kirsch 等人已经证明不提供先入先出保证的队列在性能和扩展性上更好。这些例子说明,如果你打算让并发数据出入一个单队列时,真的该重新考虑一下整体设计。
分割讨论
哲学家就餐问题的最后解法是该问题的最优解法,是“水平并行化”或“数据并行化”的极佳例子。在这个例子中,同步的开销接近于 0 或等于 0。相反,双端队列的实现是“垂直并行化”或者“管道”极佳示例,因为数据从一个线程转移到另一个线程。“管道”需要密切合作,因此为获得某种程度上的效率,需要做的工作更多。
设计准则
想要获取最佳的性能和扩展性,简单的办法就是不断尝试,直到你的程序和最优实现水平相当。但是如果你的代码不是短短数行,如何能在浩如烟海的代码中找到最优实现呢?另外,什么才是最优实现呢?前面给出了三个并行编程的目标:性能、生产率和通用性,最优的性能常常要付出生产率和通用性的代价。如果不在设计时就将这些选择考虑进去,就很难在限定的时间内开发出性能良好的并行程序。
但是除此之外,还需要更详细的设计准则来指导实际的设计,这就是本节的主题。在真实的世界中,这些准则将在某种程度上冲突,这需要设计者小心权衡得失。这些准则可以被认为是设计中的阻力,对这些阻力进行恰当权衡,就被称为“设计模式”。
基于三个并行编程目标的设计准则是加速、竞争、开销、读写比率和复杂性。
加速倍速:之所以花费如此多时间和精力进行并行化,加速性能是主要原因。加速倍速的定义是运行程序的顺序执行版本所需要的时间,除以执行并行版本所需时间的比例。
竞争:如果对一个并行程序来说,增加更多的 CPU 并不能让程序忙起来,那么多出来的 CPU 是因为竞争的关系而无法工作。可能是锁竞争、内存竞争或者其他什么性能杀手的原因。
工作-同步比率:单处理器、单线程、不可抢占、不可中断版本的并行程序完全不需要任何同步原语。因此,任何消耗在这些原语上(通信中的高速缓存为命中、消息延迟、加解锁原语、原子指令和内存屏障)的时间都是对程序意图完成的工作没有直接帮助的开销。同步开销与临界区中代码的开销之间的关系是重要的衡量准则,更大的临界区能容忍更大的同步开销。工作-同步开销比率与同步效率的概念有关。
读-写比率:对于极少更新的数据结构,更多是采用“复制”而不是“分割”,并且用非对称的同步原语来保护,以提高写者同步开销的代价来降低读者的同步开销。对频繁更新的数据结构的优化也是可以的。
复杂性:并行程序比相同的顺序执行的程序复杂,这是因为并行程序要比顺序执行程序维护更多的状态,虽然这些状态在某些情况下理解起来很容易。并行程序员必须要考虑同步原语、消息传递、锁的设计、临界区识别以及死锁等诸多问题。
更大的复杂性通常转换为了更高的开发代价和维护代价。因此,对现有程序修改的范围和类型非常受代码预算的限制,因为对原有程序的新年能加速需要消耗相当的时间和精力。在更糟糕的情况,增加复杂性甚至会降低性能和扩展性。
进一步说,在某种范围内,还可以对顺序执行程序进行一定程度的优化,这笔并行化更廉价、高效。并行化只是众多优化手段中的其中一种,并且只是 一种主要解决 CPU 为性能瓶颈的优化。
这些准则结合在一起,会让程序达到最大程度的加速倍数。前三个准则相互交织在一起,所以本节将着重分写这三个准则的交互关系。
请注意,这些准则也是需求说明的一部分。比如,加速倍速既是愿望、又是工作符合的绝对需求,或者说是运行环境。
理解这些设计准则之间的关系,对于权衡并行程序的各个设计目标十分有用。
- 程序在临界区上所花费的时间越少,潜在的加速倍速就越大。这是 Amdahl 定律的结果,这也是因为在一个时刻只能有一个 CPU 进入临界区的原因。更确切的说,程序在某个互斥的临界区上所耗费的时间必须大大小于 CPU 数的倒数,因为这样增加 CPU 数量才能达到事实上的加速。比如在 10 核系统上运行的程序只能在关键的临界区上花费少于 1/10 的时间,这样才能有效的扩展。
- 因为竞争所浪费的大量 CPU 或者时间,这些时间本来可以用于提高加速倍速,应该少于可用 CPU 的数目。CPU 数量和实际的加速倍速之间的差距越大,CPU 的使用率越低。同样,需要的效率越高,可以继续提升的加速倍速就越小。
- 如果使用的同步原语相较它们保护的临界区来说开销太大,那么加速程序运行的最佳办法是减少调用这些原语的次数(比如分批进入临界区、数据所有权、非对称同步、代码锁)。
- 如果临界区相较保护这块临界区的原语来说开销太大,那么加速程序运行的最佳办法是增加程序的并行化程度,比如使用读写锁、数据锁、非对称同步或数据所有权。
- 如果临界区相较保护这块临界区的原语来说开销太大,并且对受保护的数据结构读多于写,那么加速程序运行的最佳办法是增加程序的并行化程度,比如读写锁或非对称同步。
- 各种增加 SMP 性能的改动,比如减少锁竞争程度,能改善响应时间。
同步粒度
上图是对同步粒度不同层次的图形表示。每一种同步粒度都用一节内容来描述。
串行程序
如果程序在单处理器上运行足够快,并且不与其他进程、线程或者中断处理程序发生交互,那么你可以将代码中所有的同步原语删除,远离他们带来的开销和复杂性。好多年前曾有人争论摩尔定律最终会让所有程序变得如此,但是随着 2003 年以来 Intel CPU 的 CPU MIPS 和时钟频率增长速度的停止,此后要增加性能,就必须提高程序的并行化程度。是否这种趋势会导致一块芯片上继承几千个 CPU,这方面的争论不会很快停息,但是考虑本文作者 Paul 是在一台双核笔记本上敲下这句话的,SMP 的寿命极有可能比你我都长。另一个需要注意的地方是以太网的带宽持续增长。这种增长会进一步促进对多线程服务器的优化,这样才能有效处理通信载荷。
请注意,这并不意味着你应该在每个程序中都使用多线程方式编程。我再次说明,如果一个程序在单处理器上运行的很好,那么你就从 SMP 同步原语的开销和复杂性中解脱出来吧。
代码锁
代码锁是最简单的设计,仅使用全局锁。在已有的程序中使用代码锁,可以很容易让程序在多个处理器上运行。如果程序只有一个共享资源,那么代码锁的性能是最优的。但是,许多较大且复杂的程序会在临界区上执行多次,这就让代码锁的扩展性大大受限。
因此,最好在这样的程序中使用代码锁:只有一小段执行时间在临界区程序,或者对扩展性要求不高。在这种情况下,代码锁可以让程序相对简单,和单线程版本类似。
并且,代码锁尤其容易引起“锁竞争”,一种多个 CPU 并发访问同一把锁的情况。
数据锁
许多数据结构都可以分割,数据结构的每个部分带有一把自己的锁。这样虽然每个部分一次只能执行一个临界区,但是数据结构的各个部分形成的临界区就可以并行执行了。如果此时同步带来的开销不是主要瓶颈,那么可以使用数据来降低锁竞争程度。数据锁通过将一块过大的临界区分散到各个小的临界区来减少锁竞争,比如,维护哈希表中的 per-hash-bucket 临界区。不过这种扩展性的增强带来的是复杂性的少量提升,增加了额外的数据结构 struct bucket。
但是数据锁带来了和谐,在并行程序中,这总是意味着性能和扩展性的提升。因为这个原因,Sequent 在它的 DYNIX 和 DYNIX/ptx 操作系统中使用了数据锁。
不过,那些照顾过小孩的人可以证明,再细心的照料也不能保证一切风平浪静(多个小孩争抢一个玩具)。同样的情况也适用于 SMP 程序。比如,Linux 内核维护了一种文件和目录的缓存(dcache)。该缓存中的每个条目都有一把自己的锁。但是相较于其他条目,对应根目录的条目和它的直接后代更容易被遍历到。这将导致许多 CPU 竞争这些热门条目的锁。这就像虽然玩具有多个,但所有的孩子都要去挣同一个玩具。
在动态分配结构中,在许多情况下,可以设计算法来减少数据冲突的次数,某些情况下甚至可以完全消灭冲突(如 dcache)。数据锁通常用于分割像哈希表一样的数据结构,也适用于每个条目用某个数据结构的实例表示这种情况。
数据锁的关键挑战是对动态分配数据结构加锁,如何保证在获取锁时结构本身还存在。通过将锁放入静态分配且永不释放的哈希桶可以解决该挑战。但是这种手法不适用于哈希表大小可变的情况,所以锁也需要动态分配。在这种情况,还需要一些手段来阻止哈希桶在锁被获取之后的这段时间内释放。
数据所有权
数据所有权方法按照线程或者 CPU 的个数分割数据结构,在不需要任何同步开销的情况下,每个线程或者 CPU 都可以访问属于它的子集。但是如果线程 A 希望访问另一个线程 B 的数据,那么线程 A 是无法直接做到这一点。取而代之的是,线程 A 需要先与线程 B 通信,这样线程 B 以线程 A 的名义执行操作,或者另一种方法,将数据迁移到线程 A 上来。
数据所有权看起来很神秘,但是却应用得十分频繁:
- 任何只能被一个 CPU 或者一个线程访问的变量都属于这个 CPU 或者这个线程。
- 用户接口的实例拥有对应的用户上下文。这在与并行数据库引擎交互的应用程序中十分常见,让并行引擎看起来就像顺序执行的程序一样。这样应用程序拥有用户接口和当前操作。显式的并行化只在数据库引擎内部可见。
- 参数模拟,通常授予每个线程一段特定的参数区间,以此达到某种程度的并行化。有一些计算平台专门用来解决这类问题。
如果共享比较多,线程或者 CPU 间的通信会带来较大的复杂性和通信开销。不仅如此,如果最热的数据正好被一个 CPU 拥有,那么这个 CPU 就成了热点。不过,在不需要共享的情况下,数据所有权可以达到理想性能,代码也可以像顺序程序一样简单。最坏情况通常被称为尴尬的并行化。
另一个数据所有权的重要用法是当数据是只读时,这种情况下,所有线程可以通过复制来拥有数据。
并行快速路径
细粒度(通常能够带来更高的性能)的设计要比粗粒度的设计复杂。在许多情况下,一小部分代码带来了绝大部分开销。所以为什么不把精力放在这一小块代码上呢?
这就是并行快速路径设计模式背后的思想,尽可能并行化常见情况下的代码路径,同时不产生并行化整个算法所带来的复杂性。必须要理解这一点,不只是算法需要并行化,算法所属的工作负载也要并行化。构建这种并行快速路径,需要极大的创造性和设计上的努力。
并行快速路径结合了两种以上的设计模式,因此成为了一种模板设计模式。下列是并行快速路径结合其他设计模式的例子:
- 读写锁。
- Read-Copy-Update,大多作为读写锁的替代使用。
- 层次锁。
- 资源分配器缓存。
读写锁
如果同步开销可以忽略不计(比如程序使用了粗粒度的并行化),并且只有一小段临界区修改数据,那么让多个读者并行处理可以显著提升扩展性。写者与读者互斥,写者与另一写者也互斥。
读写锁是非对称锁的一种简单实例。Snaman 描述了一种在许多集群系统上使用的非对称锁,该锁有 6 种模式,其设计令人叹为观止。
层次锁
层次锁背后的思想是,在持有一把粗粒度锁时,同时再持有一把细粒度锁。这样一来,我们付出了获取第二把锁的开销,但是我们只持有它一小段时间。在这种情况下,简单的数据锁方法则更简单,而且性能更好。
资源分配器缓存
本节展示一种简明扼要的并行内存分配器,用于分配固定大小的内存。
并行资源分配问题
并行内存分配器锁面临的基本问题,是在大多数情况下快速地分配和释放内存,和在特殊情况下高效地分配和释放内存之间的矛盾。
假设有一个使用了数据所有权的程序——该程序简单地将内存按照 CPU 个数划分,这样每个 CPU 都有属于自己的一份内存。例如,该系统有 2 个 CPU 和 2G 内存。我们可以为每个 CPU 分配 1G 内存,这样每个 CPU 都可以访问属于自己的那一份内存,无需加锁,也不必关心由锁带来的复杂性和开销。可是这种简单的模型存在问题,如果有一种算法,需要让 CPU0 分配所有内存,让 CPU1 释放内存,就像生产者——消费者算法中的行为一样,这样该模型就失效了。
另一个极端,代码锁,则受到大量竞争和通信开销的影响。
资源分配的并行快速路径
常见的解决方案让每个 CPU 拥有一块规模适中的内存块缓存,以此作为快速路径,同时提供一块较大的共享内存池分配额外的内存块,该内存池使用代码锁加以保护。为了防止任何 CPU 独占内存块,我们给每个 CPU 的缓存可以容纳的内存块大小加以限制。在双核系统中,内存块的数据流如下图所示,当某个 CPU 的缓存池满时,该 CPU 释放的内存块被传送到全局缓存池中,类似的,当 CPU 缓存池为空时,该 CPU 所要分配的内存块也是从全局缓存池中取出来。
真实世界设计
虽然并行的玩具资源分配器非常简单,但是真实世界中的设计在几个方面上继续扩展了这个方案。
首先,真实的资源分配器需要处理各种不同的资源大小,在示例中只能分配固定的大小。一种比较流行的做法是提供一些列固定大小的资源,恰当地放置以平衡内碎片和外碎片,比如 20 世纪 80 年代后期的 BSD 内存分配器。这样做就意味着每种资源大小都要有一个“globalmem”变量,同样对应的锁也要每种一个,因此真实的实现将采用数据锁,而非玩具程序中的代码锁。
其次,产品级的系统必须可以改变内存的用途,这意味着这些系统必须能将内存块组合成更大的数据结构,比如页(page)。这种组合也需要锁的保护,这种锁必须是专属于每种资源大小的。
第三,组合后的内存必须回到内存管理系统,内存页也必须是从内存管理系统分配的。这一层面所需要的锁将依赖于内存管理系统,但也可以是代码锁。在这一层面中使用代码锁通常是可以容忍的,因为在设计良好的系统中很少触及这一级别。
尽管真实世界中的设计需要复杂许多,但背后的思想也是一样的——对并行快速路径这一原则的反复利用。以下是真实世界中的并行分配器类型:
等级 | 锁类型 | 目的 |
---|---|---|
每线程资源池 | 数据所有权 | 高速分配 |
全局内存资源池 | 数据锁 | 将内存块放在各个线程中 |
组合 | 数据锁 | 将内存块放在页中 |
系统内存 | 代码锁 | 获取、释放系统内存 |
分割之外
本章讨论了如何运用数据分割这一思想,来设计既简单又能线性扩展的并行程序。运用分割和复制的主要目标是达到线性的加速倍数,换句话说,确保需要做的工作不会随着 CPU 或线程的增长而显著增长。通过分割或复制可以解决尴尬的并行问题,使其可以线性加速,但是我们还能做得更好吗?
为了回答这个问题,让我们来看一看迷宫问题。前年依赖,迷宫问题一直是一个令人着迷的研究对象,所以请读者不要感到意外,计算机可以生产并且解决迷宫问题,其中包括生物计算机、甚至是一些可插拔硬件。大学有时会将迷宫的并行解法布置成课程作业,作为展示并行计算框架优点的工具。
常见的解法是使用一个并行工作队列的算法(PWQ)。本节比较 PWQ 方法、串行解法(SEQ)、和使用了另一种并行算法的解法,这些方法都能解决任何随机生成的矩形迷宫问题。
略。
3.3.7 - CH07-锁
近来对并行编程的研究中,锁总是扮演着坏人的角色。在许多论文和演讲中,锁背负着诸多质控,包括引起死锁、锁争抢、饥饿、不公平的锁、并发数据访问以及其他许多并发带来的罪恶。有趣的是,真正在产品级共享内存并行软件中承担重担的角色是——你猜对了——锁。那锁到底是英雄还是坏蛋呢?
这种认识源于以下几个原因:
- 很多因锁产生的问题都在设计层面就可以解决,而且在大多数场合工作良好,比如:
- 使用锁层级以避免死锁。
- 使用死锁检测工具,比如 Linux 内核 lockdep 模块。
- 使用对锁友好的数据结构,比如数组、哈希表、基树。
- 有些锁的问题只在竞争程度很高时才会出现,一般只有不良的设计才会出现竞争如此激烈的锁。
- 有些锁的问题可以通过其他同步机制配合锁来避免。包括统计计数、引用计数、危险指针、顺序锁、RCU,以及简单的非阻塞数据结构。
- 直到不久之前,几乎所有的共享内存并行程序都是闭源的,所以多数研究者很难了解业界的实践解决方案。
- 锁在某些软件上运行的很好,在某些软件上运行的很差。那些在锁运行良好的软件上做开发的程序员,对锁的态度往往比另一些没那么幸运的程序员更加正面。
- 所有美好的故事都需要一个坏人,锁在研究文献中扮演坏小子的角色已经有着悠久而光荣的历史了。
努力活着
死锁
当一组线程中的每个线程都持有至少一把锁,此时又等待该组线程中的某个成员释放它持有的一把锁时,死锁就会发生。
如果缺乏外界干预,死锁会一直持续。除非持有锁的线程释放,没有线程可以获取到该锁,但是持有锁的线程在等待获取该锁的线程释放其他锁之前,又无法释放该锁。
我们可以用有向图来表示死锁,节点代表锁和线程。
如上图。从锁指向线程的箭头表示该线程持有了该锁。比如线程 B 持有锁 2 和 4。从线程到锁的箭头表示线程在等待这把锁,比如线程 B 等待锁 3 释放。死锁场景至少包含至少一个以上的死锁循环。在上图中,死锁循环是线程 B、锁 3 、线程 C、锁 4,然后又回到线程 B。
虽然有一些软件环境,比如数据库系统,可以修复已有的死锁,但是这种方式要么杀掉其中一个线程,要么强制从某个线程中偷走一把锁。杀掉线程和强制偷锁对于事务交易是可以的,但是对内核和应用程序这种层次的锁来说问题多多,处理部分更新的数据库极端复杂,非常危险,而且很容易出错。
因此,内核和应用程序要么避免死锁,而非从死锁中恢复。避免死锁的策略有很多,包括锁的层次、锁的本地层次、锁的分级层次、包含指向锁的指针的 API 的使用策略、条件锁、先获取必须的锁、一次只用一把锁的设计,以及信号/中断处理函数的使用策略。虽然没有任何一个避免死锁策略可以适用于所有情况,但是市面上有很多避免死锁的工具可供选择。
锁的层次
锁的层次是指为锁逐级编号,禁止不按顺序获取锁。在上图中我们可以用数字为锁编号,这样如果线程已经获得了编号相同的锁或者更高编号的锁,就不允许获得编号相同或者编号更低的锁。线程 B 违反这个层次,因此它在持有锁 4 时又视图获取锁 3,因此导致死锁发生。
再次强调,按层次使用锁时要为锁编号,严禁不按顺序获取锁。在大型程序中,最好用工具来检查锁的层次。
锁的本地层次
但是所的层次本质要求全局性,因此很难应用在库函数上。如果调用了某个库函数的应用程序开没开始实现,那么倒霉的库函数程序员又怎么才能遵循这个还不存在的应用程序中的锁层次呢?
一种特殊的情况是,幸运的也是普遍的情况,是库函数并不涉及任何调用者代码。这时,如果库函数持有任何库函数的锁,它绝对不会再去获取调用者的锁,这样就避免出现库函数和调用者之间互相持有锁的死循环。
但假设某个库函数确实调用了某个调用者的代码。比如,qsort()
函数调用了调用者提供的比较函数。并发版本的 qsort()
通常会使用锁,虽然可能性不大,但是如果比较函数复杂且使用了锁,那么久有可能发生死锁。这时库函数该如何避免死锁?
出现这种情况时的黄金定律是:在调用未知代码前释放所有的锁。为了遵循该定律,qsort()
函数必须在调用比较函数前释放它所持有的全部锁。
为了理解本地层次锁的好处,让我们比较一下下面的两个图:
不带本地层次锁的 qsort:
基于所的本地层次实现的 qsort():
在两幅图中,应用程序 foo()
和 bar()
在分别持有锁 A 和锁 B 时调用了 qsort()
。因为这是并行版本,所以 qsort()
内还要获取锁 C。函数 foo()
将函数 cmp()
传给 qsort()
,而 cmp()
中要获取锁 B。函数 bar()
将一个简单的整数比较函数传给 qsort()
,而这个简单的函数不支持任何锁。
现在假设 qsort()
在持有锁 C 时调用 cmp()
,这违背了之前提过的黄金定律“释放所有锁”,那么死锁会发生。为了让读者理解,假设一个线程调用 foo()
,另一个线程调用 bar()
。第一个线程会获取锁 A,第二个线程会获取锁 B。如果第一个线程调用 qsort()
时获取锁 C,那么这时它在调用 cmp()
时将无法获得锁 B。但第一个线程获得了锁 C,所以第二个线程调用 qsort()
时无法获取锁 C,因此也无法释放锁 B,导致死锁。
相反,如果 qsort()
在调用比较函数之前释放锁 C,就可以避免死锁。
如果每个模块在调用未知代码前释放全部锁,那么每个模块自身都避免了死锁,这样整个系统也就避免发生死锁了。这个定律极大的简化了死锁分析,增强了代码的模块化。
锁的分级层次
不幸的是,有时 qsort()
无法在调用比较函数前释放全部锁。这时,我们无法通过以调用未知代码之前释放全部锁的方式来构建锁的本地层次。可是我们可以构建一种分级层次,如下图:
在这张图上,cmp()
函数在获取了锁 A、B、C 后再获取新的锁 D,这就避免了死锁。这样我们把全局层次锁分成了三级,第一级是锁 A 和锁 B,第二级是锁 C,第三级是锁 D。
请注意,让 cmp()
使用分级的层次锁 D 并不容易。恰恰相反,这种改动需要在设计层面进行大量更改。然而,这种变动往往是避免死锁时需要付出的点小小代价。
锁的层次和指向锁的指针
虽然有些例外情况,一般来说设计一个包含着指向锁的指针的 API 意味着这个设计本身就存在问题。将内部的锁传递给其他软件组件违反了信息隐藏原则,而信息隐藏恰恰是一个关键的设计准则。
比如两个函数要返回某个对象,而在对象成功返回之前必须持有调用者提供的锁。再比如 POSIX 的 pthread_cond_wait()
函数,要传递一个指向 pthread_mutex_t
的指针来放置错过唤醒而导致的挂起。
长话短说,如果你发现 API 需要将一个指向锁的指针作为参数或者返回值,请慎重考虑一下是否需要修改这个设计。有可能这是正确的做法,但是经验告诉我们这种可能性很低。
条件锁
假如某个场景设计不出合理的层次锁。这在现实生活中是可能发生的,比如,在分层网络协议栈里,报文流是双向的。当报文从一个层传向另一个层时,有可能需要在两层中同时获取锁。因为报文可以从协议栈上层往下层传,也可能相反,这简直是死锁的天然温床。
在这个例子中,当报文在协议栈中从上往下发送时,必须逆序获取下一层锁。反之则需要顺序获得锁。解决办法是强加一套锁的层次,但在必要时又可以有条件地乱序获取锁。
先获取必要的锁
条件锁有一个重要的特例,在执行真正的处理工作之前,已经拿到了所有必须的锁。在这种情况下,处理不需要是幂等的:如果这时不能在不释放锁的情况下拿到某把锁,那么释放所有持有的锁,重新获取。只有在持有所有必要的锁以后才开始处理工作。但是这样又可能导致活锁,后续将会讨论这一点。
两阶段加锁在事务数据库系统中已经存在很长时间了,它就应用了这个策略。两阶段加锁事务的第一个阶段,只获取锁但不释放锁。一旦所有必须的锁全部获得,事务进入第二阶段,只释放锁但不获取锁。这种加锁方法使得数据库可以对执行的事务提供串行化保护,换句话说,保证事务看到和产生的数据在全局范围内顺序一致。很多数据库系统都依靠这种能力来终止事务,不过两阶段加锁也可以简化这种方法,在持有所有必要的锁之前,避免修改共享数据。虽然使用两阶段锁仍然会出现活锁或死锁,但是在现有的大量数据库类教科书中已经有很多实用的解决办法。
一次只用一把锁
在某些情况下,可以避免嵌套加锁,从而避免死锁。比如,如果有一个可以完美分割的问题,每个分片拥有一把锁。然后处理任何特定分片的线程只需获取对应该分片的锁。因为没有任何线程在同一时刻持有一把以上的锁,死锁就不可能发生。但是必须有一些机制来保证在没有持锁的情况下所需数据结构依然存在。
信号/中断处理函数
涉及信号处理函数的死锁通常可以很快解决:在信号处理函数中调用 pthread_mutex_lock()
是非法的。可是,精心构造一种可以在信号处理函数中使用的锁是有可能的。除此之外,基本所有的操作系统内核都允许在中断处理函数里获取锁,中断处理函数可以说是内核对信号处理函数的模拟。
其中的诀窍是在任何可能中断的处理函数里获取锁的时候阻塞信号(或者屏蔽中断)。不仅如此,如果已经获取了锁,那么在不阻塞信号的情况下,尝试去获取任何可能中断处理函数之外被持有的锁,都是非法操作。
假如处理函数获取锁是为了处理多个信号,那么无论是否获得了锁,甚至无论锁是否是在信号处理函数之内获取的,每个信号也都必须被阻塞。
不幸的是,在一些操作系统里阻塞和解除阻塞信号都属于代价昂贵的操作,这里包括 Linux,所以出于性能上的考虑,能在信号处理函数内持有的锁仅能在信号处理函数内获取,应用程序和信号处理函数之间的通信通常使用无锁同步机制。
或者除非处理致命异常,否则完全禁用信号处理函数。
本节讨论
对于基于内存共享的并行程序员来说,有大量避免死锁的策略可用,但是如果遇到这些策略都不适用的场景,总还是可以用串行代码来实现的。这也是为什么专家级程序员的工具箱里总是有好几样工具的原因之一,但是别忘了总有些活适合用其他工具处理。不过,本节描述的这些策略在很多场合都被证明非常有用。
活锁与饥饿
虽然条件锁是一种有效避免死锁的机制,但是有可能被滥用。考虑下面的例子:
1 void thread1(void)
2 {
3 retry:
4 spin_lock(&lock1);
5 do_one_thing();
6 if (!spin_trylock(&lock2)) {
7 spin_unlock(&lock1);
8 goto retry;
9 }
10 do_another_thing();
11 spin_unlock(&lock2);
12 spin_unlock(&lock1);
13 }
14
15 void thread2(void)
16 {
17 retry:
18 spin_lock(&lock2);
19 do_a_third_thing();
20 if (!spin_trylock(&lock1)) {
21 spin_unlock(&lock2);
22 goto retry;
23 }
24 do_a_fourth_thing();
25 spin_unlock(&lock1);
26 spin_unlock(&lock2);
27 }
考虑以下事件顺序:
- 4:线程 1 获取 lock1,然后调用 do_one_thing()
- 18:线程 2 获取 lock2,然后调用 do_a_third_thing()
- 6:线程 1 试图获取 lock2,由于线程 2 已经持有而失败
- 20:线程 2 试图后去 lock1,由于线程 1 已经持有而失败
- 7:线程 1 释放 lock1,然后跳转到第 3 行的 retry
- 21:线程 2 释放 lock2,然后跳转到 17 行的 retry
- 以上过程不断重复,活锁将华丽登场
活锁可以被看做是饥饿的一种极端形式,此时不再是一个线程,而是所有线程都饥饿了。活锁和饥饿都属于事务内存软件实现中的严重问题,所以现在引入了竞争管理器这样的概念来封装这些问题。以锁为例,通常简单的指数级退避就能解决活锁和饥饿。指数级退避是指在每次重试之前增加按指数级增长的延迟。不过,为了获取更好的性能,退避应该有个上限,如果使用排队锁甚至可以在高竞争时获取更好的性能。当然,更好的办法还是通过良好的并行设计使锁的竞争程度变低。
不公平的锁
不公平的锁被看成是饥饿的一种不太严重的表现形式,当某些线程争抢同一把锁时,其中一部分线程在绝大多数时间都可以获取到锁,另一部分线程则遭遇不公平对待。这在带有道速共享缓存或者 NUMA 内存的机器上可能出现。
如上图。如果 CPU0 释放了一把其他 CPU 都想获得的锁,因为 CPU0 与 CPU1 共享内部链接,所以 CPU1 相较于 CPU2~7 则更易抢到锁。反之亦然,如果一段时间后 CPU0 又开始争抢该锁,那么 CPU1 释放时 CPU0 则更易获得锁,导致锁绕过 CPU2~7,只在 CPU0 和 CPU1 之间换手。
低效率的锁
锁是由原子操作和内存屏障实现的,并且常常带有高速缓存未命中。正如我们第三章所见,这些指令代价都是十分昂贵的,粗略地说开销比简单指令要高出两个数量级。这可能是锁的一个严重问题,如果用锁来保护一条指令,你很可能在以百倍的速度带来开销。对于相同的代码,即使假设扩展性非常完美,也需要 100 个 CPU 才能跟上一个执行不加锁版本的 CPU。
这种情况强调了“同步粒度”一节中的权衡,粒度太粗会限制扩展性,粒度太小会导致巨大的同步开销。
不过一旦持有了锁,持有者可以不受干扰的访问被锁保护的代码。获取锁可能代价高昂,但是一旦持有,特别是对较大的临界区来说,CPU 高速缓存反而是高效的性能加速器。
锁的类型
互斥锁
互斥锁正如其名,一次只能被一个线程持有。持锁者对受锁保护的代码享有排他性的访问权。当然,这是在假设该锁保护了所有应当受保护的数据的前提下。虽然有些工具可以帮你检查,但最终的责任还是落在开发者身上,一定要保证所有需要的路径都受互斥锁的保护。
读写锁
读写锁一方面允许任意数量的读者同时持有锁,另一方面允许最多一个写者持有锁。理论上,读写锁对读侧重的数据来说拥有极佳的扩展性。在实践中的扩展性则取决于具体的实现方式。
经典的读写锁实现使用一组只能以原子操作方式修改的计数和标志。这种实现和互斥锁一样,对于很小的临界区来说开销太大,获取和释放锁的开销比一条简单指令的开销要高出两个数量级。当然,如果临界区足够长,获取和释放锁的开销与之相比就可以忽略不计了。可是因为一次只有一个线程能操作锁,随着 CPU 数目的增加,临界区的代价也需要增加才能平衡掉开销。
另一个设计读写锁的方式是使用每线程互斥锁,这种读写锁对读者非常有利。线程在读的时候只需要获取本线程的锁即可,而在写的时候需要获取所有线程的锁。在没有写者的情况下,每个读锁的开销相当于一条原子操作和一个内存屏障的开销之和,而且不会有高速缓存未命中,这点对于锁来说非常不错。不过,写锁的开销包括高速缓存未命中,再加上原子操作和内存屏障的开销之和——再乘以线程的个数。
简单的说,读写锁在有些场景非常有用,但各种实现方式都有各自的缺点。读写锁的正统用法是用于非常长的只读临界区,临界区耗时几百微秒或者毫秒甚至更多则最好。
读写锁之外
读写锁和互斥锁允许的规则大不相同:互斥锁只允许一个持有者,读写锁允许任意多个持有者持有读锁(但只能有一个持有写锁)。锁可能的允许规则有很多,VAX/VMS 分布式锁管理器就是其中一个例子。下图是各种状态之间的兼容性:
规则类型 | 空(未持锁) | 并发读 | 并发写 | 受保护读 | 受保护写 | 互斥访问 |
---|---|---|---|---|---|---|
空(未持锁) | ||||||
并发读 | N | |||||
并发写 | N | N | N | |||
受保护读 | N | N | N | |||
受保护写 | N | N | N | N | ||
互斥访问 | N | N | N | N | N |
N 表示不兼容,空值表示兼容。
VAX/VMS 分布式锁管理器有 6 个状态。为了更好的比较,互斥锁有 2 个状态(持锁和未持锁),而读写锁有 3 个状态(未持锁、持读锁、持写锁)。
这里第一个状态是空状态,也就是未持锁。这个状态与其他任何状态兼容,这也是我们期待的,如果没有线程持有锁,那么也不会阻止其他获取了锁的线程执行。
第二个状态是并发读,该状态与除了排他状态之外的所有状态兼容。并发读状态可用于对数据结构进行粗略的累加统计,同时允许并发写的操作。
第三个状态是并发写,与空状态、并发读、并发写兼容。并发写状态可以用于近似统计计数的更新,同时允许并发的读操作和写操作。
第四个状态是受保护读,与空状态、并发读、受保护兼容。受保护状态可用于读取数据结构的准确结果,同时允许并发的读操作,但是不允许并发的写操作。
第五个状态是受保护写,与空状态、并发读兼容。受保护写状态可用于在可能会受到受保护读干扰的情况下写数据结构,允许并发的读操作。
第六个状态是互斥访问,仅与空状态兼容。互斥访问状态可用于需要排他访问的场合。
有趣的是,互斥锁和读写锁可以用 VAX/VMS 分布式锁管理器来模拟。互斥锁仅使用空状态和互斥访问状态,读写锁仅使用空状态、受保护的读/写状态。
虽然 VAX/VMS 分布式锁管理器广泛用于分布式数据库领域,但是在共享内存的应用程序中却很少见。其中一个可能的原因是分布式数据库中的通信开销在一定程度上可以抵消 VAX/VMS 分布式锁管理器带来的复杂性。
然而,VAX/VMS 分布式锁管理器只是一个例子,用来说明锁背后的概念和灵活性。同时这个例子也是对现代数据库管理系统所使用的锁机制的简单介绍,相对于 VAX/VMS 分布式锁管理器的 6 个状态,有些数据库中使用的锁甚至可以有 30 多个状态。
范围锁
到目前为止我们讨论的加锁原语都需要明确的获取和释放函数,比如 spin_lock()
和 spin_unlock()
。另一种方式是使用面向对象的“资源分配即初始化”(RAII)模式。该设计模式常见于支持自动变量的语言,如 C++,当进入对象的范围时调用构造函数,当退出对象的范围时调用析构函数。同理,加锁可以让构造函数去获取锁、析构函数来释放锁。
这种方法十分有用,事实上 1991 年本书作者曾认为这是唯一有用的加锁方法。RAII 式加锁有一个非常好的特性,你不需要精心思考在每个会退出对象范围的代码路径上释放锁,该特性避免了一系列 BUG 的出现。
但是,RAII 式加锁也有其黑暗面。RAII 使得对获取和释放锁的封装极其困难,比如在迭代器内。在很多迭代器的实现中,你需要在迭代器的开始函数内获取锁,在结束函数内释放锁。相反 RAII 式加锁要求获取和释放锁都发生在相同的对象范围,这使得对它们的封装变得困难,甚至无法实现。
因为范围只能嵌套,所以 RAII 式加锁不允许重叠的临界区。这让锁的很多有用的用法变得不可能,比如,对于协调对并发访问某事件的树状锁。对于任意规模的并发访问,只允许其中一个成功,其余请求最好是让他们越早失败越好。否则在大型系统上(几百个 CPU)对锁的竞争会称为大的问题。
上图是一个示例数据结构(来自 Linux 内核的 RCU 实现)。在这里,每个 CPU 都分配一个 rcu_node 的叶子节点,每个 rcu_node 节点都拥有一个指向父节点的指针 ->parent
,直到根节点的 rcu_node 节点,它的 ->parent
指针为 NULL。每个父节点可以拥有的子节点数目可以不同,但是一般是 32 或 64。每个 rcu_node 节点都有一把名为 ->fqslock
的锁。
这里使用的是一种通用策略——锦标赛,任意指定 CPU 有条件地获取它对应的 rcu_node 叶子节点的锁 ->fqslock
,如果成功,尝试获取其父节点的锁,如成功再释放子节点的锁。除此之外,CPU 在每一层检查全局变量 gp_flags
,如果每个变量表明其他 CPU 已经访问过这个事件,该 CPU 被淘汰出锦标赛。这种先获取——再释放顺序一直持续到要么 gp_flags
变量表明已经有人赢得锦标赛,某一层获取 ->fqslock
锁失败,要么拿到了根节点 rcu_node 结构的锁 ->fqslock
。
锁在实现中的问题
系统总是给开发者提供最好的加、解锁原语,例如 POSIX pthread 互斥锁。然而,学习范例实现总是有点用的,因为这样读者可以考虑极端工作负载和环境带来的挑战。
基于原子交换的互斥锁实现示例
1 typedefintxchglock_t;
2 #define DEFINE_XCHG_LOCK(n) xchglock_t n = 0
3
4 void xchg_lock(xchglock_t *xp)
5 {
6 while (xchg(xp, 1) ==1) {
7 while(*xp == 1)
8 continue;
9 }
10 }
11
12 void xchg_unlock(xchglock_t *xp)
13 {
14 (void)xchg(xp, 0);
15 }
这个锁的结构只是一个 int,如第 1 行所示,这里可以是任何整数类型。这个锁的初始值为 0,代表锁已释放,即第二行代码。
通过 4~10 行上的 xchg_lock()
函数执行锁的获取。此函数使用嵌套循环,外部循环重复地将锁的值与 1 做原子交换(即加锁)。如果旧值已经是 1(即该锁已经被别人持有),那么内部循环(7~8)持续自旋直到锁可用,那么到时候外部循环再一次尝试获取锁。
锁的释放由 12~15 行的 xchg_unlock()
函数执行。第 14 行将值 0(即解锁)原子地交换到锁中,从而标记锁已经释放。
虽然这是一个测试并设置(test-and-set)的例子,但是在生产环境中广泛采用一种非常类似的机制来实现纯自旋锁。
互斥锁的其他实现
基于原子指令的锁有很多可能的实现,Mellor-Crummey 和 Scott 综述了其中很多种。这些实现代表着设计权衡多个维度中的不同顶点。例如,上一节提到的基于原子交换的测试并设置锁,在低度锁竞争时性能良好,并且具有内存占用小的有点。它避免了给不能使用它的线程提供锁,但作为结果可能会导致不公平或甚至在高度锁竞争时出现饥饿。
相比之下,在 Linux 内核中使用的门票锁(ticket-lock)避免了在高度锁竞争时的不公平,但后果是其先入先出的准则可以将锁授予给当前无法使用它的线程,例如,线程由于被抢占、中断或其他方式而失去 CPU。然而,避免太过担心抢占和中断的可能性同样重要,因为抢占和中断也有可能在线程刚获取锁后发生。
只要是等待者在某个内存地址自旋以等待锁的各种实现,包括测试并设置锁和门票锁,都在高度锁竞争时存在性能问题。原因是释放锁的线程必须更新对应的内存地址。在低度竞争时,这不是问题:相对的缓存行很可能仍然属于本地 CPU 并且仍然可以由持有锁的线程来更改。相反,在高度竞争时,每个尝试获取锁的线程将拥有高速缓存行的只读副本,因此锁的持有者将需要使所有此类副本无效,然后才能更新内存地址来释放锁。通常,CPU 和线程越多,在高度竞争条件下释放锁时所产生的开销就约大。
这种负可扩展性已经引发了许多种不同的排队锁(queued-lock)实现。排队锁通过为每个线程分配一个队列元素,避免了高昂的缓存无效化开销。这些队列元素链接在一起构成了一个队列,控制着等待线程获取锁的顺序。这里的关键点在于每个线程只在自己的队列元素上自旋,使得锁持有者只需要使下一个线程的 CPU 缓存中的第一个元素无效即可。这种安排大大减少了在高度锁竞争时交换锁的开销。
最近的排队锁实现也将系统的架构纳入到考虑之中,优先在本地予锁,同时采取措施避免饥饿。这些实现可以看成是传统上用在调度磁盘 IO 时使用的电梯算法的模拟。
不幸的是,相同的调度逻辑虽然提高了排队锁在高度竞争时的时效率,也增加了其在低度竞争时的开销。因此,Beng-hong Lim 和 AnantAgarwal 将简单的测试并设置锁与排队锁结合,在低度竞争时使用测试并设置锁,在高度竞争时切换到排队锁,因此得以在低度竞争时获得低开销,并在高度竞争时获得公平的高吞吐量。Browning 等人采取了类似的方法,但避免了单独标志的使用,这样测试并设置锁的快速路径可以使用简单测试和设置锁实现所用的代码。这种方法已经用于生产环境。
在高度锁竞争中出现的另一个问题是当锁的持有者受到延迟,特别是当延迟的原因是抢占时,这可能导致优先级翻转,其中低优先级的线程持有锁,但是被中等优先级且绑定在某 CPU 上的线程抢占,这导致高优先级线程在尝试获取锁时阻塞。结果是绑定在某 CPU 的中优先级进程阻止高优先级进程运行。一种解决方案是优先级继承,这已被广泛用于实时计算,尽管这种做法仍有一些持续的争议。
避免优先级翻转的另一种做法是在持有锁时防止抢占。由于在持有锁的同时防止抢占也提高了吞吐量,因此大多数私有的 UNIX 内核都提供某种形式的调度器同步机制,当然这主要是由于某家大型数据库供应商的努力。这些机制通常采取提示的形式,即此时不应当抢占。这些提示通过在特定寄存器中设置某个比特位的形式实现,这使得提示机制拥有极低的锁获取开销。作为对比,Linux 没有使用提示机制,而是用一种称为 futexes 的机制来获得类似的效果。
有趣的是,在锁的实现中原子指令并不是不可或缺的部分。在 Herlihy 和 Shavit 的教科书中可以找到一种锁的漂亮实现,只使用简单的加载和存储,提到这点的目的是,虽然这个实现没有什么实际应用,但是详细的研究这个实现将非常具有娱乐性和启发性。不过,除了下面描述的一个例外,这样的研究将留下作为读者的练习。
Gamsa 等人描述了一种基于令牌的机制,其中令牌在 CPU 之间循环,当令牌到达给定的 CPU 时,它可以排他性的访问由该令牌保护的任何内容。很多方案可以实现这种基于令牌的机制,例如:
- 维护一个每 CPU 标志,对于除一个 CPU 之外的所有 CPU,其标志始终为 0。当某个 CPU 的标志非 0 时,它持有令牌。当它不需要令牌时,将令牌置 0,并将下一个 CPU 的标志设置为 1 或其他任何非 0 标志值。
- 维护每 CPU 计数器,其初始值设置为对应 CPU 的编号,我们假定其范围值为 0 到 N-1,其中 N 是 CPU 的数目。当某个 CPU 的计数大于下一个 CPU 的计数时(要考虑计数的溢出),这个 CPU 持有令牌。当它不需要令牌时,它将下一个 CPU 的计数器设置为一个比自己的计数更大的值。
这种锁不太常见,因为即使没有其他 CPU 正在持有令牌,给定的 CPU 也不一定能立即获得令牌。相反,CPU 必须等待直到令牌到来。当 CPU 需要定期访问临界区的情况下这种方法很有用,但是必须要容忍不确定的令牌传递速率。Gamas 等人使用它来实现一种 RCU 的变体,但是这种方法也可以用于保护周期性的每 CPU 操作,例如冲刷内存分配器使用的每 CPU 缓存,或者垃圾收集的每 CPU 数据结构,又或者是将每 CPU 数据写入共享内存(或大容量存储)。
随着越来越多的人熟悉并行硬件及并且越来越多的并行化代码,我们可以期望出现更多的专用加解锁原语。不过,你应该仔细考虑这个重要的安全提示,只要可能,尽量使用标准同步原语。标准同步原语与自己开发的原语相比,最大的优点就是标准原语通常更不容易出现 BUG。
基于所的存在保证
并行编程一个关键挑战是提供存在保证,使得在整个访问尝试过程中,可以在保证该对象存在的前提下访问给定对象。在某些情况下,存在保证是隐式的。
- 基本模块中的全局变量和静态局部变量在应用程序正在运行时存在。
- 加载模块中的全局和静态局部变量在该模块保持加载时存在。
- 只要存在至少一个函数还在被使用,包括将保持加载状态。
- 给定的函数实例的堆栈变量,在该实例返回前一直存在。
- 如果你正在某个函数中执行,或者正在被这个函数调用(直接或间接),那么这个函数一定有一个获得实例。
虽然这些隐式存在保证非常直白,但是设计隐式存在保证的故障真的发生过。
但更有趣也更麻烦的涉及堆内存的存在保证,动态分配的数据结构将存在到它被释放为止。这里要解决的问题是如何将结构的释放和对其的并发访问同步起来。一种方法是使用显式保证,例如加锁。如果给定结构只能在持有一个给定的锁时被释放,那么持有锁就保证那个结构的存在。
但这种保证取决于锁本身的存在。一种保证锁存在的简单方式是把锁放在一个全局变量内,但全局锁具有可扩展性受限的特点。有种可以让可扩展性随着数据结构的大小增加而改进的方法,是在每个元素中放置锁的结构。不幸的是,把锁放在一个数据元素中以保护这个数据元素本身的做法会导致微秒的竟态条件。
解决该问题的方式是使用一个全局锁的哈希集合,使得每个哈希桶都有自己的锁。该方法允许在获取指向数据元素的指针之前获取合适的锁。虽然这种方式对于只存放单个数据结构中的元素非常有效,比如哈希表,但是如果有某个数据元素可以是多个哈希表成员,或者更复杂的数据结构,比如树或图时,就会有问题了。不过这些问题还是可以解决的,事实上,这些解决办法形成了基于锁的软件事务性内存实现。后续将介绍如何简单快速地提供存在保证。
锁:英雄还是恶棍
如现实生活中的情况一样,锁可以是英雄也可以是恶棍,既取决于如何使用它,也取决于要解决的问题。以作者的经验,那些写应用程序的家伙很喜欢锁,那些写并行库的同行不那么开心,那些需啊哟并行化现有顺序库的人则非常不爽。
应用程序中的锁:英雄
当编写整个应用程序时(或整个内核)时,开发人员可以完全控制设计,包括同步设计,假设设计中能够良好地使用分割,锁可以是非常有效的同步机制,锁在生产环境级别的高质量并行软件中大量使用已经说明了一切。
然而,尽管通常其大部分同步设计是基于锁,这些软件也几乎总还是利用了其他一些同步机制,包括特殊计数算法、数据所有权、引用计数、顺序锁和 RCU。此外,业界也使用死锁检测工具。获取/释放锁平衡工具、高速缓存未命中分析和基于计数器的性能剖析等等。
通过仔细设计、使用良好的同步机制和良好的工具,锁在应用程序和内核领域工作的相当出色。
并行库中的锁:只是一个工具
与应用程序和内核不同,库的设计者不知道与库函数交互的代码中锁是如何设计的。事实上,那段代码可能在几年后才会出现。因此,库函数设计者对锁的控制力较弱,必须在思考同步设计时更加小心。
死锁当然是需要特别关注的,这里需要运用前面介绍死锁时提到的技术。一个流行的死锁避免策略是确保库函数中的锁是整个程序的锁层次中的独立子树。然而,这个策略实现起来可能比他看起来更难。
前面死锁一节中讨论了一种复杂情况,即库函数调用应用程序代码,qsort()
的比较函数的参数是切入点。另一个复杂情况是与信号处理程序的交互。如果库函数接收的信号调用了应用程序的信号处理函数,几乎可以肯定这会导致死锁,就像库函数直接调用了应用程序的信号处理程序一样。最后一种复杂情况发生在那些可以在 fork()
和 exec()
之间使用的库函数,例如,由于使用了 system()
函数。在这种情况下,如果你的库函数在 fork()
的时候持有锁,那么子进程就在持有该锁的情况下出生。因为会释放锁的线程在父进程运行,而不是子进程,如果子进程调用你的库函数,死锁会随之而来。
在这些情况下,可以使用一下策略来避免死锁问题:
- 不要使用回调或信号。
- 不要从回调或信号处理函数中获取锁。
- 让调用者控制同步。
- 将库 API 参数化,以便让调用者处理锁。
- 显式地避免回调死锁。
- 显式地避免信号处理程序死锁。
既不使用回调,也不使用信号
如果库函数避免使用回调,并且应用程序作为一个整体也避免使用信号,那么由该库函数获得的任何锁将是锁层次中的叶子节点。这种安排避免了死锁。虽然这个策略在其适用时工作的非常好,但是有一些应用程序必须使用信号处理程序,并且有一些库函数必须使用回调。这时可以使用下一个策略。
避免在回调和信号处理函数中用锁
如果回调和处理函数都不获取锁,他们就不会出现在死锁循环中,这使得库函数只能成为锁层次树上的叶子节点。这个策略对于 qsort
的大多数使用情况非常有效,它的回调通常只是比较两个传递给回调的值。这个策略也奇妙地适合许多信号处理函数,通常来说在信号处理函数内获取锁是不明智的行为,但如果应用程序需要处理来自信号处理函数的复杂数据结构,这种策略可能会行不通。
这里有一些方法,即便必须操作复杂的数据结构也可以避免在信号处理函数中获取锁。
- 使用基于非阻塞同步的简单数据结构。
- 如果数据结构太复杂,无法合理使用非阻塞同步,那么创建一个允许非阻塞入队操作的队列。在信号处理函数中,而不是在复杂的数据结构中,添加一个元素到队列,描述所需的更改。然后一个单独的线程在队列中将元素删除,并执行需要使用锁的更改。关于并发队列已经有很多现成的实现。
这种策略应当在偶尔的人工或(最好是)自动的检查回调和信号处理函数时强制使用。当进行这些检查时要小心警惕,防止那些聪明的开发者(不明智地)自制一些使用原子操作的加锁原语。
调用者控制的同步
让调用者控制同步。当调用者可控数据结构的不同实例调用库函数时,这招非常管用,这时每个实例都可以单独同步。例如,如果库函数要操作一个搜索树,并且如果应用程序需要大量的独立搜索树,那么应用程序可以将锁与每个树关联。然后应用程序获取并根据需要来释放锁,使得库函数完全不需要知道并行性。
但是,如果库函数实现的数据结构需要内部并发执行,则此策略将失败。例如,哈希表或并行排序。在这种情况下,库绝对必须控制自己的同步。
参数化的库函数同步
这里的想法是向库的 API 添加参数以指定要获取的锁、如何获取和释放锁。该策略允许应用程序通过指定要获取的锁(通过传入指向所的指针等)以及如何获取它们(通过传递指针来加锁和解锁),来全局避免死锁。而且还允许线程给定的库函数通过决定加锁和解锁的位置,来控制自己的并发性。
特别的,该策略允许加锁和解锁函数根据需要来阻塞信号,而不需要库函数代码关心那些信号需要被哪些锁阻塞。这种策略使用的分离关注点的方式十分有效,不过在某些情况下,后续介绍的策略将会表现的更好。
也就是说,如果需要明确的将指向所的指针传递给外部 API,必须非常小心考虑。虽然这种做法有时在所难免,但你总应该试着寻找一种替代设计。
明确地避免回调死锁
前面已经讨论了此策略的基本规则:在调用未知代码之前释放所有锁。这通常是最好的办法,因为它允许应用程序忽略库函数的锁层次结构,库函数仍然是应用程序锁层次结构中的一个叶子节点或孤立子树。
若在调用未知代码之前不能释放所有的锁,死锁一节介绍的分层锁层级就适合这种情况。例如,如果未知代码是一个信号处理函数,这意味着库函数要在所有持有锁的情况屏蔽信号,这种做法复杂且缓慢。因此,在信号处理函数(可能不明智地)获取锁的情况,可以使用下一种策略。
明确地避免信号处理函数死锁
信号处理函数的死锁可以按如下方式明确避免:
- 如果应用程序从信号处理函数中调用库函数,那么每次在除信号处理函数以外的地方调用库函数时,必须阻塞该信号。
- 如果应用程序在持有从某个信号处理函数中获取的锁时调用库函数,那么每次在除信号处理函数之外的地方调用库函数时,必须阻塞该信号。
这些规则可以通过使用类似 Linux 内核的 lockdep 锁依赖关系检测工具来检查。lockdep 的一大优点就是它从不受人类直觉的影响。
在 fork() 和 exec() 之间使用的库函数
如前所述,如果执行库函数的线程在其他线程调用 fork 时持有锁,父进程的内存会被复制到子进程,这个事实意味着子进程从被创建的那一刻就持有该锁。负责释放锁的线程运行在父进程的上下文,而不是在子进程,这意味着子进程中这个锁的副本永远不会被释放。因此,任何在子进程中调用相同库函数的尝试都将会导致死锁。
这个问题的解决方法是让库函数检查是否锁的持有者仍在运行,若不是,则通过重新初始化来“撬开”锁并再次获取它。然而,这种方法有几个漏洞:
- 受该锁保护的数据结构可能在某些中间状态,所以简单的“撬开”锁可能会导致任意内存被更改。
- 如果子进程创建了额外的线程,则两个线程可能会同时“撬开”锁,结果是两个线程都相信自己拥有锁。从而再次导致任意内存被更改。
atfork() 函数就是专门用来帮助处理这些情况的。这里的想法是注册一个三元组函数,一个由父进程在 fork 之前调用,一个由父进程在 fork 之后调用,一个由子进程在 fork 之后调用。然后可以在这三个点进行适当的清理工作。
但是要注意,atfork 处理函数的代码通常十分微秒。atfork 最适合的情况是锁保护的数据结构可以简单的由子进程重新初始化。
讨论
无论使用何种策略,对库 API 的描述都必须包含该策略和调用者如何使用该策略的清晰描述。简而言之,设计并行库时使用锁是完全可能的,但没有像设计并行应用程序那样简单。
并行化串行库时的锁:恶棍
随着到处可见的低成本多核系统的出现,常见的任务往往是并行化已有的库,这些库的设计仅考虑了单线程使用的情况。从并行编程的角度看,这种对于并行性的全面忽视可能导致库函数 API 的严重缺陷。比如:
- 隐式的禁止分割。
- 需要锁的回调函数。
- 面向对象的意大利面条式代码。
禁止分割
假设你正在编写一个单线程哈希表实现。可以很容易并且快速地得到哈希表中元素总数的精确计数,同时也可以很容易并且快速地在每次添加和删除操作后返回此计数。所以为什么在实际中不这么做呢?
一个原因是精确计数器在多核系统上要么执行错误,要么扩展性不佳。因此,并行化这个哈希表的实现将会出现错误或者扩展性不佳的情况。
那么我们能做什么呢?一种方式是返回近似计数,另一种方式是完全不用元素计数。无论哪种方式,都有必要检查哈希表的使用,看看为什么添加和删除操作需要元素的精确计数。这里有几种可能性:
- 确定何时调整哈希表的大小。这时,近似计数应该工作得很好。调整大小的操作也可以由哈希桶中最长链的长度触发,如果合理分割每个哈希桶的话,那么很容易得出每个链的长度。
- 得到遍历整个哈希表所需的大概时间。这时,使用近似计数也不错。
- 处于诊断的目的。例如,检查传入哈希表和从哈希表传出时丢失的元素。然而,鉴于这种用法是诊断性目的,分别维护每个哈希链的长度也可以满足要求,然后偶尔再锁住添加删除操作时将各个长度求和输出。
现在有一些理论基础研究,阐述了并行库 API 在性能和扩展性上受到的约束。任何设计并行库的人都需要密切注意这些约束。
虽然对于一个对并发不友好的 API 来说,人们很容易去职责锁是罪魁祸首,但这并没有用。另一方面,人们除了同情当年写下这段代码的倒霉程序员之外,也没有什么更好的办法。如果程序员能在 1985 年就能遇见未来对并行性的需求,那简直是稀罕和高瞻远瞩,如果那时就能设计出一个对并行友好的 API,那真是运气和荣耀的罕见巧合了。
随着时间的变化,代码必须随之改变。也就是说,如果某个受欢迎的库拥有大量用户,在这种情况下对 API 进行不兼容的更改将是相当愚蠢的。添加一个对并行友好的 API 来补充现有的串行 API,可能是这种情况下的最佳行动方案。
然而,出于人类的本性,不行的开发者们更可能抱怨的是锁带来的问题,而不是他们自身对糟糕(虽然可以理解) API 的设计选择。
容易死锁的回调
前面已经描述了对回调的无规律使用将提高加锁的难度,同时还描述了如何设计库函数来避免这些问题,但是期望一个 20 世纪 90 年代的程序员在没有并行编程经验时就能遵循这些设计,是不是有点不切实际?因此,尝试并行化拥有大量回调的已有单线程程序库的程序员,很可能会相当憎恨锁。
如果有一个库使用了大量回调,可能明智的举动是向库函数中添加一个并行友好的 API,以允许现有用户逐步进行代码的切换。或者,一些人主张在这种情况下使用事务内存。有一点需要注意,硬件事务内存无助于解决上述情景,除非硬件事务内存实现提供了前进保证(forward-progress guarantee),不过很少有事务内存做到这一点。
面向对象的意大利面条式代码
从 20 世纪 80 年代末或 90 年代初的某个时候,面向对象编程变得流行起来,因此在生产环境中出现了大量面向对象式的代码,大部分是单线程的。虽然 OO 是一种很有价值的软件技术,但是毫无节制的使用对象可以很容易写出面向对象式的意大利面条代码。在面向对象式的意大利面条代码中,执行流基本上是以随机的方式从一个对象走到另一个对象,使得代码难以理解,甚至无法加入锁层次结构。
虽然很多人可能会认为,不管在任何情况下这样的代码都应该清理,说着容易做着难。如果你的任务是并行化这样的野兽,通过对前面描述的技巧的运用,以及后续将继续讨论的技术,你对人生(还有锁)感到绝望的机会会大大减少。这种场景似乎是事务性内存出现的原因,所以事务内存也值得一试。也就是说,应该根据前面讨论的硬件习惯来选择同步机制,如果同步机制的开销大于那些被保护的操作一个数量级,结果必然不会漂亮。
这些情况下有一个问题值得提出,代码是否应该继续保持串行执行?例如,或许在进程级别而不是线程级别引入并行性。一般来说,如果任务证明是非常困难,确实值得花一些时间思考并通过其他方法来完成任务,或者通过其他任务来解决手头的问题。
总结
锁也许是最广泛也最常用的同步工具。然而,最好是在一开始设计应用程序或库时就把锁考虑进去。考虑可能要花一整天的时间,才能让很多已有的单线程代码并行运行,因此锁不应该是并行编程工具箱里的唯一工具。
3.3.8 - CH08-数据所有权
避免锁带来的同步开销的最简单方式之一,就是在线程之间(或者对于内核来说,CPU 之间)包装数据,以便让数据仅被一个线程访问或修改。这种方式非常重要,事实上,它是一种应用模式,甚至新手凭借本能也会如此使用。
多进程
在前面基于 Shell 的并行编程示例中,两个进程之间不共享内存。这种方法几乎完全消除了同步开销。这种极度简单和最佳性能的组合显然是相当有吸引力的。
部分数据所有权和 pthread 线程库
在第五章“计数”中大量使用了数据所有权技术,但是做了一些改变。不允许线程修改其他线程拥有的数据,但是允许线程读取这些数据。总之,使用共享内存允许更细粒度的所有权和访问权限概念。
纯数据所有权也是常见且有用的。比如前面讨论的每线程内存分配器缓存,在该算法中,每个线程的缓存完全归该线程所有。
函数输送
上面讨论的是一种弱形式的数据所有权,线程需要更改其他线程的数据。这可以被认为是将数据带给他需要的函数。另一种方式是将函数发送给数据。
指派线程
前面的小节描述了允许每个线程保留自己的数据副本或部分数据副本的方式。相比之下,本节将描述一种分解功能的方式,其中特定的指定线程拥有完成其他工作所需的数据的权限。之前讨论的最终一致性计数器实现就提供了一个例子。eventual() 函数中运行了一个指定线程,该线程周期性地将每线程计数拉入全局计数器,最终将全局计数器收敛于实际值。
私有化
对于共享内存的并行程序,一种提升性能的和可扩展性的方式是将共享数据转换成由特定线程拥有的私有数据。
比如使用私有化方式来解决哲学家就餐问题,这种方式具有比标准教科书解法更好的性能和扩展性。原来的问题是 5 个哲学家坐在桌子旁边,每个相邻的哲学家之间有一把叉子,最多允许两个哲学家同时就餐。我们可以通过提供 5 把额外的叉子来简单地私有化这个问题,所有每个哲学家都有自己的私人叉子。这允许所有 5 个哲学家同时就餐,也大大减少了一些传播疾病的机会。
在其他情况下,私有化会带来开销。总之,在并行程序员的工具箱中,私有化是一个强大的工具,但必须小心使用。就像其他同步原语一样,他可能会带来复杂性,同时降低性能和扩展性。
数据所有权的其他用途
当数据可以被分割时,数据所有权最为有效,此时很少或没有需要跨线程访问或更新的地方。幸运的是,这种情况很常见,并且在各种并行编程环境中广泛存在。
- 所有消息传递环境,例如 MPI。
- MapReduce。
- 客户端——服务器系统,包括 RPC、Web 服务好几乎任何带有后端数据库服务的系统。
- 无共享式数据库系统。
- 具有单独的每进程地址空间的 fork-join 系统。
- 基于进程的并行性,比如 Erlang 语言。
- 私有变量,例如 C 语言在线程环境中的堆栈自动变量。
数据所有权可能是最不起眼的同步机制。当使用得当时,它能提供无与伦比的简单性、性能、扩展性。也许他的简单性使他没有得到应有的尊重。
3.3.9 - CH09-延后处理
延后工作的策略可能在人类有记录历史出现之前就存在了,它偶尔被嘲笑为拖延甚至纯粹的懒惰。但直到最近几十年,人们才认识到该策略在简化并行化算法的价值。通用的并行编程延后处理方式包括引用计数、顺序锁、RCU。
引用计数
引用计数指的是跟踪一个对象被引用的次数,防止对象被过早释放。虽然这是一种概念上很简单的技术,但是细节中隐藏着很多魔鬼。毕竟,如果对象不会太提前释放,那么就不需要引用计数了。但是如果容易被提前释放,那么如何阻止对象在获取引用计数过程中被提前释放呢?
该问题有以下几种可能的答案:
- 在操作引用计数时必须持有一把处于对象之外的锁。
- 使用不为 0 的引用计数创建对象,只有在当前引用计数不为 0 时才能获取新的引用计数。如果线程没有对某指定对象的引用,则它可以在已经具有引用的另一线程的帮助下获得引用。
- 为对象提供存在担保,这样在任何有实体尝试获取引用的时刻都无法释放对象。存在担保通常是由自动垃圾收集器来提供,并且在后续章节介绍的 RCU 中也会能提供存在担保。
- 为对象提供类型安全的存在担保,当获取到引用时将会执行附加的类型检查。类型安全的存在担保可以由专用内存分配器提供,也可以由 Linux 内核中的 SLAB_DESTORY_BY_RCU 特性提供。
当然,任何提供存在担保的机制,根据其定义也能提供类型安全的保证。所以本节将后两种答案合并成放在 RCU 一类,这样我们就有 3 种保护引用获取的类型,即锁、引用计数和 RCU。
考虑到引用计数问题的关键是对引用获取和释放对象之间的同步,我们共有 9 种可能的机制组合。
获取同步 | 释放同步—锁 | 释放同步—引用计数 | 释放同步—RCU |
---|---|---|---|
锁 | — | CAM | CA |
引用计数 | A | AM | A |
RCU | CA | MCA | CA |
下图将引用计数机制归为以下几个大类:
- (—):简单计数,不适用原子操作、内存屏障、对齐限制。
- (A):不使用内存屏障的原子计数。
- (AM):原子计数,仅在释放时使用内存屏障。
- (CAM):原子计数,在获取时使用原子操作检查,在释放时使用内存屏障。
- (CA):原子计数,在获取时使用原子操作检查。
- (MCA):原子计数,在获取时使用原子操作检查,同时还使用内存屏障。
但是,由于 Linux 内核中所有“返回值的原子”都包含内存屏障,所有释放操作也包含内存屏障。因此类型 CA 和 MCA 与 CAM 相等,这样就剩下四种类型:—、A、AM、CAM。后续章节将会列出支持引用计数的 Linux 原语。稍后的章节也将给出一种优化,可以改进引用获取和释放十分频繁、而很少需要检查引用是否为 0 这一情况下的性能。
各种引用计数的实现
简单计数
简单计数,既不使用原子操作、也不使用内存屏障,可以用于在获取和释放引用计数时都用同一把锁保护的情况。在这种情况下,引用计数可以以非原子操作方式读写,因为锁提供了必要的互斥保护、内存屏障、原子指令和禁用编译器优化。这种方式适用于锁在保护引用计数之外还保护其他操作的情况,这样也使得引用一个对象必须得等到锁(被其他地方)释放后再持有。
原子计数
原子计数适用于这种情况:任何 CPU 必须先持有一个引用才能获取引用。这是用在当单个 CPU 创建一个对象以供自己使用时,同时也允许其他 CPU、任务、定时器处理函数或者 CPU 后来产生的 IO 完成回调处理函数来访问该对象。CPU 在将对象传递给其他实体之前,必须先以该实体的名义获取一个新的引用。在 Linux 内核中,kref 原语就是用于这种引用计数的。
因为锁无法保护所有引用计数操作,所以需要原子计数,这意味着可能会有两个不同的 CPU 并发地操纵引用计数。如果使用普通的增减函数,一对 CPU 可以同时获取引用计数,假设他们都获取到了计数值 3。如果他们各自都增加各自的值,就得到计数值 4,然后将值写回引用计数中。但是引用计数的新值本该是 5,这样就丢失了其中一次增加。因此,计数的增减操作必须使用原子操作。
如果释放引用计数由锁或 RCU 保护,那么就不需要再使用内存屏障了(以及禁用编译器优化),并且锁也可以防止一对释放操作同时执行。如果是 RCU,清理必须延后直到所有当前 RCU 读端的临界区执行完毕,RCU 框架会提供所有需要的内存屏障和进制编译器优化。因此,如果 2 个 CPU 同时释放了最后 2 个引用,实际的清理工作将延后到所有 CPU 退出它们读端的连接区才会开始。
带释放内存屏障的原子计数
Linux 内核的网络层采用了这种风格的引用,在报文路由中用于跟踪目的地缓存。实际的实现要更复杂一点,本节将关注 struct_dst_entry
引用计数是如何满足这种实例的。
如果调用者已经持有一个 dst_entry 的引用,那么可以使用 dist_clone()
原语,该原语会获取另一个引用,然后传递给内核中的其他实体。因为调用者已经持有了一个引用,dis_clone()
不需要再执行任何内存屏障。将 dst_entry 传递给其他实体的行为是否需要内存屏障,要视情况而定,不过如果需要内存屏障,那么内存屏障已经嵌入在传递给 dst_entry 的过程中了。
dist_release()
原语可以在任何情况下调用,调用者可能在调用 dst_release()
的上一条语句获取 dst_entry 结构的元素的引用。因此在第 14 行上,dst_release()
原语包含了一个内存屏障,阻止编译器和 CPU 的乱序执行。
请注意,开发者在调用 dst_clone()
和 dst_release()
时不需要关心内存屏障,只需要了解使用这两个原语的规则就够了。
带检查和释放内存屏障的原子计数
引用计数的获取和释放可以并发执行这一事实增加了引用计数的复杂性。假设某次引用计数的释放操作发现引用计数的新值为 0,这表明他现在可以安全清除被引用的对象。此时我们肯定不希望在清理工作进行时又发生一次引用计数的获取操作,所以获取操作必须包含一个检查当前引用值是否为 0 的检查。该检查必须是原子自增的一部分。
Linux 黑盒的 fget()
和 fput()
原语都属于这种风格的引用计数,下面是简化后的实现:
第 4 行的 fget 取出一个指向当前进程的文件描述符表的指针,该表可能在多个进程间共享。第 6 行调用 rcu_read_lock
,进入 RCU 读端临界区。后续任何 call_rcu
原语调用的回调函数将延后到对应的 rcu_read_unlock
完成后执行。第 7 行根据参数 fd 指定的文件描述符,查找对应的 struct file 结构,文件描述符的内容稍后再讲。如果指定的文件描述符存在一个对应的已打开文件,那么第 9 行尝试原子地获取一个引用计数。如果第 9 行的操作失败,那么第 10、11 行退出 RCU 读写端临界区,返回失败。如果第 9 行的操作成功,那么第 14、15 行退出读写端临界区,返回一个指向 struct file 的指针。
fcheck_files
原语是 fget
的辅助函数。该函数使用 rcu_dereference
原语来安全地获取受 RCU 保护的指针,用于之后的解引用(这会在如 DEC Alpha 之类的 CPU 上产生一个内存屏障,在这种机器上数据依赖并不保证内存顺序执行)。第 22 行使用 rcu_dereference
来获取指向任务当前的文件描述符表的指针,第 25 行获取该 struct file 的指针,然后调用 rcu_dereference
原语。第 26 行返回 struct file 的指针,如果第 24 行检查失败,那么这里返回 NULL。
fput
原语释放一个 struct file 的引用。第 31 行原子地减少引用计数,如果自减后值为 0,那么第 32 行调用 call_rcu
原语来释放 struct file(通过 call_rcu()
的第二个参数指定的 file_free_rcu
函数),不过这只在当前所有执行 RCU 读端临界区的代码执行完毕后才会发生。等待当前所有执行 RCU 读端临界区的时间被称为“宽限期”。请注意,atomic_dec_and_test
原语中包含一个内存屏障。在本例中该屏障并非必要,因为 struct file 只有在所有 RCU 读端临界区完成后才能销毁,但是在 Linux 中,根据定义所有会返回值的原子操作都需要包含内存屏障。
一旦宽限期完毕,第 39 行 file_free_rcu
函数获取 struct file 的指针,第 40 行释放该指针。
本方法也用于 Linux 虚拟内存系统中,请见针对 page 结构的 get_page_unless_zero
和 put_page_test_zero
函数,以及针对内存映射的 try_to_unuse
和 mmput
函数。
危险指针
前面小节讨论的所有引用计数机制都需要一些其他预防机制,以防止在正在获取引用计数的引用时删除数据元素。该机制可以是一个预先存在的对数据元素的引用、锁、RCU 或原子操作,但所有这些操作都会降低性能和扩展性,或者限制应用场景。
有一种避免这些问题的方法是反过来实现引用计数,也就是说,不是增加存储在数据元素内的某个整数,而是在每 CPU(或每线程)链表中存储指向该数据元素的指针。这个链表里的元素被称为危险指针。每个元素都有一个“虚引用计数”,其值可以通过计算有多少个危险指针指向该元素而得到。因此,如果该元素已经被标记为不可访问,并且不再有任何引用它的危险指针,该元素就可以安全地释放。
当然,这意味着危险指针的获取必须要谨慎,以避免并发删除导致的破坏性后果。
因为使用危险指针的算法可能在他们的任何步骤中重新启动对数据结构的遍历,这些算法通常在获得所有危险指针之前,必须注意避免对数据结构进行任何更改。
以这些限制为交换,危险指针可以为读端提供优秀的性能和扩展性。在第十章将会比较危险指针及其他引用计数机制的性能。
支持引用计数的 Linux 原语
atomic_t
,可提供原子操作的 32 位类型定义。void atomic_dec(atomic_t *var)
,不需要内存屏障或阻止编译器优化的原子自减引用计数操作。int atomic_dec_and_test(atomic_t *var)
,原子减少引用计数,如果结果为 0 则返回 true。需要内存屏障并且阻止编译器优化,否则可能让引用计数在原语外改变。void atomic_inc(atomic_t *var)
,原子增加引用计数,不需要内存屏障或禁用编译器优化。int atomic_inc_not_zero(atomic_t *var)
,原子增加引用计数,如果结果不为 0,那么在增加后返回 true。会产生内存屏障并禁止编译器优化,否则引用会在原语外改变。int atomic_read(atomic_t *var)
,返回引用计数的整数值。非原子操作、不需要内存屏障、不需要禁止编译器优化。void atomic_set(atomic_t *var, int val)
,将引用计数的值设置为 val。非原子操作、不需要内存屏障、不需要禁止编译器优化。void call_rcu(struct rcu_head *head, void (*func)(struct rcu_head *head))
,在当前所有执行 RCU 读端临界区完成后调用 func,不过call_rcu
原语是立即返回的。请注意,head 通常是受 RCU 保护的数据结构的一个字段,func 通常是释放该数据结构的函数。从调用call_rcu
到调用 func 之间的时间间隔被称为宽限期。任何包含一个宽限期的时间间隔本身就是一个宽限期。type *container_of(p, type, f)
,给出指针 p,指向类型为 type 的数据结构中的字段 f,返回指向数据结构的指针。void rcu_read_lock(void)
,标记一个 RCU 读端临界区的开始。void rcu_read_unlock(void)
,标记一个 RCU 读端临界区的结束。RCU 读临界区可以嵌套。void smp_mb__before_atomic_dec(vod)
,只有在该平台的atomic_dec
原语没有产生内存屏障,禁止编译器的乱序优化时才有用,执行上面的操作。struct rcu_head
用于 RCU 基础框架的数据结构,用来跟踪等待宽限期的对象。通常作为受 RCU 保护的数据结构中的一个字段。
计数优化
在经常更改计数但很少检查计数是否为 0 的场合里,像第 5 章讨论的那样,维护一个每 CPU 或者每任务计数很有用。关于此计数在 RCU 上的实例,参见关于可睡眠 RCU 的论文。该方法可避免在增减计数函数中使用原子操作或内存屏障,但还是要禁用编译器的乱序优化。另外,像 synchronize_srcu
这样的原语,检查总的引用计数是否为 0 的速度十分缓慢。这使得该方法不适合用于频繁获取和释放引用计数的场合,不过对于极少检查引用计数是否为 0 的场合还是合适的。
顺序锁
Linux 内核中使用的顺序锁主要用于保护以读取为主的数据,多个读者观察到的状态必须一致。不像读写锁,顺序锁的读者不能阻塞写者。它反而更像是危险指针,如果检测到有并发的写者,顺序锁会强迫读者重试。在代码中使用顺序锁的时候,设计很重要,尽量不要让读者有重试的机会。
顺序锁的关键组成部分是序列号,没有写着的情况下其序列号为偶数值,如果有一个更新正在进行中,其序列号为奇数值。读者在每次访问之前和之后可以对值进行快照。如果快照是奇数值,又或者如果两个快照的值不同,则存在并发更新,此时读者必须丢弃访问的结果,然后重试。读者使用 read_seqbegin
和 read_seqretry
函数访问由顺序锁保护的数据。写者必须在每次更新前后增加该值,并且在任意时间内只允许一个写者。写者使用 write_seqlock
和 write_sequnlock
函数更新由顺序锁保护的数据。
顺序锁保护的数据可以拥有任意数量的并发读者,但一次只有有一个写者。在 Linux 内核中顺序锁用于保护计时的校准值。它也用在遍历路径名时检测并发的重命名操作。
可以将顺序锁的读端和写端临界区视为事务,因此顺序锁定可以被认为是一种有限形式的事务内存,后续将会讨论。顺序锁的限制是:顺序锁限制更新和;顺序锁不允许遍历指向可能被写者释放的指针。事务内存当然不存在这些限制,但是通过配合使用其他同步原语,顺序锁也可以克服这些限制。
顺序锁允许写者延迟读者,但反之不可。在存在大量写操作的环境中,这可能引起对读者的不公平甚至饥饿。另一方面,在没有写者时,顺序锁的运行相当快且可以线性扩展。人们总是要鱼和熊掌兼得:快速的读者和不需要重试的读者,并且不会发生饥饿。此外,如果能够不受顺序锁对指针的限制就更好了。下面将介绍同时拥有这些特性的同步机制。
读-复制-修改(RCU)
RCU 介绍
假设你正在编写一个需要访问随时变化的数据的并行实时程序,数据可能是随着温度、湿度的变化而逐渐变化的大气压。该程序的实时响应要求是如此严格,不允许存在任何自旋或阻塞,因此锁就被排除了。同样也不允许使用重试循环,这就排除了顺序锁。幸运的是,温度和压力的范围通常是可控的,这样使用默认的编码数据集也可行。
但是,温度、湿度和压力偶尔会偏离默认值太远,在这种情况下,有必要提供替换默认值的数据。因为温度、湿度和压力是逐渐变化的,尽管数值必须在几分钟内更新,但提供更新值并不是非常紧急的事情。该程序使用一个全局指针,即 gptr,通常为 NULL,表示要使用默认值。否则,gptr 指向假设命名为 a/b/c 的变量,它们的值用于实时计算。
我们如何在不妨碍实时性的情况下安全地为读者提供更新后的数据呢?
上图是一种经典的方式。第一排显示默认状态,其中 gptr 等于 NULL。在第二排中,我们已经分配了一个默认的结构,如问号所示。在第三排,我们已经初始化了该结构。接下来,我们让 gptr 来引用这个新元素。在现代计算机系统中,并发的读者要么看到一个 NULL 指针、要么看到指向新结构 p 的指针,不会看到中间结果,从这种意义上说,这种赋值是原子的。因此,每个读者都可以读到默认值 NULL,或者获取新赋值的非默认值。但无论哪种方式,每个读者都会看到一致的结果。更好的是,读者不需要使用任何昂贵的同步原语,因此这种方式非常适合用于实时场景。
但是我们迟早需要从并发的读者手中删除指向指针的数据。让我们转到一个更加复杂的例子,我们正在删除一个来自链表的元素,如下图:
此链表最初包含元素 A/B/C,首先我们需要删除元素 B,我们使用 list_del()
执行删除操作,此时所有新加入的读者都将看到元素 B 已经从链表删除了。然而,可能仍然有老读者在引用这个元素。一旦所有这些旧的读者读取完成,我们可以安全地释放元素 B,如图中最后一部分所示。
但是我们怎么知道读者何时完成读取呢?
引用计数的方案很有诱惑力,但是这也可能导致长延迟,正如锁和顺序锁,我们已经拒绝这种选择。
让我们考虑极端情况下的逻辑,读者完全不将他们的存在告诉任何人。这种方式显然让读者的性能更佳(毕竟免费是一个非常好的价格),但留给写者的问题是如何才能确定所有的老读者都已经完成。如果要给这个问题提供一个合理的答案,我们显然需要一些额外的约束条件。
有一种约束适合某种类型的实时操作系统(以及某些操作系统内核),让线程不会被抢占。在这种不可抢占的环境中,每个线程将一直运行,直到它明确地并自愿地阻塞自己。这意味着一个不能阻塞的无限循环将使该 CPU 在循环开始后无法用于任何其他目的。不可抢占性还要求线程在持有自旋锁时禁止阻塞。如果没有这个禁止,当持有自旋锁的线程被阻塞后,所有 CPU 都可能陷入某个要求获取自旋锁的线程中无法自拔。要求获取自旋锁的线程在获得锁之前不会放弃他们的 CPU,但是持有锁的线程因为拿不到 CPU,又不能释放自旋锁。这是一种经典的死锁。
然后我们对遍历链表的读线程施加相同的约束:这样的线程在完成遍历之前不允许阻塞。返回到上图的第二排,其中写者刚刚执行完 list_del()
,想象 CPU0 这时做了一个上下文切换。因为读者不允许在遍历链表时阻塞,所以我们可以保证所有先前运行在 CPU0 上的读者已经完成。将这个推理扩展到其他 CPU,一旦每个 CPU 被观察到执行了上下文切换,我们就能保证所有之前的读者都已经完成,该 CPU 不会再有任何引用元素 B 的读线程。此时写者可以安全地释放元素 B 了,也就是上图最后一排所示的状态。
这种方法的示意图如下所示,图中的时间从顶部推移到底部:
虽然这种方法在生产环境上的实现可能相当复杂,但是玩具实现却非常简单:
for_each_online_cpu(cpu);
run_on(cpu);
for_each_online_cpu()
原语遍历所有 CUP,run_on()
函数导致当前线程在指定的 CPU 上运行,这会强制目标 CPU 切换上下文。因此,一旦 for_each_online_cpu
完成,每个 CPU 都执行了一次上下文切换,这又保证了所有之前存在的读线程已经完成。
请注意,该方法不能用于生产环境。正确处理各种边界条件和对性能优化的强烈要求意味着用于生产环境的代码实现将十分复杂。此外,可抢占环境的 RCU 实现需要读者实际去做点什么事情。不过,这种简单的不可抢占方法在概念上是完整的,并为下一节理解 RCU 的基本原理形成了良好的初步基础。
RCU 基础
RCU 是一种同步机制,2002 年 10 月引入 Linux 内核。RCU 允许读操作可以与更新操作并发执行,这一点提升了程序的可扩展性。常规的互斥锁让并发线程互斥执行,并不关心该线程是读者还是写者,而读写锁在没有写者时允许并发的读者,相比于这些常规操作,RCU 在维护对象的每个版本时确保读线程保持一致,同时保证只在所有当前读端临界区都执行完毕后才释放对象。RCU 定义并使用了高效且易于扩展的机制,用来发布和读取对象的新版本,还用于延后旧版本对象的垃圾收集工作。这些机制恰当地在读端和更新端分布工作,让读端非常快速。在某些场合下(比如非抢占式内核里),RCU 读端的函数完全是零开销。
看到这里,读者通常会疑惑“究竟 RCU 是什么”,或者“RCU 怎么工作”。本节将致力于从一种基本的视角回答上述问题,稍后的章节将从用户使用和 API 的视角从新看待这些问题。最后一节会给出一个图表。
RCU 由三种基础机制构成,第一个机制用于插入,第二个机制用于删除,第三个用于让读者可以不受并发插入和删除的干扰。
订阅机制
RCU 的一个关键特性是可以安全的扫描数据,即使数据此时正被修改。RCU 通过一种发布——订阅机制达到了并发的数据插入。举个例子,假设初始值为 NULL 的全局指针 gp 现在被赋值指向一个刚分配并初始化的数据结构。如下代码所示:
1 struct foo {
2 int a;
3 int b;
4 int c;
5 };
6 struct foo *gp = NULL;
7
8 /* . . . */
9
10 p = kmalloc(sizeof(*p), GFP_KERNEL);
11 p->a = 1;
12 p->b = 2;
13 p->c = 3;
14 gp = p;
不幸的是,这块代码无法保证编译器和 CPU 会按照顺序执行最后 4 条赋值语句。如果对 gp 的复制发生在初始化 p 的各种字段之前,那么并发的读者会读到未初始化的值。这里需要内存屏障来保证事情按顺序发生,可是内存屏障又向来以难用著称。所以我们这里用一句 rcu_assign_pointer()
原语将内存屏障封装起来,让其拥有发布的语义。最后 4 行代码如下:
1 p->a = 1;
2 p->b = 2;
3 p->c = 3;
4 rcu_assign_pointer(gp, p);
rcu_assign_pointer “发布”一个新结构,强制让编译器和 CPU 在为 p 的个字段复制之后再去为 gp 赋值。
不过,只保证更新者的执行顺序并不够,因为读者也需要保证读取顺序。请看下面的代码:
1 p = gp;
2 if (p != NULL) {
3 do_something_with(p->a, p->b, p->c);
4 }
这块代码看起来好像不会受乱序执行的影响,可惜事与愿违,在 DEC Alpha CPU 机器上,还有启用编译器值推测优化时,会让 p->a, p->b, p->c 的值在 p 赋值之前被读取,此时编译器会先猜测 p->a, p->b, p->c 的值,然后再去读取 p 的实际值来检查编译器的猜测是否正确。这种类型的优化十分激进,甚至有点疯狂,但是这确实发生在档案驱动(profile-driven)优化的上下文中。
显然,我们必须在编译器和 CPU 层面阻止这种危险的优化。rcu_dereferenc
e 原语用了各种内存屏障和编译器指令来达到这一目的。
1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 if (p != NULL) {
4 do_something_with(p->a, p->b, p->c);
5 }
6 rcu_read_unlock();
rcu_dereference()
原语用一种订阅的方式来获取指定指针的值。然后后续的解引用操作可以看见在对应的发布操作(rcu_read_pointer)前进行的初始化。rcu_read_lock
和 rcu_read_unlock
是肯定需要的:这对原语定义了 RCU 读端的临界区。后续将会介绍它们的意图,不过请注意,这对原语既不会自旋或阻塞,也不会组织 list_add_rcu
的并发执行。事实上,在没有配置 CONFIG_PREEMPT 的内核里,这对原语就是空函数。
虽然理论上 rcu_assign_pointer
的 rcu_dereference
可以用于构造任何能想象到的受 RCU 保护的数据结构,但是实践中常常只用于上层的构造。因此这两个原语是嵌入在特殊的 RCU 变体——即 Linux 操纵链表的 API 中。Linux 有两种双链表的变体,循环链表和哈希表 struct hlist_head/struct hlist_node。前一种如第一个图所示,深色代表链表元头,浅色代表链表元素。而第二张图给出了一种简化方法:
第 15 行必须采用某种同步机制(最常见的是各种锁)来保护,放置多核 list_add 实例并发执行。不过,同步并不能阻止 list_add 的实例与 RCU 的读者并发执行。
订阅一个受 RCU 保护的链表则非常直接:
1 struct foo {
2 struct list_head *list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 list_add_rcu(&p->list, &head);
list_add_rcu 原语向指定的链表发布了一条项目,保证对应的 list_for_each_entry_rcu 可以订阅到同一条目。
Linux 的其他双链表、哈希链表都是线性链表,这意味着它的头部节点只需要一个指针,而不是向循环链表那样需要两个,如上图所示。因此哈希表的使用可以减少哈希表的 hash bucket 数组一半的内存消耗。和前面一样,这种表示法太麻烦了,哈希表也可以用和链表一样的简化表达方式。
向受 RCU 保护的哈希表发布新元素和向循环链表的操作十分类似,如下面的示例:
1 struct foo {
2 struct hlist_node *list;
3 int a;
4 int b;
5 int c;
6 };
7 HLIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 hlist_add_head_rcu(&p->list, &head);
和之前一样,第 15 行必须要使用某种同步机制来保护,比如锁。
订阅受 RCU 保护的哈希表和订阅循环链表没什么区别:
1 rcu_read_lock();
2 hlist_for_each_entry_rcu(p, head, list) {
3 do_something_with(p->a, p->b, p->c);
4 }
5 rcu_read_unlock();
下表是 RCU 的订阅和发布原语,及一个取消发布原语:
类别 | 发布 | 取消发布 | 订阅 |
---|---|---|---|
指针 | rcu_asign_pointer | rcu_assign_pointer(…, NULL) | rcu_dereference |
链表 | list_add_rcu list_add_tail_rcu list_replace_rcu | list_del_rcu | list_for_each_entry_rcu |
哈希链表 | hlist_add_after_rcu hlist_add_before_rcu hlist_add_head_rcu hlist_replace_rcu | hlist_del_rcu | hlist_for_each_entry_rcu |
请注意,list_replace_rcu/list_del_rcu/hlist_replace_rcu/hlist_del_rcu 这些 API 引入了一点复杂性。何时才能安全地释放刚被替换或删除的数据元素?我们怎么知道何时所有读者释放了他们对数据元素的引用?
这些问题将在随后的小节中得到回到。
等待已有的 RCU 读者执行完毕
从最基本的角度来说,RCU 就是一种等待事物结束的方式。当然,有很多其他的方式可以用来等待事物结束,比如引用计数、读写锁、事件等等。RCU 最伟大之处在于它可以等待 20000 种不同的事物,而无需显式的跟追他们中的每一个,也无需去单行对性能的影响、对扩展性的限制、复杂的死锁场景、还有内存泄露带来的危害等等那些使用显式跟踪手段会出现的问题。
在 RCU 的例子中,被等待的事物称为 RCU 读端临界区。RCU 读端临界区从 rcu_read_lock 原语开始,到对应的 rcu_read_unlock 原语结束。RCU 读端临界区可以嵌套,也可以包含一大段代码,只要这其中的代码不会阻塞会睡眠。如果遵守这些约定,就可以使用 RCU 去等待任何代码的完成。
RCU 通过间接地确定这些事物合适完成,才实现了这样的壮举。
如上图所示,RCU 是一种等待已有的 RCU 读端临界区执行完毕的方法,这里的执行完毕也包括在临界区内执行的内存操作。不过请注意,某个宽限期开始后才启动的 RCU 读端临界区会扩展到该宽限期的结尾处。
下列伪代码展示了使用 RCU 等待读者的基本算法:
- 做出改变,比如替换链表中的一个元素。
- 等待所有已有的 RCU 读端临界区执行完毕,这里需要注意的是后续的 RCU 读端临界区无法获取刚刚删除元素的引用。
- 清理,比如释放刚才被替换的元素。
如下面的代码片段所示,其中演示了这个过程,其中字段 a 是搜索关键字:
1 struct foo {
2 struct list_head *list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = search(head, key);
12 if (p == NULL) {
13 /* Take appropriate action, unlock, & return. */
14 }
15 q = kmalloc(sizeof(*p), GFP_KERNEL);
16 *q = *p;
17 q->b = 2;
18 q->c = 3;
19 list_replace_rcu(&p->list, &q->list);
20 synchronize_rcu();
21 kfree(p);
第 9、20、21 行实现了刚才提到的 3 个步骤。16~19 行正如 RCU 其名(读-复制-更新),在允许并发度的同时,第 16 行复制,17~19 更新。
正如前面讨论的 synchronize_rcu
原语可以相当简单。然而,想要达到生产质量,代码实现必须处理一些困难的边界情况,并且需要大量优化,这两者都将导致显著的复杂性。虽然知道 synchronize_rcu
有一个简单的实现很好,但是其他问题仍然存在。例如,当 RCU 读者遍历正在更新的链表时会看到什么?该问题将在下节讨论。
维护最近被更新对象的多个版本
本节将展示 RCU 如何维护链表的多个版本,供并发的读者访问。本节通过两个例子来说明在读者还处于 RCU 读端临界区时,被读者引用的数据元素如何保持完整性。第一个例子展示了链表元素的删除,第二个例子展示了链表元素的替换。
例子1:在删除过程中维护多个版本
在开始这个例子前,我们先将上面的代码中 11~20 行改为如下形式:
1 p = search(head, key);
2 if (p != NULL) {
3 list_del_rcu(&p->list);
4 synchronize_rcu();
5 kfree(p);
6 }
这段代码用下图展示的方式跟新链表。每个元素中的三个数字分别代表子弹 a/b/c 的值。红色的元素表示 RCU 读者此时正持有该元素的引用。请注意,我们为了让图更清楚,忽略了后向指针和从尾指向头的指针。
等第 3 行的 list_del_rcu 执行完毕后,5、6、7 元素从链表中被删除,如第 2 行代码所示。如果读者不直接与更新者同步,所以读者可能还在并发地扫描链表。这些并发的读者都有可能看见,也有可能看不见刚刚被删除的元素,这取决于扫描的时机。不过,刚好在取出指向被删除元素指针后被延迟的读者(比如由于终端、ECC 内存错误、配置了 CONFIG_PREEMPT_RT 内核中的抢占),有就可能在删除后还看见链表元素的值。因此,我们此时有两个版本的链表,一个拥有元素 5、6、7,而另一个没有。元素 5、6、7 用黄色标注,表名老读者可能还在引用它,但是新读者已经无法得到它的引用。
请注意,读者不允许在退出 RCU 读临界区后还维护元素 5、6、7的引用。因此,一旦第 4 行的 synchronize_rcu 执行完毕,所有已有的读者都要保证执行完成,不能再有读者引用该元素,图图中第三排的绿色部分。这样我们就又回到了唯一版本的链表。
此时,元素 5、6、7 可以被安全释放了。如图中最后一排所示。这样我们就完成了元素的删除。本节后面部分将描述元素的替换。
例子2:在替换过程中维护多个版本
在开始替换之前,我们先看看前面例子中最后几行代码:
1 q = kmalloc(sizeof(*p), GFP_KERNEL);
2 *q = *p;
3 q->b = 2;
4 q->c = 3;
5 list_replace_rcu(&p->list, &q->list);
6 synchronize_rcu();
7 kfree(p);
链表的初始状态包括指针 p 都和删除例子中一样,如下图中的第一排所示:
和前面一样,每个元素的三个数字分别代表字段 a/b/c。红色的元素表示读者可能正在引用,并且因为读者不直接与更新者同步,所以读者有可能与整个替换过程并发执行。请注意我们为了图表的清晰,再一次忽略了后向指针和指向头的指针。
下面描述了元素 2、5、3 如何替换元素 5、6、7 的过程,任何特定的读者都有可能看见这两个值的其中一个。
第 1 行用 kmalloc 分配了要替换的元素,如图第二排所示。此时没有读者持有刚分配的元素的引用(绿色),并且该元素是未初始化的(问号)。
第 2 行将旧元素复制给新元素,如图第三排所示。新元素此时还不能被读者访问,但是已经初始化了。
第 3 行将 q->b 的值更新为 2,这样新元素终于对读者可见了,因此颜色也变成了红色,如图第五排所示。此时,链表就有两个版本了。已经存在的老读者也可能看到元素 5、6、7(黄色),而新读者可能会看到 5、2、3。不过这里可以保证任何读者都能看到一个完好的链表。
随着第 6 行 synchronize_rcu 的返回,宽限期结束,所有在 list_replace_rcu 之前开始的读者都已经完成。特别是任何可能持有元素 5、6、7 引用的读者保证已经退出了它们的 RCU 读端临界区,不能继续持有引用。因此,不再有任何读者持有旧数据的引用,如图第六排绿色部分所示。这样我们又回到了单一版本的链表,只是用新元素替换了旧元素。
等第 7 行的 kfree 完成后,链表就成了图中最后一排的样子。
不过尽管 RCU 是因替换的例子而得名的,但是 RCU 在内核中的用途还是和简单的删除例子一样。
讨论上述这些例子假设整个更新操作都持有一把互斥锁,这意味着任意时刻最多会有两个版本的链表。
这个事件序列显示了 RCU 更新如何使用多个版本,在有读者并发的情况下安全地执行改变。当然,有些算法无法优雅地处理多个版本。有些技术在 RCU 中采用了这些算法,但是超过了本节的范围。
RCU 基础总结
本节描述了 RCU 算法的三个基本组件。
- 添加新数据的发布——订阅机制。
- 等待已有 RCU 读者结束的方法。
- 维护多个版本数据的准则,允许在不影响或延迟其他并发 RCU 读者的前提下改变数据。
这三个 RCU 组件使得数据可以在有并发读者时被改写,通过不同方式的组合,这三种组件可以实现各种基于 RCU 算法的变体,后续将会讨论。
RCU 的用法
本节将从使用 RCU 的视角,以及使用哪种 RCU 的角度来回答“什么是 RCU”。因为 RCU 最常用的目的是替换已有的机制,所以我们首先观察 RCU 与这些机制之间的关系。
RCU 是读写锁的替代者
也许在 Linux 内核中 RCU 最常见的用途就是在读占大多数时间的情况下替换读写锁了。可是在一开始我并没有想到 RCU 的这个用途,事实上在 20 世纪 90 年代初期,我在实现通用 RCU 实现之前选择实现了一种轻量的读写锁。我为这个轻量级读写锁原型想象的每个用途最后都是用 RCU 来实现了。事实上,在请练级读写锁第一次实际使用时 RCU 已经出现了不止三年了。兄弟们,我是不是看起来很傻!
RCU 和读写锁最关键的相似之处在于两者都有可以并行执行的读端临界区。事实上,在某些情况下,完全可以从机制上用对应的读写锁 API 来替换 RCU 的 API。不过,这样做有什么必要呢?
RCU 的有点在于性能、没有死锁,并能提供实时的延迟。当然 RCU 也有一些缺点,比如读者与写者并发执行,比如低优先级 RCU 读者可以阻塞正等待宽限期完毕的高优先级线程,还比如宽限期的延迟可以有好几毫秒。这些优点和缺点在后续会继续介绍。
如下图,“性能 RCU”相较于读写锁在读端的性能优势。
请注意,在单个 CPU 上读写锁比 RCU 慢一个数量级,在 16 个 CPU 要慢两个数量级,RCU 的扩展性要好很多。在上面两个例子中,错误曲线几乎是水平的。
更温和的视角来自 CONFIG_PREEMPT 内核,虽然 RCU 仍然超过了读写锁 1 到 3 个数量级,如下图。请注意,读写锁在 CPU 数目很多时的陡峭曲线。在任一方向上误差都超过了一个标准差。
当然,如下图中所示,由于不现实的零临界区长度,读写锁的低性能被夸大了。随着临界区的增长,RCU 的性能优势也不再显著,在上图的 16 个 CPU 系统中,Y 轴代表读端原语的总开销,X 轴代表临界区长度。
但是考虑到很多系统调用(以及他们所包含的 RCU 读端临界区)都能在几毫秒内完成,所以这个结果对 RCU 是有利的。另外,下面将会讨论,RCU 读端原语基本上是不会死锁的。
免于死锁虽然 RCU 在多数为读的工作符合下提供了显著的性能优势,但是使用 RCU 的主要目标却不是它可以免于死锁的特性。这种免于死锁的能力来源于 RCU 的读端原语不阻塞、不自旋,甚至不会向后跳转,所以 RCU 读端原语的执行实现是确定的。这使得 RCU 读端原语不可能组成死锁循环。
RCU 读端免于死锁的能力带来了一个有趣的结果,RCU 读者可以无条件地升级为 RCU 更新者。在读写锁中尝试这种升级则会造成死锁。进行 RCU 读者到更新者提升的代码如下所示:
1 rcu_read_lock();
2 list_for_each_entry_rcu(p, &head, list_field) {
3 do_something_with(p);
4 if (need_update(p)) {
5 spin_lock(my_lock);
6 do_update(p);
7 spin_unlock(&my_lock);
8 }
9 }
10 rcu_read_unlock();
请注意,do_update 是在所的保护下执行的,也是在 RCU 读端的保护下执行。
RCU 免于死锁的特性带来的另一个有趣后果是 RCU 不会受很多优先级反转问题的影响。比如,低优先级的 RCU 读者无法阻止高优先级的 RCU 更新者获取更新端锁。类似的,低优先级的更新者也无法阻止高优先级的 RCU 读者进入 RCU 读端临界区。
实时延迟因为 RCU 读端原语既不自旋也不阻塞,所以这些原语有着极佳的实时延迟。另外,如之前所说,这也就意味着这些原语不会受与 RCU 读端原语和锁有关的优先级反转影响。
但是,RCU 还是会受到更隐晦的优先级反转问题影响,比如,在等待 RCU 宽限期结束时阻塞的高优先级进程,会被 -rt 内核的低优先级 RCU 读者阻塞。这也可以用 RCU 优先级提升来解决。
RCU 读者与更新着并发执行因为 RCU 读者既不自旋也不阻塞,还因为 RCU 更新者没有任何类似回滚或中止的语义,所以 RCU 读者和更新者必然可以并发执行。这意味着 RCU 读者有可能访问旧数据,还有可能发现数据不一致,无论这两个问题中的哪一个都有可能让读写锁卷土重来。
不过,令人吃惊的是在大量场景中,数据不一致的旧数据都不是问题。网络路由表是一个经典例子。因为路由的更新可能要花相当长的一段时间才能到达指定系统,所以系统可能会在更新到来后的一段时间内仍然将报文发到错误的地址去。通常在几毫秒内将报文发送到错误的地址并不算什么问题。并且,因为 RCU 的更新者可以在无需等待 RCU 读者执行完毕的情况下发生,所以 RCU 读者可能会比读写锁的读者更早看到更新后的路由表。如下图所示:
一旦收到更新,rwlock 的写者在最后一个读者完成之前不能继续执行,后续的读者在写者更新完成之前不能去读。不过,这一点也保证了后续的读者可以看见最新的值,如果图中绿色部分。相反,RCU 读者和更新者互相不会阻塞,这就允许 RCU 读者可以更快看见更新后的值。当然,因为读者和更新者的执行重叠了一部分,所以所有 RCU 读者都“可能”看见更新后的值,包括图中三个在更新者之前就已开始的 RCU 读者。然而,再一次强调,只有绿色的 RCU 读者才能保证看到更新后的值。
简而言之,读写锁和 RCU 提供了不同的保证。在读写锁中,任何在写者开始之后开始的读者都保证能看到新值,而在写者正在自旋时候开始的读者有可能看到新值,也有可能看到旧值,这取决于读写锁实现中的读写这哪一个优先级更高。与之相反,在 RCU 中,在更新者完成后才开始的读者都保证能看见新值,在更新者开始后才完成的读者有可能看见新值或旧值,这取决于具体的时机。
这里面的关键点是,虽然限定在计算机系统这一范围内读写锁保证了一致性,但是这种一致性是以增加外部世界的不一致性作为代价的。换句话说,读写锁以外部世界的旧数据作为代价,获取了内部的一致性。
然而,总有一种场合让系统无法容忍数据不一致和旧数据。幸运的是,有很多种办法可以避免这种问题,有一些办法是基于前面提到的引用计数。
低优先级 RCU 读者可以阻塞高优先级的回收者。在实时 RCU 中,被抢占的读者将阻止正在进行中的宽限期完成,即使高优先级的任务因为等待宽限期完成而阻塞也是如此。实时 RCU 可以通过用 call_rcu 替换 synchronize_rcu 来避免该问题,或者采用 RCU 优先级提升来避免,不过该方法在 2008 年初还处于实验状态。虽然有必要讨论 SRCU 和 QRCU 的优先级提升,但是现在实时领域还没有实际的需求。
延续好几毫秒的 RCU 宽限期。除了 QRCU 和前面提到的几个玩具 RCU 实现,RCU 宽限期会延续好几个毫秒。虽然有些手段可以消除这样长的延迟带来的损害,比如使用在可能时使用异步接口,但是根据拇指定律,这也是 RCU 使用在读数据占多数的场景的主要原因。
读写锁与 RCU 代码对比。在最好的情况下,将读写锁转换成 RCU 非常简单,如下图所示,这些都来自 Wikipedia。
详细阐述如何使用 RCU 替换读写锁已经超出了本书的范围。
RCU 是一种受限的引用计数机制
因为宽限期不能在 RCU 读端临界区进行时完毕,所以 RCU 读端原语可以像受限的引用计数机制一样使用。比如下面的代码片段:
1 rcu_read_lock(); /* acquire reference. */
2 p = rcu_dereference(head);
3 /* do something with p. */
4 rcu_read_unlock(); /* release reference. */
rcu_read_lock 原语可以看做是获取对 p 的引用,因为在 rcu_dereference 为 p 赋值之后才开始宽限期无法在配对的 rcu_read_unlock 之前完成。这种引用计数机制是受限的,因为我们不允许在 RCU 读端临界区中阻塞,也不允许将一个任务的 RCU 读端临界区传递给另一个任务。
不管上述的限制,下列代码可以安全的删除 p:
1 spin_lock(&mylock);
2 p = head;
3 rcu_assign_pointer(head, NULL);
4 spin_unlock(&mylock);
5 /* Wait for all references to be released. */
6 synchronize_rcu();
7 kfree(p);
将 p 赋值给 head 阻止了任何获取将来对 p 的引用的操作,synchronize_rcu 等待所有值钱获取的引用释放。
当然,RCU 也可以与传统的引用计数结合,LKML 中对此有过讨论,前面也做过了总结。
但是何必这么麻烦呢?我再回到一次,部分原因是性能,如果下图所示,图中再次显示了在 16 个 3GHZ CPU 的 Intel x86 系统中采集的数据。
并且,和读写锁一样,RCU 的性能优势主要来源于较短的临界区,如下图中所示。另外,和读写锁一样,许多系统调用(以及他们包含的任何 RCU 读端临界区)都在几毫秒内完成。
但是,伴随着 RCU 的限制有可能相当麻烦,比如,在许多情况下,在 RCU 读端临界区中禁止睡眠可能与我们的整个目标不符。下节将从解决该问题的方式触发,同时涉及在某些情况下如何降低传统引用计数的复杂性。
RCU 是一种可大规模使用的引用计数机制
前面曾经说过,传统的引用计数通常与某种或者一组数据结构有联系。然而,维护大量不同种类的数据结构的单一全局引用计数,通常会导致包含引用计数的缓存来回“乒乓”。这种缓存行“乒乓”会严重影响系统性能。
相反,RCU 的较轻量级读端原语允许读端极其频繁的调用,却只带来微不足道的性能影响,这使得 RCU 可以作为一种几乎没有任何惩罚的“批量引用计数机制”。当某个任务需要在一系列代码中持有引用时,可以送可休眠 RCU(SRCU)。但是这里没有包含特殊情景,一个任务将引用传递给另一个引用,在开始一次 IO 时获取引用,然后当对应的 IO 完成时在中断处理函数里释放该引用。(原则上 SRCU 的实现可以处理这一点,但是在实践中还不清楚这是否是一个好的权衡)
当然,SRCU 带来了它自己的限制条件,即要传递给对应 srcu_read_lock 和 srcu_read_unlock 返回值,以及硬件终端处理函数或者 NMI/SMI 处理函数不能调用 SRCU 原语。SRCU 的限制会带来多少问题,如何更好的处理这些问题,这一切还尚未有定论。
RCU 是穷人版的垃圾回收器
当人们开始学习 RCU 时,有种比较少见的感叹是“RCU 有点像垃圾回收器”。这种感叹有一部分是对的,不过还是会给学习造成误导。
也许思考 RCU 与垃圾回收器(GC)之间关系的最好办法是,RCU 类似自动的决定垃圾回收的时机。但是 RCU 与 GC 有两个不同点:
- 程序员必须手动指示何时可以回收数据结构。
- 程序员必须手动标出可以合法持有引用的 RCU 读端临界区。
尽管存在这些差异,两者的相似度仍然很高,就我所知至少有一篇理论分析 RCU 的文献曾经分析过两者的相似度。不仅如此,我所知道的第一种类 RCU 的机制就是运用垃圾回收器来处理宽限期。下节提供了一种更好思考 RCU 的方法。
RCU 是一种提供存在担保的方法
Gamsa 等人讨论了存在担保,并且描述了如何用一种类似 RCU 的机制提供这种担保。前面讨论过如何通过锁来提供存在担保及其弊端。如果任何受 RCU 保护的数据元素在 RCU 读端临界区中被访问,那么数据元素在 RCU 读端临界区持续期间保证存在。
1 int delete(int key)
2 {
3 struct element *p;
4 int b;
5
6 b = hashfunction(key);
7 rcu_read_lock();
8 p = rcu_dereference(hashtable[b]);
9 if (p == NULL || p->key != key) {
10 rcu_read_unlock();
11 return 0;
12 }
13 spin_lock(&p->lock);
14 if (hashtable[b] == p && p->key == key) {
15 rcu_read_unlock();
16 rcu_assign_pointer(hashtable[b], NULL);
17 spin_unlock(&p->lock);
18 synchronize_rcu();
19 kfree(p);
20 return 1;
21 }
22 spin_unlock(&p->lock);
23 rcu_read_unlock();
24 return 0;
25 }
上面的代码展示了基于 RCU 的存在担保如何通过从哈希表删除元素的函数来实现每数据元素锁。第 6 行计算哈希函数,第 7 行进入 RCU 读端临界区。如果第 9 行发现哈希表对应的哈希项(bucket)为空,或者数据元素不是我们想要删除的那个,那么第 10 行退出 RCU 读端临界区,第 11 行返回错误。
如果第 9 行判断为 false,第 13 行获取更新端的自旋锁,然后第 14 行检查元素是否还是我们想要的。如果是,第 15 行退出 RCU 读端临界区,第 16 行从哈希表中删除找到的元素,第 17 行释放锁,第 18 行等待所有值钱已经存在的 RCU 读端临界区退出,第 19 行释放刚被删除的元素,最后 20 行返回成功。如果 14 行的判断发现元素不再是我们想要的,那么 22 行释放锁,第 23 行退出 RCU 读端临界区,第 24 行返回错误以删除该关键字。
细心的读者可能会发现,该例子中只不过是前面“RCU 是一种等待事物结束的方式”中那个例子的变体。细心的读者还会发现免于死锁要比 前面讨论的基于锁的存在担保更好。
RCU 是一种提供类型安全内存的方法
很多无锁算法并不需要数据元素在被 RCU 读端临界区引用时保持完全一致,只要数据元素的类型不变就可以了。换句话说,只要结构类型不变,无锁算法可以允许某个数据元素在被其他对象引用时可以释放并重新分配,但是决不允许类型上的改变。这种“保证”,在学术文献中被称为“类型安全的内存”,比前一节提到的存在担保要弱一些,因此处理起来也要困难一些。类型安全的内存算法在 Linux 内核中的应用是 slab 缓存,被 SLAB_DESTROY_BY_RCU 专门标记出来的缓存通过 RCU 将释放的 slab 返回给系统内存。在任何已有的 RCU 读端临界区持续读期间,使用 RCU 可以保证所有带有 SLAB_DESTROY_BY_RCU 标记且正在使用的 slab 元素仍然在 slab 中,且类型保持一致。
这些算法一般使用了一个验证步骤,用于确定刚刚被引用的数据结构确实是被请求的数据。这种验证要求数据结构的一部分不能被释放——重新分配过程触碰。通常这种有效性检查很难保证不存在隐晦且难以解决的故障。
因此,虽然基于类型安全的无锁算法在一种很难达到的情景下非常有效,但是你最好还是尽量使用存在担保。毕竟简单总是好的。
RCU 是一种等待事物结束的方式
在前面我们提到 RCU 的一个重要组件是等待 RCU 读者结束的方法。RCU 的强大之处,其中之一就是允许你在等待上千个不同事物结束的同时,又不用显式去跟踪其中每一个,因此也就无需担心性能下降、扩展限制、复杂的死锁场景、内存泄露等显式跟踪机制自身的问题。
在本节中,我们将展示 synchronize_sched 的读端版本(包括禁止抢占、禁止中断原语)如何让你实现与不可屏蔽中断(NMI)处理函数的交互,如果用锁来实现,这将极其困难。这种方法被称为“纯 RCU”,Linux 的多处使用了该方法。
“纯 RCU” 设计的基本形式如下:
- 做出改变,比如,OS 对一个 NMI 做出反应。
- 等待所有已有读端临界区完全退出(比如使用 synchronize_sched 原语)。这里的关键是后续的 RCU 读端临界区保证可以看见变化发生后的样子。
- 扫尾工作,比如,返回表明改变成功完成的状态。
本节剩下的部分将使用 Linux 内核中的例子做展示。在下面的例子中 timer_stop 函数使用 synchronize_sched 确保在释放相关资源之前,所有正在处理的 NMI 处理函数都已完成。下面是对该例简化后的代码实现:
1 struct profile_buffer {
2 long size;
3 atomic_t entry[0];
4 };
5 static struct profile_buffer *buf = NULL;
6
7 void nmi_profile(unsigned long pcvalue)
8 {
9 struct profile_buffer *p = rcu_dereference(buf);
10
11 if (p == NULL)
12 return;
13 if (pcvalue >= p->size)
14 return;
15 atomic_inc(&p->entry[pcvalue]);
16 }
17
18 void nmi_stop(void)
19 {
20 struct profile_buffer *p = buf;
21
22 if (p == NULL)
23 return;
24 rcu_assign_pointer(buf, NULL);
25 synchronize_sched();
26 kfree(p);
27 }
第 1~4 行定义了 profile_buffer 结构,包含一个大小和一个变长数据的入口。第 5 行定义了指向 profile_buffer 的指针,这里假设别处对该指针进行了初始化,指向内存的动态分配区。
第 7~16 行定义了 nmi_profile 函数,供 NMI 中断处理函数使用。该函数不会被抢占,也不会被普通的中断处理函数中断,但是,该函数还是会受高速缓存未命中、ECC 错误以及被一个核的其他硬件线程抢占时钟周期等因素影响。第 9 行使用 rcu_dereference 原语来获取指向 profile_buffer 的本地指针,这样做是为了确保在 DEC Alpha 上的内存顺序执行,如果当前没有分配 profile_buffer,第 11 和 12 行退出,如果参数 pcvalue 超出范围,第 13 和 14 行退出。否则,第 15 行增加以参数 pcvalue 为下标的 profile_buffer 项的值。请注意,profile_buffer 结构中的 size 保证了 pcvalue 不会超出缓冲区的范围,即使突然将较大的缓冲区替换成了较小的缓冲区也是如此。
第 18~27 行定义了 nmi_stop 函数,由调用者负责互斥访问(比如持有正确的锁)。第 20 行获取 profile_buffer 的指针,如果缓冲区为空,第 22 和 23 行退出。否则,第 24 行将 profile_buffer 的指针置 NULL(使用 rcu_assign_pointer 原语在弱顺序的机器中保证内存顺序访问)。第 25 行等待 RCU Sched 的宽限期结束,尤其是等待所有不可抢占的代码——包括 NMI 中断处理函数一一结束。一旦执行到第 26 行,我们就可以保证所有获取到指向旧缓冲区指针的 nmi_profile 实例都已经返回了。现在可以安全释放缓冲区,这时使用 kfree 原语。
简而言之,RCU 让 profile_buffer 动态切换变得简单(你可以试试原子操作,或者还可以用锁来折磨下自己)。但是,RCU 通常还是运用在较高层次的抽象上,正如前面几个小节所述。
RCU 用法总结
RCU 的核心只是提供一下功能的 API。
- 用于添加新数据的发布——订阅机制。
- 等待已有 RCU 读者结束的方法。
- 维护多版本的准则,使得在有 RCU 读者并发时不会影响或延迟数据更新。
也就是说,在 RCU 之上建造更高抽象级别的架构是可能的,比如前面几节列出的读写锁、引用计数和存在担保。更进一步,我对 Linux 社区会继续为 RCU 寻找新用法丝毫不感到怀疑,当然其他的同步原语肯定也是这样。
上图展示了 RCU 的适用范围,这是关于 RCU 最有用的经验法则。
如图中顶部的蓝色框所示,如果你的读侧重数据允许获取旧值和不一致的结果,RCU 是最好的(但有关旧值和不一致数据的更多信息见下面的部分)。Linux 内核在这种情况的例子是路由表。因为可能需要很多秒甚至几分钟,才能更新路由表并通过互联网传播出去,这时系统已经以错误的方式发送相当一段时间的数据包了。再以小概率发送几毫秒错误的数据就简直算不上什么事了。
如果你有一个以读为主工作的负载,需要一致的数据,RCU 可以工作的不错,如绿色“读侧重,需要一致数据”框所示。Linux 内核在这种情况下的例子是从系统 V 的用户态信号 ID 映射到相应的内核数据结构。读信号量往往大大超过它们被创建和销毁的速度,所以这个映射是读侧重的。然而,在已被删除的信号量上执行信号量操作是错误的。这种对一致性的要求是通过内核信号量数据结构中的锁、以及在删除信号量时设置“已删除”标志达到的。如果用户 ID 映射到了一个具有“已删除”标志的内核数据结构,这个内核数据结构将被忽略,同时用户 ID 被设为无效。
虽然这要求读者获得内核信号量的锁,但这允许内核不用对要映射的数据结构加锁。因此,读者可以无锁地遍历从 ID 映射来的树状数据结构,这反过来大大提升了性能、可扩展性和实时响应性。
如黄色“读写”框所示,当数据需要一致性时,RCU 也可用于读写平衡的工作负载,虽然通常要与其他同步原语结合使用。例如,在最近的 Linux 内核中,目录项缓存使用了 RCU、顺序锁、每 CPU 锁和每数据结构锁,这才用于在常见情况下无锁地遍历路径名。虽然 RCU 在这种读写平衡的情况下可以非常有益,但是这种用法通常要比读侧重情况复杂的多。
最后,如底部的红色框所示,当以更新为主并且需要数据一致性的工作负载时,很少有适用 RCU 的地方,虽然也存在一些例外。此外,如前所述,在 Linux 内核里,SLAB_DESTROY_BY_RCU slab 分配器为 RCU 读者提供类型安全的内存,这可以大大简化非阻塞同步和其他无锁算法的实现。
简而言之,RCU 是一个包括用于添加新数据的发布——订阅机制的 API,等待已存在的 RCU 读者完成的一种方式,以及一门维护多个版本以使更新不会上海或延迟并发的 RCU 读者的学科。这个 RCU API 最适合以读者为主的情况,特别是如果应用程序可以容仍陈旧不一致的数据。
Linux 内核中的 RCU API
等待完成的 API 族
发布-订阅、版本维护 API
这些 API 的用处
RCU 究竟是什么
RCU 的核心不过是一种支持对插入操作的发布和订阅、等待所有 RCU 读者完成、维护多个版本的 API。也就是说,完全可以在 RCU 之上构建抽象级别更高的模型,比如读写锁、引用计数、存在担保等在前面列出的模型。更进一步,我相信 Linux 社区也会继续不断的寻找新的用法,当然其他的同步原语肯定也是一样。
当然,对 RCU 更复杂的看法还包括所有拿这些 API 能做的事情。
但是,对很多人来说,想要完整观察 RCU,就需要一个 RCU 的例子实现。
RCU 的玩具实现
基于锁的 RCU
基于每线程锁的 RCU
基于计数的简单 RCU
避免更新者饥饿的引用计数 RCU
可扩展的基于计数的 RCU
基于自由增长计数的 RCU
基于自由增长计数的可嵌套 RCU
基于静止状态的 RCU
总结
之前的章节列出了各种 RCU 原语的理想特性。这里我们整理一个列表,供有意实现自己的 RCU 的读者作为参考:
- 必须有读者端原语和宽限期原语,如:rcu_read_lock/rcu_read_unlock, synchronize_rcu。任何在宽限期开始前就存在的 RCU 读端临界区必须在宽限期结束前完毕。
- RCU 读端原语应该有最小的开销。特别是应该避免如高速缓存未命中、原子操作、内存平展和分支之类的操作。
- RCU 读端原语应该有 O(1) 的时间复杂度,可以用于实时用途。(这意味着读者可以与更新着并发执行)
- RCU 读端原语应该在所有上下文中都可以使用(在 Linux 内核中,只有空循环时不能使用 RCU 读端原语)。一个重要的特例是 RCU 读端原语必须可以在 RCU 读端临界区中使用,换句话说,必须允许 RCU 读端临界区嵌套。
- RCU 读端临界区不应该有条件判断,不会返回失败。该特性十分重要,因为错误检查会增加复杂度,让测试和验证变得更加复杂。
- 除了静止状态意外的任何操作都能在 RCU 读端原语中执行。比如像 IO 这样不幂等的操作也应用允许。
- 应该允许在 RCU 读端临界区中执行的同时更新一个受 RCU 保护的数据结构。
- RCU 读端和更新端的原语应该在内存分配器的设计和实现上独立,换句话说,同样的 RCU 实现应该能在不管数据原语是分配还是释放的同时,保护数据元素。
- RCU 宽限期不应该被在 RCU 读端临界区之外阻塞的线程而阻塞(但是请注意,大多数基于静止状态的实现破坏了这一愿望)。
RCU 练习
如何选择
下图提供了一些粗略的经验法则,可以帮助你选择延迟处理技术。
存在保证 | 写读者并行 | 读取端开销 | 批量引用 | 低内存占用 | 无条件获取 | 非阻塞更新 | |
---|---|---|---|---|---|---|---|
引用计数 | Y | Y | ++->atomic(*) | Y | ? | ||
危险指针 | Y | Y | MB(**) | Y | Y | ||
顺序锁 | 2MB(***) | N/A | N/A | ||||
RCU | Y | Y | 0->2MB | Y | Y | ? |
*
:在每次重试中遍历的每个元素上产生**
:在每次重试时产生***
:原子操作MB
:内存屏障
如“存在保证”一列中所示,如果你需要链接的数据元素的存在保证,那么必须使用引用计数、危险指针或 RCU。顺序锁不提供存在保证,而是提供更新检测,遭遇更新时重试读取端临界区。
当然,如“写读者并行”一列中所示,更新检测意味着顺序锁定不允许更新者和读者同步进行。毕竟,防止这种同步前进是使用顺序锁的全部意义所在。这时可以让顺序锁与引用计数、危险指针或 RCU 结合,以便同时提供存在保证和更新检测。事实上,Linux 内核就是以结合 RCU 和顺序锁的方式进行路径名查找的。
“读取端开销”一列给出了这些技术在读取端的大致开销。引用技术的开销变化范围很大。在低端,简单的非原子自增就够了,至少在有锁的保护下获取引用时如此。在高端,则需要完全有序的原子操作。引用计数会在遍历每个数据元素时产生此开销。危险指针在遍历每个元素时都产生一个内存屏障的开销,顺序锁在每次尝试执行临界区会产生两个内存屏障的开销。RCU 实现的开销从零到每次执行读取端临界区时的两个内存屏障开销不等,后者为 RCU 带来最佳性能,特别是对于读取端临界区需要遍历很多数据元素时。
“批量引用”一列表示只有 RCU 能够以恒定开销获取多个引用。属性怒锁的条件“N/A”,这是因为顺序锁采用更新检测额不是获取引用。
“低内存占用”一列表示那些技术的内存占用较低。此列和“批量引用”一列互补:因为获取大量元素的引用的能力意味着所有这些数据元素必须持续存在,这反过来意味着交道的内存占用。例如,一个线程可能会删除大量的数据元素,而此时另一个线程则并发执行长时间的 RCU 读端临界区。因为读端临界区可能潜在保留对任何新近删除元素的引用,所以在整个临界区持续时间内都必须保留所有这些元素。相反,引用计数和危险指针保留只有那些实际上并发读者引用的特定数据元素。
然而,这种低内存占用的优势是有代价的,如表中“无条件获取”一列。想要看到这一点,请想象哟一个大型的链式数据结构,引用技术或危险指针的读者(线程 A)持有该结构中某个鼓励数据元素的引用。考虑如下事件顺序:
- 线程 B 删除 A 引用的数据元素。由于这个引用,数据元素还不能被释放。
- B 删除 与 A 引用的所有数据元素相邻的所有数据元素。因为没有指向这些数据元素的引用,所有他们都被立即释放。因为 A 的数据元素已被删除,它指向的外部指针不更新。
- 所有 A 的数据元素的外部指针现在指向的是被释放的地址,因此已经不能安全的遍历。
- 因此,引用计数或危险指针的实现无法让 A 通过任何指向数据元素外部的指针来获取引用。
简而言之。任何提供精确引用追踪的延后处理计数都要做好无法获取引用的准备。因此,RCU 高内存占用的缺点反而意味着易于使用的优势,即 RCU 读者不需要处理获取失败的情况。
Linux 内核有时通过结合使用 RCU 和引用技术,来解决内存占用、精确跟踪和获取失败之间的这种竞争关系。RCU 用于短期引用,这意味着 RCU 读端临界区可以很短。这就意味着响应的 RCU 宽限期也很短,从而限制了内存占用。对于一些需要长期引用的数据元素,可以使用引用计数。这意味着只有少数数据元素需要处理应用获取失败的复杂性,因为 RCU,大部分引用的获取都是无条件的。
最后,“非阻塞更新”一列表示危险指针可以提供这种特性。引用计数则要取决于实现。然而,因为在更新端的锁,顺序锁定不能提供非阻塞更新。RCU 的写者必须等待读者,这也排除了完全非阻塞更新。不过有时唯一的阻塞操作是等待释放内存,这在很多情况下都可视为是非阻塞的。
更新端的问题
对于读侧重的情况,本章中提到的延迟处理技术一般都非常适用,但这提出了一个问题:“更新端怎么办?”。毕竟,增加读者的性能和扩展性是很好的,但是自然而然我们也希望为写者提供出色的性能和扩展性。
对于写者,我们已经看到了一种具有高性能和扩展性的情况,即前面提到的计数算法。这些计数算法通过部分分割数据结构,使得可以在本地进行更新,而较昂贵的读取则必须在整个数据结构上求和。Silas BoydWickhizer 把这种概念推广到 OpLog 上,Linux 内核路径名查找、VM 反向映射和 stat 系统调用都使用了这个工具。
另一种方法,称为 Disruptor,是为处理大量流数据输入的引用程序设计的。该方法是依靠单个生产者和单个消费者的 FIFO 队列,最小化对同步的需要。对于 Java 应用程序,Disruptor 还具有减少对垃圾处理器的使用这个优点。
当然,只要是可行的情况,完全分割或“分片”系统总是能提供优秀的性能和扩展性。
3.3.10 - CH10-数据结构
访问数据的效率如此重要,因此对算法的讨论也包括相关数据结构的时间复杂度。然而,对于并行程序,时间复杂度的度量还必须包括并发效应。如前所述,这些效应可能是压倒性的因素,这意味着对并发数据结构的设计,必须要像关注串行数据结构中时间复杂度一样关注并发复杂度。
例子
我们将使用薛定谔的动物园这个应用来评估性能。薛定谔拥有一个动物园,里面有大量的动物,他想使用内存数据库来记录他们。动物园中的每个动物在数据库中都有一个条目,每个动物都有一个唯一的名称作为主键,同时还有与每个动物有关的各种数据。
出生、捕获、购买将导致数据插入,而死亡、释放和销售将导致数据删除。因为薛定谔的动物园包括了大量短命动物,包括老鼠可昆虫,所以数据库必须能够支持高频更新请求。
对薛定谔的动物感兴趣的人可以查阅它们,但是,薛定谔已经注意到他的猫拥有非常高的查询率,多到以至于他怀疑是他的老鼠们在使用数据库来查询它们的天敌。这意味着薛定谔的应用必须支持对单个数据条目的高频查询请求。
记住这个应用程序,这里面包含了大量的数据结构。
可分割的数据结构
如今的计算机世界使用了各种各样的数据结构,市面上讲数据结构的教科书多如牛毛。本节专注于单个数据结构,即哈希表。这种方法允许我们更深入地研究如何与并发数据结构交互,同时也能让我们更加熟悉这个在实践中大量应用的数据结构。
哈希表的设计
第六章中强调了通过分割来获得可观性能和扩展性的必要,因此可分割性必须是选择数据结构的首要标准。并行性的主力军——哈希表——很好的满足了这个标准。哈希表在概念上非常简单,包含一个哈希桶的数组。哈希函数将指定数据的键映射到哈希桶元素,也就是存储数据的地方。因此,每个哈希桶有一个数据元素的链表,称为哈希链。如果配置得当,这些哈希链会相当短,允许哈希表可以非常有效地访问任意指定的元素。
另外,每个桶可以有自己的锁,所以哈希表中不同的桶可以完全独立的插入、删除和查找。因此,包含大量元素的哈希表提供了极好的可扩展性。
哈希表的实现
1 struct ht_elem {
2 struct cds_list_head hte_next;
3 unsigned long hte_hash;
4 };
5
6 struct ht_bucket {
7 struct cds_list_head htb_head;
8 spinlock_t htb_lock;
9 };
10
11 struct hashtab {
12 unsigned long ht_nbuckets;
13 struct ht_bucket ht_bkt[0];
14 };
上面的片段展示了简单的固定大小的哈希表使用的一组数据结构,使用链表和每哈希桶的锁。下面的片段展示了如何将它们组合在一起。hashtab 结构包含了 4 个 ht_bucket 结构,->bt_nbuckets
字段代表桶的数量。每个桶都包含链表头 ->htb_head
和锁 ->htb_lock
。链表元素 htb_elem 结构通过它们的 ->hte_next
字段找到下一个元素,每个 ht_elem 结构在 ->hte_hash
字段中缓存相应元素的哈希值。ht_elem 结构嵌入在哈希表中的另一个较大结构里,并且这个较大结构可能包含了复杂的键。
下面的片段展示了映射和加解锁函数。第 1~2 行定义了宏 HASH2BKT,它将哈希值映射到相应的 ht_bucket 结构体。这个宏使用了一个简单的模数,如果需要更好的哈希函数,调用者需要自行实现从数据键到哈希值的映射函数。剩下的两个函数分别获取和释放与指定的哈希桶对应的 ->htb_lock 锁。
1 #define HASH2BKT(htp, h) \
2 (&(htp)->ht_bkt[h % (htp)->ht_nbuckets])
3
4 static void hashtab_lock(struct hashtab *htp,
5 unsigned long hash)
6 {
7 spin_lock(&HASH2BKT(htp, hash)->htb_lock);
8 }
9
10 static void hashtab_unlock(struct hashtab *htp,
11 unsigned long hash)
12 {
13 spin_unlock(&HASH2BKT(htp, hash)->htb_lock);
14 }
下面的片段展示了 hashtab_lookup,如果指定的键或哈希值存在,它返回一个指向元素的指针,否则返回 NULL。此函数同时接受哈希值和指向键的指针,因为这允许此函数的调用者使用任意的键和哈希函数,cmp 函数指针用于传递比较键的函数,类似于 qsort 的方式。第 11 行将哈希值映射成指向相应哈希桶的指针。第 12~19 行的循环每次执行检查哈希桶中链表的一个元素。第 15 行检查是否与哈希值匹配,如果否,则第 16 行前进到下一个元素。第 17 行检查是否与实际的键匹配,如果是,则第 18 行返回指向匹配元素的指针。如果没有与之匹配的元素,则第 20 行返回 NULL。
1 struct ht_elem *
2 hashtab_lookup(struct hashtab *htp,
3 unsigned long hash,
4 void *key,
5 int (*cmp)(struct ht_elem *htep,
6 void *key))
7 {
8 struct ht_bucket *htb;
9 struct ht_elem *htep;
10
11 htb = HASH2BKT(htp, hash);
12 cds_list_for_each_entry(htep,
13 &htb->htb_head, 14 hte_next) {
15 if (htep->hte_hash != hash)
16 continue;
17 if (cmp(htep, key))
18 return htep;
19 }
20 return NULL;
21 }
下面的片段是 hashtab_add 和 hashtab_del 函数,分别从哈希表中添加和删除元素。
1 void
2 hashtab_add(struct hashtab *htp,
3 unsigned long hash,
4 struct ht_elem *htep)
5 {
6 htep->hte_hash = hash;
7 cds_list_add(&htep->hte_next,
8 &HASH2BKT(htp, hash)->htb_head);
9 }
10
11 void hashtab_del(struct ht_elem *htep)
12 {
13 cds_list_del_init(&htep->hte_next);
14 }
hashtab_add 函数只是简单的在第 6 行设置元素的哈希值,然后将其添加到第 7~8 行的相应桶中。hashtab_del 函数简单地从哈希桶的链表中移除指定的元素,因为是双向链表所以这很容易。在调用这个函数中任何一个之前,调用者需要确保此时没有其他线程正在访问或修改相同的哈希桶,例如,可以通过事先调用 hashtab_lock 来加以保护。
下面的片段展示了 hashtab_alloc 和 hashtab_free 函数,分别负责表的分配和释放。分配从第 7~9 行开始,分配使用的是系统内存。如果第 10 行检测到内存已耗尽,则第 11 行返回 NULL 给调用者。否则,第 12 行将初始化桶的数量,第 13~16 行的循环初始化桶本身,第 14 行初始化链表头,第 15 行初始化锁。最后,第 17 行返回一个指向新分配哈希表的指针。第 20~23 行的 hashtab_free 函数则直接了当地释放内存。
1 void
2 hashtab_add(struct hashtab *htp,
3 unsigned long hash,
4 struct ht_elem *htep)
5 {
6 htep->hte_hash = hash;
7 cds_list_add(&htep->hte_next,
8 &HASH2BKT(htp, hash)->htb_head);
9 }
10
11 void hashtab_del(struct ht_elem *htep)
12 {
13 cds_list_del_init(&htep->hte_next);
14 }
哈希表的性能
上图展示的是在 8 核 2GHZ Intel Xeon 系统上的性能测试结果,使用的哈希表具有 1024 个桶,每个桶带有一个锁。性能的扩展性几乎接近线性,但是即使只有 8 个 CPU,性能已经不到理想性能水平的一半。产生这个缺口的一部分原因是由于虽然在单 CPU 上获取和释放锁不会产生高速缓存未命中,但是在两个或更多 CPU 上则不然。
随着 CPU 数目的增加,情况只有变得更糟,如下图所示。
我们甚至不需要额外的线来表示理想性能, 9 个或 9 个以上的 CPU 时性能非常糟糕。这显然警示了我们按照中等数量的 CPU 外推性能的危险。
当然,性能大幅下降的一个原因可能是哈系桶数目的不足。毕竟,我们没有将哈系桶填充到占据一条完整的缓存行,因此每条缓存行有多个哈系桶。这可能是在 9 个 CPU 上导致高速缓存颠簸的原因。当然这一点很容易通过增加哈希桶的数量来验证。
如上图所示,虽然增加了哈系统的数量后性能确实有点提高,可扩展性仍然惨不忍睹。特别是,我们还是看到 9 个 CPU 之后性能会急剧下降。此外,从 8192 个桶增加到 16384 个桶,性能几乎没有提升。显然还有别的东西再捣鬼。
其实这是多 CPU 插槽系统惹的祸,CPU 0~7 和 32~39 会映射到第一个槽位,如下图所示。因此测试程序只用前 8 个 CPU 时性能相当好,但是测试涉及到插槽 0 的 CPU 0~7 和插槽 1 的 CPU 8 时,会产生跨插槽边界数据传递的开销。如果前所述,这可能会验证降低性能。总之,对于多插槽系统来说,除了数据结构完全分割之外,还需要良好的局部性访问能力。
读侧重的数据结构
虽然通过分割数据结构可以帮助我们带来出色的可扩展性,但是 NUMA 效应也会导致性能和扩展性的严重恶化。另外,要求读者互斥写者也可能会导致在读侧重的场景下的性能。然而,我们可以通过使用前面介绍的 RCU 来实现性能和可扩展性的双丰收。使用危险指针也可以达到类似的效果。
受 RCU 保护的哈希表实现
对于受 RCU 保护的每桶一锁的哈希表,写者按照上节描述的方式使用锁,但读者使用 RCU。数据结构、HASH2BKT、hashtab_lock、hashtab_unlock 函数与上一节保持一致。但是读者使用拥有更轻量级并发控制的 hashtab_lock_lookup。如下片段所示:
下面的片段则展示了 hashtab_lookup 函数的实现。这与前面的实现类似,除了将 cds_list_for_each_entry 替换为 cds_list_for_each_entry_rcu。这两个原语都按照顺序遍历 htb->htb_head 指向的哈希链表,但是 cds_list_for_each_entry_rcu 还要强制执行内存屏障以应对并发插入的情况。这是两种哈希表实现的重大区别,与纯粹的每桶一锁实现不同,受 RCU 保护的实现允许查找、插入、删除操作同时运行,支持 RCU 的 cds_list_for_each_entry_rcu 可以正确处理这种增加的并发性。还要主要 hashtab_lookup 的调用者必须在 RCU 读端临界区内,例如,调用者必须在调用 hashtab_lookup 之前调用 hashtab_lock_lookup(当前在之后还要调用 hashtab_unlock_lookup)。
下面的片段展示了 hashtab_add 和 hashtab_del,两者都是非常类似于其在非 RCU 哈希表实现中的对应函数。hashtab_add 函数使用 cds_list_add_rcu 而不是 cds_list_add,以便在有人正在查询哈希表时,将元素安装正确的排序添加到哈希表。hashtab_del 函数使用 cds_list_del_rcu 而不是 cds_list_del_init,它允许在查找到某数据元素后该元素马上被删除的情况。和 cds_list_del_init 不同,cds_list_del_rcu 仍然保留该元素的前向指针,这样 hashtab_lookup 可以遍历新删除元素的后继元素。
当让,在调用 hashtab_del 之后,调用者必须等待一个 RCU 宽限期(例如在释放或以其他方式重用新删除元素的内存之前调用 syncronize_rcu)。
受 RCU 保护的哈希表的性能
上图展示了受 RCU 保护的和受危险指针保护的只读哈希表的性能,同时与上一节的每桶一锁实现做比较。如你所见,尽管 CPU 数和 NUMA 效应更大,RCU 和危险指针实现都能接近理想的性能和可扩展性。使用全局锁的实现的性能也在图中给出了标示,正如预期一样,其结果针织比每桶一锁的实现更加糟糕。RCU 做得比危险指针稍微好一些,但在这个以对数刻度表示的图中很难看出差异来。
上图显示了线性刻度上的相同数据。这使得全局锁实现基本和 X 轴平行,但也更容易辨别 RCU 和危险指针的相对性能。两者都显示在 32 CPU 处的斜率变化,这是由于硬件多线程的缘故。当使用 32 个或更少的 CPU 时,每个线程都有自己的 CPU 时,每个线程都有自己的 CPU 核。在这种情况下,RCU 比危险指针做的更好,因为危险指针的读取端内存屏障会导致 CPU 时间的浪费。总之,RCU 比危险指针能更好地利用每个硬件线程上的 CPU 核。
如前所述,薛定谔对他的猫的受欢迎程度感到惊讶,但是随后他认识到需要在他的设计中考虑这种受欢迎程度。下图显示了程序在 60 个 CPU 上运行的结果,应用程序除了查询猫咪以外什么也不做。对这个挑战,RCU 和危险指针实现的表现很好,但是每桶一锁实现的负载为负,最终性能比全局锁实现还差。我们不应该对此感到吃惊,因为如果所有的 CPU 都在查询猫咪,对应于猫的那个桶的锁实际上就是全局锁。
这个只有猫的基准测试显示了数据分片方法的一个潜在问题。只有与猫的分区关联的 CPU 才能访问有关猫的数据,这限制了只查询猫时系统的吞吞量。当然,有很多应用程序可以将数据均匀地分散,对于这些应用,数据分片非常适用。然而,数据分片不能很好地处理“热点”,由薛定谔的猫触发的热点只是其中一个例子。
当然,如果我们只是去读数据,那么一开始我们并不需要任何并发控制。因此,下图显示修正后的结果。在该图的最左侧,所有 60 个 CPU 都在进行查找,在图的最右侧,所有 60 个 CPU 都在做更新。对于哈希表的 4 种实现来说,每毫秒查找数随着做更新的 CPU 数量的增加而减少,当所有 60 个 CPU 都在更新时,每毫秒查找数达到 0。相对于危险指针 RCU 做的更好一些,因为危险指针的读取端内存屏障在有更新存在时产生了更大的开销。这似乎也说明,现代硬件大大优化了内存屏障的执行,从而大幅减少了在只读情况下的内存屏障开销。
上图展示了更新频率增加对查找的影响,而下图则显示了更新频率的增加对更新本身的影响。危险指针和 RCU 从一开始就占据领先,因为,与每桶一锁不同,危险指针和 RCU 的读者并不排斥写者。然而,随着做更新操作的 CPU 数量的增加,开始显示出更新端开销的存在,首先是 RCU,然后是危险指针。当然,所有这三种实现都要比全局锁实现更好。
当然,很可能查找性能的差异也受到更新速率差异的影响。为了检查这一点,一种方法是人为地限制每桶一锁和危险指针实现的更新速率,以匹配 RCU 的更新速率。这样做显然不会显著提高每桶一锁实现的查找性能,也不会拉近危险指针与 RCU 之间的差距。但是,去掉危险指针的读取端内存屏障(从而导致危险指针的实现不安全)确实弥合了危险指针和 RCU 之间的差距。虽然这种不安全的危险指针实现通常足够可靠,足以用于基准测试用途,但是绝对不推荐用于生产用途。
对受 RCU 保护的哈希表的讨论
RCU 实现和危险指针会导致一种后果,一对并发读者可能会不同意猫此时的状态。比如,其中在某只猫被删除之前,一个读者可能已经提取了指向猫的数据结构的指针,而另一个读者可能在之后获得了相同的指针。第一个读者会相信那只猫还活着,而第二个读者会相信猫已经死了。
当然,薛定谔的猫不就是这么一回事吗,但事实证明这对于正常的非量子猫也是相当合理的。
合理的原因是,我们我发准确得知动物出生或死亡的时间。
为了搞明白这一点,让我们假设我们可以通过心跳检查来测得猫的死亡。这又带来一个问题,我们应该在最后一次心跳之后等待多久才宣布死亡。只等待 1ms?这无疑是荒谬的,因为这样一只健康活猫也会被宣布死亡——然后复活,而且每秒钟还发生不止一次。等待一整个月也是可笑的,因为到那时我们通过嗅觉手段也能非常清楚地知道,这只可怜的猫已经死亡。
因为动物的心脏可以停止几秒钟,然后再次跳动,因此及时发现死亡和假警概率之间存在一种权衡。在最后一次心跳和死亡宣言之间要等待多久,两个兽医很有可能不同意彼此的意见。例如,一个兽医可能声明死亡发生在最后一次心跳后 30s,而另一个可能要坚持等待完整的一分钟。在猫咪最后一次心跳后第二个 30s 周期内,两个兽医会对猫的状态有不同的看法。
当然,海森堡教导我们生活充满了这种不确定性,这也是一件好事,因为计算机硬件和软件的行为在某种程度上类似。例如,你怎么知道计算机有个硬件出问题了呢?通常是因为它没有及时回应。就像猫的心跳,折让硬件是否有故障出现了一个不确定性窗口。
此外,大多数的计算机系统旨在与外部世界交互。因此,对外的一致性是至关重要的。然而,正如我们在前面看到的,增加内部的一致性常常以外部一致性作为代价。像 RCU 和危险指针这样的技术放弃了某种程度上的内部一致性,以获得改善后的外部一致性。
总之,内部一致性不一定是所有问题域关心的部分,而且经常在性能、可扩展性、外部一致性或者所有上述方面产生巨大的开销。
不可分割的数据结构
固定大小的哈希表可以完美分割,但是当可扩展的哈希表在增长或收缩时,就不那么容易分割了。不过事实证明,对于受 RCU 保护的哈希表,完全可以写出高性能并且可扩展的实现。
可扩展哈希表的设计
与 21 世纪初的情况形成鲜明对比的是,现在有不少于三个不同类型的可扩展的受 RCU 保护的哈希表实现。第一个也是最简单的一个是 Herbert Xu 为 Linux 内核开发的实现,我们首先来介绍它。
这个哈希表实现背后的关键之处,是每个数据元素可以有两组链表指针,RCU 读者(以及非 RCU 的写者)使用其中一组,而另一组则用于构造新的可扩展的哈希表。此方法允许在哈希表调整大小时,可以并发地执行查找、插入和删除操作。
调整大小操作的过程下图 1~4 所示,图 1 展示了两个哈希桶的初始状态,时间从图 2 推进到 图 3。初始状态使用 0 号链表来将元素与哈希桶链接起来。然后分配一个包含 4 个桶的数组,并且用 1 号链表将进入 4 个新的哈希桶的元素链接起来。者产生了如图 2 所示的状态(b),此时 RCU 读者仍让使用原来的两桶数组。
随着新的四桶数组暴露给读者,紧接着是等待所有老读者完成读取的宽限期操作,产生如图 3 所示的状态(c)。这时,所有 RCU 读者都开始使用新的四桶数组,这意味着现在可以释放旧的两桶数组,产生图 4 所示的状态(d)。
可扩展哈希表的实现
调整大小操作是通过插入一个中间层次的经典方法完成的,这个中间层次如下面的片段中 12~25 行的结构体 ht 所示。第 27~30 行所示的是结构体 hashtab 仅包含指向当前 ht 结构的指针以及用于控制并发地请求调整哈希大小的自旋锁。如果我们使用传统的基于锁或原子操作的实现,这个 hashtab 结构可能会称为性能和可扩展性的严重瓶颈。但是,因为调整大小操作应该相对少见,所以这里 RCU 应该能帮我们大忙。
结构体 ht 表示哈希表的尺寸信息,其大小第 13 行的 ->ht_nbuckets 字段指定。为了避免出现不匹配的情况,哈希表的大小和哈系统的数组(第 24 行 ->ht_btk[]
)存储于相同的结构中。第 14 行上的 ->ht_resize_cur 字段通常等于 -1,当正在进行调整大小操作时,该字段的值表示其对应数据已经添入新哈希表的哈希桶索引,如果当前没有进行调整大小的操作,则 ->ht_new 为 NULL。因此,进行调整大小操作本质上就是通过分配新的 ht 结构体并让 ->ht_new 指针指向它,然后没遍历一个旧表的桶就前进一次 ->ht_resize_cur。当所有元素都移入新表时,hashtab 结构的 ->ht_cur 字段开始指向新表。一旦所有的旧 RCU 读者完成读取,就可以释放旧哈希表的 ht 结构了。
第 16 行的 ->ht_idx 字段指示哈希表实例此时应该使用哪一组链表指针,ht_elem 结构体里的 ->hte_next[] 数组用该字段作为此时的数组下标,见第 3 行。
第 17~23 行定义了 ->ht_hash_private、->ht_cmp、->ht_gethash、->ht_getkey 等字段,分别代表着每个元素的键和哈希函数。->ht_hash_private 用于扰乱哈希函数,其目的是防止通过对哈希函数所使用的参数进行统计分析来发起拒绝服务攻击。->ht_cmp 函数用于比较两个键,->ht_gethash 计算指定键的哈希值,->ht_getkey 从数据中提取键。
ht_bucket 结构与之前相同,而 ht_elem 结构与先前实现的不同仅在于用两组链表指针代替了代替了先前的单个链表指针。
在固定大小的哈希表中,对桶的选择非常简单,将哈希值转换成相应的桶索引。相比之下,当哈希表调整大小时,还有必要确定此时应该从旧表还是新表的哈希桶中进行选择。如果旧表中要选择的桶已经被移入新表中,那么应该从新表中选择存储桶。相反,如果旧表中要选择的桶还没有被移入新表,则应从旧表中选择。
桶的选择如相面的代码所示,包括第 1~8 行的 ht_get_bucket_single 和第 10~24 行的 ht_get_bucket。ht_get_bucket_single 函数返回一个指向包含指定键的桶,调用时不允许发生调整大小的操作。第 5~6 行,他还将与键相对应的哈希值存储到参数 b 指向的内存。第 7 行返回对应的桶。
ht_get_bucket 函数处理哈希表的选择,在第 16 行调用 ht_get_bucket_single 选择当前哈希表的哈希值对应的桶,用参数 b 存储哈希值。如果第 17 行确定哈希表正在调整大小,并且第 16 行的桶已经被移入新表,则第 18 行选择新的哈希表,并且第 19 行选择新表中的哈希值对应的桶,并再次用参数 b 存储哈希值。
如果第 21 行确定参数 i 为空,则第 22 行记录当前使用的是哪一组链表指针。最后,第 23 行返回指向所选哈希桶的指针。
这个实现的 ht_get_bucket_single 和 ht_get_bucket 允许查找、修改和调整大小操作同时执行。
读取端的并发控制由 RCU 提供。但是更新端的并发控制函数 hashtab_lock_mod 和 hashtab_unlock_mod 现在需要处理并发调整带下操作的可能性,如下片段所示:
第 1~19 行是 hashtab_lock_mod,第 9 行进入 RCU 读端临界区,以放置数据结构在遍历期间被释放,第 10 行获取对当前哈希表的引用,然后第 11 行获得哈希桶所对应的键的指针。第 12 行获得了哈希桶的锁,这将防止任何并发的调整大小操作移动这个桶,当然如果调整大小操作已经移动了该桶,则这一步没有任何效果。然后第 13 行检查并发的调整大小操作是否已经将这个桶移入新表。然后第 13 行检查并发的调整大小操作是否已经将这个桶移入新表,如果没有,则第 14 行在持有所选桶的锁时返回(此时仍处于 RCU 读端临界区内)。
否则,并发的调整大小操作将此桶移入新表,因此第 15 行获取新的哈希表,第 16 行选择键对应的桶。最后,第 17 行获取桶的锁,第 18 行释放旧表的桶的锁。最后,hashtab_lock_mod 退出 RCU 读端临界区。
hashtab_unlock_mod 函数负责释放由 hashtab_lock_mod 获取的锁。第 28 行能拿到当前哈希表,然后第 29 行调用 ht_get_bucket,以获得键锁对应的桶的指针,当让该桶可能已经位于新表。第 30 行释放桶的锁,以及最后第 31 行退出 RCU 读端临界区。
现在已经有了桶选择和并发控制逻辑,我们已经准备好开始搜索和更新哈希表了。如下所示的 hashtab_lookup、hashtab_add、hashtab_del 函数:
上面第 1~21 行的 hashtab_lookup 函数执行哈希查找。第 11 行获取当前哈希表,第 12 行获取指定键对应的桶的指针。当调整大小操作已经越过该桶在旧表中的位置时,则该桶位于新表中。注意,第 12 行业传入了表明使用哪一组链表指针的索引。第 13~19 行的循环搜索指定的桶,如果第 16 行检测到匹配的桶,第 18 行则返回指向包含数据元素的指针。否则如果没有找到匹配的桶,第 20 行返回 NULL 表示失败。
第 23~37 行的 hashtab_add 函数向哈希表添加了新的数据元素。第 32~34 行获取指定键对应的桶的指针(同时提供链表指针组下标)。第 35 行如前所述,为哈希表添加新元素。在这里条用者需要处理并发性,例如在调用 hashtab_add 前后分别调用 hashtab_lock_mod 和 hashtab_unlock_mod。这两个并发控制函数能正确地与并发调整大小操作互相同步:如果调整大小操作已经超越了该数据元素被添加到的桶,那么元素将添加到新表中。
第 39~52 行的 hashtab_del 函数从哈希表中删除一个已经存在的元素。与之前一样,第 48~50 行获取桶并传入索引,第 51 行删除指定元素。与 hashtab_add 一样,调用者负责并发控制,并且需要确保并发控制可以处理并发调整大小的操作。
实际调整大小由 hashtab_resize 执行,如上所示。第 17 行有条件地获取最顶层的 ht_lock,如果获取失败,则第 18 行返回 EBUSY 以表明正在进行调整大小操作。否则,第 19 行获取当前哈希表的指针,第 21~24 行分配所需大小的新哈希表。如果指定了新的哈希函数,将其应用于表,否则继续使用旧哈希表的哈希函数。如果第 25 行检测到内存分配失败,则第 26 行释放 htlock 锁并且在第 27 行返回出错的原因。
第 29 行开始桶的移动过程,将新表的指针放入酒标的 ht_new 字段,第 30 行确保所有不知道新表存在的读者在调整大小操作继续之前完成释放。第 31 行获取当前表的链表指针索引,并将其存储到新表中,以防止两个哈希表重写彼此的链表。
第 33~44 行的循环每次将旧表的一个桶移动到新的哈希表中。第 34 行将获取旧表的当前桶的指针,第 35 行获取该桶的自旋锁,并且第 36 行更新 ht_resize_cur 以表明此桶正在移动中。
第 37~42 行的循环每次从旧表的桶中移动一个元素到对应新表的桶中,在整个操作期间持有新表桶中的锁。最后,第 43 行释放旧表中的桶锁。
一旦执行到 45 行,所有旧表的桶都已经被移动到新表。第 45 行将新创建的表视为当前表,第 46 行等待所有的旧读者(可能还在引用旧表)完成。然后第 47 行释放调整大小操作的锁,第 48 行释放旧的哈希表,最后第 49 行返回成功。
可扩展哈希表的讨论
上图比较了可扩展哈希表和固定哈希表在拥有不同元素数量下的性能。图中为每种元素个数绘制了三条曲线。
最上面的三条线是有 2048 个元素的哈希表,这三条线分别是:2048 个桶的固定哈希表、1024 个桶的固定哈希表、可扩展哈希表。这种情况下,因为哈希链很短因此正常的查找开销很低,因此调整大小的开销反而占据主导地位。不过,因为桶的个数越多,固定大小哈希表性能优势越大,至少在给予足够操作暂停时间的情况下,调整大小操作还是有用的,一次 1ms 的暂停时间显然太短。
中间三条线是 16348 个元素的哈希表。这三条线分别是:2048 个桶的固定哈希表、可扩展哈希表、1024 个桶的固定哈希表。这种情况下,较长的哈希链将导致较高的查找开销,因此查找开销大大超过了调整哈希表大小的操作开销。但是,所有这三种方法的性能在 131072 个元素时比在 2048 个元素时差一个数量级以上,这表明每次将哈希表大小增加 64 倍才是最佳策略。
该图的一个关键点是,对受 RCU 保护的可扩展哈希表来说,无论是执行效率还是可扩展性都是与其固定大小的对应者相当。当然在实际调整大小过程中的性能还是受到一定程度的影响,这是由于更新每个元素的指针时产生了高速缓存未命中,当桶的链表很短时这种效果最显著。这表明每次调整哈希表的大小时应该一步到位,并且应该防止由于频繁的调整操作而导致的性能下降,在内存宽裕的环境中,哈希表大小的增长幅度应该比缩小时的幅度更大。
该图的另一个关键点是,虽然 hashtab 结构体是不可分割的,但是它特使读侧重的数据结构,这说明可以使用 RCU。鉴于无论是在性能还是在扩展性上,可扩展哈希表都非常接近于受 RCU 保护的固定大小哈希表,我们必须承认这种方法是相当成功的。
最后,请注意插入、删除和查找操作可以与调整大小操作同时进行。当调整元素个数极多的哈希表大小时,需要重视这种并发性,特别是对于那些必须有严格响应是时间限制的应用程序来说。
当然,ht_elem 结构的两个指针集合确实会带来一定的内存开销,我们将在下一节展开讨论。
其他可扩展哈希表
上一节讨论的可扩展哈希表,其缺点之一就是消耗的内存大。每个数据元素拥有两对链表指针。是否可以设计一种受 RCU 保护的可扩展哈希表,但链表只有一对?
答案是“是”。Josh Triplett 等人创造了一种相对(relativistic)哈希表,可以递增地分割和组合相应的哈希链,以便读者在调整大小操作期间始终可以看到有效的哈希链。这种增量分割和组合取决于一点事实,读者可以看到在其他哈希链中的数据元素。这一点是无害的,当发生这种情况时,读者可以简单地忽略这些由于键不匹配而看见的无关数据元素。
上图展示了如何将相对哈希表缩小两倍的过程。此时,两个桶的哈希表收缩成一个桶的哈希表,又称为线性链表。这个过程将较大的旧表中的桶合并为较小的新标中的桶。为了让这个过程正常进行,我们显然需要限制两个表的哈希函数。一种约束是在底层两个表中使用相同的哈希函数,但是当从大到小收缩时去除哈希值的最低位。例如,旧的两桶哈希表使用哈希值的高两位,而新的单桶哈希表使用哈希值的最高位。这样,在较大的旧表中相邻的偶数和奇数桶可以合并成较小的新表中的单个桶里,同时哈希值仍然可以覆盖单桶中的所有元素。
初始状态显示在图的顶部,从初始状态(a)开始,时间从顶部到底部前进,收缩过程从分配新的较小数组开始,并且使新数组的每个桶都指向旧表中相应桶的第一个元素,到达状态(b)。
然后,两个哈希链连接在一起,到达状态(c)。在这种状态下,读了偶数编号元素的读者看不出有什么变化,查找元素 1 和 3 的读者同样也看不到变化。然而,查找其他奇数编号元素的读者会遍历元素 0 和 2。这样做是无害的,因为任何奇数键都不等于这两个元素。这里会有一些性能损失,但是另一方面,这与新表完全就位以后将经历的性能损失完全一样。
接下来,读者可以开始访问新表,产生状态(d)。请注意,较旧的读者可能仍然在遍历大哈希表,所以在这种状态下两个哈希表都在使用。
下一步是等待所有旧的读者完成,产生状态(e)。
在这种状态下,所有读者都使用新表,以便旧表中桶可以被释放,最终到达状态(f)。
扩展相对哈希表的过程与收缩相反,但需要更多的宽限期步骤,如下图。
在该图的顶部是初始状态(a),时间从顶部前进到底部。
一开始我们分配较大的哈希表,带有两个桶,到达状态(b)。请注意,这些新桶指向旧桶对应部分的第一个元素。当这些新桶被发布给读者后,到达状态(c)。过了宽限期后,所有读者都将使用新的大哈希表,到达状态(d)。在这个状态下,只有那些遍历偶数值哈希桶的读者才会遍历到元素 0,因此现在的元素 0 是白色的。
此时,旧表的哈希桶可以被释放,但是在很多实现中仍然使用这些旧桶来跟踪将链表元素“解压缩”到对应新桶中的进度。在对这些元素的第一遍执行中,最后一个偶数编号的元素其“next”指针将指向后面的偶数编号元素。在随后的宽限期操作之后,到达状态(e)。垂直箭头表示要解压缩的下一个元素,元素 1 的颜色现在为黑色,表示只有那些遍历奇数哈希桶的读者才可以接触到它。
接下来,在对这些元素的第一遍执行中,最后一个奇数编号的元素其“next”指针将指向后面的奇数编号元素。在随后的宽限期操作之后,到达状态(f)。最后的解压缩操作(包括宽限期操作)到达最终状态(g)。
简而言之,相对哈希表减少了每个元素的链表指针的数量,但是在调整大小期间产生额外的宽限期。一般来说这些额外的宽限期不是问题,因为插入、删除和查找可能与调整大小同时进行。
结果证明,完全可以将每元素的内存开销从一对指针降到一个指针,同时保留 O(1) 复杂度的删除操作。这是通过使用受 RCU 保护的增广拆序链表(split-order list)做到的。哈希表中的数据元素被排列成排序后的单向链表,每个哈希桶指向该桶中的第一个元素。通过设置元素的 next 指针的低阶位来标记为删除,并且在随后的遍历中再次访问这些元素时将它们从链表中移除。
受 RCU 保护的拆序链表非常复杂,但是为所有插入、删除、查找操作提供无锁的进度保证。在实时应用中这种保证及其重要。最新版本的用户态 RCU 库提供了一个实现。
其他数据结构
前面的小节主要关注因为可分割性而带来的高并发性数据结构,高效的处理读侧重的访问模式,或者应用读侧重性来避免不可分割性。本节会简要介绍其他数据结构。
哈希表在并行应用上的最大优点之一就是它是可以完全分割的,至少是在不调整大小时。一种保持可分割性的尺寸独立性的方法是使用基树(radix tree)。也称 trie。Trie 将需要搜索的键进行分割,通过各个连续的键分区来遍历下一级 trie。因此,trie 可以被认为是一组嵌套的哈希表,从而提供所需的可分割性。Trie 的一个缺点是稀疏的键空间导致无法充分利用内存。有许多压缩技术可以解决这个缺点,包括在遍历前将键映射到较小的键空间中。基树在实践中被大量使用,包括在 Linux 内核中。
哈希表和 trie 的一种重要特例,同时也可能是最古老的数据结构,是数组及其多维对应物——矩阵。因为矩阵的可完全分割性质,在并发数值计算算法中大量应用了矩阵。
自平衡树在串行代码中被大量使用,AVL 树和红黑树可能是最著名的例子。早期尝试并行化 AVL 树的实现很复杂,效率也很可疑。但是最近关于红黑树的研究是使用 RCU 读者来保护读,哈希后的锁数组来保护写,这种实现提供了更好的性能和可扩展性。事实证明,红黑树的积极再平衡虽然适用于串行程序,但不一定适用于并行场景。因此,最近有文章创造出受 RCU 保护的较少再平衡的“盆景树”,通过付出最佳树深度的代价以获得跟有效的并发更新。
并发跳跃链表(skip list)非常适合 RCU 读者,事实上这代表着早期在学术上对类似 RCU 技术的使用。
前面讨论过的并行双端队列,虽然同是在性能和扩展性上不太令人印象深刻,并行堆栈和并行队列也有着悠久的历史。但是它们往往是并行库具有的共同特征。研究人员最近提出放松堆栈和队列对排序的约束,有一些工作表名放松排序的队列实际上比严格 FIFO 的队列具有更好的排序属性。
乐观来说,未来对并行数据结构的持续研究似乎会产生具有惊人性能的新颖算法。
微优化
以上展示的数据结构都比较直白,没有利用底层系统的缓存层次结构。此外,对于键到哈希值的转换和其他一些频繁的操作,很多实现使用了指向函数的指针。虽然这种方式提供了简单性和可移植性,但在很多情况下会损失一些性能。
以下部分涉及实例化(specializtion)、节省内存、基于硬件角度的考虑。请不要错误的将这些小节看成是本书讨论的主题。市面上已经有大部头讲述如何在特定 CPU 上做优化,更不用说如今常用的 CPU 了。
实例化
前面提到的可扩展哈希表使用不透明类型的键。这给我们带来了极大地灵活性,允许使用任何类型的键,但是由于使用了函数指针,也导致了显著的开销。现在,现代化的硬件使用复杂的分支预测技术来最小化这种开销,但在另一方面,真实世界的软件往往比今天的大型硬件分支越策表可容纳的范围更大。对于调用指针来说尤其如此,在这种情况下,分支预测硬件必须是在分支信息之外另外记录指针信息。
这种开销可以通过实例化哈希表实现来确定键的类型和哈希函数。这样做出列图 10.24 和图 10.25 的 ht 结构体中的 ht_cmp、ht_gethash、ht_getkey 函数指针,也消除了这些指针的相应调用。这使得编译器可以内联生成固定函数,这消除的不仅是调用指令的开销,而且消除了参数打包的开销。
此外,可扩展哈希表的设计考虑了将桶选择与并发控制分离的 API。虽然这样可以用单个极限测试来执行本章中的所有哈希表实现,但是这也意味着许多操作必须将计算哈希值和与可能的大小调整操作交互这些事情来回做两次。在要求性能的环境中,hashtab_lock_mod 函数也可以返回对所选桶的指针,从而避免后续调用 ht_get_bucket。
除此之外,和我在 20 世纪 70 年代带一次开始学习编程相比,现代硬件的一大好处是不太需要实例化。这可比回到 4K 地址空间的时代效率高多了。
比特与字节
本章讨论的哈希表几乎没有尝试节省内存。例如在 10.24 中,ht 结构体的 ht_idx 字段的取值只能是 0 或 1,但是却占用完整的 32 位内存。完全可以删除它,例如,从 ht_resize_key 字段窃取一个比特。因为 ht_resize_key 字段足够大寻址任何内存地址,而且 ht_bucket 结构体总是要比一个字节长,所以 ht_resize_key 字段肯定有多个空闲比特。
这种比特打包技巧经常用在高度复制的数据结构中,就像 Linux 内核中的 page 结构体一样。但是,可扩展哈希表的 ht 结构复制程度并不太高。相反我们应该关注 ht_bucket 结构体。有两个地方可以减小 ht_bucket 结构:将 htb_lock 字段放在 htb_head 指针的低位比特中;减少所需的指针数量。
第一点可以利用 Linux 内核中的位自旋锁,由 include/linux/bit_spinlock.h 头文件提供。他们用在 Linux 内核的内存敏感数据结构中,但也不是没有缺点:
- 比传统的自旋锁语义慢。
- 不能参与 Linux 内核中的 lockdep 死锁检测工具。
- 不记录锁的所有权,想要进一步调试会变得复杂。
- 不参与 -rt 内核中的优先级提升,这意味着保持位自旋锁时必须禁用抢占,这可能会降级实时延迟。
尽管有这些缺点,位自旋锁在内存十分珍贵时非常有用。
10.4.4 节讨论了第二点的一个方面,可扩展哈希表只需要一组桶链表指针来代替 10.4 节实现中所需的两组指针。另一个办法是使用单链表来替代在此使用的双向链表。这种方式的一个缺点是,删除需要额外的开销:要么标记传出指针以便以后删除、要么通过搜索要删除的元素的桶链表。
简而言之,人们需要在最小内存开销和性能、简单性之间权衡。幸运的是,在现代系统上连接内存允许我们优先考虑性能和简单性,而不是内存开销。然而,即使拥有今天的大内存系统,有时仍然需要采取极端措施以减少内存开销。
硬件层面的考虑
现代计算机通常在 CPU 和主存储器之间移动固定大小的数据块,从 32 字节到 256 字节不等。这些块名为缓存行(cacheline),如 3.2 节所述,这对于高性能和可扩展性是非常重要的。将不兼容的变量放入同一缓存行会严重降低性能和扩展性。例如,假设一个可扩展哈希表的数据元素具有一个 ht_elem 结构,它与某个频繁增加的计数器处于相同的高速缓存行中。频繁增加的计数器将导致高速缓存行出现在执行增量的 CPU 中。如果其他 CPU 尝试遍历包含数据元素的哈希桶链表,则会导致昂贵的告诉缓存未命中,降低性能和扩展性。
如上图所示,这是一种在 64 位字节高速缓存行的系统上解决问题的方法。这里的 gcc aligned 属性用于强制将 counter 字段和 ht_elem 结构体分成独立的缓存行。这将允许 CPU 全速遍历哈希桶链表,尽管此时计数器也在频繁增加。
当然,这引出了一个问题,“我们怎么知道缓存行是 64 位大小?”。在 Linux 系统中,此信息可以从 /sys/devices/system/cpu/cpu */cache/
目录得到,甚至有时可以在安装过程重新编译应用程序以适应系统的硬件结构。然而,如果你想要体验困难,也可以让应用程序在非 Linux 系统上运行。此外,即使你满足于运行只有在 Linux 上,这种自修改安装又带来了验证的挑战。
幸运的是,有一些经验法则在实践中工作的相当好,这是作者从一份 1995 年的文件中收到的。第一组规则设计重新排列结构以适应告诉缓存的拓扑结构。
- 将经常更新的数据与以读为主的数据分开。例如,将读侧重高的数据放在结构的开头,而将频繁更新的数据放于末尾。如果可能,将很少访问的数据放在中间。
- 如果结构有几组字段,并且每组字段都会在独立的代码路径中被更新,将这些组彼此分开。再次,尽量在不同的组之间放置很少访问的数据。在某些情况下,将每个这样的组放置在被原始结构单独引用的数据结构中也是可行的。
- 在可能的情况下,将经常更新的数据与 CPU、线程或任务相关联。
- 在有可能的情况下,应该尽量将数据分割在每 CPU、每线程、每任务。
最近已经有一些朝向基于痕迹的自动重排的结构域的研究。这项工作可能会让优化的工作变得不那么痛苦,从而从多线程软件中获得出色性能和可扩展性。
以下是一组处理锁的额外的经验法则:
- 当使用高竞争度的锁来保护被频繁更新的数据时,采取以下方式之一:
- 将锁与其保护的数据处于不同的缓存行中。
- 使用适用于高度竞争的锁,例如排队锁。
- 重新设计以减少竞争。
- 将低度竞争的锁置于与它们保护的数据相同的高速缓存行中。这种方法意味着因锁导致的当前 CPU 的高速缓存未命中同时也带来了它的数据。
- 使用 RCU 保护读侧重的数据,或者,如果 RCU 不能使用并且临界区非常长时,使用读写锁。
当然这些只是经验法则,而非绝对规则。最好是先做一些实验以找到最适合你的特殊情况的方法。
总结
本章主要关注哈希表,包括不可完全分割的可扩展哈希表。本章关于哈希表的阐述是围绕高性能可扩展数据访问的许多问题的绝佳展示,包括:
- 可完全分割的数据结构在小型系统上工作良好,比如单 CPU 插槽系统。
- 对于较大型的系统,需要将局部数据访问性和完全分割同等看待。
- 读侧重技术,如危险指针和 RCU,在以读为主的工作负载时提供了良好的局部访问性,因此即使在大型系统中也能提供出色的新能和可扩展性。
- 读侧重技术在某些不可分割的数据结构上也工作得不错,例如可扩展的哈希表。
- 在特定工作负载时实例化数据可以获得额外的性能和可扩展性,例如,将通用的键替换成 32 位整数。
- 尽管可移植性和极端性能的要求通常是互相干扰的,但是还是有一些数据结构布局技术可以在这两套要求之间达到良好的平衡。
但是如果没有可靠性,性能和可扩展性也算不上什么。因此下一章将介绍“验证”。
3.3.11 - CH11-验证
我也写过一些并行软件,他们一来就能够运行。但这仅仅是因为在过去的 20 年中我写了大量的并行软件。而更多的并行程序是在捉弄我,但却让我认为它们第一次就能正确工作。
因此,我强烈需要对我的并行程序进行验证。与其他软件验证相比,并行软件验证的基本点是:意识到计算机知道什么是错误的。因此,你的任务就是逼迫计算机告诉你哪里是错误的。所以说,本章可以认为是一个审问计算机的简单教程。
更多的教程可以在最近的验证书籍中找到,至少有一本比较老但相当有价值的书籍。验证是及其重要的主题,它涵盖了所有形式的软件,因此也值得深入研究。但是,本书主要关注并行方面,因此本章只会粗略对这一重要主题进行阐述。
简介
BUG 来自何处
BUG 来自于开发者。基本问题是:人类大脑并没有伴随着计算机一起进化。相反,人类大脑是伴随着其他人类以及动物大脑而进化的。由于这个历史原因,下面三个计算机的特征往往会让人类觉得惊奇。
- 计算机缺乏常识性的东西。几十年来,人工智能总是无功而返。
- 计算机通常无法理解人类的意图,或者更正式的说,计算机缺少心理理论。
- 计算机通常不能做局部性的计划,与之相反,它需要你将所有细节和每一个可能的场景都一一列出。
前两点是毋庸置疑的,这已经被大量失败的产品证明。这些产品中,最著名的可能要算 Clippy 和 Microsoft Bob 了。通过视图与人类用户相关联,这两款产品所表现出的常识和心理理论预期不尽如人意。也许,最近在智能手机上出现的软件助手将有良好的表现。也就是说,开发者仍然在走老路,软件助手可能对终端用户有益,但对开发者来说并没有什么助益。
对于人类喜欢的局部性计划来说,需要跟过的解释。特别是它是一个典型的双刃剑。很显然,人类对局部性计划的偏爱是由于我们假设这些计划将拥有常识和对计划意图的良好理解。后一个假设通常类似于这样一种常见情况,执行计划的人和制定计划的人是同一个人。在这种情况下,当阻碍计划执行的情况出现时,计划总是会在随后被修正。因此,局部性计划对于人类来说表现的很不错。举个特别的例子,在无法订立计划时,与其等待死亡还不如采取一些随机动作,这有更高的可能性找到食物。不过,以往在日常生活中行之有效的局部性计划,在计算机中并不见得凑效。
而且,对遵循局部性计划的需求,对于人类心灵有重要的影响。这来自于贯穿人类历史的事实,生命通常是艰难而危险的。这一点通常好不令人奇怪,当遭遇到锋利的牙齿好爪子时,执行一个局部性的计划需要一种几乎癫狂的乐观精神——这种精神实际上存在于绝大多数人类身上。这也延伸到对编程能力的自我评估上来了。这已经被包括实验性编程这样的面试技术的效果锁证实。实际上,比疯狂更低一级的乐观水平,在临床上被称为“临床型郁闷”。在他们的日常生活中,这类人通常面临严重的困扰。这里强调一下,近乎疯狂的乐观对于一个正常、健康的生命反直觉的重要性。如果你没有近乎癫狂的乐观精神,就不太可能会启动一个困难但有价值的项目。
一个重要的特殊情况是,虽然项目有价值,但是其价值尚不值得花费它所需要的时间。这种特殊情况是十分常见的,早期遇到的情况是,投资者没有足够的意愿投入项目实际需要的投资。对于开发者来说,自然的反应就是产生不切实际的乐观估计,认为项目已经被允许启动。如果组织足够强大,幸运的结果是进度受到影响或者预算超支,而项目终归还有见到天日的哪一天。但是,如果组织还不够强大,并且决策者在项目变得明朗之前预估它不值得投资,因而快速、错误的终止项目,这样的项目将可能毁掉组织。这可能导致其他组织重拾该项目,并且要么完美它、要么终止它,或者被它所毁掉。这样的项目可能会在毁掉多个组织后取得成功。人们只能期望,组织最终能够成功的管理一系列杀手项目,使其保持一个适当的水平,使得自身不会被下一个项目毁掉。
虽然疯狂乐观可能是重要的,但是它是 BUG 的重要来源(也许还包括组织失败)。因此问题是,如何保持一个大型项目所需要的乐观情绪,同时保持足够清醒的认识,使 BUG 保持在足够低的水平?
心态
当你进行任何验证工作时,应当记住以下规则:
- 没有 BUG 的程序,仅仅是那种微不足道的程序。
- 一个可靠的程序,不存在已知的 BUG。
从这些规则来看,可以得出结论,任何可靠的、有用的程序至少都包含一个未知的 BUG。因此,对一个有用的程序进行验证工作,如果没有找到任何 BUG,这本身就是一件失败的事情。因此,一个好的验证工作,就是一项破坏性的实践工作。这意味着,如果你是那种乐于破坏事务的人,验证工作就是一项好差事。
要求脚本检查错误的输入,如果找到 time 输出错误,还要给出相应的诊断结果。你应当向这个程序提供什么样的测试输入?这些输入与单线程程序生成的 time 输出一致。
但是,也许你是超级程序员,你的代码每次都在初次完成时就完美无缺。如果真是这样,那么祝贺你!可以放心跳过本章了。但是请原谅,我对此表示怀疑。我遇到那些声称能在第一次就能写出完美程序的人,比真正能够实现这一壮举的人还要多的多。根据前面堆乐观和过于自信的讨论,这并不令人奇怪。并且,即使你真是一个超级程序员,也将会发现,你的调试工作也仅仅是比一般人少一些而已。
对我们其他人来说,另一种情况是,在正常的乐观状态和严重的悲观情绪之间摇摆。如果你乐于毁坏事物,这将是有帮助的。如果你不喜欢毁坏事物,或者仅仅乐于毁坏其他人的事物,那就找那些喜欢毁坏代码并且让他们帮助你测试这些代码吧。
另一种有用的心态是,当其他人找到代码中的 BUG 时,你就仇恨代码吧。这种仇恨有助于你越过理智的界限,折磨你的代码,以便增加自己发现代码中 BUG 的可能性,而不是由其他人来发现。
最后一种心态是,考虑其他人的生命依赖于你的代码的正确性的几率。这将激励你去折磨代码,以找到 BUG 的下落。
不同种类的心态,导致了这样一种可能性,不同的人带着不同的心态参与到项目中。如果组织得当,就能很好的工作。
有些人可能会提醒自己,他们只不过是在折磨一个没有生命的物品。而且,他们也会做这样的假设,谁不折磨自己的代码,代码将会反过来折磨自己。
不过,这也留下一个问题,在项目生命周期中,何时开始验证工作。
何时开始验证
验证工作应该与项目的启动同时进行。
需要明白这一点,需要考虑到,与小型软件相比,在大型软件中找到一个 BUG 困难的多。因此,要将查找 BUG 的时间和精力减少到最小,应当对较小的代码单元进行测试。即使这种方式不会找到所有 BUG,至少能找到相当大一部分 BUG,并且更易于找到并修复这些 BUG。这种层次的测试也可以提醒在设计中的不足之处,将设计不足造成的浪费在代码编写上的时间减少的最小。
但是为什么在验证设计之前,要等待代码就绪呢?希望你阅读一下第 3、4 章,这两章展示了避免一些常见设计缺陷的信息。与同事讨论你的设计,甚至将其简单写出来,这将有助于消除额外的缺陷。
有一种很常见的情形,当你拥有一份设计,并等待开始验证时,其等待时间过长。在你完整理解需求之前,过于乐观的心态难道不会导致你开始设计?对此问题的回到总是会是“是的”。避免缺陷需求的一个好办法是,了解你的用户。要真正为用户服务好,你不得不与他们一起共度一段时间。
某类项目的首个项目,需要不同的方法进行验证,例如,快速原型。第一个原型的主要目的,是学习应当如何实现项目,而不是在第一次尝试时就创建一个正确的实现。但是,请注意,你不应该忽略验证工作,这是很重要的。不过,对于一个原型的验证工作可以采取不同的、快速的方法。
现在,我们已经为你树立了这样的观念,你应当在开始项目时就启动验证工作。后面的章节包含了一定数量的验证技术和方法,这些技术和方法已经证明了其价值。
开元之路
开源编程技术已经证明其有效,它包含严格的代码审查和测试。
我本人可以证明开源社区代码审查的有效性。我早期为 Linux 内核所提供的某个补丁,涉及一个分布式文件系统。在这个分布式文件系统中,某个节点上的用户向一个特定文件写入数据,而另一个节点的用户已经将该文件映射到内存中。在这种情况下,有必要使收到影响的页面映射失效,以允许在写入操作期间,文件系统所维护数据的一致性。我在补丁中进行了初次尝试,并且恪守开源格言“尽早发布、经常发布”,我提交了补丁。然后考虑如何测试它。
但是就在我确定整体测试策略之前,我收到一个回复,指出了补丁中的一些 BUG。我修复了这些 BUG,重新提交补丁,然后回过头来考虑测试策略。但是,在我有机会编写测试代码之前,我收到了针对重新提交补丁的回复,指出了更多的 BUG。这样的过程重复了很多次,以至于我不确定自己是否有机会测试补丁了。
这个经历将开源界所说的真理在我的脑海中打上了深深的烙印:只要有足够多的眼球,所有 BUG 都是浅显的。
当你提交代码或补丁时,想想以下问题:
- 到底有多少这样的眼球真正看了你的代码?
- 到底有多少这样的眼球,他们经验丰富、足够聪明,能够真正找到你的 BUG?
- 他们究竟什么时候看你的代码?
我是幸运的,有一些人,他们期望我的补丁中提供的功能,他们在分布式文件系统方面有着长期的经验,并且几乎立即就查看了我的补丁。如果没有人查看我的补丁,就不会有代码走查,因此也就不会找到 BUG。如果查看我补丁的人缺少分布式文件系统方面的经验,那么就不大可能找到所有的 BUG。如果它们等几个月或几年之后才查看我的补丁,我可能会忘记补丁是如何工作的,修复它们将更困难。
我们也千万不能忘记开源开发的第二个原则,即密集测试。例如,大量的人测试 Linux 内核。它们某些人会提交一些测试补丁,甚至你也提交过这样的补丁。另外一些人测试 next 树,这是有益的。但是,很有可能在你编写补丁,到补丁出现在 next 树之间,存在几周甚至几个月的延迟。这样的延迟可能使你对补丁没了新鲜感。对于其他测试维护树来说,仍然有类似的延迟。
相当一部分人直接将补丁提交到主线,或者提交到主源码树时,才测试它们的代码。如果你的维护者只有在你已经提交测试之后才会接受代码,这将形成死锁情形,你的代码需要测试后才能被接受,而只有被接受后才能开始测试。但是,测试主线代码的人们还是很积极的,因为很多人及组织要等到代码被拉入 Linux 分发版才测试其代码。
即使有人测试了你的补丁,也不能保证他们在适当的硬件和软件配置,以及适当的工作负载下测试了这些补丁,而这些配置和负载是找到 BUG 所必须的。
因此,即使你是为开源项目写代码,也有必要为开发和运行自己的测试套件而做好准备。测试开发是一项被低估,但是非常有价值的技能,因此请务必获取可用套件的全部优势。鉴于测试开发的重要性,我们将对这个主题进行更多的讨论。因此,随后的章节中,将讨论当你已经有一个好的测试套件时,怎么找到代码中的 BUG。
跟踪
如果你正在基于用户态 C 语言程序进行工作,当所有其他手段失效时,添加 printk 或 printf。
原理很简单,如果你不清楚如何运行到代码中某一点,在代码中多加点打印语句,以展示出到底发生了什么。你可以通过使用类似 gdb 或 kgdb 这样的调试器,来达到类似的效果,并且这些调试器拥有更多的方便性和灵活性。还有其他更多先进的工具,一些最新发行的工具提供在错误点回放的能力。
这些强大的测试工具都是有价值的。尤其是目前的典型系统都拥有超过 64K 内存,并且 CPU 都允许在超过 4MHZ 的频率。关于这些工具已经存在不少文章了,本章将再补充一点。
但是,当手上的工作是为了在高性能并行算法的快速路径上指出错误所在,那么这些工具都有严重的缺陷,即这些工具本身机会带来过高的负载。为了这个目的,存在一些特定的跟踪技术,典型的是使用数据所有权技术,以便将运行时数据收集负载最小化。在 Linux 内核中的一个例子是“teace event”。另一个处理用户态程序的例子是 LTTng。这些技术都无一例外使用了每 CPU 缓冲区,这允许以极低的负载来收集数据。即使如此,使用跟踪有时也会改变时序,并足以隐藏 BUG,导致海森堡 BUG。
即使你避免了海森堡 BUG,也还有其他陷阱。例如,即使机器知道所有的东西,它几乎知道所有的东西,以至于超过了大脑的处理能力,该怎么办呢?为此,高质量的测试套件通常配有精巧的脚本来分析大量的输出数据。但是请注意:脚本并不必然揭示那些奇怪的事件。有的 RCU 压力脚本就是一个很好的例子,在 RCU 周期被无限延迟的情况下,这个脚本的早期版本运行的很好。这当然会导致脚本被修改,以检查 RCU 优雅周期延迟的情况,但是这并不能改变如下事实,该脚本仅仅检查那些为认为能够检查的问题。这个脚本是有用的,但是有时候,它仍然不能代替对 RCU 压力输出结果的手动扫描。
对应产品来说,使用追踪,特别是使用 printk 调用进行追踪,存在另外一个问题,他们的负载太高了。在这样的情况下,断言是有用的。
断言
通常假设以下面的方式实现断言:
1 if (something_bad_is_happening())
2 complain();
这种模式通常被封装成 C-预处理宏或者语言内联函数,例如,在 Linux 内核中,它可能被表示为 WARN_ON(something_bad_is_happening())。当然,如果 something_bad_is_happening 被调用得过于频繁,其输出结果将掩盖其错误报告,在这种情况下 WARN_ON_ONCE(something_bad_is_happening) 可能更合适。
在并行代码中,可能发生的一个特别糟糕的情况是,某个函数期望在一个特定的锁保护下运行,但是实际上并没有获得锁。有时候,这样的函数会有这样的注释,调用者在调用本函数时,必须持有 foo_lock。但是,这样的注释并没有真正的作用,除非有人真的读了它。像 lock_is_held(&foo_lock) 这样的语句则会更有效。
Linux 内核的 lockdep 机制更进一步,它即报告潜在的死锁,也允许函数验证适当的锁持有者。当然,这些额外的函数引入了大量负载。因此,lockdep 并不一定适用于生产环境。
那么,当检查是必须的,但是运行时负载不能被容忍时,能够做些什么呢?一种方式是静态分析。
静态分析
静态分析是一种验证技术,其中一个程序将第二个程序作为输入,它报告第二个程序的错误和漏洞。非常有趣的是,几乎所有的程序都通过它们的编译器和解释器来执行静态分析。这些工具远远算不上完美,但在过去的几十年中,它们定位错误的能力得到了几大的改善。部分原因是它们现在拥有超过 64K 内存进行它们的分析工作。
早期的 UNIX lint 工具是非常有用的,虽然它的很多功能都被并入 C 编译器了。目前仍然有类似 lint 的工具在开发和使用中。
Sparse 静态分析查找 Linux 内核中的高级错误,包括:
- 对指向用户态数据结构的指针进行误用。
- 对过长的常量接收赋值。
- 空的 switch 语句。
- 不匹配的锁申请、释放原语。
- 对每 CPU 原语的误用。
- 在非 RCU 指针上使用 RCU 原语,反之亦然。
虽然编译器极有可能会继续提升其静态分析能力,但是 sparse 静态分析展示了编译器外静态分析的优势,尤其是查找应用特定 BUG 的优势。
代码走查
各种代码走查活动是特殊的静态分析活动,只不过是由人来完成分析而已。
审查
传统意义上来说,正式的代码审查采取面对面会谈的形式,会谈者有正式定义的角色:主持人、开发者以及一个或两个其他参与者。开发者通读整个代码,解释做什么,以及它为什么这样运行。一个或者两个参与者提出疑问并抛出问题。而主持人的任务,则是解决冲突并做记录。这个过程对于定于 BUG 是非常有效的,尤其是当所有参与者都熟悉手头代码时,更加有效。
但是,对于全球 Linux 内核开发社区来说,这种面对面的过程并不一定运行得很好,虽然通过 IRC 会话它也许能够很好运行。与之相反,全球 Linux 内核社区有个人进行单独的代码审查,并通过邮件或者 IRC 提供意见。则记录由邮件文档或者 IRC 日志提供。主持人志愿提供相应的服务。偶尔来一点口水战,这样的过程也允许的相当不错,尤其是参与者对手头的代码都很熟悉的时候。
是时候进行 Linux 内核社区的代码审查过程改进了,这是很有可能的。
- 有时,人们缺少进行有效的代码审查所需要的时间和专业知识。
- 即使所有的审查讨论都被存档,人们也经常没有记录对问题的见解,人们通常无法找到这些讨论过程。这会导致相同的错误被再次引入。
- 当参与者吵得不可开交时,有时难于解决口水纷争。尤其是交战双方的目的、经验及词汇都没有共同之处时。
因此,在审查时,查阅相关的提交记录、错误报告及 LWN 文档等相关文档是有价值的。
走查
传统的代码走查类似于正式的代码审查,只不过小组成员以特定的测试用例集来驱动,对着代码摆弄电脑。典型的走查小组包含一个主持人,一个秘书,一个测试专家,以及一个或者两个其他的人。这也是非常有效的,但是也非常耗时。
自从我参加到正式的走查依赖,已经有好几十年了。而且我也怀疑如今的走查将使用单步调试。我想到的一个特别恐怖的过程是这样的:
- 测试者提供测试用例。
- 主持人使用特定的用例作为输入,在调试器中启动代码。
- 在每一行语句执行前,开发者需要预先指出语句的输出,并解释为什么这样的输出是正确的。
- 如果输出与开发者预先指出的不一致,这将被作为一个潜在 BUG 的迹象。
- 在并发代码走查中,一个并发老手将提出问题,什么样的代码会与当前代码并发运行,为什么这样的并行没有问题?
恐怖吧,当然。但是有效吗?也许。如果参与者对需求、软件工具、数据结构及算法都有良好的理解,相应的走查可能非常有效。如果不是如此,走查通常是在浪费时间。
自查
虽然开发者审查自己的代码并不总是有效,但是有一些情形下,无法找到合适的替代方案。例如,开发者可能是被授权查看代码的唯一人员,其他合格的开发人员可能太忙,或者有问题的代码太离奇,以至于只有在开发者展示一个原型后,他才能说服他人认证对待它。在这行情况下,下面的过程是十分有用的,特别是对于复杂的并行代码而言。
- 写出包含需求的设计文档、数据结构图表,以及设计选择的原因。
- 咨询专家,如果有必要就修正设计文档。
- 用笔在纸上写下代码,一边写代码一边修正错误。抵制住对已经存在的、几乎相同的代码序列的引用,相反,你应该复制他们。
- 如果有错误,用笔在干净的纸上面复制代码,以便做这些事情一边修正错误。一直重复,直到最后两份副本完全相同。
- 为那些不是那么显而易见的代码,给出正确性证明。
- 在可能的情况下,自底向上的测试代码片段。
- 当所有代码都集成后,进行全功能测试和压力测试。
- 一旦代码通过了所有测试,写下代码级的文档,也许还会对前面讨论的设计文档进行扩充。
当我在新的 RCU 代码中,忠实的遵循这个流程时,最终只有少量 BUG 存在。在面对一些著名的异常时,我通常能够在其他人之前定位 BUG。也就是说,随着时间的推移,以及 Linux 内核用户数量和种类的增加,这变得更难以解决。
对于新代码来说,上面的过程运转的很好,但是如果你需要对已经编写完成的代码进行审查时,又会怎样呢?如果你编写哪种将废弃的代码,在这种情况下,当然可以实施上面的过程,但是下面的方法也是有帮助的,这是不是会令你感到不适那么绝望。
- 使用你喜欢的文档工具,描述问题中所述代码的高层设计。使用大量的图来表示数据结构,以及这些是如何被修改的。
- 复制一份代码,删掉所有的注释。
- 用文档逐行记录代码是在干什么。
- 修复你所找到的 BUG。
这种方法能够工作,是因为对代码进行详细描述,是一种极为有效的发现 BUG 的方法。虽然后面的过程也是一种真正理解别人代码的好方法,但是在很多情况下,只需要第一步就够了。
虽然由别人来进行复查及审查可能更有效,但是由于某种原因无法让别人参与进来时,上述过程就十分有用了。
在这一点上,你可能想知道如何在不做上述那些无聊的纸面工作的情况下,编写并行代码。下面是一些能够达到目的的且经过时间检验的方法。
- 通过扩展使用已有并行库函数,写出一个顺序程序。
- 为并行框架写出顺序执行的插件。如果地图渲染、BIONC 或者 WEB 应用服务器。
- 做如下优秀的并行设计,问题被完整的分割,然后仅仅实现顺序程序,这些顺序程序并发运行而不必相互通信。
- 坚守在某个领域(如线性代数),在这些领域中,工具可以自定对问题进行分解及并行化。
- 对并行原语进行及其严格的使用,这样最终代码容易被看出其正确性。但是请注意,它总是诱使你打破“小步前进”这个规则,以获得更好的性能可扩展性。破坏规则常常导致意外。也就是说,除非你小心进行本节描述的纸面工作,否则就会有意外发生。
一个不幸的事情是,即使你做了纸面工作,或者使用前述某个方法,以安全地避免纸面工作,仍然会有 BUG。如果不出意外,更多用户或者更多类型的用户将更快暴露出更多的 BUG。特别是,这些用户做了最初那些开发者所没有考虑到的事情时,更容易暴露 BUG。下一步将描述如何处理概率性 BUG,这些 BUG 在验证并行软件时都非常常见。
几率和海森堡 BUG
某些时候你的并行程序失败了。
但是你使用前面章节的技术定位问题,现在,有了适当的修复办法!
现在的问题是,需要多少测试以确定你真的修复了 BUG,而不仅仅是降低了故障发生的几率,或者说仅仅修复了几个相关 BUG 中的某几个,或者是干了一些无效的、不相关的修改。简而言之,是通过了还是侥幸?
不幸的是,摸着良心来回答这个问题,其答案是:要获得绝对的确定性,其所需要的测试量是无限的。
假如我们愿意放弃绝对的确定性,取而代之的是获得某种高几率的东西。那么我们可以用强大的统计工具来应对这个问题。但是,本节专注于简单的统计工具。这些工具是及其有用的,但是请注意,阅读本节并不能代替你采用那些优秀的统计工具。
从简单的统计工具开始,我们需要确定,我们是在做离散测试,还是在做连续测试。离散测试以良好定义的、独立的测试用例为特征。例如,Linux 内核补丁的启动测试就是一个离散测试的例子。启动内核,它要么启动、要么不能启动。虽然你可能花费一小时来进行内核启动测试,试图启动内核的次数、启动成功的次数,通常比花在测试上面的时间更人关注。功能测试往往是离散的。
另一方面,如果我的补丁与 RCU 相关,我很可能会运行 rcutortue,这是一个十分奇妙的内核模块,用于测试 RCU。它不同于启动测试。在启动测试中,一旦出现相应的登录提示符,就表名离散测试已经成功结束。Rcutortue 则会一直持续运行,知道内存崩溃或要求它停止为止。因此,rcutortue 测试的持续时间,将比启动、停止它的次数更令人关注。所以说,rcutortue 是一个持续测试的例子,这类测试包含很多压力测试。
离散测试和持续测试的统计方式有所不同。离散测试的统计更简单,并且,离散测试的统计通常可以被计入持续测试中。因此,我们先从离散测试开始。
离散测试统计
假设在一个特定的测试中,BUG 有 10% 的机会发生,并且我们做了 5 次测试。我们怎么算一次运行失败的几率?方法如下:
- 计算一次测试过程成功的几率,应该是 90%。
- 计算所有 5 次测试成功的几率,应该是 0.9 的 5 次方,大约是 59%。
- 存在两种可能性,要么 5 次全都成功,要么至少又一次失败。因此,至少又一次失败的可能是 100% 减去 59%,即 41%。
假设一个特定测试有 10% 的几率失败。那需要允许多少次测试用例,才能导致失败的几率超过 99%?毕竟,如果我们将测试用例运行的次数足够多,使得至少有一次失败的几率达到 99%,如果此时并没有失败,那么斤斤有 1% 的几率表名这是由于好运气所导致。
公式太多….省略…
持续测试统计
定位海森堡 BUG
这个思路也有助于说明海森堡 BUG,增加追踪和断言可以轻易减少 BUG 出现的几率。这也是为什么轻量级追踪和断言机制是如此重要的原因。
“海森堡 BUG” 这个名字来源于量子力学的海森堡不确定性原理,该原理指出,在任何特定时间点,不可能同时精确计量某个粒子的位置和速度。任何视图更精确计量某个粒子位置的手段,都会增加速度的不确定性。类似的效果出现在海森堡 BUG 上,视图对海森堡 BUG 进行追踪,将会根本上改变其症状,甚至导致 BUG 不再出现。
既然物理领域启发出这个问题的名字,那么我们着眼于物理领域的解决方案是合乎逻辑的。幸运的是,粒子物理学能够用于这个任务,为什么不构造“反——海森堡 BUG”的东西来消灭海森堡 BUG 呢?
本节描述一些手段来实现这一点。
- 为竞争区增加延迟。
- 增加负载强度。
- 独立的测试可疑子系统。
- 模拟不常见的事件。
- 对有惊无险的事件进行计数。
针对海森堡 BUG 来构造“反——海森堡 BUG”,这更像是一种艺术,而不是科学。
增加延迟
增加负载强度
隔离可疑的子系统
模拟不常见的事件
对有惊无险的事件进行计数
性能评估
3.3.12 - CH12-形式验证
本章通过形式证明的方式来弥补测试的不足。略。
3.3.13 - CH13-综合应用
本章会给出一些处理某些并发编程难题的提示。
计数难题
对更新进行计数
假设薛定谔想要对每一只动物的更新数量进行计数,并且这些更新使用一个没数据元素锁进行同步。这样的计数怎样才能做得最好?
当然,可以考虑第 5 章中任何一种计数算法,但是在这种情况下,最优的方法简单的多。仅仅需要在每一个数据元素中放置一个计数器,并且在元素锁的保护下递增元素就行了。
对查找进行计数
如果薛定谔还想对每只动物的查找进行计数,而这些查找由 RCU 保护。怎样的计数才能做到最好?
一种方法是像 13.1.1 节所述,由一个每元素锁来对查找计数进行保护。不幸的是,这将要求所有查找过程都获得这个锁,在大型系统中,这将形成一个严重的瓶颈。
另一种方法是对计数说“不”,就像 noatime 挂载选项的例子。如果这种方法可行,那显然是最好的办法。毕竟,什么都没有比什么都不做还快。如果查找计数不能被省略,就继续读下去。
第 5 章中的任何计数都可以做成服务,5.2 节中描述的统计计数可能是最常见的选择。但是,这导致大量的内存访问,所需要的计数器数量是数据元素的数量乘以线程数量。
如果内存开销太大,另一个方法是保持每 socket 计数,而不是每 CPU 计数,请注意图 10.8 所示的哈希表性能结果。这需要计数递增作为原子操作,尤其对于用户态来说更是这样。在用户态中,一个特定的线程可能随时迁移到另一个 CPU 上运行。
如果某些元素被频繁的查找,那么存在一些其他方法。这些方法通过维护一个每线程日志来进行批量更新,其对特定元素的多次日志操作可以被合并。当对一个特定日志操作达到一定的递增次数,或者一定的时间过去以后,日志记录将被反映到相应的数据元素中去。Silas Boyd-Wickizer 已经做了一些工作。
使用 RCU 拯救并行软件性能
本节展示如何对本书较早讨论的某些例子应用 RCU 技术。某些情况下,RCU 提供更加简单的代码,另外一些情况下则能提供更好的性能和可扩展性,还有一些情况下,同时提供两者的优势。
RCU 和基于每 CPU 变量的统计计数
5.2.4 节描述了一个统计计数的实现,该实现提供了良好的性能,大致的说是简单的递增,并且能够线性扩展——但仅仅通过 inc_count 递增。不幸的是,需要通过 read_count 读取其值的线程需要获得一个全局锁,因此招致高的高效,并且扩展性不佳。
设计
设计的目的是使用 RCU 而不是 final_mutex 来保护线程在 read_count 中的遍历,已获得良好的性能和扩展性,而不仅仅是保护 inc_count。但是,我们并不希望放弃求和计算的精确性。特别是,当一个特定线程退出时,我们绝对不能丢失退出线程的计数,也不能重复对它进行计数。这样的错误将导致将不精确的结果作为精确结果,换句话说,这样的错误使得结果完全没有意义。并且事实上,final_mutex 的一个目的是,确保线程不会在 read_count 运行过程中,进入并退出。
因此,如果我们不用 final_mutex,就必须拿出其他确保一致性的方法。其中一种方法是将所有已退出线程的计数和,以及指向每线程计数的指针放到一个单一的数据结构。这样的数据结构,一旦没 read_count 使用就保持不变,以确保 read_count 看到一致的数据。
实现
片段 13.5 第 1~4 行展示了 countarray 结构,它包含一个 total 字段,用于对之前已经退出线程的计数、counterp[] 数组,指向当前正在运行的每线程 counter。这个及饿哦股允许特定的 read_count 执行过程看到一致的计数总和,以及运行线程的集合。
第 6~8 行包含每线程 counter 变量的定义,全局指针 countarray 引用单签 countarray 结构,以及 final_mutex 自旋锁。
第 10~13 行展示 inc_count,与之前没有变化。
第 15~29 行展示 read_count,它被大量修改了。第 21~27 行以 rcu_read_lock 和 rcu_read_unlock 代替获得、释放 final_mutex 锁。第 22 行使用 rcu_dereference 将当前 countarray 数据结构的快照获取到临时变量 cap 中。正确的使用 RCU 将确保:在第 27 行的 RCU 读端临界区结束前,该 countarray 数据结构不会被释放掉。第 23 行初始化 sum 作为 cap->total,它表示之前已经退出的线程计数值之和。第 23~26 行将正在运行的线程对应的每线程计数值添加到 sum 中。最后第 28 行返回 sum。
countarray 的初始值由第 31~39 行的 count_init 提供。这个函数在第一个线程创建之前运行,其任务是分配初始数据结构,并将其置为 0,然后将它赋值给 countarray。
第 41~48 行展示了 count_register_thread 函数,他被每一个新创建线程所调用。第 43 行获取当前线程的索引,第 45 行获取 final_mutex,的 46 行将指针指向线程的 counter,第 47 行释放 final_mutex 锁。
第 50~70 行展示了 count_unregister_thread 函数,没一个线程在退出前,条用此函数。第 56~60 行分配一个新的 countarray 数据结构,第 61 行获得 final_mutex 锁,第 67 行释放锁。第 62 行将当前 countarray 的值复制到新分配的副本,第 63 行将现存线程的 counterp 添加到新结构的总和值中,第 64 行将真正退出线程的 counterp[] 数组元素置空,第 66 行保留当前值(很快就会变成旧的)countarray 结构的指针引用,第 66 行使用 rcu_assign_pointer 设置 countarray 结构的新版本。第 68 行等待一个优雅周期的流逝。这样,任何可能并发执行 read_count,并且可能拥有对旧的 countarray 结构引用的线程,都能退出它们的 RCU 读端临界区,并放弃对这些结构的引用。因此,第 69 行能够安全释放旧的 countarray 结构。
讨论
对 RCU 的使用,使得正在退出的线程进行等待,直到其他线程保证,其已经结束对退出线程的 thread 变量的使用。这允许 read_count 函数免于使用锁,因而对 inc_count 和 read_count 函数来说,都为其提供了优良的性能和可扩展性。但是这些性能和扩展性来自于代码复杂性的增加。希望编译器和库函数的编写者能够提供用户层的 RCU,以实现跨越线程安全访问 thread 变量,大大减少 thread 变量使用者所能见到的复杂性。
RCU 及可插拔 IO 设备的计数器
5.5 节展示了一对奇怪的代码段,以处理对可插拔设备的 IO 访问计数。由于需要获取读写锁,因此这些代码段会在快速路径上(开始一个 IO)招致过高的负载。
执行 IO 的代码与原来的代码非常类似,它使用 RCU 读端临界区代替原代码中的读写锁的读端临界区。
1 rcu_read_lock();
2 if (removing) {
3 rcu_read_unlock();
4 cancel_io();
5 } else {
6 add_count(1);
7 rcu_read_unlock();
8 do_io();
9 sub_count(1);
10 }
RCU 读端原语拥有极小的负载,因此提升了快速路径的速度。
移除设备的新代码片段如下:
1 spin_lock(&mylock);
2 removing = 1;
3 sub_count(mybias);
4 spin_unlock(&mylock);
5 synchronize_rcu();
6 while (read_count() != 0) {
7 poll(NULL, 0, 1);
8 }
9 remove_device();
在此,我们将读写锁替换为排他自旋锁,并增加 synchronize_rcu 以等待所有 RCU 读端临界区完成。由于 synchronize_rcu 的缘故,一旦我们允许到第 6 行,就能够知道,所有剩余 IO 已经被识别到了。
当然 synchronize_rcu 的开销可能比较大。不过,既然移除设备这种情况比较少见,那么这种方法通常是一个不错的权衡。
数组及长度
如果我们有一个受 RCU 保护的可变长度数组,如下面的代码片段:
1 struct foo {
2 int length;
3 char *a;
4 };
数组 ->a[] 的长度可能会动态变化。在任意时刻,其长度由字段 ->length 表示。当然,这带来了如下竞争条件。
- 数组被初始化为 16 个字节,因此 length 等于 16。
- CPU0 紧挨着 length 的值,得到 16。
- CPU1 压缩数组长度到 8,并将 ->a[] 赋值为指向新 8 字节长的内存块的指针。
- CPU0 从 ->a[] 获取到新的指针,并且将新值存储到元素 12 中。由于数组仅仅有 8 个字符,这导致 SEGV 或内存破坏。
我们可以使用内存屏障来放置这种情况。该方法确实可行,但是带来了读端的开销,更糟的是需要显式使用内存屏障。
一个更好的办法是将值及数组放进同一个数据结构,如下所示:
1 struct foo_a {
2 int length;
3 char a[0];
4 };
5
6 struct foo {
7 struct foo_a *fa;
8 };
分配一个新的数组(foo_a 数据结构),然后为新的数组长度提供一个新的存储空间。这意味着,如果某个 CPU 获得 fa 引用,也就能能确保 length 能够与 a 的长度相匹配。
- 数组最初为 16 字节,因此 length 等于 16.
- CPU0 加载 fa 的值,获得指向数据结构的指针,该数据结构包含值 16,以及 16 字节的数组。
- CPU0 加载 fa->length 的值,获得其值 16.
- CPU 压缩数组,使其长度为 8,并且将指针赋值为新分配的 foo_a 数据结构,该结构包含一个 8 字节的内存块 a。
- CPU 0 从 a 获得新指针,并且将新值存储到第 12 个元素。由于 CPU0 仍然引用旧的 foo_a 数据结构,该结构包含 16 字节的数组,一切都正常。
当然,在所有情况下,CPU1 必须在释放旧数组前等待下一个优雅周期。
相关联的字段
假设每一只薛定谔动物由下面所示的数据元素表示:
1 struct animal {
2 char name[40];
3 double age;
4 double meas_1;
5 double meas_2;
6 double meas_3;
7 char photo[0]; /* large bitmap. */
8 };
meas_1、meas_2、meas_3 字段是一组相关联的计量字段,它们被频繁更新。读端从单词完整更新的角度看到这三个值,这是特别重要的,如果读端看到 meas_1 的旧值,而看到 meas_2 和 meas_3 的新值,读端将会变得非常迷惑。我们怎样才能确保读端看到协调一致的三个值呢?
一种方法是分配一个新的 animal 数据结构,将旧结构复制到新结构中,更新新结构的三个字段,然后,通过更新指针的方式,将旧的结构替换为新的结构。这确保所有读 端看到测量值的一致集合。但是由于 photo 字段的原因,这需要复制一个大的数据结构。这样的复制操作可能带来不能接受的大开销。
另一种方式是如下所示中的那样插入一个中间层:
1 struct measurement {
2 double meas_1;
3 double meas_2;
4 double meas_3;
5 };
6
7 struct animal {
8 char name[40];
9 double age;
10 struct measurement *mp;
11 char photo[0]; /* large bitmap. */
12 };
当进行一次新的测量时,一个新的 measurement 数据结构被分配,将测量值填充到该结构,并且 animal 的及饿哦股 mp 字段被更新为指向先 measurement 结构,这是使用 rcu_asign_pointer 完成的更新。当一个优雅周期流逝以后,旧的 measurement 数据可以被释放。
这种方式运行读端以最小的开销,看到所选字段的关联值。
散列问题
本节着眼于在处理哈希表时,可能会碰上的一些问题。请注意,这些问题也适用于许多其他与搜索相关的数据结构。
相关联的数据元素
这种情形类似于 13.2.4 节中的问题:存在一个哈希表,我们需要两个或更多元素的关联视图。这些数据元素被同时更新,并且我们不希望看到不同元素之间的不同版本。
一种方式是使用顺序锁,这样更新将在 write_seqlock 的保护下进行。而要求一致性的读请求将在 read_seqbegin/read_seqretry 循环体中进行。请注意,顺序锁并不是 RCU 保护机制的替代品:顺序锁是保护并发修改操作,而 RCU 仍然是需要的,它保护并发的删除。
当相关数据元素少,读这些元素的时间很短,更新速度也低的时候,这种方式可以运行的很好。否则,更新可能会频繁发生,以至于读者总是不能完成。要逃避读者饥饿问题,一种方式是在读端重试太多次之后让其使用写端原语,但是这会同时降低性能和扩展性。
另外,如果写端原语使用得太频繁,那么,由于锁竞争的原因,将带来性能和扩展性的问题。要避免这个问题,其中一种方法是维护一个每数据元素的顺序锁,并且,在更新时应该持有所有涉及元素的锁。但是复杂性在于:在单词扫描数据库期间,需要获得所有数据的文档视图。
如果元素分组被良好定义且有持久性,那么一种方式是将指针添加到数据元素中,将特定组的元素链接在一起。读者就能遍历所有这些指针,以访问同一组内的所有元素。
对更新友好的哈希表遍历
如果需要对哈希表中的所有元素进行统计扫描。例如,薛定谔可能希望计算所有动物的平均长度——重量比率。更进一步假设,薛定谔愿意忽略在统计扫描进行时,那些正在从哈希表中添加或移除的动物引起的轻微错误。那么如何来控制并发性?
一种方法是:将统计扫描置于 RCU 读端临界区之内。这允许更新并发的进行,而不影响扫描进程。特别是,扫描过程并不阻塞更新操作,反之亦然。这允许对包含大量数据元素的哈希表进行扫描,这样的扫描将被优雅的支持,即使面对高频率的更新时也是如此。
3.3.14 - CH14-高级同步
本章将介绍高级同步的两个分类:无锁同步、实时同步。
当面临极端要求时,无锁同步会非常有帮助,但不幸的是无锁同步并非灵丹妙药。如在第五章的末尾所述,你应该在考虑采用无锁同步之前首先考虑分区、并行,以及在第八、九章所述的充分测试的脆弱 API。
避免锁
尽管锁在并行生产环境中吃苦耐劳,但在很多场景下可以通过无锁技术来大幅提高性能、扩展性和实时响应性。这种无锁技术的一个实际例子是第 5.2 节中所述的统计计数,它不但避免了锁,同时还避免了原子操作、内存屏障,甚至是计数器自增时的缓存未命中。我们已经介绍过的与无锁技术相关的例子有:
- 第 5 章中一些计数算法的快速路径。
- 第 6.4.3 中资源分配器缓存的快速路径。
- 第 6.5 中的迷宫求解器。
- 第 8 章中的数据所有权技术。
- 第 9 章中介绍的引用计数与 RCU 技术。
- 第 10 章中查找逻辑的代码路径。
- 第 13 章中介绍的大多数技术。
总的来说,无锁技术十分有用且已被大量应用。
然后,无锁技术最好是能够因此在设计良好的 API 之后,比如 inc_count、memblock_allock、rcu_read_lock 等等。因为对无锁的技术的混乱使用可能会引入一些难以解决的 BUG。
很多无锁技术的关键组件是内存屏障,下面的章节将会详细介绍。
无阻塞同步
术语“非阻塞同步(NBS)”描述 6 类线性化算法,这些算法具有前向执行保证。这些前向执行保证与构成实时程序的基础相混淆。
- 实时前向执行保证通常有些与之相关的确定时间。例如,“调度延迟必须小于 100ms”。相反,NBS 仅仅要求执行过程限定在有限时间内,没有确定的边界。
- 有时,实时前向执行具有概率性。比如,在软实时保证中“至少在 99.9% 的时间内,调度延迟必须小于 100ms”。相反,NBS 的前向执行保证传统上是无条件的。
- 实时前向执行保证通常以环境约束为条件。例如,仅仅当每个 CPU 至少有一定比例处于空闲时间,或者 IO 速度低于某些特定的最大值时,对最高优先级任务才能得到保证。相反,NBS 的前向执行保证通常是无条件的。
- 实时前向执行保证通常适用于没有软件 BUG 的情况下。相反,绝大多数 NBS 保证即使在面对错误终止 BUG 时也适用。
- NBS 前向执行保证隐含线性化的意思。相反,实时前向执行保证通常独立于像线性化这样的约束。
不考虑这样的差异,很多 NBS 算法对实时程序极其有用。
在 NBS 层级中,目前有 6 种级别,大致如下:
- 无等待同步:每个线程在有限时间内运行。
- 无锁同步:至少某一个线程将在有限时间内运行。
- 无障碍同步:在没有争用的情况下,每个线程将在有限时间内运行。
- 无冲突同步:在没有争用的情况下,至少某一线程将在有限时间内运行。
- 无饥饿同步:在没有错误的情况下,每个线程将在有限时间内运行。
- 无死锁同步:在么有错误的情况下,至少某一个线程将在有限时间内运行。
第 1、2 类 NBS 于 1990 年代初期制定。第 3 类首次在 2000 年代初期制定。第 4 类首次在 2013 年制定。最后两类已经非正式使用了数十年,但是在 2013 年重新制定。
从原理上讲,任何并行算法都能够被转换为无等待形式,但是存在一个相对小的常用 NBS 算法子集,将在后续章节列出。
简单 NBS
最简单的 NBS 算法可能是使用获取——增加(atomic_add_return)原语对下整型计数器进行原子更新。
另一个简单的 NBS 算法用数组实现整数集合。在此,数组索引标识一个值,该值可能是集合的成员,并且数组元素标识该值是否真的是集合成员。NBS 算法的线性化准则要求对数组的读写,要么使用原子指令、要么与内存屏障一起使用,但是在某些不太罕见的情况下,线性化并不重要,简单使用易失性加载和存储就足够了。例如,使用 ACCESS_ONCE。
NBS 集合也可以使用位图来实现,其中每一个值可能是集合中的某一位。通常,读写操作可以通过原子位维护指令来实现。虽然 CAS 指令也可以使用。
5.2 一节中讨论的统计计数算法可被认为是无等待算法,但仅仅是用了一个狡猾的定义技巧,在该定义中,综合被考虑为近似值而不是精确值。由于足够大的误差区间是计算计数器综合的 read_count 函数的时间长度函数,因此不可能证明发生了任何非线性化行为。这绝对将统计计算算法划分为无等待算法。该算法可能是 Linux 内核中最常见的 NBS 算法。
另一个常见的 NBS 算法是原子队列,其中元素入队操作通过一个原子交换指令实现,随后是对新元素前驱元素的 next 指针的存储,如图 14.19 所示。该图展示了用户态 RCU 库的实现。当返回前向元素的引用时,第 9 行更新引用新元素的尾指针,该指针存储在局部变量 old_tail 中。然后第 10 行更新前向 next 指针,以引用最新添加的元素。最后第 11 行返回队列最初是否为空的标志。
虽然将单个元素出队需要互斥(因此出队是阻塞的),但是将所有队列元素非阻塞的移除是可能的。不可能的是以非阻塞的方式将特定元素出队。入队可能是在第 9 行和第10 行之间失败,因此问题中的元素仅仅部分入队。这将导致半 NBS 算法,其中入队是 NBS 但是出队是阻塞式的。因此在实践中使用此算法的部分原因是,大多数产品软件不需要容忍随意的故障终止错误。
NBS 讨论
创建完全的非阻塞队列是可能的。但是,这样的队列要比上面列出的半 NBS 算法负责的多。这里的经验是,认真考虑你真的需要什么?放宽不相关的需求通常可以极大增加简单性和性能。
最近的研究指出另一种放宽需求的重要方式。结果是,不管是从理论上还是从实践上来说,提供公平调度的系统可以得到大部分无等待同步的优势,即使当算法仅仅提供 NBS 时也是这样。事实上,由于大量产品中使用的调度器都提供公平性,因此,与简单也更快的 NBS 相比,提供无等待同步的更复杂算法通常并没有实际的优势。
有趣的是,公平调度仅仅是一个有益的约束,在实践中常常得到满足。其他的约束集合可以允许阻塞算法实现确定性的实时响应。例如,如果以特定有限级的 FIFO 顺序来授予请求的公平锁,那么避免优先级翻转(如优先级继承或优先级上限)、有限数量的线程、有限长度的临界区、有限的加载,以及避免故障终止 BUG,可以让基于锁的应用获得确定性的响应时间。这个方法当然模糊了锁及无等待同步之间的区别,一切无疑都是好的。期望理论框架持续进步,进一步提高其描述如何在实践中构建软件的能力。
并行实时计算
什么是实时计算
将实时计算进行分类的一种传统方式,是将其分为硬实时和软实时。其中充满阳刚之气的硬实时应用绝不会错过其最后期限,而仅有阴柔之美的软实时应用,则可能被频繁(并且经常)错误其最后期限。
软实时
很容易发现软实时定义的问题。一方面,通过这个定义,任何软件都可以被说成是软实时应用:我的应用在 0.5ps 内计算 100 万点傅里叶变换;没门,系统时钟周期超过 300ps!如果术语软实时被滥用,那就明显需要某些限定条件。
因此,我们应当这么说:一个特定软实时应用必须至少在一定比例的时间范围内,满足实时响应的要求。例如,我们可能这么说,它必须在 99.9% 的时间范围内,在 20ms 内执行完毕。
这当然带来的问题,当应用程序不能满足响应时间要求,应当做什么?答案根据应用程序而不同,不过有一个可能是,被控制的系统有足够的灵活性和惯性,对于偶尔出现的延迟控制行文,也不会出现问题。另一种可能的做法是,应用有两种方式计算结果,一种方式是快速且具有确定性,但是不太精确的方法,还有一种方式是非常精确,但是具有不确定的计算时间。合理的方法是并行启动这两种方法,如果精确的方法不能按时完成,就中止并使用快速但不精确方法所产生的结果。对于快速但不精确的方法,一种实现是在当前时间周期内不采取任何控制行为,另一种实现是采取上一个时间周期同样的控制行为。
简而言之,不对软实时进行精确的度量,谈论软实时就没有任何意义。
硬实时
相对的,硬实时的定义相当明确。毕竟,一个特定的系统,它要么是满足其执行期限,要么不满足。不幸的是,这种严格的定义意味着不可能存在任何硬实时的系统。事实上,你能够构建更强大的系统,也许还有额外的冗余性。但是另一个事实是,我们可以找到一把更大的锤子。
不过话说回来,由于这明显不仅仅是一个硬件问题,而实在是一个大的硬件问题,因此指责软件是不公平的。这表明我们定义硬实时软件为哪种总是能够满足其最后期限的软件,其前提是没有硬件故障。不幸的是,故障不仅仅是一个可选项。硬实时响应是整个系统的属性,而不仅仅是软件属性。
但是我们不能求全责备,也许我们可以像前面所述的软实时方法那样,通过发出通知消息的方法来解决问题。
如果一个系统在不能满足符合法律条文的最后期限是,总是立即发出告警通知。但是这样的系统是无用的。很明显,很明显,必须要求系统在一定比例的时间内,满足其最后期限,或者,必须禁止其连续突破其最后期限这样的操作达到一定次数。
显然,我们没办法来对硬实时或软实时给出一种明确无误的说法。
现实世界的实时
虽然像“硬实时系统总是满足最后期限的要求”这样的句子读起来很上口,无疑也易于记忆,但是,其他一些东西也是现实世界的实时系统所需要的。虽然最终规格难于记忆,但是可以对环境、负载及实时应用本省施加一些约束,以简化构建实时系统。
环境约束
环境约束处理“硬实时”所隐含的响应时间上的无限制承诺。这些约束指定允许的操作温度、空气质量、电磁辐射的水平及类型。
当然,某些约束比其他一些约束更容易满足。人们都知道市面上的计算机组件通常不能在低于冰点的温度下运行,这表明了对气候控制的要求。
一位大学老朋友曾经遇到过这样的挑战,在具有相当活跃的氯化合物条件下的太空中,操作实时系统。他明智的将这个跳转转交给硬件设计同事了。实际上,同事在计算机上环绕施加大气成分的约束,这样的约束是由硬件设计者通过物理密封来实现的。
另一个大学朋友在计算机控制系统上工作,该系统在真空中使用工业强度的电弧来喷镀钛锭。有时,有时,将不会基于钛锭的路径来确定电弧的路径,而是选择更短更优的路径。正如我们在物理课程中学习到的一样,电流的突然变化会形成电磁波,电流越大、变化越大,形成超高功率的电磁波。这种情况下,形成的电磁脉冲足以导致 400 米外的 rubber ducky 天线引线产生 1/4 伏的变化。这意味着附近的导体能看到更大的电压。这包含那些组成控制喷镀过程的计算机导体。尤其是,包括计算机复位线的电压,也足以将计算机复位。这使得每一位涉及的人感到惊奇。这种情况下,面临的挑战是使用适当的硬件,包括屏蔽电缆、低速光钎网络。也就是说,不太引人注目的电子环境通常可能通过使用错误检测及纠正这样的软件代码来处理。也就是说,重要的是需要记住,虽然错误检测及纠正代码可以减少错误几率,但是通常不能将错误几率降低到零,这可能形成另一种实时响应的障碍。
也存在一些其他情形,需要最低水平的能源。例如,通过系统电源线和通过设备的能源。系统与这些设备通信,这些设备是被监控或者控制的外部系统的一部分。
一些欲在高强度的震动、冲击环境下运行的系统,例如发动机控制系统。当我们从连续震动转向间歇性冲击,将会发现更多令人头疼的需求。例如,在我大学本科学习期间,遇到一台老旧的雅典娜弹道计算机,它被设计用于即使手榴弹在其附近引爆也能持续正常工作。最后一个例子,是飞机上的黑匣子,它必须在飞机发生意外之前、之中、之后都持续运行。
当然,在面对环境冲击和碰撞时,使硬件更健壮是有可能的。巧妙的机械减震装置可以减少震动和冲击的影响,多层屏蔽可以减少低能量的电磁辐射的影响,错误纠正代码可以减少高能量辐射的影响,不同的灌封、密封技术可以减少空气质量的影响,加热、制冷系统可以应付温度的影响。极端情况下,三模冗余可以减少系统部分失效导致的整体不正确几率。但是,所有这些方法都有一个共同点:虽然它们能减少系统失败的几率,但是不能将其减低为零。
尽管这些重要的环境约束通常是通过使用更健壮的硬件来处理,但是在接下来的两节中的工作负载及应用约束通常由软件来处理。
负载约束
和人一样的道理,通过使其过载,通常可以阻止实时系统满足其最后期限的要求。例如,如果系统被过于频繁的中断,他就没有足够的 CPU 带宽来处理它的实时应用。对于这种问题,一种使用硬件的解决方案是限制中断提交给系统的速率。可能的软件解决方案包括:当中断被频繁提交给系统时,在一段时间内禁止中断,将频繁产生中断的设备进行复位,甚至完全禁止中断,转而采用轮询。
由于排队的影响,过载也可能降低响应时间,因此对于实时系统来说,过度供应 CPU 贷款并非不正常,一个运行的系统应该有 80% 的空闲时间。这种方法也适用于存储和网络设备。某些情况下,应该讲独立的存储和网络硬件保留给高优先级实时应用所使用。当然,这些硬件大部分时间都处于空闲状态,这并非不正常。因为对于实时系统来说,响应时间比吞吐量更重要。
单谈,要想保持足够低的利用率,在整个设计和实现过程汇总都需要强大的专业知识。没有什么事情与之相似,一个小小的功能就不经意间将最后期限破坏掉。
应用约束
对于某些操作来说,比其他操作更易于提供其最后响应时间。例如,对于中断和唤醒操作来说,得到其响应时间规格是很常见的,而对于文件系统卸载操作来说,则很难得到其响应时间规格。其中一个原因是,非常难于阶段文件系统卸载操作锁需要完成的工作量,因为卸载操作需要将所有内存中的数据刷新到存储设备中。
这意味着,实时应用程序必须限定其操作,这些操作必须合理提供受限的延迟。不能提供合理延迟的操作,要么将其放到非实时部分中去,要么将其完全放弃。
也可能对应用的非实时部分进行约束。例如,非实时应用是否可以合法使用实时应用的 CPU?在应用个实时部分预期非常繁忙期间,是否允许非实时部分全速运行?最后,应用实时部分允许将非实时应用的吞吐量降低到多少?
现实世界的实时规格
正如前面章节所见,现实世界的实时规格需要包装环境约束,负载及应用本身的约束。此外,应用的实时部分允许使用的操作,必然受限于硬件及软件实现方面的约束。
对于每一个这样的操作,这些约束包括最大响应时间(也可能包含一个最小响应时间),以及满足响应时间的几率。100% 的几率表示相应的操作必须提供硬实时服务。
某些情况下,响应时间以及满足响应时间的几率,都十分依赖于操作参数。例如,在本地局域网中的网络操作很有可能在 100ms 内完成,这好于穿越大陆的广域网上的网络操作。更进一步来说,在铜制电缆和光纤网络上的网络操作,更有可能不需要耗时的重传操作就能完成,而相同的操作,在有损 WIFI 网络之上,则更有可能错误严格的最后期限。类似的可以预期,从固态硬盘 SSD 读取数据,将比从老式 USB 连接的旋转硬盘读取更快完成。
某些实时应用贯穿操作的不同阶段。例如,一个控制胶合板的实时系统,它从旋转的原木上剥离木材薄片。这样的系统必须:将原木装载到车床;将原木固定在车床上,以便将原木中最大的柱面暴露给刀片;开始旋转原木;持续的改变刀具位置,以将原木切割为木板;将残留下来的、太小而不能切割的原木移除;同时,等待下一根原木。5 个阶段的每一步,都有自身的最后期限和环境约束,例如,第 4 步的最后期限远比第 6 步严格,其最后期限是毫秒级而不是秒级。因此,希望低优先级任务在第 6 阶段运行,而不要在第 4 阶段运行。也就是说,应当小心选择硬件、驱动和软件装置,这些选择将被要求支持第 4 步更严格的要求。
每种阶段区别对待的方法,其关键优势是,延迟额度可以被细分,这样应用的不同部分可以被独立的开发,每一部分都有其自己的延迟额度。当然,与其他种类的额度相比,偶尔会存在一些冲突,即哪些组件应当获得多大比例的额度。并且,从另一个角度来说,与其他种类的额度相比,严格的验证工作是需要的,以确保正确聚焦与延迟,并且对于延迟方面的问题给出早期预警。成功的验证工作几乎总是包含一个好的测试集,这样的测试集对于学究来说并不总是感到满意,但是好在有助于完成相应的任务。事实上,截止 2015 年初,大多数现实世界的实时系统使用验收测试,而不是形式化证明。
也就是说,广泛使用测试条件来验证实时系统有一个确实存在的缺点,即实时软件仅仅在特定硬件上,使用特定的硬件和软件配置来进行验证。额外的硬件及配置需要额外的开销,也需要耗时的测试。也许形式验证领域将大大改进,足以改变这种状况,但是直到 2015 年初,形式验证还需要继续进行大的改进。
除了应用程序实时部分的延迟需求,也存在应用程序非实时部分的性能及扩展性需求。这些额外的需求反映出一个事实,最终的实时延迟通常都是通过降低扩展性和平均性能来实现的。
软件工程需求也是很重要的,尤其是对于大型应用程序来说,更是如此。这些大型应用程序必须被大型项目组锁开发和维护。这些工程需求往往偏重于增加模块化和故障的隔离性。
以上所述,仅仅是产品化实时系统中,最后期限及环境约束所需工作的一个大概说明。我们期望,它们能够清晰展示那些实时计算方面教科书式方法的不足。
谁需要实时计算
如果说,所有计算实际上都是实时计算,这可能会引起争议。举一个极端的例子,当在线购买生日礼物的时候,你可能在接受者生日之前礼物能够到达。甚至是前年之交的 Web 服务,也存在亚秒级的响应约束,这样的需求并没有随着时间的推移而缓解。虽然如此,专注于那些实时应用更好一点,这些实时应用的实时需求并不能由非实时系统及其应用所实现。当然,由于硬件成本的降低,以及带宽和内存的增加,实时和非实时之间的界限在持续变化,不过这样的变化并不是坏事。
实时计算用于工业控制应用,范围涵盖制造业到航空电子;科学应用,也许最引人注目的是用于大型天文望远镜上的自适应光学;军事应用,包含前面提到的航空电子;金融服务应用,其第一台挖掘出机会的计算机最有可能获得大多数最终利润。这 4 个领域以“产品探索”、“声明探索”、“死亡探索”、“金钱探索”为特征。
金融服务应用于其他三种应用之间的微秒差异在于其他的非物质特征,这意味着非计算机方面的延迟非常小。与之相对的是,其他三类应用的固有延迟使得实时响应的优势很小,甚至没有什么优势。所以金融服务应用,相对于其他实时信息处理应用来说,更面临着装备竞争,有最低延迟的应用通常能够获胜。虽然最终的延迟需求仍然可以由第 15.1.3.4 节中描述的内容来指定,但是这些需求的特殊性质,已经将金融和信息处理应用的需求变为“低延迟”,而不是实时。
不管我们到底如何称呼它,实时计算总是有实实在在的需求。
谁需要并行实时计算
还不太清楚谁真正需要并行实时计算,但是低成本多核系统的出现已经将并行实时计算推向了前沿。不幸的是,传统实时计算的数学基础均假设运行在单 CPU 系统中,很少有例外。例如,有一些现代平方计算硬件,其方式适合于实时计算周期,一些 Linux 内核黑客已经鼓励学术界进行转型,以利用其优势。
一种方法是,意识到如下事实,许多实时系统表现为生物神经系统,其相应范围包含实时反映和非实时策略与计划,如图 15.4 所示。硬实时反应运行在单 CPU 上,它从传感器读数据并控制动作。而应用的非实时策略与计划部分,则运行在余下的 CPU 上面。策略与计划活动可能包括静态分析、定期校准、用户接口、支撑链活动及其他准备活动。高计算负载准备活动的例子,请回想 15.1.3.4 节讨论的应用。当某个 CPU 正在进行剥离原木的高速实时计算时,其他 CPU 可以分析下一原木的长度及形状,以确定如何放置原木,以最大可能的获得更多数量的高品质模板。事实证明,很多应用都包含非实时及实时组件。因此这种方法通常能用于将传统实时分析与现代多核硬件相结合。
另一个不太有用的方法,是将所有其他硬件线程关闭,只保留其中一个硬件线程,这就回到了单处理器实时数学计算。不过,这种方法失去了潜在的成本和能源优势。也就是说,获得这些优势需要克服第 3 章所述的并行计算困难。而且,不但要处理一般情况,更要处理最坏的情况。
因此,实现并行实时系统可能是一个巨大的挑战。处理这些挑战的方法将在随后的章节给出。
实现并行实时系统
我们将着眼于两种类型的实时系统:事件驱动及轮询。事件驱动的实时系统有更多事件处理空闲状态,对实时事件的响应,是通过操作系统向上传递给应用的。可选的系统可以在后台运行非实时的工作负载,而不是使其处于空闲状态。轮询实时系统有一个特点,存在一个绑在 CPU 上运行的实时线程,该线程运行在一个紧凑循环中,在每一轮轮询中,线程轮询输入事件并更新输出。该循环通常完全运行在用户态,它读取并写入硬件寄存器,这些寄存器被映射到用户态应用程序的地址空间。可选的,某些应用将轮询循环放到内核中,例如,通过使用可加载内核磨矿将其放到内核中。
不管选择何种类型,用来实现实时系统的方法都依赖于最后期限。如图 15.5 所示。从图的顶部开始,如果你可以接受超过 1s 的响应时间,就可以使用脚本语言来实现实时应用。实际上,脚本语言通常是奇怪的用户,并不是我推荐一定要用这种方法。如果要求延迟大于几十毫秒,旧的 Linux 内核也可以使用,同样的,这也不是我推荐一定要用这种方法。特定的实时 Java 实现可以通过几毫秒的实时响应延迟,即使在垃圾回收器被使用时也是这样。如果仔细配置、调整并运行在实时友好的硬件中,Linux 2.6 及 3.x 内核能够提供几百微秒的实时延迟。如果小心避免垃圾回收,特定的 Java 实现可以提供低于 100ms 的实时延迟。(但是请注意,避免垃圾回收就意味着避免使用 Java 大型标准库,也就失去了 Java 的生成效率优势)。打上了 -rt 实时补丁的 Linux 内核可以提供低于 20ms 的延迟。没有内存转换的特定实时系统(RTOSes)可以提供低于 10ms 的延迟。典型的,要实现低于微秒的延迟,需要手写汇编代码,甚至需要特殊硬件。
当然,小心的配置及调节工作,需要针对所有调用路径。特别是需要考虑硬件或固件不能提供实时延迟的情况,这种情况下,想要弥补其消耗的时间,软件是无能为力的。并且,那些高性能的硬件有时会牺牲最坏情况下的表现,以获得吞吐量。实际上,在禁止中断的情况允许紧致循环,可以提供高质量随机数生成器的基础。而且,某些固件窃取时钟周期,以进行各种内置任务,在某些情况下,它们还会视图通过重新对受影响 CPU 的硬件时钟进行编程,来掩盖其踪迹。当然,在虚拟化环境中,窃取时钟周期是其期望的行为,不过人们仍然努力在虚拟化环境中实现实时响应。因此,对你的硬件和固件的实时能力进行评估,是至关重要的。存在一些组织,它们进行这种评估,包括开源自动开发实验室(OSADL)。
假设有合适的实时硬件和挂进,栈中更上一层就是操作系统,这将在下一节讨论。
实现并行实时操作系统
存在一些可用于实现实时系统的策略。其中一种方法是,将长剑非实时系统置于特定目的的实时操作系统之上,如图 15.6 所示。其中绿色的 “Linux 进程” 框表示非实时任务,这些进程运行在 Linux 内核中,而黄色的“RTOS 进程”框表示运行在 RTOS 之中的实时任务。
在 Linux 内核拥有实时能力之前,这是一种非常常见的方法,并且至今仍然在用。但是,这种方法要求应用被分割为不同的部分,其中一部分运行在 RTOS 之中,而另外的部分运行在 Linux 之中。虽然有可能使两种运行环境看起来类似,例如,通过将 RTOS 侧的 POSIX 系统调用转发给 Linux 侧的线程。这种方法还是存在一些粗糙的边界。
另外,RTOS 必须同时与硬件和内核进行交互,因此,当硬件和内核更改时,需要大量的维护工作。而且,每一个这样的 RTOS 通常都有其独有的系统调用接口和系统库集合,其生态系统和开发者都相互对立。事实上,正是这些问题,驱使将 Linux 和 RTOS 进行结合,因为这种方法允许访问 RTOS 的全实时能力,同时允许应用的非实时代码完全访问 Linux 丰富而充满活力的开源生态系统。
虽然,在 Linux 仅仅拥有最小实时能力的时候,将 Linux 内核与 RTOS 绑在一起,不失为明智而且有用的临时应对措施,这也将激励将实时能力添加到 Linux 内核中。实现这一目标的进展情况如图 15.7 所示。上面的进展展示了抢占禁止的 Linux 内核图。由于抢占被禁止的原因,它基本没有实时能力。中间的展示的一组图其中包含了抢占 Linux 主线内核,其实时能力的增加过程。最后,最下面的行展示了打上 -rt 补丁包的 Linux 内核,它拥有最大化的实时能力。来自于 -rt 补丁包的功能,已经被添加到主线分支,因此随着时间的推移,主线 Linux 内核的能力在不断增加。但是,最苛刻的实时应用仍然使用 -rt 补丁包。
如图 15.7 顶部所示的不可抢占内核以 CONFIG_PREEMPT=n 的配置进行构建,因此在 Linux 内核中的执行是不能被抢占的。这就意味着,内核的实时响应延迟由 Linux 内核中最长的代码路径所决定,这是在是有点长。不过,用户态的执行是可抢占的,因此在右上角所示的实时 Linux 进程,可以在任意时刻抢占左上角的,运行在用户态的非实时进程。
图 15.7 中部所示的可抢占内核,以 CONFIG_PREEMPT=n 的配置进行构建,这样大多数运行在 Linux 内核中的、进程级的代码可以被抢占。这当然极大改善了实时响应延迟,但是在 RCU 读端临界区、自旋锁临界区、中断处理、中断关闭代码段,以及抢占禁止代码段中,抢占仍然是禁止的。禁止抢占的部分,由图中间行中,最左边的红色框所示。可抢占 RCU 的出现,允许 RCU 读端临界区被抢占,如图中间部分所示。线程化中断处理函数的实现,允许设备中断处理被抢占,如图最右边所示。当然,在此期间,大量其他实时功能被添加,不过,在这种图中不容易将其表示出来。这将在 15.4.1.1 节讨论。
最后一个方法是简单将所有与实时任务无关的东西,都从实时任务中移除,将所有其他事务都从实时任务所需的 CPU 上面清除。在 3.10 Linux 内核中,这是通过 CONFIG_NO_HZ_FULL 配置参数来实现的。请注意,这种方法需要至少一个守护 CPU 执行后台处理,例如运行内核守护任务,这是非常重要的。当然,当在特定的、非守护 CPU 上面,如果仅仅只有一个可运行任务,那么该 CPU 上面的调度时钟中断被关闭,这移除了一个重要的干扰源和 OS 颠簸。除了少数例外情况,内核不会强制将其他非守护 CPU 下线,当在特定 CPU 上只有一个可运行任务时,这会简单的提供更好的性能。如果配置适当,可以郑重向你保证,CONFIG_NO_HZ_FULL 将提供近乎裸机系统的实时线程级性能。
当然,这有一些争议,这些方法到底是不是实时系统最好的方式。而且这些争议已经持续了相当长一段时间。一般来说,正如后面章节所讨论的那样,答案要视情况而定。15.4.1.1 节考虑事件驱动的实时系统,15.4.1.2 节考虑使用 CPU 绑定的轮序循环的实时系统。
事件驱动的实时支持
操作系统为事件确定的实时应用所提供的支持是相当广泛的。不过,本节只关注一部分内容,即时钟、线程化中断、优先级继承、可抢占 CPU、可抢占自旋锁。
很明显,定时器对于实时操作系统来说是极其重要的。毕竟,如果你不能指定某些事件在特定事件完成,又怎能在某个事件点得到其响应?即使在非实时系统中,也会产生大量定时器,因此必须高效处理它们。作为示例的用法,包括 TCP 连接重传定时器(它们几乎总是会在触发之前被中止),定时延迟(在 sleep(1) 中,它几乎不被中断),超时 poll 系统调用(它通常会在触发之前就被中止)。对于这些定时器,一个好的数据结构是优先级队列,对于这样的队列,其添加删除原语非常快速,并且与已经入队的定时器数量相比,其时间复杂度是 O(1)。
用于此目的的经典数据结构是日历队列,在 Linux 内核中被称为时钟轮。这个古老的数据结构也被大量用于离散时间模拟。其思想是时间时能度量的,例如,在 Linux 内核中,时间度量周期是调度时钟中断的周期。一个特定时间可以被表示为整型数,任何视图在一个非整型数时刻提交一个定时器,都将被取整到一个最接近的整数时间值。
一种简单的实现是分配一个一维数组,以时间的地界位进行索引。从原理上来说,这可以运转,但是在那些创建大量长周期超时定时器的实际系统中(例如为 TCP 会话而创建的 45 分钟保活超时定时器),这些定时器几乎总是被中止。长周期的超过定时器对于小数组来说会导致问题,这是因为有太多时间浪费在跳过那些还没有到期的定时器上。从另一个方面来说,一个大道足以优雅的容纳大量长周期定时器的数组,会浪费很多内存,尤其是处于性能和可扩展性考虑,每个 CPU 都需要这样的数组。
解决该冲突的一个常规办法是,以多级分层的方式提供多个数组。在最底层,每一个数组元素表示一个单位时间。在第二层,每个数组元素表示 N 个单位时间,这里的 N 是每个数组的元素个数。在第三层,每一个数组元素表示 N2 个单位时间,一次类推。这种方法允许不同的数组以不同的位进行索引,如图 15.9 所示,它表示一个不太实际的、小的 8 位时钟。在此图中,每一个数组有 16 个元素,因此时钟低 4 位(0xf)对低阶(最右边)数组进行索引,接下来 4 位(0x1)对上一级进行索引。这样,我们有两个数组,每一个数组有 16 个元素,共计 32 个元素。远小于单一数组所需要的 256 的元素。
这个方法对于基于流量的系统来说运行的非常好。每一个特定时间操作的时间复杂度是小于常数的 O(1),每个元素最多访问 m+1 次,其中 m 是层数。
不过,时钟轮对于实时系统来说并不好,有两个原因。第一个原因是:需要在定时器精度和定时器开销之间进行权衡:定时器处理仅仅每毫秒才发生一次,这在很多(但非全部)工作环境是可以接受的。但是这也意味着不能保证定时器低于 1ms 的精度;从另一个角度来说,每 10us 进行一次定时器处理,对于绝大部分(但非全部)环境来说,这提供了可以接受的定时精度,但是这种情况下,处理定时器是如此频繁,以至于不能有时间去做任何其他事情。
第二个原因是需要将定时器从上级级联移动到下级。再次参照 15.9,我们将看到,在上层数组(最左边)的元素 1x 必须向下移动到更低(最右边)数组中,这样才能在它们到期后被调用。不幸的是,可能有大量的超时定时器等待移动,尤其是有较多层数时。这种移动操作的效率,对于面向吞吐量的系统来说是没有问题的,但是在实时系统中,可能导致有问题的延迟。
当然,实时系统可以简单宣策一个不同的数据结构,例如某种形式的堆或者树,对于插入或删除这样的数据维护操作来说,这样会失去 O(1) 时间复杂度,而变成 O(log n)。对于特定目的的 RTOS 来说,这可能是一个不错的选择。对于像 Linux 这样的通用操作系统来说,其效率不高。Linux 这样的通用操作系统通常支持非常大量的定时器。
Linux 内核的 -rt 补丁所做的选择,是将两种定时器进行区分处理,一种是调度延后活动的定时器,一种是对类似于 TCP 报文丢失这样的低可能性粗无偶进行调度的定时器。其中一个关键点是:错误处理通常并不是对时间特别敏感,因此时钟轮的毫秒级精度就足够了。另一个关键点是:错误处理超定时器通常会在早期就被中止,这通常是发生在它们被级联移动之前。最后一点是:与执行事件的定时器相比,系统通常拥有更多的执行错误处理的超时定时器。对于定时事件来说,O(log n) 的数据结构能够提供可接受的性能。
简而言之,Linux 内核的 -rt 补丁将时钟轮用于超时错误处理,将树这样的数据结构用于定时器事件,为所需要的服务类型提供不同的定时器类型。
线程化中断用于处理那些显著降低实时延迟的事件源,即长时间运行的中断处理程序,如图 15.12 所示。这些延迟对那些在单次中断后,发送大量事件的设备来说尤其验证,这意味着中断处理程序将运行一个超长的时间周期以处理这些事情。更糟糕的是,在中断处理程序正在运行时,设备可能产生新的事件,这样的总段处理程序可能会无限期运行,因为无限期的降低实时响应。
处理这个问题的方法是,使用如图 15.13 所示的线程化中断。中断处理程序运行在可抢占 IRQ 线程上下文,它运行在可配置的优先级。设备中断处理程序仅仅运行一小段时间,其运行时间仅仅可以使 IRQ 线程知道新事件的产生。如图所示,线程化中断可以极大的提升实时延迟,部分原因是运行在 IRQ 线程上下文的中断处理程序可以被高优先级实时线程抢占。
但是天下没有免费的午餐,线程化中断有一些缺点。其中一个缺点是增加了中断延迟。中断处理程序并不会立即运行,其执行被延后到 IRQ 线程中。当然,除非设备在实时应用关键路径执行时产生中断,否则也不会存在问题。
另一个缺点是写得不好的高优先级实时代码可能会中断处理程序饿死,例如,会阻止网络代码运行,导致调试问题非常困难。因此,开发者在编写高优先级实时代码时必须非常小心。这被称为蜘蛛侠原则:能力越大、责任越大。
优先级继承用于处理优先级反转。优先级反转可能是这样产生的,在处理其他事情时,锁被可抢占中断处理程序获得。假定一个低优先级线程获得某个锁,但是他被一组中优先级线程所抢占,每个 CPU 都至少有一个这样的中优先级线程。如果一个中断产生,那么一个高优先级 IRQ 线程将抢占一个中优先级线程,但是直到它决定火红的被低优先级所获得的锁,才会发生优先级反转。不幸的是,低优先级线程直到它开始运行才能释放锁,而中优先级线程会阻止它这样做。这样一来,直到某个中优先级线程释放它的 CPU 之后,高优先级 IRQ 线程才能获得锁。简而言之,中优先级线程间接阻塞了高优先级 IRQ 线程,这是一种典型的优先级反转。
注意,这样的优先级饭庄在非线程化中断中不会发生,这是因为低优先级线程必须在持有锁的时候,禁止中断,这也阻止了中优先级线程抢占它。
在优先级继承方案中,视图获得锁的高优先级线程将其优先级传递给获得锁的低优先级线程,直到锁被释放。这组织了长时间的优先级反转。
当然,优先级继承有其限制。比如,如果能够设计你的应用,已完全避免优先级反转,将很有肯能获得稍微好一些的延迟。这不足为怪,因为优先级继承在最坏的情况下,增加了两次上下文切换。也就是说,优先级继承可以将无限期的延迟转换为有限增加的延迟,并且在许多应用中,优先级继承的软件工程优势可能超过其他延迟成本。
另一个限制是,它仅仅处理特定操作系统环境中,基于所的优先级反转。它所能处理的一种优先级反转情况是,一个高优先级线程等待网络 Socket 消息,而该消息被低优先级进程所写入,但是低优先级线程被一组绑定在 CPU 上的中优先级进程所抢占。
最后一个限制包含读写锁。假设我们有非常多的低优先级线程吗,也许有数千个,每个读线程持有一个特定的读写锁。如果所有这些线程都被中优先级线程抢占,而每 CPU 都有至少一个这样的中优先级线程。最终,假设一个高优先级线层被唤醒并试图获得相同读写锁的锁。我们要如何去大量提升持有这些读写锁的线程优先级,这本身更没有什么问题,但是在高优先级线程获得写锁之前,他可能要等待很长一段时间。
有不少针对这种读写锁优先级反转难题的解决方案。
- 在同一时刻,仅仅允许一个读写锁有一个读请求(这是被 Linux 内核的 -rt 补丁所采用的传统方法)。
- 在同一时刻,对于某个特定读写锁,仅仅允许 N 个读请求。其中 N 是 CPU 个数。
- 在同一时刻,对于某个特定读写锁,仅仅允许 N 个读请求。其中 N 是由开发者指定的某个数值。Linux 内核的 -rt 补丁将在某个事件采取这种方法的几率还是比较大的。
- 当读写锁被正在运行的低优先级线程获得读锁时,防止高优先级线层获得其写锁(这是优先级上限协议的一个变种)。
某些情况下,可以通过将读写锁转换为 RCU,来避免读写锁优先级反转。
有时,可抢占 RCU 可被用作读写锁的替代品。在它可以被使用的地方,它允许读者和写者并发运行,这防止了低优先级的读者对高优先级的写者加以任何类型的优先级反转。但是,要使其有用,能够抢占长时间允许的 RCU 读端临界区是有必要的。否则,长时间运行的 RCU 读端临界区将导致过长的实时延迟。
因此,可抢占 RCU 实现被添加到 Linux 内核中。通过在当前读端临界区中,跟踪所有可抢占任务的链表这种方式,该实现就不必分别跟踪每一个任务的状态。以下情况下,允许终止一个优雅周期:所有 CPU 都已经完成所有读端临界区,这些临界区在当前优雅周期之前已经有效;在这些已经存在的临界区运行期间,被抢占的所有任务都已经从链表移除。这种实现的简单版本如 15.15 所示。__rcu_read_lock
函数位于第 1~5 行,而 __rcu_read_unlock
函数位于第 7~22 行。
__rcu_read_lock
函数第 3 行递增一个每任务计数,该计数是嵌套调用 __rcu_read_lock
的计数,第 4 行放置编译器将 RCU 读端临界区后面的代码与 __rcu_read_lock
之前的代码之间进行乱序。
__rcu_read_unlock
函数第 11 行检查嵌套计数是否为 1,换句话说,检查当前是否为 __rcu_read_unlock
嵌套调用的最外一层。如果不是,第 12 行递减该计数,并将控制流程返回到调用者。否则,这是 __rcu_read_unlock
的最外层,这需要通过第 14~20 行对终止临界区进行处理。
第 14 行防止编译器将临界区中的代码与构成 rcu_read_unlock 函数的代码进行乱序。第 15 行设置嵌套计数为一个大的负数,以防止与包含在中断处理程序中的读端临界区产生破坏性竞争。第 16 行防止编译器将这一行的赋值与第 17 行对特殊处理的检查进行乱序。如果第 17 行确定需要进行特殊处理,就在第 18 行调用 rcu_read_unlock_special 进行特殊处理。
有几种情况需要进行特殊处理,但是我们将关注其中一种情况,即当 RCU 读端临界区被抢占时的处理。这种情况下,任务必须将自己从链表中移除,当它第一次在 RCU 读端临界区中被抢占时,它被添加到这个链表中。不过,请注意这些链表被锁保护很重要。这意味着 rcu_read_unlock 不再是有锁的。不过,最高优先级的线程不会被抢占,因此,对那些最高优先级线程来说,rcu_read_unlock 将不会视图去获取任何锁。另外,如果小心实现,锁可以被用来同步实时软件。
无论是否需要特殊处理,第 19 行防止编译器将第 17 行的检查与第 20 行进行乱序,第 20 行将嵌套计数置 0。
在大量读的数据结构中,对于大量读者的情况下,这个可抢占 RCU 实现能到达实时响应,而不会有优先级提升方法所固有的延迟。
由于在 Linux 内核中,持续周期长的基于自旋锁的临界区的原因,可抢占自旋锁是 -rt 补丁集的重要组成部分。这个功能仍然没有合入主线,虽然从概念上来说,用睡眠锁代替自旋锁是一个简单的方案,但是已经证实这是有争议的。不过,对于那些想要实现低于 10us 延迟的实时任务来说,它是非常有必要的。
当然了,有其他不少数量的 Linux 内核组件,他们对于实现显示世界的延迟非常重要,例如最近的最终期限调度策略。不过,本节中的列表,已经可以让你对 -rt 补丁集所增加的 Linux 内核功能,找到好的感觉。
轮询实时支持
乍看之下,使用轮询可能会避免所有可能的操作系统的干扰问题。毕竟,如果一个特定的 CPU 从不进入内核,内核就完全不在我们的视线之内。要将内核排除在外的传统方法,最简单的是不使用内核,许多实时应用确实是运行在裸机之上,特别是那些运行在 8 位微控制器上的应用程序。
人们可能希望,在现代操作系统上,简单通过在特定 CPU 上运行一个 CPU 绑定的用户态线程,避免所有干扰,以获得裸机应用的性能。虽然事实上更复杂一些,但是这已经能够实现了,这是通过 NO_HZ_FULL 实现的。该实现由 Frederic Weisbcher 引入,并已经被接收进 Linux 内核 3.10 版本。不过,需要小心对这种环境进行适当的设置,因为对一些 OS 抖动来源进行控制是必要的。随后的讨论包含对不同 OS 抖动源的控制,包括设备中断、内核线程和守护线程序、调度器实时限制、定时器、非实时设备驱动、内核中的全局同步、调度时钟中断、页面异常,最后,还包括非实时硬件及固件。
中断是大量 OS 抖动源中很突出的一种。不幸的是,大多数情况下,中断是绝对需要的,以实现系统与外部世界的通信。解决 OS 抖动与外部世界通信之间的冲突,其中一个方法是保留少量守护 CPU,并强制将所有中断移动到这些 CPU 中。Linux 源码树中的文件 Documentation/IRQ-affinity.txt 描述了如何将设备中断绑定到特定 CPU、直到 2015 年初,解决该问题的方法如下所示:
echo 0f > /proc/irq/44/smp_affinity
该命令将第 44 号中断限制到 CPU 0~3。请注意,需要对调度时钟中断进行特殊处理,这将在随后章节进行讨论。
第二个 OS 抖动源是来自于内核线程和守护线程。个别的内核线程,例如 RCU 优雅周期内核线程,可以通过使用 taskset 命令、sched_setaffinity 系统调用或者 cgroups,来将其强制绑定到任意目标 CPU。
每 CPU 线程通常更具有挑战性,有时它限制了硬件配置及负载均衡布局。要防止来自于这些内核线程的 OS 干扰,要么不将特定类型的硬件应用到实时系统中,其所有中断和 IO 初始化均运行在守护 CPU 中,这种情况下,特定内核 Kconfig 或者启动参数被选择,从而将其事务从工作 CPU 中移除;要么工作 CPU 干脆不受内核管理。针对内核线程的建议可以在 Linux 内核源码 Documentation 目录 kernek-per-CPU-kthreads.txt 中找到。
在 Linux 内核中,运行在实时优先级的 CPU 绑定线程受到的第三个 OS 抖动是调度器本身。这是一个故意为之的调试空能,设计用于确保重要的非实时任务每秒至少分配到 30ms 的 CPU 时间,甚至是在你的实时应用存在死循环 BUG 时也是如此。不过,当你正在运行一个轮询实时应用时,需要禁止这个调度功能。可以用如下命令完成此项工作。
echo -1 > /proc/sys/kernel/sched_rt_runtime_us
当然,你必须以 Root 身份运行以执行以上命令,并且需要小心考虑蜘蛛侠原理。一种将风险最小化的方法,是将中断和内核线程、守护线程从所有运行 CPU 绑定线程的 CPU 中卸载,正如前面几段中所述那样。另外,应当认真阅读 Documentation/scheduler 目录中的材料。sched-rt-group.txt 中的材料尤为重要,当你正在使用 cgroups 实时功能时更是如此,这个功能通过 CONFIG_RT_GROUP_SCHED Kconfig 参数打开,这种情况下,你也应当阅读 Documentation/cgroups 目录下的材料。
第四个 OS 抖动来自于定时器。绝大多数情况下,将某个 CPU 配置于内核之外,将防止定时器被调度到该 CPU 上。一个重要的例外是再生定时器,即一个特定定时器处理函数触发同样的定时器在随后某个事件内再次发生。如果由于某种原因,这样的定时器在某个 CPU 上已经启动,该定时器被在该 CPU 上持续周期性运行,反复造成 OS 抖动。一个粗暴但是有效的移除再生定时器的方法,是使用 CPU 热插拔将所有运行 CPU 绑定实时应用线程的 CPU 卸载,并重新将这些 CPU 上线,然后启动你的实时应用。
第五个 OS 抖动来自于驱动设备,这些驱动不是用于实时用途。举一个老的典型例子,在 2005 年,VGA 驱动会在禁止中断的情况下,通过将帧缓冲置 0,以清除屏幕,这将导致数十毫秒的 OS 抖动。一种避免设备驱动引入 OS 抖动的方法,是小心选择那些已经在实时系统中大量使用的设备,由于已被大量使用,其实时故障已经被修复。另一个方法是将设备中断和使用该设备的代码限制到特定守护 CPU 中。第三个方法是测试设备支持实时负载的能力,并修复其实时 BUG。
第六个 OS 抖动源来自于一些内核全系统同步算法,也许最引人注目的是全局 TLB 刷新算法。这可以通过避免内存 unmap 操作来避免,特别是要避免在内核中的 unmap 操作。直到 2015 年年初,避免内核 unmap 操作的方法是避免卸载内核模块。
第七个 OS 抖动源来自于调度时钟中断及 RCU 回调。这些可以通过打开 NO_HZ_FULL Kconfig 参数来构建内核,然后 nohz_full= 参数启动内核来加以避免,该参数指定运行实时线程的工作 CPU 列表。例如,nohz_full=2-7 将保留 CPU 2~7 作为工作 CPU,余下 CPU 0~1 作为守护 CPU。只要在每一个工作 CPU 上,没有超过一个可运行任务,那么工作 CPU 将不会产生调度时钟中断。并且每个工作 CPU 的 RCU 回调将在守护 CPU 上被调用。由于其上仅仅只有一个可运行任务,因此那些抑制了调度时钟的 CPU 被称为处于自适应节拍模式。
作为 nohz_full= 启动参数的另一种可选方法,你可以用 NO_HZ_FULL_ALL 来构建内核,它将保留 CPU0 作为守护 CPU,其他所有 CPU 作为工作 CPU。无论哪种方式,重要的是确保保留足够多的守护 CPU,以处理其他所负担的系统其他部分的守护负载,这需要小心的进行评估和调整。
当然,天下没有免费的午餐,NO_HZ_FULL 也不例外。正如前面所提示的那样,NO_HZ_FULL 使得内核/用户之间的切换消耗更大,这是由于需要增加进程统计,也需要将切换事件通知给内核子系统(如 RCU)。开启 POSIX CPU 定时器的进程,其上的 CPU 也被阻止进入“自适应节拍模式”。额外的限制、权衡、配置建议可以在 Documentation/timers/NO_HZ.txt 中找到。
第八个 OS 抖动源是页面异常。由于绝大部分 Linux 实现使用 MMU 进行内存保护,运行在这些系统中的实时应用需要遵从页面异常的影响。使用 mlock 和 mlockall 系统调用来将应用页面锁进内存,以避免主要的页面异常。当然,蜘蛛侠原理仍然适用,因为锁住太多内存可能会阻止其他工作顺利完成。
很不幸,第九个 OS 抖动源是硬件和固件。因此使用那些涉及用于实时用途的系统是重要的。OSADL 运行产期的系统测试,参考其网站(http://osadl.org)定会有所收获。
1 cd /sys/kernel/debug/tracing
2 echo 1 > max_graph_depth
3 echo function_graph > current_tracer
4 # run workload
5 cat per_cpu/cpuN/trace
不幸的是,OS 抖动源列表觉不完整,因为它会随着每个新版本的内核而变化。这使得能够跟踪额外的 OS 抖动源是有必要的。加入 CPU N 运行一个 CPU 绑定的用户态线程。上面的命令片段将给出所有该 CPU 进入内核的时间列表。当然,第 5 行的 N 必须被替换为所要求的 CPU 的编号,第 2 行中 1 可以增加,以显式内核中函数调用的级别。跟踪结果有助于跟踪 OS 抖动源。
正如你所见到那样,在像 Linux 这样的通用 OS 上,运行 CPU 绑定实时线程来获得裸机性能,需要对细节进行耐心细致的关注。自动化将是有用的,某些自动化也得到了应用,但是鉴于其用户相对较少,预期其出现将相对缓慢。不过,在通用操作系统上获得几乎裸机系统的能力,将有望简化某些类型的实时系统建设。
实现并行实时应用
开发实时应用是一个宽泛的话题,本节仅仅涉及某些方面。
实时组件
在所有工程领域,健壮的组件集对于生产率和可靠性来说是比不可少的。本节不是完整的实时软件组件分类——这样的分类需要一整本书,这是一个可用组件类型的简要概述。
查看实时软件组件的一个很自然的地方,是实现无等待同步的算法,实际上,无锁算法对于实时计算也非常重要。不过,无等待同步仅仅保证在有限时间内推进处理过程,并且实时计算需要算法更严格的保证在有限时间内将处理过程向前推进。毕竟,一个世纪也是有限的时间,但是在你的最终期限是以毫秒计算时,它将无意义。
不过,有一些重要的无等待算法,以提供限期响应时间。包含原子测试和设置、原子交换、原子读加、基于唤醒数组的单生成者/单消费者 FIFO 队列,以及不少每线程分区算法。另外,最近的研究已经证实,在随机公平调度及不考虑错误终止故障的情况下,无锁算法确保提供相同的延迟。这意味着,无锁栈及队列将适用于实时用途。
在实践中,锁通常用于实时应用,尽管理论上讲并不完全如此。不过,在严格的约束中,基于所的算法也存在有限延迟。这些约束包括以下几点:
- 公平调度器。在固定优先级调度器的通常情况下,有限延迟通常提供给最高有限级的线程。
- 充足的带宽以支持负载。支持这个约束的一个实现原则也许是“正常运行,在所有 CPU 上至少存在 50% 的空闲时间”,或者更正式的说“提供的负载足够低,以允许工作负载在所有时刻都能够被调度”。
- 没有错误终止故障。
- 获得、切换、释放延迟均有限期的 FIFO 锁原语。同样,通常情况下的锁原语是带有优先级的 FIFO,有限延迟仅仅是提供给最高优先级的线程。
- 某些防止无限优先级反转的方法。本章前面部分提到的优先级上限及优先级继承就足够了。
- 有限的嵌套锁获取。我们可以有无限数量的锁,但是在同一个时刻,只要一个特定线程绝不获得超过一定数量的锁就行了。
- 有限数量的线程。与前面的约束相结合,这个约束意味着等待特定锁的线程数量是有限的。
- 消耗在任何特定临界区上的有限时间。对于有限的等待特定锁的线程数量,以及有限的临界区长度,其等待时间也是有限度的。
这个结果打开用于实时软件的算法及数据结构的宝藏,它也验证尝试的实时实践。
当然,仔细的、简单的应用设计也是十分重要的。世上最好的实时组件,也不能弥补那些缺乏深思熟虑的设计。对于并行实时应用来说,同步开销明显是设计的关键组件。
轮询应用
许多实时应用由绑定 CPU 的单个循环构成,该循环读取传感器数据,计算控制规则,并输出控制。如果提供传感器数据及控制输出的硬件寄存器被映射到应用地址空间,那么该循环就完全不可以使用系统调用。但是请当心蜘蛛侠原则,更多的权利伴随着更多的责任,在这种情况下,其责任是指避免通过对硬件寄存器的不恰当引用而破坏硬件。
这种方式通常运行在裸机上面,这没有操作系统带来的优势(或者说也没有其带来的干扰)。不过,需要增加硬件能力及增加自动化水平来提升软件功能,如用户界面、日志及报告,所有这些都可以受益于操作系统。
在裸机上运行,同时仍然想要获得通用擦做系统的所有特征和功能,其中一个方法是使用 Linux 内核的 NO_HZ_FULL 功能,该功能在 15.4.1.2 节中描述。该支持首先在 Linux 内核 3.10 版本中可用。
流应用程序
一种流行的大数据实时应用获得多种输入源的输入,内部处理它,并输出警告和摘要。这些流应用通常是高度并发的,并发的处理不同信息源。
实现流应用程序的一种方法是使用循环数组缓冲 FIFO,来联结不同的处理步骤。每一个这样的 FIFO,仅仅有一个线程向其放入数据,并有一个(大概是不同的线程)线程从其中取出数据。扇入扇出点使用线程而不是数据结构,因此,如果需要合并几个 FIFO 的输出,一个独立的线程将从一个 FIFO 中输入数据,并将它输出到另外一个 FIFO,该线程是唯一的处理者。类似的,如果一个特定 FIFO 的输出需要被分拆,一个单独的线程将从这个 FIFO 进行获取输入,并且将其输出到多个 FIFO 中。
该规则看起来严格,但是它允许在线程间的通信有最小的同步开销,当试图满足严格的延迟约束时,最下的同步开销是重要的。当每一步中的处理量小,因而同步开销与数据处理相比,同步负载所占比例更大时,这显得尤其重要。
不同的线程可能是 CPU 绑定的,这时,15.4.2.2 节中的建议是适用的。另一方面,如果不同线程阻塞等待其输入 FIFO 的数据,那么 15.4.2.4 节中的建议是适用的。
事件驱动应用
对于事件驱动应用,我们将展示一个奇特的例子,将燃料注入中型工业发动机。在正常的操作条件下,该发动机要求一个特定的时间点,以一度的间隔将燃料注入到顶端正中。我们假设 1500-RPM 的旋转速度,这样就是每秒 25 转,或者大约每秒 9000 个旋转刻度,转换为没刻度即为 111ms。因此,我们需要在大约 100ms 内调度燃料注入。
假设事件等待被用于初始化燃料注入,但是如果你正在构造一个发动机,我希望你提供一个旋转传感器。我们需要测试时间等待功能,可能使用图 15.17 所示的测试程序。不幸的是,如果运行这个程序,我们将遇到不可接受的时钟抖动,即使在 -rt 内核中也是如此。
一个问题是,POSIX CLOCK_REALTIME 并不是为了实时应用,很奇怪吧。相反的,它所表示的“实时”是与进程或者线程所消耗的 CPU 总时间相对。对于实时用途,应当使用 CLOCK_MONOTOMIC。但是,即使做了这样的改变,结果仍然是不可接受的。
另一个问题是,线程必须通过使用 sched_setscheduler 系统调用来提供实时优先级。但是即使有了这个改变还是不够的,因为我们仍然能遇到缺页异常。我们也必须使用 mlockall 系统调用来锁住应用的内存,以防止缺页异常。应用的所有这些改变,结果可能最终是可接受的。
在其他情况下,可能需要进一步调整。可能需要将时间关键的线程绑定到他们自己的 CPU 中,并且可能需要将中断从这些 CPU 中移除。也需要谨慎选择硬件和驱动,并且可能需要谨慎选择内核配置。
从这个例子可以看出,实时计算真不是省油的灯。
RCU 的角色
假设你正在编写一个并行实时应用,该应用需要访问可能会随着温度、湿度和气压的变化而变化的数据。对该应用的实时响应约束相当严格,因此不允许自旋或阻塞,这样就排除掉了锁;也不允许使用重试循环,这样一来排除掉了顺序锁和危险指针。幸运的是,温度和压力通常是受控制的,因此默认的编码数据集通常是足够的。
但是,温度、湿度和压力偶尔会偏离默认值较远,这时有必要提供替换默认值的数据。由于温度、湿度和压力是逐渐变化的,虽然需要在几分钟内完成,但提供更新的值并非紧急事项。该应用将使用一个名为 cur_cal 的全局指针,该指针通常引用 default_cal,这是一个静态分配并初始化的结构,并包含命名为 a/b/c 的默认校准值。否则,cur_dal 将指向提供当前校准值的动态分配结构。
上面的代码清单展示了如何使用 RCU 来解决这个问题。查找逻辑是确定的,如第 9~15 行中的 calc_control,并能符合实时要求。更新逻辑则比较复杂,如第 17~35 行的 update_cal 所示。
该示例展示了 RCU 如何为实时应用提供确定的读端数据结构访问。
实时 vs. 快速
在实时与快速计算之间进行选择可能是一件困难的事情。因为实时系统通常造成非实时系统的吞吐量损失,在不需要使用的时候,使用实时计算会带来问题。另一方面,在需要实时计算时,使用错误也会带来问题。
基于经验法则,使用以下 4 个问题来助你选择:
- 平均的长期吞吐量是唯一目标吗?
- 是否允许重负载降低响应时间?
- 是否有高内存压力,排除使用 mlockall 系统调用?
- 应用的基本工作项是否需要超过 100ms 才能完成?
如果每一个问题的答案都是“是”,则应当选择“快速”而不是“实时”,否则,则可以选择“实时”。请明智选择,并且如果你选择了“实时”,请确保硬件、固件、操作系统都恩能够胜任。
3.3.15 - CH15-高级同步-内存序
因果关系与顺序是非常直观的,黑客往往能比一般人更好的掌握这些概念。在编写、分析和调试使用标准互斥机制(尤其是锁)的顺序、并行代码时,这些直觉可以成为非常强大的工具。不幸的是,当面对那些并未使用标准机制的代码时这种直觉将彻底失败。一个重要的例子当然是一些代码使用了这些标准机制,但是另外一些使用了更弱的同步机制。事实上,一些人认为更弱也算是一个有点。本章将帮助你了解有效实现同步原语和性能敏感代码的内存排序。
15.1 排序:Why & How?
内存排序的一个动机可以在代码清单 15.1 中的石蕊测试中看到,咋一看似乎 exists 子句可能永远不会触发。毕竟,如果 exists 子句中的 0:r2=0
,我们可以期望“线程 P0 中由 x1 加载到 r2”发生在“线程 P1 对 x1 的保存”,进一步期望“P1 中由 x0 加载到 r2”必须发生在“P0 对 x0 的保存”,因此 1:r2=2
,从而不会触发 exists 子句。这个例子是对称的,因此类似的原因会让我们认为 1:r2=0
保证 0:r2=2
。不幸的是,内存屏障的缺失使得这些期望破灭。即使是在相对更强排序的系统(x86)上,CPU 也有权对 P0 和 P1 中的语句进行重排序。
QQ 15.1:编译器同样能够对代码清单 15.1 中 P0 和 P1 的内存访问进行重排序,对吗?
通常来说,编译器优化比 CPU 可以执行更加广泛和深刻的重排序。但是,在这种情况下,READ_ONCE 和 WRITE_ONCE 中的易失性访问会阻止编译器重排序。而且还要做许多其他事情,因此本节中的示例将大量使用 READ_ONCE 和 WRITE_ONCE。有关 READ_ONCE 和 WRITE_ONCE 的更多细节可以参考 15.3 节。
这种重排序趋向可以使用诸如 Litmus7 之类的工具进行验证,该工具发现在我的笔记本上进行的 1 亿次实验中有 314 次反直觉排序。奇怪的是,两个加载返回值为 2 的完全合法的结果发生的概率反而较低,只有 167 次。这里的教训很清楚:增加反干涉性并不一定意味着能够降低概率!
15.1.1 为什么硬件乱序?
但是为什么第一处会发生内存乱序呢?是 CPU 自身不能追踪顺序吗?
人们期望计算机能够对事物保持追踪,而且很多人还坚持要求它们能够快速的追踪事物。然而如第 3 章中看到的那样,主存没有办法跟上 CPU 的速度,因为在从内存中读取单个变量的时间内,CPU 可以执行数百个指令。因此 CPU 引入了越来越大的缓存,如图 3.9 所示,这意味着虽然一个 CPU 对指定变量的第一次加载会导致昂贵的缓存未命中,如第 3.1.5 一节所述,随后的重复加载会因为初始化未命中会将该变量记载到该 CPU 的缓存中,因此 CPU 后续会非常快速的执行与该变量相关的操作。
然而,还需要适应从多个 CPU 到一组共享变量的频繁并发存储。在缓存一致性系统中,如果缓存包含指定变量的多个副本,则该变量的所有副本必须拥有相同的值。这对于并发加载非常有效,但对于并发存储不太友好:每个存储必须对旧值的所有副本(这是另一个缓存未命中)执行一些操作,考虑到光的有限速度和物质的原子性质,这要比性急的软件黑客预期的速度慢。
因此,CPU 配备了存储缓冲区,如图 15.1 所示。当一个 CPU 需要保存的变量 不在缓存时,新值将会被放置到 CPU 的存储缓冲区。然后 CPU 可以立即继续后续的执行,而不必等待存储对滞留在其他 CPU 缓存中的所有旧值执行操作。
尽管存储缓冲区能够大大提升性能,但可能导致指令和内存引用无序执行,这反过来会导致严重的混淆。需要特别之处的是,这些存储缓冲区会导致内存错误排序,如清单 15.1 中 store-buffering 石蕊测试所示。
表 15.1 展示了内存乱序是如何发生的。第一行是初始状态,这时 CPU0 的缓存中持有 x1、CPU1 的缓存中持有 x0,两个变量的值均为 0。第二行展示了基于 CPU 存储的状态变化(即代码清单 15.1 中的第 9~18 行)。因为两个 CPU 在缓存中都没有要保存的变量,因此两个 CPU 都将他们的存储记录在自己的存储缓冲区中。
第 3 行展示了两个加载(即代码清单 15.1 中的第 1~19 行)。因为变量已被 CPU 的各自缓存加载,因此加载操作会立即返回已缓存的值,这里都是 0。
但是 CPU 还没有完成:稍后会清空自己的存储缓冲区。因为缓存在名为缓存行的较大的块中移动数据,并且因为每个缓存行可以保存多个变量,因此每个 CPU 必须将缓存行放入自己的缓存中,以便它可以更新与其存储器中的变量对应的缓存行部分,但不会影响缓存行的任何其他部分。每个 CPU 还必须确保缓存行不存在于任何其他 CPU 的缓存中,因此使用了“读无效”操作。如第 4 行所示,在两个“读无效”操作完成之后,两个 CPU 都交换了缓存行,因此 CPU0 的缓存现在包含 x0,而 CPU1 的缓存现在包含 x1。一旦两个变量出现在他们的新家中,每个 CPU 都可以将其存储缓冲区放入对应的缓存行中,使每个变量的最终值如第 5 行所示。
QQ 15.3: 但是这些值不需要由缓存被刷新到主存吗?
令人惊讶的是并不必要!在某些系统上,如果两个变量被大量使用,它们可能会在 CPU 之间来回反弹,而不会落到主存中。
总之,需要存储缓冲区来支持 CPU 高效的处理存储指令,但是可能会导致反直觉的存储器乱序。
但是如果你的算法真的需要有序的内存引用,又该怎么办呢?比如,你正在使用一对标志位来与驱动程序通信,其中一个标志表示驱动程序是否正在运行,另一个标志表示是否存在针对该驱动程序的待处理请求。请求者需要设置请求挂起的标志,然后检查驱动程序运行状态的标志,如果为 false 则唤醒驱动程序。一旦驱动程序开始为所有待处理的请求提供服务,就需要清除待处理请求的标志,然后检查请求挂起标志以查看是否需要重新启动。除非有某种办法可以确保硬件按顺序处理存储和加载,否则这种看起来非常合理的方式将无法工作。这也就是下一节的主题。
15.1.2 如何强制排序
事实上,存在编译器指令和标准同步原语(比如锁和 RCU),他们负责通过使用内存屏障(比如 Linux 内核中的 smp_mb)来维护排序错觉。这些内存屏障可以是显式指令,比如在 ARM、POWER、Itanium、Alpha 系统上;或者由其他指令暗含,通常在 X86 系统中既是如此。因为这些标准同步原语维护了排序错觉,从而让你能够以最小的阻力使用这些原语,这样你就可以不用阅读本章的内容了。
然而,如果你需要实现自己的同步原语,或者仅仅是想了解内存排序是如何工作的,那么请继续阅读!第一站是代码清单 15.2,这里将 Linux 内核的完整内存屏障 smp_mb 分别放置到 P0 和 P1 的存储和加载操作之间,其他部分则与代码清单 15.1 保持相同。这些屏障阻止了我的 X86 电脑上 1 亿次实现的反直觉结果的出现。有趣的是,由于这些屏障增加的开销导致结果合法,其中两个负载的返回值都超过 80 万次,而代码清单 15.1 中的无屏障代码仅有 167 次。
这些屏障对排序产生了深远的影响,如表 15.2 所示。虽然前两行与表 15.1 相同,同时第 3 行的 smp_mb 也不会改变状态,但是它们能够确保存储在加载之前完成,这会避免代码清单 15.1 中出现的反直觉结果。请注意,变量 x0 和 x1 在第二行仍然具有多个值,但是正如前面承诺的那样,smp_mb 最终会将一切理顺。
虽然像 smp_mb 这样的完整内存屏障拥有非常强的排序保证,其强度也伴随着极高的代价。很多情况下都可以使用更弱的排序保证来处理,这些保证使用更廉价的内存排序指令,或者在某些情况下根本不适用内存排序指令。表 15.3 提供了 Linux 内核的排序原语及对应保证的清单。每行对应一个原语或原语的类别,以及是否能够提供排序保证,同时又分为 “Prior Ordered Operation” 与 “Subsequent Ordered Operation” 两列。“Y” 表示无条件提供排序保证,否则表示排序保证是部分或有条件提供。“空”则表示不提供任何排序。
需要注意的是这只是一个速查表,因此并不能替代对内存排序的良好理解。
15.1.3 经验法则
QQ:但是我怎么知道一个给定的项目可以在这些经验法则的范围内进行设计和编码呢?
本节的内容将回答该问题。
- 一个给定线程将按顺序看到自身的访问。
- 排序具有条件性的 if-then 语义。
- 排序操作必须是成对的。
- 排序操作几乎不会提供加速效果。
- 排序操作并非魔法。
15.2 技巧与陷阱
现在你只知道硬件可以对内存访问进行重排序,而你也可以阻止这一切的发生,下一步则是让你承认自己的自觉有问题。
首先,让我们快速了解一个变量在一个时间点可能有多少值。
15.2.1 变量带有多个值
将一个变量视为在一个定义良好的全局顺序中采用一个定义良好的值序列是很自然的。不幸的是,接下来将对这个令人欣慰的事情说再见。
考虑代码清单 15.3 中的片段。该代码又多个 CPU 并行执行。第 1 行将共享变量设置为当前 CPU 的 ID,第 2 行从 gettb 函数初始化多个变量,该函数提供在所有 CPU 之间同步的细粒度硬件“基于时间的”计数器的值。第 3~8 行的循环记录了变量保留该 CPU 分配给他的值的时间长度。当然,其中一个 CPU 将赢,因此如果不是第 6~7 行的检查,将永远不会退出循环。
退出循环后,firsttb 将保留分配后不久的时间戳,lasttb 将保持在指定值的共享变量的最后一次采样之前所采用的时间戳,或者如果共享变量已更改则保持等于在进入循环之前的 firsttb 的值。这样我们可以子啊 532ns 的时间段内绘制每个 CPU 对 state.variable 值的视图,如图 15.4 所示。
每个横轴表示指定 CPU 随时间的观察结果,左侧的灰色区域表示对应 CPU 的第一次测量之前的时间。在前 5ns 期间,只有 CPU3 对变量的值有判断。在接下来的 10ns 期间,CPU2 和 CPU3 对变量的值不一致,但此后一致认为该值为 2,这实际是最终商定的值。但是 CPU1 认为该值为 1 的持续时间长达 300ns,而 CPU4 认为该值为 4 的持续时间长达 500ns。
15.2.2 内存引用重排序
上一节表明,即使是相对强有序的系统(x86)也可能在后续的加载中重排序之前的存储,至少在存储和加载不同的变量是会发生这样的状况。本节以此结果为基础,查看加载和存储的其他组合。
15.2.2.1 加载后跟加载
代码清单 15.4 展示了经典的消息传递石蕊测试,x0 表示消息,x1 作为一个标志来表示消息是否可用。在测试中,smp_mb 强制 P0 的存储排序,但是未对加载指定排序。相对强有序的体系结构(如 x86)会强制执行排序。但是弱有序的体系结构通常不会(如 AMP+11)。因此。列表第 25 行的 exists 子句可以被触发。
从不同位置排序加载的一个基本原理是,这样做允许在较早的加载错过缓存时继续执行,但是后续加载的值已经存在。
因此,依赖于有序加载的可移植代码必须添加显式排序,比如在清单 15.5 的第 20 行添加的 smp_mb,这会组织触发 exists 子句。
15.2.2.2 加载后跟存储
代码清单 15.6 展示了经典的“加载缓冲(load-buffering)”石蕊测试。尽管相对强排序的系统(如 x86 或 IBM Mainframe)不会使用后续的存储来重排序先前的加载,但是较弱排序的系统则允许这种排序(如 AMP+11)。因此第 22 行的 exist 子句确实可能被触发。
虽然实际硬件很少展示这种重排序,但可能需要这么做的一种原因是当加载错误缓存时,存储缓冲区几乎已满,且后续存储的缓存行已准备就绪。因此,可移植代码必须强制执行任何必要的排序。比如清单 15.7 所示。smp_store_release 和 smp_load_acquire 保证第 22 行的子句永远不会触发。
15.2.2.3 存储后跟存储
代码清单 15.8 再次展示了经典的消息传递石蕊测试,其中使用 smp_mb 为 P1 的加载提供排序,但是并未给 P0 的存储提供任何排序。再次,相对强的系统架构会执行强制排序,而相对弱的系统架构则并不一定会这么做,这意味着 exists 子句可能会触发。这种重排序可能有益的一种场景是:当存储缓冲区已满,另一个存储区已准备好执行,但最旧的缓存行尚不可用。在这种情况下,允许存储无需完成将允许继续执行。因此,可移植代码必须显式的对存储进行排序,如代码清单 15.5 所示,从而防止 exists 子句的触发。
15.2.3 地址依赖
当加载指令的返回值被用于计算稍后的内存引用所要使用的地址时,产生地址依赖。
代码清单 15.9 展示了消息传递模式的一种链式变型。头指针是 x1,它最初引用 int 变量 y(第 5 行),后者又被初始化为值 1(第 4 行)。P0 将头指针 x1 更新为引用 x0(第 12 行),但仅将其初始化为 2(第 10 行)并强制排序(第 11 行)。P1 获取头指针 x1(第 21 行),然后加载引用的值(第 22 行)。因此,从 21 行的加载到 22 行上的加载存在地址依赖性。在这种情况下,第 21 行返回的值恰好是第 22 行使用的地址,但是可能存在很多变化。
人们可能会期望在第 22 行的取消引用之前排序来自头指针的第 21 行的加载。但是,DEC Alpha 并不是这种做法,它可以使用相关加载的推测值,如第 15.4.1 节中更详细的描述。因此,代码清单 15.9 中的 exists 子句可以被触发。
代码清单 15.10 展示了如何使其正确的工作,即便是在 DEC Alpha 上,将第 21 行的 READ_ONCE 替换为 lockless_dereference,其作用类似于 DEC Alpha 意外所有平台上的 READ_ONCE 后跟 smp_mb,从而在所有平台上执行所需的排序,进而防止 exists 子句触发。
但是,如果依赖操作是存储而非加载又会发生什么呢,比如,在代码清单 15.11 中显示的石蕊测试。因为没有产生质量的平台推测存储,第 10 行的 WRITE_ONCE 不可能覆盖第 21 行的 WEITE_ONCE,这意味着第 25 行的 exists 子句及时在 DEC Alpha 上也无法触发,即使没有在依赖负载的情况下需要的 lockless_dereference。
然而,特别需要注意的是,地址依赖很脆弱,很容易就能被编译器优化打破。
15.2.4 数据依赖
当加载指令的返回值被用于计算稍后的存储指令要存储的数据时,则会发生数据依赖。请注意上面的“数据”:如果加载返回的值用于计算稍后的存储指令要使用的地址时,那么将是地址依赖性。
代码清单 15.12 与清单 15.7 类似,不同之处在于第 18~19 行之间的 P1 排序不是由获取加载强制执行,而是由数据依赖性完成:第 18 行加载的值是第 19 行要存储的值。这种数据依赖性提供的排序足以放置 exists 子句的触发。
和地址依赖性一样,数据依赖性也很脆弱,可以被编译器优化轻松破解。如第 15.3.2 节所述。实际上,数据依赖性可能比地址依赖性更加脆弱。原因是地址依赖性通常涉及指针值。相反,如清单 15.12 所示,通过整数值来传递数据依赖性是很诱人的,编译器可以更自由的优化不存在性。仅举一个例子,如果加载的整数乘以常数 0,编译器将知道结果为 0,因此可以用常数 0 替换加载的值,从而打破依赖性。
简而言之,你可以依赖于数据依赖性,但前提是你要注意防止编译器对他们的破坏。
15.2.5 控制依赖
当需要测试加载指令的返回值以确定是否执行稍后的存储指令时,发生控制依赖性,请注意“稍后的存储指令”:很多平台并不遵循加载到加载控制的依赖关系。
代码清单 15.13 展示了另一个加载——启动示例,这里使用的是控制依赖关系(第 19 行)来命令第 18 行的加载和第 20 行的存储。这种排序足以避免 exists 子句的触发。
但是,控制依赖性甚至比数据依赖性更易被优化掉。第 15.3.3 节描述了为了防止编译器破坏控制依赖性必须要遵循的一些规则。
值得重申的是,控制依赖性仅提供从加载到存储的排序。因此,代码清单 15.14 的第 17~19 行展示的加载到加载过程并不提供排序,因此不会阻止 exists 子句的触发。
总之,控制依赖关系可能很有用,但它们是高维护项。因此,只有在性能考虑不允许其他解决方案时才应使用。
15.2.6 缓存连贯性
在缓存一致性平台上,所有 CPU 都遵循加载到存储指定变量的顺序。幸运的是,当使用 READ_ONCE 和 WRITE_ONCE 时,几乎所有平台都是缓存一致的,如表 15.3 中所示的 SV 一列。
代码清单 15.15 展示了测试缓存连贯性的石蕊测试,其中 IRIW 代表独立写入的独立读取。因为该石蕊测试仅使用一个变量,所以 P2 和 P3 必须在 P0 和 P1 的存储顺序上达成一致。换句话说,如果 P2 认为 P0 的存储首先进行,则 P3 最好不要相信 P1 的存储首先出现。事实上,如果出现这样的情况,第 35 行的 exists 语句将被触发。
很容易推测,对于单个内存区域(比如使用 C 语言的 union 关键字设置),不同大小的重叠加载和存储将提供类似的排序保证。然而 Flur 等人发现了一些令人惊讶的简单石蕊测试,这名这些保证可以在真实硬件上被打破。因此,至少在考虑可移植性的情况下,有必要将代码限制为对指定变量的非重叠相同大小的对齐访问。
添加更多的变量和线程会增加重排序和违反其他反直觉行文的范围,如下一节所述。
15.2.7 多重原子性
Threads running on a multicopy atomic (SF95) platform are guaranteed to agree on the order of stores, even to different variables. A useful mental model of such a system is the single-bus architecture shown in Figure 15.7. If each store resulted in a message on the bus, and if the bus could accommodate only one store at a time, then any pair of CPUs would agree on the order of all stores that they observed. Unfortunately, building a computer system as shown in the figure, without store buffers or even caches, would result in glacial computation. CPU vendors interested in providing multicopy atomicity have therefore instead provided the slightly weaker other-multicopy atomicity (ARM17, Section B2.3), which excludes the CPU doing a given store from the requirement that all CPUs agree on the order of all stores. This means that if only a subset of CPUs are doing stores, the other CPUs will agree on the order of stores, hence the “other” in “other-multicopy atomicity”. Unlike multicopy-atomic platforms, within other-multicopy-atomic platforms, the CPU doing the store is permitted to observe its store early, which allows its later loads to obtain the newly stored value directly from the store buffer. This in turn avoids abysmal performance.
Perhaps there will come a day when all platforms provide some flavor of multicopy atomicity, but in the meantime, non-multicopy-atomic platforms do exist, and so software must deal with them.
Listing 15.16 (C-WRC+o+o-data-o+o-rmb-o.litmus) demonstrates multicopy atomicity, that is, on a multicopy-atomic platform, the exists clause on line 29 cannot trigger. In contrast, on a non-multicopy-atomic platform this exists clause can trigger, despite P1()’s accesses being ordered by a data dependency and P2()’s accesses being ordered by an smp_rmb(). Recall that the definition of multicopy atomicity requires that all threads agree on the order of stores, which can be thought of as all stores reaching all threads at the same time. Therefore, a non-multicopy-atomic platform can have a store reach different threads at different times. In particular, P0()’s store might reach P1() long before it reaches P2(), which raises the possibility that P1()’s store might reach P2() before P0()’s store does.
This leads to the question of why a real system constrained by the usual laws of physics would ever trigger the exists clause of Listing 15.16. The cartoonish diagram of a such a real system is shown in Figure 15.8. CPU 0 and CPU 1 share a store buffer, as do CPUs 2 and 3. This means that CPU 1 can load a value out of the store buffer, thus potentially immediately seeing a value stored by CPU 0. In contrast, CPUs 2 and 3 will have to wait for the corresponding cache line to carry this new value to them.
Table 15.4 shows one sequence of events that can result in the exists clause in Listing 15.16 triggering. This sequence of events will depend critically on P0() and P1() sharing both cache and a store buffer in the manner shown in Figure 15.8.
Row 1 shows the initial state, with the initial value of y in P0()’s and P1()’s shared cache, and the initial value of x in P2()’s cache.
Row 2 shows the immediate effect of P0() executing its store on line 8. Because the cacheline containing x is not in P0()’s and P1()’s shared cache, the new value (1) is stored in the shared store buffer.
Row 3 shows two transitions. First, P0() issues a read-invalidate operation to fetch the cacheline containing x so that it can flush the new value for x out of the shared store buffer. Second, P1() loads from x (line 15), an operation that completes immediately because the new value of x is immediately available from the shared store buffer.
Row 4 also shows two transitions. First, it shows the immediate effect of P1() executing its store to y (line 16), placing the new value into the shared store buffer. Second, it shows the start of P2()’s load from y (line 24).
Row 5 continues the tradition of showing two transitions. First, it shows P1() complete its store to y, flushing from the shared store buffer to the cache. Second, it shows P2() request the cacheline containing y.
Row 6 shows P2() receive the cacheline containing y, allowing it to finish its load into r2, which takes on the value 1.
Row 7 shows P2() execute its smp_rmb() (line 25), thus keeping its two loads ordered.
Row 8 shows P2() execute its load from x, which immediately returns with the value zero from P2()’s cache.
Row 9 shows P2() finally responding to P0()’s request for the cacheline containing x, which was made way back up on row 3.
Finally, row 10 shows P0() finish its store, flushing its value of x from the shared store buffer to the shared cache.
Note well that the exists clause on line 29 has triggered. The values of r1 and r2 are both the value one, and the final value of r3 the value zero. This strange result occurred because P0()’s new value of x was communicated to P1() long before it was communicated to P2().
This counter-intuitive result happens because although dependencies do provide ordering, they provide it only within the confines of their own thread. This threethread example requires stronger ordering, which is the subject of Sections 15.2.7.1 through 15.2.7.4.
15.2.7.1 Cumulativity
The three-thread example shown in Listing 15.16 requires cumulative ordering, or cumulativity. A cumulative memory-ordering operation orders not just any given access preceding it, but also earlier accesses by any thread to that same variable.
Dependencies do not provide cumulativity, which is why the “C” column is blank for both the READ_ONCE() and the smp_read_barrier_depends() rows of Table 15.3. However, as indicated by the “C” in their “C” column, release operations do provide cumulativity. Therefore, Listing 15.17 (C-WRC+o+o-r+a-o.litmus) substitutes a release operation for Listing 15.16’s data dependency. Because the release operation is cumulative, its ordering applies not only to Listing 15.17’s load from x by P1() on line 15, but also to the store to x by P0() on line 8—but only if that load returns the value stored, which matches the 1:r1=1 in the exists clause on line 28. This means that P2()’s load-acquire suffices to force the load from x on line 25 to happen after the store on line 8, so the value returned is one, which does not match 2:r3=0, which in turn prevents the exists clause from triggering.
These ordering constraints are depicted graphically in Figure 15.9. Note also that cumulativity is not limited to a single step back in time. If there was another load from x or store to x from any thread that came before the store on line 13, that prior load or store would also be ordered before the store on line 32, though only if both r1 and r2 both end up containing the address of x.
In short, use of cumulative ordering operations can suppress non-multicopy-atomic behaviors in some situations. Cumulativity nevertheless has limits, which are examined in the next section.
15.2.7.2 Propagation
Listing 15.18 (C-W+RWC+o-r+a-o+o-mb-o.litmus) shows the limitations of cumulativity and store-release, even with a full memory barrier. The problem is that although the smp_store_release() on line 12 has cumulativity, and although that cumulativity does order P2()’s load on line 30, the smp_store_release()’s ordering cannot propagate through the combination of P1()’s load (line 21) and P2()’s store (line 28). This means that the exists clause on line 33 really can trigger.
This situation might seem completely counter-intuitive, but keep in mind that the speed of light is finite and computers are of non-zero size. It therefore takes time for the effect of the P2()’s store to z to propagate to P1(), which in turn means that it is possible that P1()’s read from z happens much later in time, but nevertheless still sees the old value of zero. This situation is depicted in Figure 15.10: Just because a load sees the old value does not mean that this load executed at an earlier time than did the store of the new value.
Note that Listing 15.18 also shows the limitations of memory-barrier pairing, given that there are not two but three processes. These more complex litmus tests can instead be said to have cycles, where memory-barrier pairing is the special case of a twothread cycle. The cycle in Listing 15.18 goes through P0() (lines 11 and 12), P1() (lines 20 and 21), P2() (lines 28, 29, and 30), and back to P0() (line 11). The exists clause delineates this cycle: the 1:r1=1 indicates that the smp_load_acquire() on line 20 returned the value stored by the smp_store_release() on line 12, the 1:r2=0 indicates that the WRITE_ONCE() on line 28 came too late to affect the value returned by the READ_ONCE() on line 21, and finally the 2:r3=0 indicates that the WRITE_ONCE() on line 11 came too late to affect the value returned by the READ_ONCE() on line 30. In this case, the fact that the exists clause can trigger means that the cycle is said to be allowed. In contrast, in cases where the exists clause cannot trigger, the cycle is said to be prohibited.
But what if we need to keep the exists clause on line 33 of Listing 15.18? One solution is to replace P0()’s smp_store_release() with an smp_mb(), which Table 15.3 shows to have not only cumulativity, but also propagation. The result is shown in Listing 15.19 (C-W+RWC+o-mb-o+a-o+o-mb-o.litmus).
For completeness, Figure 15.11 shows that the “winning” store among a group of stores to the same variable is not necessarily the store that started last. This should not come as a surprise to anyone who carefully examined Figure 15.5.
But sometimes time really is on our side. Read on!
Happens-Before
As shown in Figure 15.12, on platforms without user-visible speculation, if a load returns the value from a particular store, then, courtesy of the finite speed of light and the non-zero size of modern computing systems, the store absolutely has to have executed at an earlier time than did the load. This means that carefully constructed programs can rely on the passage of time itself as an memory-ordering operation.
Of course, just the passage of time by itself is not enough, as was seen in Listing 15.6, which has nothing but store-to-load links and, because it provides absolutely no ordering, still can trigger its exists clause. However, as long as each thread provides even the weakest possible ordering, exists clause would not be able to trigger. For example, Listing 15.21 (C-LB+a-o+o-data-o+o-data-o.litmus) shows P0() ordered with an smp_load_acquire() and both P1() and P2() ordered with data dependencies. These orderings, which are close to the top of Table 15.3, suffice to prevent the exists clause from triggering.
An important, to say nothing of more useful, use of time for ordering memory accesses is covered in the next section.
Release-Acquire Chains
A minimal release-acquire chain was shown in Listing 15.7 (C-LB+a-r+a-r+a-r+ar.litmus), but these chains can be much longer, as shown in Listing 15.22. The longer the release-acquire chain, the more ordering is gained from the passage of time, so that no matter how many threads are involved, the corresponding exists clause cannot trigger.
Although release-acquire chains are inherently store-to-load creatures, it turns out that they can tolerate one load-to-store step, despite such steps being counter-temporal, as shown in Figure 15.10. For example, Listing 15.23 (C-ISA2+o-r+a-r+a-r+ao.litmus) shows a three-step release-acquire chain, but where P3()’s final access is a READ_ONCE() from x0, which is accessed via WRITE_ONCE() by P0(), forming a non-temporal load-to-store link between these two processes. However, because P0()’s smp_store_release() (line 12) is cumulative, if P3()’s READ_ONCE() returns zero, this cumulativity will force the READ_ONCE() to be ordered before P0()’s smp_store_release(). In addition, the release-acquire chain (lines 12, 20, 21, 28, 29, and 37) forces P3()’s READ_ONCE() to be ordered after P0()’s smp_store_ release(). Because P3()’s READ_ONCE() cannot be both before and after P0()’s smp_store_release(), either or both of two things must be true:
P3()’s READ_ONCE() came after P0()’s WRITE_ONCE(), so that the READ_ ONCE() returned the value two, so that the exists clause’s 3:r2=0 is false.
The release-acquire chain did not form, that is, one or more of the exists clause’s 1:r2=2, 2:r2=2, or 3:r1=2 is false.
Either way, the exists clause cannot trigger, despite this litmus test containing a notorious load-to-store link between P3() and P0(). But never forget that releaseacquire chains can tolerate only one load-to-store link, as was seen in Listing 15.18.
Release-acquire chains can also tolerate a single store-to-store step, as shown in Listing 15.24 (C-Z6.2+o-r+a-r+a-r+a-o.litmus). As with the previous example, smp_store_release()’s cumulativity combined with the temporal nature of the release-acquire chain prevents the exists clause on line 36 from triggering. But beware: Adding a second store-to-store step would allow the correspondingly updated exists clause to trigger.
In short, properly constructed release-acquire chains form a peaceful island of intuitive bliss surrounded by a strongly counter-intuitive sea of more complex memoryordering constraints.
15.3 Compile-Time Consternation
Most languages, including C, were developed on uniprocessor systems by people with little or no parallel-programming experience. As a results, unless explicitly told otherwise, these languages assume that the current CPU is the only thing that is reading or writing memory. This in turn means that these languages’ compilers’ optimizers are ready, willing, and oh so able to make dramatic changes to the order, number, and sizes of memory references that your program executes. In fact, the reordering carried out by hardware can seem quite tame by comparison.
15.3.1 Memory-Reference Restrictions
Again, unless told otherwise, compilers assume that nothing else is affecting the variables being accessed by the generated code. This assumption is not simply some design error, but is instead enshrined in various standards. 9 This assumption means that compilers are within their rights (as defined by the standards) to optimize the following code so as to hoist the load from a out of the loop, at least in cases where the compiler can prove that do_something() does not modify a:
1 while (a)
2 do_something();
The optimized code might look something like this, essentially fusing an arbitrarily large number of intended loads into a single actual load:
1 if (a)
2 for (;;)
3 do_something();
This optimization might come as a fatal surprise to code elsewhere that expected to terminate this loop by storing a zero to a. Fortunately, there are several ways of avoiding this sort of problem:
- Volatile accesses.
- Atomic variables.
- Prohibitions against introducing data races.
The volatile restrictions are necessary to write reliable device drivers, and the atomic variables and prohibitions against introducing data races are necessary to write reliable concurrent code.
Starting with volatile accesses, the following code relies on the volatile casts in READ_ONCE() to prevent the unfortunate infinite-loop optimization:
1 while (READ_ONCE(a))
2 do_something();
READ_ONCE() marks the load with a volatile cast. Now volatile was originally designed for accessing memory-mapped I/O (MMIO) registers, which are accessed using the same load and store instructions that are used when accessing normal memory. However, MMIO registers need not act at all like normal memory. Storing a value to an MMIO register does not necessarily mean that a subsequent load from that register will return the value stored. Loading from an MMIO register might well have side effects, for example, changing the device state or affecting the response to subsequent loads and stores involving other MMIO registers. Loads and stores of different sizes to the same MMIO address might well have different effects.
This means that, even on a uniprocessor system, changing the order, number, or size of MMIO accesses is strictly forbidden. And this is exactly the purpose of the C-language volatile keyword, to constrain the compiler so as to allow implementation of reliable device drivers.
This is why READ_ONCE() prevents the destructive hoisting of the load from a out of the loop: Doing so changes the number of volatile loads from a, so this optimization is disallowed. However, note well that volatile does absolutely nothing to constrain the hardware. Therefore, if the code following the loop needs to see the result of any memory references preceding the store of zero that terminated the loop, you will instead need to use something like smp_store_release() to store the zero and smp_load_ acquire() in the loop condition. But if all you need is to reliably control the loop without any other ordering, READ_ONCE() can do the job.
Compilers can also replicate loads. For example, consider this all-too-real code fragment:
1 tmp = p;
2 if (tmp != NULL && tmp <= q)
3 do_something(tmp);
Here the intent is that the do_something() function is never passed a NULL pointer or a pointer that is greater than q. However, the compiler is within its rights to transform this into the following:
1 if (p != NULL && p <= q)
2 do_something(p);
In this transformed code, the value of p is loaded three separate times. This transformation might seem silly at first glance, but it is quite useful when the surrounding code has consumed all of the machine registers. It is possible that the current value of p passes the test on line 1, but that some other thread stores NULL to p before line 2 executes, and the resulting NULL pointer could be a fatal surprise to do_something(). 10 To prevent the compiler from replicating the load, use READ_ONCE(), for example as follows:
1 tmp = READ_ONCE(p);
2 if (tmp != NULL && tmp <= q)
3 do_something(tmp);
Alternatively, the variable p could be declared volatile.
Compilers can also fuse stores. The most infamous example is probably the progressbar example shown below:
1 while (!am_done()) {
2 do_something(p);
3 progress++;
4 }
If the compiler used a feedback-driven optimizer, it might well notice that the store to the shared variable progress was quite expensive, resulting in the following well-intentioned optimization:
1 while (!am_done()) {
2 do_something(p);
3 tmp++;
4 }
5 progress = tmp;
This might well slightly increase performance, but the poor user watching the progress bar might be forgiven for harboring significant ill will towards this particular optimization. The progress bar will after all be stuck at zero for a long time, then jump at the very end. The following code will usually prevent this problem:
1 while (!am_done()) {
2 do_something(p);
3 WRITE_ONCE(progress, progress + 1);
4 }
Exceptions can occur if the compiler is able to analyze do_something() and learn that it has no accesses to atomic or volatile variables. In these cases the compiler could produce two loops, one invoking do_something() and the other incrementing progress. It may be necessary to replace the WRITE_ONCE() with something like smp_store_release() in the unlikely event that this occurs. It is important to note that although the compiler is forbidden from changing the number, size, or order of volatile accesses, it is perfectly within its rights to reorder normal accesses with unrelated volatile accesses.
Oddly enough, the compiler is within its rights to use a variable as temporary storage just before a normal store to that variable, thus inventing stores to that variable. Fortunately, most compilers avoid this sort of thing, at least outside of stack variables. In any case, using WRITE_ONCE(), declaring the variable volatile, or declaring the variable atomic (in recent C and C++ compilers supporting atomics) will prevent this sort of thing.
The previous examples involved compiler optimizations that changed the number of accesses. Now, it might seem that preventing the compiler from changing the order of accesses is an act of futility, given that the underlying hardware is free to reorder them. However, modern machines have exact exceptions and exact interrupts, meaning that any interrupt or exception will appear to have happened at a specific place in the instruction stream, so that the handler will see the effect of all prior instructions, but won’t see the effect of any subsequent instructions. READ_ONCE() and WRITE_ONCE() can therefore be used to control communication between interrupted code and interrupt handlers. 11
This leaves changes to the size of accesses, which is known as load tearing and store tearing when the actual size is smaller than desired. For example, storing the constant 0x00010002 into a 32-bit variable might seem quite safe. However, there are CPUs that can store small immediate values directly into memory, and on such CPUs, the compiler can be expected to split this into two 16-bit stores in order to avoid the overhead of explicitly forming the 32-bit constant. This could come as a fatal surprise to another thread concurrently loading from this variable, which might not expect to see the result of a half-completed store. Use of READ_ONCE() and WRITE_ONCE() prevent the compiler from engaging in load tearing and store tearing, respectively.
In short, use of READ_ONCE(), WRITE_ONCE(), and volatile are valuable tools in preventing the compiler from optimizing your parallel algorithm out of existence. Compilers are starting to provide other mechanisms for avoiding load and store tearing, for example, memory_order_relaxed atomic loads and stores, however, volatile is still needed to avoid fusing and splitting of accesses.
Please note that, it is possible to overdo use of READ_ONCE() and WRITE_ONCE(). For example, if you have prevented a given variable from changing (perhaps by holding the lock guarding all updates to that variable), there is no point in using READ_ONCE(). Similarly, if you have prevented any other CPUs or threads from reading a given variable (perhaps because you are initializing that variable before any other CPU or thread has access to it), there is no point in using WRITE_ONCE(). However, in my experience, developers need to use things like READ_ONCE() and WRITE_ONCE() more often than they think that they do, the overhead of unnecessary uses is quite low. Furthermore, the penalty for failing to use them when needed is quite high.
15.3.2 Address- and Data-Dependency Difficulties
Compilers do not understand either address or data dependencies, although there are efforts underway to teach them, or at the very least, standardize the process of teaching them (MWB + 17, MRP + 17). In the meantime, it is necessary to be very careful in order to prevent your compiler from breaking your dependencies.
15.3.2.1 Give your dependency chain a good start
The load that heads your dependency chain must use proper ordering, for example, lockless_dereference(), rcu_dereference(), or a READ_ONCE() followed by smp_read_barrier_depends(). Failure to follow this rule can have serious side effects:
On DEC Alpha, a dependent load might not be ordered with the load heading the dependency chain, as described in Section 15.4.1.
If the load heading the dependency chain is a C11 non-volatile memory_order_ relaxed load, the compiler could omit the load, for example, by using a value that it loaded in the past.
If the load heading the dependency chain is a plain load, the compiler can omit the load, again by using a value that it loaded in the past. Worse yet, it could load twice instead of once, so that different parts of your code use different values—and compilers really do this, especially when under register pressure.
The value loaded by the head of the dependency chain must be a pointer. In theory, yes, you could load an integer, perhaps to use it as an array index. In practice, the compiler knows too much about integers, and thus has way too many opportunities to break your dependency chain (MWB + 17).
15.3.2.2 Avoid arithmetic dependency breakage
Although it is just fine to do some arithmetic operations on a pointer in your dependency chain, you need to be careful to avoid giving the compiler too much information. After all, if the compiler learns enough to determine the exact value of the pointer, it can use that exact value instead of the pointer itself. As soon as the compiler does that, the dependency is broken and all ordering is lost.
- Although it is permissible to compute offsets from a pointer, these offsets must not result in total cancellation. For example, given a char pointer cp, cp-(uintptr_ t)cp) will cancel and can allow the compiler to break your dependency chain.
On the other hand, canceling offset values with each other is perfectly safe and legal. For example, if a and b are equal, cp+a-b is an identity function, including preserving the dependency.
- Comparisons can break dependencies. Listing 15.26 shows how this can happen. Here global pointer gp points to a dynamically allocated integer, but if memory is low, it might instead point to the reserve_int variable. This reserve_ int case might need special handling, as shown on lines 6 and 7 of the listing. But the compiler could reasonably transform this code into the form shown in Listing 15.27, especially on systems where instructions with absolute addresses run faster than instructions using addresses supplied in registers. However, there is clearly no ordering between the pointer load on line 5 and the dereference on line 8. Please note that this is simply an example: There are a great many other ways to break dependency chains with comparisons.
Note that a series of inequality comparisons might, when taken together, give the compiler enough information to determine the exact value of the pointer, at which point the dependency is broken. Furthermore, the compiler might be able to combine information from even a single inequality comparison with other information to learn the exact value, again breaking the dependency. Pointers to elements in arrays are especially susceptible to this latter form of dependency breakage.
15.3.2.3 Safe comparison of dependent pointers
It turns out that there are several safe ways to compare dependent pointers:
Comparisons against the NULL pointer. In this case, all the compiler can learn is that the pointer is NULL, in which case you are not allowed to dereference it anyway.
The dependent pointer is never dereferenced, whether before or after the comparison.
The dependent pointer is compared to a pointer that references objects that were last modified a very long time ago, where the only unconditionally safe value of “a very long time ago” is “at compile time”. The key point is that there absolutely must be something other than the address or data dependency that guarantees ordering.
Comparisons between two pointers, each of which is carrying a good-enough dependency. For example, you have a pair of pointers, each carrying a dependency, and you want to avoid deadlock by acquiring locks of the pointed-to data elements in address order.
The comparison is not-equal, and the compiler does not have enough other information to deduce the value of the pointer carrying the dependency.
Pointer comparisons can be quite tricky, and so it is well worth working through the example shown in Listing 15.28. This example uses a simple struct foo shown on lines 1-5 and two global pointers, gp1 and gp2, shown on lines 6 and 7, respectively. This example uses two threads, namely updater() on lines 9-22 and reader() on lines 24-39.
The updater() thread allocates memory on line 13, and complains bitterly on line 14 if none is available. Lines 15-17 initialize the newly allocated structure, and then line 18 assigns the pointer to gp1. Lines 19 and 20 then update two of the structure’s fields, and does so after line 18 has made those fields visible to readers. Please note that unsynchronized update of reader-visible fields often constitutes a bug. Although there are legitimate use cases doing just this, such use cases require more care than is exercised in this example.
Finally, line 21 assigns the pointer to gp2.
The reader() thread first fetches gp2 on line 30, with lines 31 and 32 checking for NULL and returning if so. Line 33 then fetches field ->b. Now line 34 fetches gp1, and if line 35 sees that the pointers fetched on lines 30 and 34 are equal, line 36 fetches p->c. Note that line 36 uses pointer p fetched on line 30, not pointer q fetched on line 34.
But this difference might not matter. An equals comparison on line 35 might lead the compiler to (incorrectly) conclude that both pointers are equivalent, when in fact they carry different dependencies. This means that the compiler might well transform line 36 to instead be r2 = q->c, which might well cause the value 44 to be loaded instead of the expected value 144.
In short, some care is required in order to ensure that dependency chains in your source code are still dependency chains once the compiler has gotten done with them.
15.3.3 Control-Dependency Calamities
Control dependencies are especially tricky because current compilers do not understand them and can easily break them. The rules and examples in this section are intended to help you prevent your compiler’s ignorance from breaking your code.
A load-load control dependency requires a full read memory barrier, not simply a data dependency barrier. Consider the following bit of code:
1 q = READ_ONCE(x);
2 if (q) {
3 <data dependency barrier>
4 q = READ_ONCE(y);
5 }
This will not have the desired effect because there is no actual data dependency, but rather a control dependency that the CPU may short-circuit by attempting to predict the outcome in advance, so that other CPUs see the load from y as having happened before the load from x. In such a case what’s actually required is:
1 q = READ_ONCE(x);
2 if (q) {
3 <read barrier>
4 q = READ_ONCE(y);
5 }
However, stores are not speculated. This means that ordering is provided for loadstore control dependencies, as in the following example:
1 q = READ_ONCE(x);
2 if (q)
3 WRITE_ONCE(y, 1);
Control dependencies pair normally with other types of ordering operations. That said, please note that neither READ_ONCE() nor WRITE_ONCE() are optional! Without the READ_ONCE(), the compiler might combine the load from x with other loads from x. Without the WRITE_ONCE(), the compiler might combine the store to y with other stores to y. Either can result in highly counterintuitive effects on ordering.
Worse yet, if the compiler is able to prove (say) that the value of variable x is always non-zero, it would be well within its rights to optimize the original example by eliminating the “if” statement as follows:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 1); /* BUG: CPU can reorder!!! */
It is tempting to try to enforce ordering on identical stores on both branches of the “if” statement as follows:
1 q = READ_ONCE(x);
2 if (q) {
3 barrier();
4 WRITE_ONCE(y, 1);
5 do_something();
6 } else {
7 barrier();
8 WRITE_ONCE(y, 1);
9 do_something_else();
10 }
Unfortunately, current compilers will transform this as follows at high optimization levels:
1 q = READ_ONCE(x);
2 barrier();
3 WRITE_ONCE(y, 1); /* BUG: No ordering!!! */
4 if (q) {
5 do_something();
6 } else {
7 do_something_else();
8 }
Now there is no conditional between the load from x and the store to y, which means that the CPU is within its rights to reorder them: The conditional is absolutely required, and must be present in the assembly code even after all compiler optimizations have been applied. Therefore, if you need ordering in this example, you need explicit memory-ordering operations, for example, a release store:
1 q = READ_ONCE(x);
2 if (q) {
3 smp_store_release(&y, 1);
4 do_something();
5 } else {
6 smp_store_release(&y, 1);
7 do_something_else();
8 }
The initial READ_ONCE() is still required to prevent the compiler from proving the value of x.
In addition, you need to be careful what you do with the local variable q, otherwise the compiler might be able to guess the value and again remove the needed conditional. For example:
1 q = READ_ONCE(x);
2 if (q % MAX) {
3 WRITE_ONCE(y, 1);
4 do_something();
5 } else {
6 WRITE_ONCE(y, 2);
7 do_something_else();
8 }
If MAX is defined to be 1, then the compiler knows that (q%MAX) is equal to zero, in which case the compiler is within its rights to transform the above code into the following:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 2);
3 do_something_else();
Given this transformation, the CPU is not required to respect the ordering between the load from variable x and the store to variable y. It is tempting to add a barrier() to constrain the compiler, but this does not help. The conditional is gone, and the barrier() won’t bring it back. Therefore, if you are relying on this ordering, you should make sure that MAX is greater than one, perhaps as follows:
1 q = READ_ONCE(x);
2 BUILD_BUG_ON(MAX <= 1);
3 if (q % MAX) {
4 WRITE_ONCE(y, 1);
5 do_something();
6 } else {
7 WRITE_ONCE(y, 2);
8 do_something_else();
9 }
Please note once again that the stores to y differ. If they were identical, as noted earlier, the compiler could pull this store outside of the “if” statement.
You must also avoid excessive reliance on boolean short-circuit evaluation. Consider this example:
1 q = READ_ONCE(x);
2 if (q || 1 > 0)
3 WRITE_ONCE(y, 1);
Because the first condition cannot fault and the second condition is always true, the compiler can transform this example as following, defeating control dependency:
1 q = READ_ONCE(x);
2 WRITE_ONCE(y, 1);
This example underscores the need to ensure that the compiler cannot out-guess your code. More generally, although READ_ONCE() does force the compiler to actually emit code for a given load, it does not force the compiler to use the results.
In addition, control dependencies apply only to the then-clause and else-clause of the if-statement in question. In particular, it does not necessarily apply to code following the if-statement:
1 q = READ_ONCE(x);
2 if (q) {
3 WRITE_ONCE(y, 1);
4 } else {
5 WRITE_ONCE(y, 2);
6 }
7 WRITE_ONCE(z, 1); /* BUG: No ordering. */
It is tempting to argue that there in fact is ordering because the compiler cannot reorder volatile accesses and also cannot reorder the writes to y with the condition. Unfortunately for this line of reasoning, the compiler might compile the two writes to y as conditional-move instructions, as in this fanciful pseudo-assembly language:
1 ld r1,x
2 cmp r1,$0
3 cmov,ne r4,$1
4 cmov,eq r4,$2
5 st r4,y
6 st $1,z
A weakly ordered CPU would have no dependency of any sort between the load from x and the store to z. The control dependencies would extend only to the pair of cmov instructions and the store depending on them. In short, control dependencies apply only to the stores in the “then” and “else” of the “if” in question (including functions invoked by those two clauses), not to code following that “if”.
Finally, control dependencies do not provide cumulativity. 12 This is demonstrated by two related litmus tests, namely Listings 15.29 and 15.30 with the initial values of x and y both being zero.
The exists clause in the two-thread example of Listing 15.29 (C-LB+o-cgt-o+ocgt-o.litmus) will never trigger. If control dependencies guaranteed cumulativity (which they do not), then adding a thread to the example as in Listing 15.30 (CWWC+o-cgt-o+o-cgt-o+o.litmus) would guarantee the related exists clause never to trigger.
But because control dependencies do not provide cumulativity, the exists clause in the three-thread litmus test can trigger. If you need the three-thread example to provide ordering, you will need smp_mb() between the load and store in P0(), that is, just before or just after the “if” statements. Furthermore, the original two-thread example is very fragile and should be avoided.
The following list of rules summarizes the lessons of this section:
Compilers do not understand control dependencies, so it is your job to make sure that the compiler cannot break your code.
Control dependencies can order prior loads against later stores. However, they do not guarantee any other sort of ordering: Not prior loads against later loads, nor prior stores against later anything. If you need these other forms of ordering, use smp_rmb(), smp_wmb(), or, in the case of prior stores and later loads, smp_mb().
If both legs of the “if” statement begin with identical stores to the same variable, then those stores must be ordered, either by preceding both of them with smp_ mb() or by using smp_store_release() to carry out the stores. Please note that it is not sufficient to use barrier() at beginning of each leg of the “if” statement because, as shown by the example above, optimizing compilers can destroy the control dependency while respecting the letter of the barrier() law.
Control dependencies require at least one run-time conditional between the prior load and the subsequent store, and this conditional must involve the prior load. If the compiler is able to optimize the conditional away, it will have also optimized away the ordering. Careful use of READ_ONCE() and WRITE_ONCE() can help to preserve the needed conditional.
Control dependencies require that the compiler avoid reordering the dependency into nonexistence. Careful use of READ_ONCE(), atomic_read(), or atomic64_read() can help to preserve your control dependency.
Control dependencies apply only to the “then” and “else” of the “if” containing the control dependency, including any functions that these two clauses call. Control dependencies do not apply to code following the end of the “if” statement containing the control dependency.
Control dependencies pair normally with other types of memory-ordering operations.
Control dependencies do not provide cumulativity. If you need cumulativity, use smp_mb().
In short, many popular languages were designed primarily with single-threaded use in mind. Successfully using these languages to construct multi-threaded software requires that you pay special attention to your memory references and dependencies.
15.4 Hardware Specifics
略。
15.5. WHERE IS MEMORY ORDERING NEEDED?
Memory-ordering operations are only required where there is a possibility of interaction involving at least two variables between at least two threads. As always, if a single-threaded program will provide sufficient performance, why bother with parallelism? 15 After all, avoiding parallelism also avoids the added cost of memory-ordering operations.
If all thread-to-thread communication in a given cycle use store-to-load links (that is, the next thread’s load returning the value that the previous thread stored), minimal ordering suffices, as illustrated by Listings 15.12 and 15.13. Minimal ordering includes dependencies, acquires, and all stronger ordering operations.
If all but one of the links in a given cycle is a store-to-load link, it is sufficient to use release-acquire pairs for each of those store-to-load links, as illustrated by Listings 15.23 and 15.24. You can replace a given acquire with a a dependency in environments permitting this, keeping in mind that the C11 standard’s memory model does not permit this. Note also that a dependency leading to a load must be headed by a lockless_ dereference() or an rcu_dereference(): READ_ONCE() is not sufficient. Never forget to carefully review Sections 15.3.2 and 15.3.3, because a dependency broken by your compiler is no help at all! The two threads sharing the sole non-store-to-load link can usually substitute WRITE_ONCE() plus smp_wmb() for smp_store_release() on the one hand, and READ_ONCE() plus smp_rmb() for smp_load_acquire() on the other.
If a given cycle contains two or more non-store-to-load links (that is, a total of two or more load-to-store and store-to-store links), you will need at least one full barrier between each pair of non-store-to-load links in that cycle, as illustrated by Listing 15.19 as well as in the answer to Quick Quiz 15.23. Full barriers include smp_mb(), successful full-strength non-void atomic RMW operations, and other atomic RMW operations in conjunction with either smp_mb__before_atomic() or smp_mb__after_atomic(). Any of RCU’s grace-period-wait primitives (synchronize_rcu() and friends) also act as full barriers, but at even greater expense than smp_mb(). With strength comes expense, though the overhead of full barriers usually hurts performance more than it hurts scalability.
Note that these are the minimum guarantees. Different architectures may give more substantial guarantees, as discussed in Section 15.4, but they may not be relied upon outside of code specifically designed to run only on the corresponding architecture.
One final word of advice: Again, use of raw memory-ordering primitives is a last resort. It is almost always better to use existing primitives, such as locking or RCU, that take care of memory ordering for you.
3.3.16 - ENDIX-C-内存屏障
是什么原因,让疯狂的 CPU 设计者将内存屏障强加给可怜的 SMP 软件设计者?
简而言之,这是由于重排内存引用可以达到更好的性能。因此,在某些情况下,如在同步原语中,正确的操作结果依赖于按序的内存引用,这就需要内存屏障以强制保证内存顺序。
对于这个问题,要得到更详细的回答,需要很好理解 CPU 缓存是如何工作的,特别是要使缓存工作的更好,我们需要什么东西。
缓存结构
现代 CPU 的速度比现代内存系统的速度快的多。2006 年的 CPU 可以在的每纳秒内执行 10 条指令。但是需要多个 10ns 才能从物理内存中读取一条数据。它们的速度差异(超过两个数量级)已经导致在现代 CPU 中出现了数兆级别的缓存。这些缓存与 CPU 相关联,如果 C.1 中所示,典型的,可以在几个时钟周期内被访问。
CPU 缓存和内存之间的数据流是固定长度的块,称为“缓存行”,其大小通常是 2 的 N 次方。范围从 16 到 256 字节不等。当一个特定的数据项初次被 CPU 访问时,它在缓存中还不存在,这被称为“缓存缺失”(或者更精确的称为“首次缓存缺失”或者“运行时缓存缺失”)。“缓存缺失”意味着从物理内存中读取数据时,CPU 必须等待(或处于“停顿”状态)数百个 CPU 周期。但是,数据项被装载入 CPU 缓存,因此后续的访问将在缓存中找到,于是 CPU 可以全速运行。
经过一段时间后,CPU 的缓存被填满,后续的缓存缺失很可能需要换出缓存中现有的数据,以便为最近的访问项腾出统建。这种“缓存缺失”被称为“容量缺失”,因为它是由于缓存容量限制而造成的。但是,即便此时缓存还没有被填满,大量缓存也可能由于一个新数据被换出。这是由于大容量缓存是通过硬件哈希表来实现的,这样哈希表有固定长度的哈希桶(或者叫 “sets”,CPU 设计者是这样称呼的),如图 C.2。
该缓存有 16 个 sets 和两条“路”,共 32 个缓存行,每个节点包含一个 256 字节的“缓存行”,它是一个 256 字节对齐的内存块。对于大容量缓存来说,这个缓存行的长度稍小了点,但是这使得 16 禁止的运行更加简单。从硬件角度来说,这是一个两路组相连缓存,类似于带 16 个桶的软件哈希表,每个桶的哈西链被限制为最多两个元素。大小(本例中是32个缓存行)和相连性(本例中是2)都被称为缓存行的 germetry。由于缓存是硬件实现的,哈希函数非常简单,从地址中取出 4 位作为哈希键值。
在 C.2 中,每个方框对应一个缓存项,每个缓存项包含一个 256 字节的缓存行。不过,一个缓存项可能为空,在图中表现为空框。其他的块用它所包含的内存行的内存地址标记。由于缓存行必须是 256 字节对齐,因此每一个地址的低 8 位为 0。并且,硬件哈希函数的选择。意味着接下来的高 4 位匹配缓存行中的位置。
如果陈旭代码位于地址 0x43210E00 到 0x43210EFF,并且程序一次访问地址 0x1234500 到 0x12345EFF,图中的情况就可能发生。假设程序正在准备访问地址 0x12345F00,这个地址会哈希到 0xF 行,该行的两路都是空的,因此可以容纳对应的 256 字节缓存行。如果程序访问地址 0x1233000,将会被哈希到第 0 行,相应的 256 字节缓存行可以放到第一路。但是,如果程序访问地址 0x123E00,将会哈希到 0xE 行,其中一个已经存在于缓存中缓存行必须被替换出去,以腾出空间给新的缓存行。如果随后访问刚被替换出去的行,会产生一次“缓存缺失”,这样的缓存缺失被称为“关联性缺失”。
更进一步说,我们仅仅考虑了某个 CPU 读数据的情况。当写的时候会发生什么呢?由于让所有 CPU 都对特定数据项达成一致,这一点非常重要。因此,在一个特定的 CPU 写数据前,它必须首先从其他 CPU 缓存中移除,或者叫做“使无效”。一旦“使无效”操作完成,CPU 可以安全的修改数据项。如果数据存在于该 CPU 缓存中,但是是只读的,这个过程被称为“写缺失”、一旦某个特定的 CPU 完成了对某个数据项的“使无效”操作,该 CPU 可以反复的重新写(或读)该数据项。
随后,如果另外某个 CPU 视图访问数据项,将会引起一次缓存缺失,此时,由于第一个 CPU 为了写而使得缓存项无效,这种类型的缓存缺失被称为“通信缺失”、因为通常是由于几个 CPU 使用数据项进行通信造成的。比如,锁就是一个用于在 CPU 之间使用互斥算法进行通信的数据项。
很明显,必须小心确保,所有 CPU 报纸一致性数据视图。可以很容易想到,通过所有取数据、使无效、写操作。它操作的数据可能已经丢失,或者(也许更糟糕)在不同 CPU 缓存之间拥有冲突的值。这些问题由“缓存一致性”来防止,将在下一节介绍。
缓存一致性协议
缓存一致性协议管理缓存行的状态,以防止数据不一致或者丢失数据。这些协议可能十分复杂,可能有数十种状态。但是为了我们的目的,我们仅仅需要关心仅有 4 种状态的 MESI 协议。
MESI 状态
MESI 代表 modified、exclusive、shared、inbalid,特定缓存行可以使用该协议采用的四种状态。因此,使用该协议的缓存,在每一个缓存行中,维护一个两位的状态标记,这个标记附着在缓存行的物理地址和数据后面。
处于 modified 状态的缓存行,已经收到了来自于响应 CPU 最近进行的内存存储。并且相应的内存却白没有在其他 CPU 的缓存中出现。因此,除以 modified 状态的缓存行可以被认为为被 CPU 所“拥有”。由于该缓存行持有最新的数据复制,因此缓存最终有责任:要么将数据写回到内存,要么将数据转移给其他缓存,并且必须在重新使用该缓存行以持有其他数据之前完成这些事情。
exclusive 状态非常类似于 modified 状态,唯一的差别是,该缓存行还没有被相应的 CPU 修改,这也表示缓存行中对内存数据的复制是最新的。但是,由于 CPU 能够在任意时刻将数据存储到该行,而不考虑其他 CPU,因此,处于 exclusive 状态也可以认为被相应的 CPU 所“拥有”。也就是说,由于物理内存中相应的值是最新的,该缓存行可以直接丢弃而不用会写到内存,也不用将该缓存转移给其他 CPU 的缓存。
处于 shared 状态的缓存行可能被复制到至少一个其他 CPU 的缓存行中,这样在没有得到其他 CPU 的许可时,不能向缓存行存储数据。与 exclusive 状态相同,由于内存中的值是最新的,因此可以不用向内存回写值而直接丢弃缓存中的值,也不用将该缓存转移给其他 CPU。
处于 invalid 状态的行是空的,换句话说,他没有持有任何有效数据。当新数据进入缓存时,如果有可能,它就会被放置到一个处于 invalid 状态的缓存行。这个方法是首选的,因为替换其他状态的缓存行将引起开销昂贵的缓存缺失,这些被替换的行在将来会被引用。
由于所有 CPU 必须维护那些已经搬运进缓存行中的数据一致性视图,因此缓存一致性协议提供消息以协调系统中缓存行的动作。
MESI 协议消息
前面章节中描述的许多事务都需要在 CPU 之间通信。如果 CPU 位于单一共享总线上,只需要如下消息就足够了。
- 读消息:包含要读取的缓存行的物理地址。
- 读响应消息:包含之前的“读消息”所请求的数据。这个读响应消息要么由物理内存提供,要么由一个其他缓存提供。例如,如果某一缓存拥有处于 modified 状态的目标数据,那么该缓存必须提供读响应消息。
- 使无效消息:包含要使无效的缓存行的物理地址。所有其他缓存行必须从它们的缓存中移除相应的数据并且响应此消息。
- 使无效应答消息:一个接收到使无效消息的 CPU 必须在移除指定数据后响应一个使无效应答消息。
- 读使无效消息:包含要被读取的缓存行的物理地址。同时指示其他缓存移除其数据。因此,正如名字所示,它将读和使无效消息进行合并。读使无效消息同时需要一个读响应消息及一组使无效应答消息进行应答。
- 写回消息:包含要写回到物理内存的地址和数据(并且也会“嗅探”进其他 CPU 的缓存)。该消息允许缓存在必要时换出处于 modified 状态的数据以便为其他数据腾出空间。
有趣的是,共享内存的多核系统实际上是一个消息传递的计算机。这意味着:使用分布式共享内存的 SMP 机器集群,正在以两种不同级别的系统架构,使用消息传递来共享内存。
MESI 状态图
由于接受或发送协议消息,特定的缓存行状态会变换,如图 C.3 所示:
图中的转换弧如下:
- 转 a:缓存行被写回到物理内存,但是 CPU 仍然将它保留在缓存中,并进一步的保留修改它的权限。这个转换需要一个“写回”消息。
- 转换 b:CPU 将数据写到缓存行,该缓存目前处于排他访问。该转换不需要发送或者接收任何消息。
- 转换 c:CPU 收到一个针对某个缓存行的“使无效”消息,对应的缓存行已经被修改。CPU 必须使无效本地副本,然后同时响应“读响应”和“使无效应答”消息,同时发送数据给请求的 CPU,并且标示它的本地副本不再有效。
- 转换 d:CPU 对一个数据项进行一个原子读——修改——写操作,对应的数据没有在他的缓存中。它发送一个“使无效”消息,通过“读响应”消息接收数据。一旦他接收到一个完整的“使无效应答”响应集合,CPU 就完成此转换。
- 转换 e:CPU 对一个数据项进行一个原子读——修改——写操作,对应的数据在缓存中是只读的。它必须发送一个“使无效”消息,并在完成此转换前,它必须等待一个完整的“使无效应答”响应集合。
- 转换 f:其他某些 CPU 读取缓存行,其数据由本 CPU 提供,本 CPU 包含一个只读副本,也可能已经将其写回内存。这个转换开始于接收到一个读消息,并且本 CPU 响应一个包含了所请求数据的“读响应”消息。
- 转换 g:其他 CPU 读取位于本缓存行的数据,并且数据要么是从本 CPU 的缓存提供,要么是从物理内存提供。无论哪种情况,本 CPU 都会保留一个只读副本。该转换开始于接收到一个“读”消息,并且本 CPU 响应一个包含所请求数据的“读响应”消息。
- 转换 h:当前 CPU 意识到,它很快将要写入一些位于本 CPU 缓存行的数据项,于是发送一个“使无效”消息。直到它接收到完整的“使无效”应答消息集合,CPU 才完成转换。可选的,所有其他 CPU 通过写回消息,从其缓存行将数据取出(可能是为其他缓存行腾出空间)。这样,当前 CPU 就是最后一个缓存该数据的 CPU。
- 转换 i:其他某些 CPU 对某个数据项进行了一个原子读——修改——写操作,相应的缓存行仅仅被本地的 CPU 缓存所持有。因此本 CPU 将缓存行状态编程无效状态。这个转换开始于接收到“读使无效”消息,并且本 CPU 返回一个“读响应”消息及一个“使无效应答”消息。
- 转换 j:本 CPU 保存一个数据项到缓存行,但是数据还没有在其他缓存行中。因此发送一个“使读无效”消息。直到它接收到“读响应”消息及完整的“使无效应答”消息集合后,才完成转换。缓存行可能会很快转到“修改”状态,这是在存储完成后由交换 b 完成的。
- 转换 k:本 CPU 装在一个数据项到缓存中,但是数据项还没有在缓存行中。CPU 发送一个“读”消息,当它接收到相应的“读响应”消息后完成转换。
- 转换 l:其他 CPU 存储一个位于本 CPU 缓存行的数据项,但是由于其他 CPU 也持有该缓存行的原因,本 CPU 仅仅以只读方式持有该缓存行。这个转换开始于接收到一个“使无效”消息,并且本 CPU 返回一个“使无效应答”消息。
MESI 协议实例
现在,让我们从数据缓存行价值的角度来看这一点。最初,数据驻留在地址为 0 的物理内存中。在一个 4-CPU 的系统中,它在几个直接映射的单缓存行中移动,表 C.1 展示了数据流向。第一列是操作序号,第二列表示执行操作的 CPU,第三列表示执行的操作,接下来的四列表示每一个缓存行的状态(内存地址后紧跟 MESI 状态)。最后两列表示对应的内存内容是否是最新的。V 表示最新,I 表示非最新。
最初,将要驻留数据的的 CPU 缓存行处于 invalid 状态,对应的数据在物理内存中是无效的。当 CPU0 从地址0装载数据时,它在 CPU0 的缓存中进入 shared 状态,并且物理内存中的数据仍是有效的。CPU3 也是从地址0装载数据,这样两个 CPU 中的缓存都处于 shared 状态,并且内存中的数据仍然有效。接下来 CPU0 转载其他缓存行(地址8),这个操作通过使无效操作强制将地址0的数据换出缓存,将缓存中的数据被换成地址8的数据。现在,CPU2 装载地址0的数据,但是该 CPU 发现它很快就会存储该数据,因此它使用一个“使读无效”消息以获得一个独享副本,这样,将使 CPU3 缓存中的数据变为无效(但是内存中的数据依然是有效的)。接下来 CPU2 开始预期的存储操作,并将状态改为 modified。内存中的数据副本不再是最新的。CPU1 开始一个原子递增操作,使用一个“读使无效”消息从 CPU2 的缓存中窥探数据并使之无效,这样 CPU1 的缓存编程 modified 状态,(内存中的数据仍然不是最新的)。最后,CPU1 从地址 8 读取数据,它使用一个写回消息将地址0的数据会写到内存。
请注意,我们最终使数据位于某缓存行中。
存储导致不必要的停顿
对于特定的 CPU 反复读写特定的数据来说,图 C.1 显示的缓存结构提供了好的性能。但是对于特定缓存行的第一次写来说,其性能是不好的。要理解这一点,参考图 C.4,它显示了 CPU0 写数据到一个缓存行的时间线,而这个缓存行被 CPU1 缓存。在 CPU0 能够写数据前,它必须首先等到缓存行的数据到来。CPU0 不得不停顿额外的时间周期。
其他没有理由强制让 CPU0 延迟这么久,毕竟,不管 CPU1 发送给他的缓存数据是什么,CPU0 都会无条件的覆盖它。
存储缓冲
避免这种不必要的写停顿的方法之一,是在每个 CPU 和它的缓存之间,增加“存储缓冲”,如图 C.5。通过增加这些存储缓冲区,CPU0 可以简单的将要保存的数据放到存储缓冲区中,并且继续运行。当缓存行最终从 CPU1 转到 CPU0 时,数据将从存储缓冲区转到缓存行中。
这些存储缓冲对于特定 CPU 来说,是属于本地的。或者在硬件多线程系统中,对于特定核来说,是属于本地的。无论哪一种情况,一个特定 CPU 仅允许访问分配给它的存储缓冲。例如,在图 C.5 中,CPU0 不能访问 CPU0 的存储缓冲,反之亦然。通过将两者关注的点分开,该限制简化了硬件,存储缓冲区提升了连续写的性能,而在 CPU(核或其他可能的东西)之间的通信责任完全由缓存一致性协议承担。然而,及时有了这个限制,仍然有一些复杂的事情需要处理,将在下面两节中描述。
存储转发
第一个复杂的地方,违反了自身一致性。考虑变量 a 和 b 都初始化为 0,包含变量 a 的缓存行,最初被 CPU1 拥有,而包含变量 b 的缓存行最初被 CPU0 拥有。
1 a = 1;
2 b = a + 1;
3 assert(b == 2);
人们并不期望断言失败。可是,难道有谁足够愚蠢,以至于使用如果 C.5 所示的简单体系结构,这种体系结构是令人惊奇的。这样的系统可能看起来会按以下的事件顺序。
- CPU0 开始执行 a=1。
- CPU0 在缓存中查找 a,并且发现缓存缺失。
- 因此 CPU0 发送一个“读使无效”消息,以获得包含“a”的独享缓存行。
- CPU0 将 a 记录到存储缓冲区。
- CPU1 接收到“读使无效”消息,它通过发送缓存行数据,并从他的缓存行中移除数据来响应这个消息。
- CPU0 开始执行 b=a+1。
- CPU0 从 CPU1 接收到缓存行,它仍然拥有一个为 0 的 a 值。
- CPU0 从它的缓存中读取到 a 的值,发现其值为 0。
- CPU0 将存储队列中的条目应用到最近到达的缓存行,设置缓存行中的 a 的值为 1。
- CPU0 将前面加载的 a 值 0 加 1,并存储该值到包含 b 的缓存行中(假设已经被 CPU0 拥有)。
- CPU0 执行 assert(b==2),并引起错误。
问题在于我们拥有两个 a 的副本,一个在缓存中,另一个在存储缓冲区中。
这个例子破坏了一个重要的前提:即每个 CPU 将总是按照编程顺序看到他的操作。没有这个前提,结果将于直觉相反。因此,硬件设计者同情并实现了“存储转发”。在此,每个 CPU 在执行加载操作时,将考虑(或者嗅探)它的存储缓冲,如图 C.6。换句话说,一个特定的 CPU 存储操作直接转发给后续的读操作,而并不必然经过其缓存。
通过就地存储转发,在前面执行顺序的第 8 步,将在存储缓冲区中为 a 找到正确的值 1,因此最终 b 的值将是 2,这也正是我们期望的。
存储缓冲区及内存屏障
要明白第二个复杂性违反了全局内存序。开率如下的代码顺序,其中变量 a、b 的初始值为 0。
1 void foo(void)
2 {
3 a = 1;
4 b = 1;
5 }
6
7 void bar(void)
8 {
9 while (b == 0) continue;
10 assert(a == 1);
11 }
假设 CPU0 执行 foo 函数,CPU1 执行 bar 函数,再进一步假设包含 a 的缓存行仅仅位于 CPU1 的缓存中,包含 b 的缓存行被 CPU0 所拥有。那么操作属顺序可能如下:
- CPU0 执行 a=1、缓存行不在 CPU0 的缓存中,因此 CPU0 将 a 的新值放到存储缓冲区,并发送一个“读使无效”消息。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存内,因此发送一个“读”消息。
- CPU0 执行 b=1,它已经拥有了该缓存行(换句话说,缓存行要么处于 modified 要么处于 exclusive),因此它存储新的 b 值到它的缓存中。
- CPU0 接收到“读”消息,并且发送缓存行中的最近更新的 b 的值到 CPU1,同时将缓存行设置为 shared 状态。
- CPU1 接收到包含 b 值的缓存行,并将其值写到它的缓存行中。
- CPU1 现在结束执行 while(b==0) continue,引文它发现 b 的值为 1,它开始处理下一条语句。
- CPU1 执行 assert(a==1),并且,由于 CPU1 工作在旧的 a 的值,因此断言失败。
- CPU1 接收到 “读使无效”消息,并且发送包含 a 的缓存行到 CPU0,同时在它的缓存中,将该缓存行变成无效。但是已经太迟了。
- CPU0 接收到包含 a 的缓存行,并且及时将存储缓冲区的数据保存到缓存行中,CPU1 的断言失败受害于该缓存行。
在此,硬件设计者不能直接帮助我们,因为 CPU 没有方法识别那些相关联的变量,更不用说他们之间关联的方式。因此,硬件设计值提供了内存屏障指令,以允许软件告诉 CPU 这些关系的存在。程序必须修改以包含内存屏障。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
内存屏障 smp_mb 将导致 CPU 在刷新后续的存储到变量的缓存行之前,前面的存储缓冲被刷新。在继续处理之前,CPU 可能简单的停顿下来,直到存储缓冲区变为空;也可能是使用存储缓冲区来持有后续的存储操作,直到前面所有的存储缓冲区已经被保存到缓存行中。
后一种情况下,操作序列可能如下所示。
- CPU0 执行 a=1。缓存行不在 CPU0 的缓存内,因此 CPU0 将 a 的新值放到存储缓冲中,并发送一个“读使无效”消息。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不在它的缓存中,因此它发送一个“读”消息。
- CPU0 执行 smp_mb,并标记当前所有存储缓冲区的条目。即 a=1 这个条目。
- CPU0 执行 b=1。它已经拥有这个缓存行了。(即缓存行已经处于 modified 或 exclusive),但是在存储缓冲区中存在一个标记条目。因此,它不讲 b 的新值存放到缓存行,而是存放到存储缓冲区,这里 b 不是一个标记条目。
- CPU0 接收到“读”消息,随后发送包含原始 b 值的缓存行给 CPU1.它也标记该缓存行的复制为 shared 状态。
- CPU1 读取到包含 b 的缓存行,并将它复制到本地缓存中。
- CPU1 现在可以装在 b 的值了。但是,由于它发现其值仍然我 0,因此它重复执行 while 语句。b 的心智被安全的隐藏在 CPU0 的存储缓冲区中。
- CPU1 接收到“读使无效”消息,发送包含 a 的缓存行给 CPU0,并且是他的缓存无效。
- CPU0 接收到包含 a 的缓存行,使用存储缓冲区的值替换缓存行,将这一行设置为 modified 状态。
- 由于被存储的 a 是存储缓冲区中唯一被 smp_mb 标记的条目,因此 CPU0 能够存储 b 的新值到缓存行中,除非包含 b 的缓存行当前处于 shared 状态。
- CPU0 发送一个“使无效”消息给 CPU1。
- CPU1 接收到“使无效”消息,使包含 b 的缓存行无效,并且发送一个“使无效应答”消息给 CPU0.
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存中,因此它发送一个“读”消息给 CPU0.
- CPU0 接收到“使无效应答”消息,将包含 b 的缓存行设置成 exclusive 状态。CPU0 现在存储新的 b 值到缓存行。
- CPU0 接收到“读”消息,同时发送包含新的 b 值的缓存行给 CPU1。他也标记该缓存行的复制为“shared”状态。
- CPU1 接收到包含 b 的缓存行,并将它复制到本地缓存中。
- CPU1 现在能够装载 b 的值了,由于它发现 b 的值为 1,它对出 while 循环并执行下一条语句。
- CPU1 执行 assert(a==1),但是包含 a 的缓存行不再它的缓存中。一旦它从 CPU0 获得这个缓存行,它将使用最新的 a 的值,因此断言语句将通过。
正如你看到的那样,这个过程涉及不少工作。即使某些事情从直觉上看是简单的操作,就像“加载 a 的值”这样的操作,都会包含大量复杂的步骤。
存储序列导致不必要的停顿
不幸的是,每一个存储缓冲区相对而言都比较小,这意味着执行一段较小的存储操作序列的 CPU,就可能填满它的存储缓冲区(比如当所有的这些结果发生了缓存缺失时)。从这一点来看,CPU 在能够继续执行前,必须再次等待刷新操作完成,其目的是为了清空它的存储缓冲。相同的情况可能在内存屏障之后发生,内存屏障之后的所有存储操作指令,都必须等待刷新操作完成,而不管这些后续存储是否存在缓存缺失。
这可以通过使用“使无效应答”消息更快到达 CPU 来得到改善。实现这一点的方法之一是使用每 CPU 的使无效消息队列,或者成为“使无效队列”。
使无效队列
“使无效应答”消息需要如此长的时间,其原因之一是它们必须确保相应的缓存行确实已经变成无效了。如果缓存比较忙的话,这个使无效操作可能被延迟。例如,如果 CPU 密集的装载或者存储数据,并且这些数据都在缓存中。另外,如果在一个较短的时间内,大量的“使无效”消息到达,一个特定的 CPU 会忙于处理它们。这会使得其他 CPU 陷入停顿。
但是,在发送应答前,CPU 不必真正使无效缓存行。它可以将使无效消息排队,在发送各国的关于该缓存行的消息前,需要处理这个消息。
使无效队列及使无效应答
图 C.7 显示了一个包含使无效队列的系统。只要将使无效消息放入队列,一个带有使无效队列的 CPU 就可以迅速应答使无效消息,而不必等待相应的缓存行真的变成无效状态。当然,CPU 必须在准备发送使无效消息前引用它的使无效队列。如果一个缓存行对应的条目在使无效队列中,则 CPU 不能立即发送使无效消息,它必须等待使无效队列中的条目被处理。
将一个条目放进使无效队列,实际上是由 CPU 承诺,在发送任何与该缓存行相关的 MESI 协议消息前处理该条目。只要相应的数据结构不存在大的竞争,CPU 会很出色的完成此事。
但是,消息能够被缓冲在使无效队列中,该事实带来了额外的内存乱序机会,浙江在下一节讨论。
使无效队列及内存屏障
我们假设 CPU 将使用使无效请求队列,并立即响应它们。这个方法使得执行存储操作的 CPU 看到的缓存使无效消息的延迟降到最小,但是这会将内存屏障失效,看看如下示例。
假设 a 和 b 都被初始化为 0,a 是只读的(MESI 状态为 shared),b 被 CPU0 拥有(MESI 状态为 exclusive 或 modified)。然后假设 CPU0 执行 foo 而 CPU1 执行 bar,代码片段如下。
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 assert(a == 1);
12 }
操作顺序可能如下:
- CPU0 执行 a=1。在 CPU0 中,对应的缓存行是只读的,因此 CPU0 将 a 的新值放入存储缓冲区,并发送一个“使无效”消息,这是为了使 CPU1 的缓存中的对应缓存行失效。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不在它的缓存中,因此发送一个“读”消息。
- CPU1 接收到 CPU0 的“使无效”消息,将其排队并立即响应该消息。
- CPU0 接收到来自于 CPU1 的使无效消息,因此它放心的通过第 4 行的 smp_mb,从存储缓冲区移动 a 的值到缓存行。
- CPU0 执行 b=1。它已经拥有这个缓存行(也即是说,缓存行已经处于 modified 或者 exclusive 状态),因此它将 b 的新值存储到缓存行中。
- CPU0 接收到“读”消息,并且发送包含 b 的新值的缓存行到 CPU1,同时在自己的缓存中标记缓存行为“shared”状态。
- CPU1 接收到包含 b 的缓存行并且将其应用到本地缓存。
- CPU1 现在可以完成 while(b==0) continue,因为它发现 b 的值为 1,因此开始处理下一条语句。
- CPU1 执行 assert(a==1),并且,由于旧的 a 值还在 CPU1 的缓存中,因此陷入错误。
- 虽然陷入错误,CPU1 处理已经排队的使无效消息,并且(迟到)在自己的缓存中刷新包含 a 值的缓存行。
如果加速使无效响应会导致内存屏障被忽略,那么就没有什么意义了。但是,内存屏障指令能够与使无效队列交互,这样,当一个特定的 CPU 执行一个内存屏障时,它标记无效队列中的所有条目,并强制所有后续的装载操作进行等待,直到所有标记的条目都保存到 CPU 的缓存中。因此,我们可以在 bar 函数中添加一个内存屏障,具体如下:
1 void foo(void)
2 {
3 a = 1;
4 smp_mb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_mb();
12 assert(a == 1);
13 }
有了这个变化之后,操作顺序可能如下:
- CPU0 执行 a=1。对应的缓存行在 CPU0 的缓存中是只读的,因此 CPU0 将 a 的新值放入它的存储缓冲区,并且发送一个使无效消息以刷新 CPU1 对应的缓存行。
- CPU1 执行 while(b==0) continue,但是包含 b 的缓存行不再它的缓存中,因此它发送一个 读 消息。
- CPU1 接收到 CPU0 的使无效消息,将其排队并立即响应它。
- CPU0 接收到 CPU1 的响应,因此它放心的通过第 4 行 smp_mb 语句将 a 从他的存储缓冲区移到缓存行。
- CPU0 执行 b=1。它已经拥有该缓存行(MESI 状态为 modified 或 exclusive),因此它存储 b 的新值到缓存行。
- CPU0 接收到读消息,并且发送包含新的 b 值的缓存行给 CPU1,同时在自己的缓存行中,标记缓存行为 shared 状态。
- CPU1 接收到包含 b 的缓存行并更新到它的缓存中。
- CPU1 现在结束执行 while 循环,引文它发现 b 的值为 1,因此开始处理小一条语句,这是一条内存屏障指令。
- CPU1 必须停顿,知道它处理完使无效队列中的所有消息。
- CPU1 处理已经入队的 使无效 消息,从它的缓存中使无效包含 a 的缓存行。
- CPU1 执行 assert(a==1),由于包含 a 的缓存行已经不在它的缓存中,它发送一个 读 消息。
- CPU0 使用包含新的 a 值的缓存行来响应该读消息。
- CPU1 接收到该缓存行,它包含新的 a 的值 1,因此断言不会被触发。
即使有很多 MESI 消息传递,CPU 最终都会正确的应答。下一节阐述了 CPU 设计者为什么必须格外小心的处理它的缓存一致性优化操作。
读和写内存屏障
在前一节,内存屏障用来标记存储缓冲区和使无效队列中的条目。但是在我们的代码片段中 foo 没有必要进行使无效队列相关的任何操作,类似的,bar 也没有必要进行与存储缓冲区相关的任何操作。
因此,很多 CPU 体系结构提供更弱的内存屏障指令,这些指令仅仅做其中一项或者几项工作。不准确的说,一个“读内存屏障”仅仅标记它的使无效队列,一个“写内存屏障”仅仅标记它的存储缓冲区,而完整的内存屏障同时标记使无效队列和存储缓冲区。
这样的效果是,读内存屏障仅仅保证执行该指令的 CPU 上面的装载顺序,因此所有在读内存平展之前的装载,将在所有随后的装载前完成。类似的,写内存屏障仅仅保证写之间的属性怒,也是针对执行该指令的 CPU 来说。同样的,所有在内存屏障之前的存储操作,将在其后的存储操作完成之前完成。完整的内存屏障同时保证写和读之间的顺序,这也仅仅针对执行该内存屏障的 CPU 来说的。
我们修改 foo 好 bar,以使用读和写内存屏障,如下所示:
1 void foo(void)
2 {
3 a = 1;
4 smp_wmb();
5 b = 1;
6 }
7
8 void bar(void)
9 {
10 while (b == 0) continue;
11 smp_rmb();
12 assert(a == 1);
13 }
某些计算机甚至拥有更多的内存屏障,理解这三个屏障通常能让我们更好的理解内存屏障。
内存屏障示例
本节提供了一些有趣的、但是稍显不同的内存屏障用法。虽然他们能在大多数时候正常工作,但是其中一些仅能在特定 CPU 上运行。如果目的是为了产生哪些能在所有 CPU 上都能运行的代码,那么这些用法是必须要避免的。为了能够更好理解他们之间的细微差别,我们首先要关注乱序体系结构。
乱序体系结构
一定数量的乱序计算机系统已经被生产了数十年。不过乱序问题的实质十分微秒,真正理解它需要非常丰富的特定硬件方面的知识。与其针对一个特定的硬件厂商说事,这会把读者带到详细的技术规范中去,不如让我们设计一个虚构的、最大限度的乱序体系结构。
这个硬件必须遵循以下顺序约束:
- 单个 CPU 总是按照编程顺序来感知它自己的内存访问。
- 仅仅在操作不同地址时,CPU 才对给定的存储操作进行重排序。
- 一个特定的 CPU,在内存屏障之前的所有装载操作(smp_rmb)将在所有读内存屏障后面的操作之前被其他 CPU 所感知。
- 一个特定的 CPU,所有在写内存屏障之前的写操作(smp_wmb)都将在所有内存屏障之后的写操作之前被其他 CPU 所感知。
- 一个特定的 CPU,所有在内存屏障之前的内存访问(装载和存储)(smp_mb)都将在所有内存屏障之后的内存访问之前,被所有其他 CPU 感知。
假设一个大的非一致性缓存体系(NUCA)系统,为了给特定节点内部的 CPU 提供一个公平的内部访问带宽,在每一个节点的内连接口提供了一个每 CPU 队列,如图 C.8。虽然一个特定 CPU 的访问是由内存屏障排序的,但是,一堆相关 CPU 的相关访问顺序被严重的重排,正如我们将要看到的。
示例 1
表 C.2 展示了三个代码片段,被 CPU 0/1/2 并发执行,a、b、c 都被初始化为 0。
假设 CPU0 刚经过很多缓存缺失,因此它的消息队列是满的,但是 CPU1 在它的缓存中独占性运行,因此它的消息队列是空的。那么 CPU0 在向 a/b 赋值时,看起来节点 0 的缓存是立即生效的(因此对 CPU1 来说也是可见的),但是将阻塞于 CPU0 之前的流量。与之相对的是,CPU1 向 c 赋值时,由于 CPU1 的消息队列为空,因此可以很快执行。因此,CPU2 将在看到 CPU0 对 a 的赋值前,先看到 CPU1 对 c 的赋值,这将导致验证失败,即使有内存屏障也会如此。
可移植代码不能认为断言不会触发。由于编译器和 CPU 都能够重排代码,因此可能触发断言。
示例 2
表 C.3 展示了代码片段,在 CPU 0/1/2 上并行执行,a/b 均被赋值为 0。
我们再一次假设 CPU0 刚遇到很多缓存缺失,因此它的消息队列满了,但是 CPU1 在它的缓存中独占性运行,因此它的消息是空的。那么,CPU0 给 a 赋值将立即反映在及诶单 0 上,(因此对于 CPU1 来说也是立即可见的),但是将阻塞于 CPU0 之前的流量。相对的,CPU1 对 b 的赋值将于 CPU1 的空队列进行工作。因此,CPU2 在看到 CPU2 对 a 赋值前,可以看到 CPU1 对 b 的赋值。这将导致断言失败,尽管存在内存屏障。
从原理上来说,编写可移植代码不能用上面的例子,但是,正如前面一样,实际上这段代码可以在大多数主流的计算机上正常运行。
示例 3
表 C.4 展示了三个代码片段,在 CPU 0/1/2 上并行执行。所有变量均被初始化为 0。
请注意,不管是 CPU1 还是 CPU2 都要看到 CPU0 在第三行对 b 的赋值后,才能处理第 5 行,一旦 CPU 1 和 2 已经执行了第 4 行的内存屏障,他们就能看到 CPU0 在第 2 行的内存屏障前的所有赋值。类似的,CPU0 在第 8 行的内存屏障与 CPU1 和 CPU2 在第 4 行的内存屏障是一对内存屏障,因此 CPU0 将不会执行第 9 行的内存赋值,直到它对 a 的赋值被其他 CPU 可见。因此,CPU2 在第 9 行的 assert 将不会触发。
Linux 内核中的 synchronize_rcu 原语使用了类似于本地中的算法。
内存屏障是永恒的吗
已经有不少最近的系统,他们对于通常的乱序执行,特别是对乱序内存引用不大积极。这个趋势将会持续下去以至于将内存屏障变为历史吗?
赞成这个观点的人会拿大规模多线程硬件体系说事,这样一来每个线程都必须等待内存就绪,在此期间,可能有数十个、数百个甚至数千个线程在继续运行。在这样的体系结构中,没有必要使用内存屏障了。因为一个特定的线程在处理下一条指令前,将简单的等待所有外部操作全部完成。由于可能有数千个其他线程,CPU 将被完全利用,没有 CPU 周期会被浪费。
反对者则会说,极少量的应用有能力扩展到上千个线程。除此以外,还有越来越严重的实时响应需求,对某些应用来说,其响应需求是数十毫秒。在这种系统中,实时响应需求是难以实现的。而且,对于大规模多线程场景来说,机器低的单线程吞吐量更难以实现。
另一种支持的观点认为,更多的减少延迟的硬件实现技术会给 CPU 一种假象,使得 CPU 觉得按全频率、一致性的运行,这几乎提供了与乱序执行一样的性能优势。反对的观点则会认为,对于电池供电的设备及环境责任来说,这将带来严重的能耗需求。
没法下结论谁是对的,因此咱们还是准备同时接受两者吧。
对硬件设计者的建议
硬件设计者可以做很多事情,这些事情给软件开发者带来了困难。以下是我们在过去遇到的一些事情,在此列出来,希望能够帮助你防止在将来出现下列问题。
IO 设备忽略了缓存一致性
这个糟糕的特性将导致从内存中机械能 DMA 会丢失刚从输出缓冲区中对他进行的修改。同样不好的是,也导致输入缓冲区在 DMA 完成后被 CPU 缓存中的内容覆盖。要使你的系统在这样的情况下正常工作,必须在为 IO 设备准备 DMA 缓冲区时,小心刷新 CPU 缓存。类似的,在 DMA 操作完成后,你需要刷新所有位于 DMA 缓冲区的缓存。而且,你需要非常小心的避免指针方面的 BUG,因为错误的读取输入缓冲区可能会导致对输入数据的破坏。
外部总线错误的发送缓存一致性数据
该问题是上一问题的一个更难缠的变种,导致设备组甚至是在内存自身不能遵从缓存一致性。我痛苦的责任是通知你:随着嵌入式系统转移到多核体系,不用怀疑,这样的问题会越来越多。希望这些问题能在 2015 年得到处理。
设备中忽略了缓存一致性
这听起来真的无辜,毕竟中断不是内存引用,对吧?但是假设一个 CPU 有一个分区的缓存,其中一个缓存带非常忙,因此一直持有输入缓冲的最后一个缓存行。如果对应的 IO 完成中断到达这个 CPU,在 CPU 中引用这个缓存行的内存引用将返回旧值,再导致数据被破坏,在随后以异常转储的形式被发现。但是,当系统对引起错误的输入缓冲区进行转储时,DMA 很可能已经完成了。
核间中断(IPI)忽略了缓存一致性
当位于对应的消息缓冲区的所有缓存行,他们被提交到内存之前,IPI 就已经到达该目标 CPU,这可能会有问题。
上下文切换领先于缓存一致性
如果内存访问可以完全乱序,那么上下文切换就很麻烦。如果任务从一个 CPU 切迁移到另一个 CPU,而原 CPU 上的内存访问在目标 CPU 上还不完全可见,那么任务就会发现,它看到的变量还是以前的值,这会扰乱大多数算法。
过度宽松的模拟器和仿真器
编写模拟器或者仿真器来模拟内存乱序是很困难的。因此在这些环境上面运行得很好的软件,在实际硬件上运行时,将得到令人惊讶的结果。不幸的是,规则仍然是,硬件比模拟器和仿真器更复杂,但是我们这种状况能够改变。
我们再次鼓励硬件设计者避免这些做法。
3.4 - 七并发模型
3.4.1 - CH01-概述
基于摩尔定律的“免费午餐”时代已结束,为了让代码运行的更快,现在需要以软件的形式利用多核优势,发掘并行编程的潜力。
并发 vs. 并行
并发程序含有多个逻辑上的独立执行块,它们可以独立的并行执行,也可以串行执行。
并行程序解决问题的速度往往比串行程序快的多,因为它可以同时执行整个任务的多个部分。并行程序可能有多个独立执行块,也可能仅有一个。
并发是问题域中的概念——程序需要被设计成能够处理多个同时(或者几乎同时)发生的事件;而并行则是方法域中的概念——通过将问题中的多个部分并行执行来加速解决问题。
来自 Rob Pike 的经典描述:
- 并发是同一时间应对(dealing with)多件事情的能力。
- 并行是同一时间动手做(doing)多件事情的能力。
并发与并行经常被混淆的原因之一是,传统的“线程与锁”模型并没有显式的支持并行。如果要用线程与锁模型为多核进行开发,唯一的选择就是写一个并发的程序,然后并行的运行在多核上。
并发程序的执行通常是不确定的,它会随着事件时序的改变而给出不同的结果。对于真正的并发程序,不确定性是与生俱来且伴随始终的属性。与之相反,并行程序可能是确定的——比如将数组中的每个数都加倍,一种做法是将数组分为两个部分然后分别交给两个核处理,这种做法的结果是确定的。
并行架构
人们通常认为并行等同于多核,但现代计算机在不同层次上使用了并行技术。比如在由多个晶体管组成的单个核内,可以在位级和指令级两个层次上并行使用这些晶体管资源。
位级并行
因为并行,32 位计算机要比 8 位计算机的运算速度快。对于两个 32 位数的加法运算,8 位计算机必须进行多次 8 位运算,而 32 位计算机可以一步完成。计算机经历了 8、16、32 位时代,目前处于 64 位时代,由于位升级带来的性能提升存在瓶颈,因此我们短期内无法进入 128 位时代。
指令级并行
现代 CPU 的并行度很高,其中使用的技术包括流水线、乱序执行和分支预测等。
开发者可以不用关心 CPU 的内部并行细节,因为尽管 CPU 内部的并行度很高,但是经过精心设计,从外部看上去所有处理器都像是串行的。
但这种看上去像是串行的设计也逐渐变得不再适用。CPU 的设计者们为单核提升速度变得越来越困难。进入多核时代后我们必须要面对的情况是:无论表面上还是实质上,指令都不再是串行执行了。
数据级
数据级并行(也称单指令多数据,SIMD)架构,可以并行的在大量数据上施加同一操作。这并不适合解决所有问题,但在有些场景可以大展身手。
比如图像处理,为了增加图片亮度就需要增加每一个像素的亮度,现代 GPU 也因图像处理的特点演化成了极其强大的数据并行处理器。
任务级
这也是大家所认为的并行形式——多处理器。从开发者的角度看,多处理器架构最明显的分类特征是其内存模型(共享内存模型或分布式内存模型)。
- 对于共享内存模型,每个处理器都能访问整个内存,处理器之间的通信也通过内存完成。
- 对于分布式内存模型,每个处理器都拥有自己的内存,处理器之间的通信主要通过网络完成。
通过内存通信比通过网络通信更加简单快速,因此使用共享内存模型编写也更容易。但是当处理器个数不断增加,共享内存模型就会遇到瓶颈——这时不得不转向分布式内存模型。如果要开发一个容错系统,就要使用多台计算机以规避硬件故障对系统的影响,此时也必须借助分布式内存模型。
并发,不只是多核
并发的目的不仅仅在于让程序以并行方式运行以发挥多核优势。如果能够正确的使用并发,程序还能获得以下优点:及时响应、高效、容错、简单。
并发的真实世界
世界是并发的,为了与其有效交互,软件也应该是并发的。并发是系统及时响应的关键。比如,文件下载可以在后台运行,用户就不必等待鼠标上的沙漏了。再比如,Web 服务器可以并发的处理多个连接请求,一个慢请求不会影响服务器对其他请求的响应。
分布式世界
我们有时候需要解决地理分布问题。软件在非同步运行的多台计算机上分布式的运行,其本质也是并发。
此外,分布式软件还具有容错性。其中一台或一个区域内的几台机器宕机后,剩余机器仍然能够提供服务。
不可预测的世界
任何软件都无法避免 BUG 的存在,即便是没有 BUG,也无法完全避免硬件故障。为了增强软件的容错性,并发代码的关键是独立性和故障检测。
- 独立性:一个故障不会影响到故障任务以外的其他任务。
- 故障检测:当一个任务失败时,需要通知负责处理故障的其他任务来解决。
而串行程序的容错性远不如并发程序。
复杂的世界
在选对编程语言和工具的情况下,一个并发的解决方案要比串行方案简单清晰。
用串行方案解决一个现实世界的并发问题往往需要付出额外的代价,而且最终方案会晦涩难懂。如果解决方案有着与现实问题类似的并发结构,事情就会变得简单很多。
七种模型
- 线程与锁:存在很多总所周知的不足,但它是其他模型的基础,也是很多并发软件的首选。
- 函数式编程:函数式编程逐渐变得重要的原因之一,是其对并发和并行编程提供了良好的支持。函数式编程消除了可变状态,所以从根本上就是线程安全的,而且易于并行执行。
- 分离标识与状态:Clojure 是一种指令式与函数式混搭的语言,在两种编码方式上取得了微秒的平衡来发挥两者的优势。
- Actor:是一种适用性很广的并发编程模型,适用于共享内存模型和分布式内存模型,也适合解决地理分布问题,能提供强大的容错性。
- 通讯顺序进程(CSP):CSP 与 Actor 模型在表面上很相似,两者都基于消息传递。但是 CSP 模型侧重于传递信息的通道,而 Actor 模型侧重于通道两端的实体,因此基于 CSP 模型的代码会有明显不同的风格。
- 数据级并行:GPU 利用了数据级并行,不仅可以快速的处理图像,还可以用于更加广阔的领域。
- Lambda 架构:Lambda 架构综合了 MapReduce 和流式处理的特点,是一种可以处理多种大数据问题的架构。
后续将针对每种模型详细讨论以下问题:
- 该模型适用于解决并发问题?还是并行问题?还是两者都适用?
- 该模型适用于哪种并行架构?
- 该模型是否有利于写出高容错的代码,或是能够解决分布式问题的代码?
3.4.2 - CH02-线程与锁
虽然该模型稍显原始且难以驾驭、不够可靠还有点危险,但仍然是并发编程的首选项,也是其他并发模型的基石。
对硬件运行过程形式化
该模型其实是对底层硬件运行过程的形式化。这种形式化既是该模型的最大优点、也是其最大的缺点。
该模型非常简单直接,几乎所有编程语言都提供了对该模型的支持,且不对其使用方式加以限制。
互斥与内存模型
互斥:使用锁来保证某一时间仅有一个线程可以访问数据。它会带来竟态条件和死锁。
乱序执行的来源:
- 编译器和静态优化
- JVM 动态优化
- 底层硬件优化
从直觉上来说,编译器、JVM、硬件都不应该修改原有的代码逻辑,但是近几年的运行效率提升,尤其是共享内存架构的运行效率提升,均基于此类代码的优化。
Java 内存模型为这类优化提供了标准。
内存可见性
Java 内存模型定义了一个线程对内存的修改何时对另一个线程可见。基本原则是,如果读线程和写线程不进行同步,就不能保证可见性。
多把锁
很容易得出一个结论:让多线程代码安全运行的方法只能是让所有的代码都同步。但是这么做有两个缺点:
- 效率低下:如果每个方法都同步,大多数线程会频繁阻塞,也就失去了并发的意义。
- 死锁:哲学家就餐问题。
总结
- 对共享变量的所有访问都需要同步化。
- 读线程、写线程都需要同步化。
- 按照约定的全局顺序来获取多把锁。
- 当持有锁时避免调用外部方法(无法确保线程安全性)。
- 持有锁的时间尽可能的短。
更多同步机制
内置锁的限制
- 一个线程因为等待内置锁而进入阻塞之后,就无法中断该线程了。
- 尝试获得内置锁时无法设置超时。
- 必须通过 synchronized 块来获取内置锁。
可中断的锁
ReentrantLock 提供了显式的加解锁方法,可以在代码的不同位置来实现加解锁逻辑,这是 synchronized 块无法做到的。
同时,ReentrantLock 提供的 lockInterruptibly 方法可以用于终止死锁线程。
超时设置
ReentrantLock 还可以为获取锁的超时设置超时时间。
交替锁
设想我们要在链表插入一个节点,一种做法是用锁保护整个链表,但链表加锁时其他使用者无法访问该链表。而交替锁可以做到仅锁住链表的一部分,允许不涉及被锁部分的其他线程继续自由的访问链表。同样可以由 ReentrantLock 实现。
条件变量
并发编程经常需要等待某个事件发生。比如从队列删除元素前需要等待队列非空、向缓存添加数据前需要等待缓存拥有足够的空间。这时就需要条件变量 Condition。
一个条件变量需要与一把锁关联,线程在开始等待条件之前必须先获取这把锁。获取锁后,线程检查所有等待的条件是否为真。如果条件为真,线程将解锁并继续执行。
如果条件不为真,线程会调优 await 方法,它将原子的解锁并阻塞等待该条件。
当另一个线程调用了 signal 或 signalAll,意味着对应的条件可能已变为真,await 方法将原子的恢复运行并重新加锁。
原子变量
比如 AtomicInteger。与锁相比,原子变量有很多好处。首先,我们不会忘记在正确的时候获取锁;其次,由于没有锁的参与,对原子变量的操作不会引发死锁;最后,原子变量是无锁(lock-free)非阻塞(non-blocking)算法的基础,这种算法可以不使用锁和阻塞来达到同步的目的。
无锁代码比起有锁代码更加复杂,JUC 中的类都尽量使用了无锁代码。
总结
ReentrantLock 和 JUC.atomic 突破了使用内置锁的限制,可以利用它们做到:
- 在线程获取锁时将其中断。
- 设置线程获取锁时的超时时间。
- 按任意顺序获取和释放锁。
- 用条件变量来等待某个条件变为真。
- 使用原子变量来避免使用锁。
利用已有工具
线程池
比如,编写服务端应用时为每个连接请求创建一个线程,这样存在两个隐患:
- 创建线程是有代价的。
- 连接数的增长会使得线程数不断增长,而系统资源(如内存)是有限的。
可以使用线程池来对线程进行复用,JUC 提供了各种类型的线程池。
写时复制
比如 CopyOnWriteArrayList,它使用了保护性复制策略。它并非在遍历链表前进行复制,而是在链表被修改时复制,已经投入使用的迭代器会使用当时的旧副本。
其他概念
- 使用线程构建“生产者——消费者模型”。
- 毒丸(Poison Pill) 是一个特殊对象,告诉消费者“数据已取完,你可以退出了”。
- 使用线程构建“单生产者——多消费者模型”。
- 使用并发集合汇总多个消费者并发生成的结果。
- 使用线程池来优化线程的使用。
- 使用 ConcurrentHashMap 的分段锁优势,避免过多线程对单个资源的过度竞争。
- 为各个消费者提供各自的结构缓存,最后再汇总这些缓存,以避免没有必要的数据竞争。
总结
- 使用线程池,而不是直接创建线程。
- 使用写时复制让监听器先关的代码更简单高效。
- 使用同步队列构建生产者消费者模型。
- ConcurrentHashMap 提供了更好的并发访问。
本章总结
优点
- 适用面广,是许多其他技术的基础,更加接近于本质——近似对硬件工作方式的形式化,真确应用可以得到很高的效率。能够解决从小达到不同粒度的问题。
- 该模型可以被集成到大多数编程语言中。语言设计者可以轻易让一门指令式语言或 OO 语言支持该模型。
缺点
- 该模型没有为并行提供直接的支持。
- 该模型仅支持共享内存模型。如果要支持分布式内存模型则需要借助其他工具。
- 最大的缺点在于“无助”,应用开发者在编程语言层面没有得到足够的帮助。
隐性错误
应用多线程的难点不在编程,而在于难以测试。而测试中的一个大问题是难以复现。
随着项目的迭代和时间的流式,复杂的多线程代码会变得难以维护。
3.4.3 - CH03-函数式编程
CH03-函数式编程
命令式编程的代码由一些列该变全局状态的语句构成,而函数是编程则是将计算过程抽象成表达式求值。这些表达式由纯数学函数构成,这些作为一类对象(可以像操作数值一样操作函数)的数学函数没有副作用。因为没有副作用,函数式编程可以更容易做到线程安全,因此尤其适合用于并发编程。同时,函数式编程也是一个直接支持并行的模型。
函数式
线程与锁的模型中,核心是共享可变状态。而对不可变的数据,多线程不使用锁就可以安全的访问。
抛弃可变状态
- 可变状态的风险
- 被隐藏的可变状态
- 逃逸的可变状态
惰性
惰性序列不仅意味着仅在需要时候才生成尾元素,还意味着序列的头元素在使用后可以被丢弃。
函数式并行
基于 Clojure 的语言特性。
总结
- pmap 可以将映射操作并行化,构造一个半懒惰的 map。
- 利用 partition-all 可以对并行的映射操作执行批处理,从而提高效率。
- fold 使用分而治之的策略,可以将 reduce 操作并行化。
- clojure.core.reducers 包内提供的类似 map、mapcat、filter 的函数返回的不是序列,而是 reduciable,这是简化操作的关键。
函数式并发
在 Java 这类命令式语言中,求值顺序与源码的语句顺序紧密相关,虽然编译器和运行时都可能造成一些乱序,但一般来说,求值顺序与其在代码中的顺序基本一致。
函数式语言更有一种声明式的风格。函数式程序并不是描述“如何求值以得到结果”,而是描述“结构应当是什么样的”。因此,在函数式编程中,如何安排求值顺序来获得最终结果是相对自由的,这正是函数式编程可以轻松实现并行的关键所在。
引用透明性
指的是,在任何调用函数的位置,都可以使用函数运行的结构来替换函数的调用,而不会对程序产生影响。
虽然 Java 中有些操作也可以达到这样的效果,但函数式编程中的每个函数都具有引用透明性。当然,除了带有副作用的函数。
数据流
如上图中所示的数据流,由于 (+ 1 2) 和 (+ 3 4) 之间没有依赖关系,所以理论上这两步求值能以任意顺序进行,包括同时执行。前两步求值得到结果后,最优异步加法才能进行。
理论上,运行时可以从这幅图的左端出发,向右端推进数据。当一个函数所依赖的数据都可用时,该函数就可以执行了。至少在理论上所有函数都可以同时执行。这种方式被称为“数据流式编程”。
Clojure 语言中提供了 future 和 promise 来支持这种执行方式。即以操作数据流的形式完成并发编程任务。
总结
许多人对并行编程的理解存在一个误区:认为并行一定会伴随着不确定性,如果不串行执行就不能依赖某一种执行顺序的结果,必须时刻警惕竟态条件。
当然,有一些并发程序一定会带有不确定性。这对它们来说是不可避免的——有一些场景天生就依赖时序。但这并不意味着所有的并行程序都具有不确定性。
在使用线程与锁模型的程序中,大多数潜藏的竟态条件并不来自于问题本身的不确定性,而是因此在解决方案的细节中。
函数式编程具有引用透明性,因此可以随意改变其执行顺序,而不会对最终结果产生影响。我们可以顺理成章的让互相独立的函数并行执行。
关于函数式
开发者对编程语言的偏好很大程度上取决于语言的类型系统。使用 Java、Scala 之类的静态类型语言,与使用 Python、Ruby 之类的动态类型语言的体验是完全不同的。
静态类型语言强迫开发者在早期就必须选择正确的类型。只有付出这样的代价,编译器才能确保运行时不会发生类型错误,同时类型系统还可以优化执行效率。
在函数式编程中也存在这样的分歧。像 Haskell 这种静态类型的函数式语言利用 单子 和 幺半群 等数学概念为类型系统增加了以下能力:明确限制了某些函数和某些值可以使用的位置,在保持函数性的同时能够检测代码的副作用。
但 Clojure 并不拥有静态类型系统。
想要深入学习函数式理论可以尝试学习 Haskell,如《趣学 Haskell》。
想要在生产中应用函数式编程则可以尝试学习 Scala,如《Scala 函数式编程》。
优点
函数式编程的最大好处是我们可以确信程序会按照我们预想的方式运行。一旦上手,比起等价的命令式程序,函数式会更加简单、更易推理、更易测试。
如果采用了函数式解决方案,利用函数式的引用透明性,可以轻松将程序并行化,或者将程序应用于并发环境。由于函数式的不可变特性,大部分存在于线程与锁的 BUG 将销声匿迹。
缺点
很多人认为函数式代码比起命令式代码的效率低。对于某些场景确实存在性能损失,但大部分性能损失是远低于预期的。而且用少许性能损失来换取健壮性和扩展性的提升是值得的。
而且,函数式的优点也远远不止于体现在并发编程上。
3.4.4 - CH04-分离标识与状态
在此要特别强调不纯粹的函数是语言与命令式语言的区别。在命令式语言中,变量默认都是状态易变的,代码会经常修改变量。而在不纯粹的函数式语言中,变量默认是状态不易变的,代码仅在十分必要时才修改变量。
本节将介绍如何使用可变量和持久数据结构来分离状态与标识。采用这些技术,多线程可以不使用锁来访问可变量,同时也不会出现隐藏可变状态或逃逸可变状态。
基本组件
原子变量
持久数据结构
这里所说的持久并不是指将数据持久化到磁盘或保存到数据库中,而是指数据结构在被修改时总是保留其之前的版本,从而为代码提供一致的数据视角。
持久数据结构在被修改时,看上去就像是创建了一个完整的副本。如果持久数据结构在实现时也是创建完整的副本,将会非常低效且带来很大的使用限制。幸运的是,持久数据结构选择了更精巧的方法,即“共享结构”。
比如创建一个列表:
(def listv1 (list 1 2 3))
先现在使用 cons 创建一个上述列表的修改版,cons 返回列表的副本并在副本的首部添加一个元素:
(def listv2 (cons 4 listv1))
新列表可以完全共享原列表的元素——不需要进行复制,如上图所示。
下面再尝试创建一个修改版:
(def listv3 (cons 5 (rest listv1)))
这时仅共享了原始列表的部分元素,但扔不需要进行复制。有些情况下是无法避免复制的。有共同尾端的列表可以共享结构——如果两个列表拥有不同的尾端,就只能进行复制了。
(def listv1 (list 1 2 3 4))
(def listv2 (take 2 listv1))
在 Clojure 中集合都是持久的。持久的 vector、map、set 在实现上都比列表复杂,但它们都使用了共享结构,且与 Ruby 或 Java 中对应的数据结构心梗接近。
标识与状态
如果一个线程引用了持久数据结构,那么其他线程对数据结构的修改对该线程就是不可见的。因此持久数据结构对并发编程的意义非比寻常,其分离了标识(inentity)与状态(state)。
油箱中有多少油呢?现在可能有一半油,一段时间后可能就空了,再后来可能又满了。“油箱中有多少油”是一个标识,其状态是一直在改变的,也就是说,实际上它是一系列不同的值。
命令式语言中,变量混合了标识与状态——一个标识只能拥有一个值。这让我们很容易忽略一个事实:状态实际上是随时间变化的一系列值。持久化数据结构将标识与状态进行了分离——如果获取了一个标识的当前状态,无论将来对这个标识怎样修改,获取的那个状态将不会改变。
重试
由于 Clojure 是函数式语言,其原子是无锁的——内部使用了 JUC.AtomicReference 提供的 compareAndSet 方法。因此使用原子变量的效率很高且不会发生阻塞,因此也不会有死锁。
但这要求 swap!(用于更新原子变量的值)需要处理这种情况:当 swap! 调用其参数函数来生成新值、但尚未修改原子变量的值时,其他线程就修改了原子变量的值。如果发生了这种情况,swap! 就需要重试。swap! 将放弃从参数函数中生成的值,并使用原子变量的新值来重新调用参数函数。因此这要求该参数函数必须没有副作用——否则,多次重试时也会多次引起这些副作用。
校验器
在创建原子变量时可以提供一个校验器。校验器是一个函数,当改变原子变量的值时就会调用它。如果校验器返回 true,就允许本次修改,否则就放弃本次修改。
校验器在原子变量的值改变生效之前被调用。与“重试”机制中传给 swap! 的参数函数类似,当 swap! 进行重试时,校验器可能会被调用多次,因此校验器不能有副作用。
监视器
可以为原子变量添加一个监视器。添加监视器时需要提供一个键值和一个监视函数。键值用于区分不同的监视器。原子变量的值被改变时会调用监视器。监视器接收四个参数——调用 add-watch 时指定的键值、原子变量的引用、原子变量的旧值、原子变量的新值。
与校验器不同,监视器是在原子变量的值改变之后才被调用,且无论 swap! 重试多少次,监视器仅会被触发一次。因此监视器可以拥有副作用。注意:监视器被调用时,原子变量的值可能已被再次改变,因此监视器必须使用参数中提供的(触发时的)新值,而不能通过对原子变量进行解引用来获取(当前的)新值。
代理与软件事务内存
下面介绍两种可变数据类型:代理(agent) 和引用(ref)。与原子变量性质相同,代理和引用都可以用于并发,也能与持久数据结构一起使用,以实现标识与状态的分离。学习引用时将介绍 Clojure 中实现的对软件事务内存的支持,使变量在无锁的情况下可以被并行的修改,同时仍保持一致性。
代理
与原子变量类似,代理包含了对一个值的引用。可以通过 deref 或 @ 获取其值。与 swap! 类似,send 接受一个函数,并用代理的当前值作为参数来调用该函数,函数的返回值再作为代理的新值。
send 与 swap! 的区别是,前者会(在代理的值更新之前)立即返回——传给 send 的函数将在某个时间点被调用。如果多个线程同时调用 send,传给 send 的函数将被串行调用:同一事件只会调用一个。也就是说该函数不会进行重试,并且可以具有副作用。
与 Actor 相似?两者存在很大的差异:
- 通过 deref 可以获得代理的值,而 actor 没有提供直接获取值的方式。
- actor 可以包含行为,而代理不可以:对数据的操作函数必须由调用者提供。
- actor 提供了复杂的错误检测和错误恢复机制,而代理仅提供了简单的错误报告机制。
- 使用多个 actor 可能会引起死锁,但使用多个代理不会。
send 的异步更新机制相比同步优势明显,尤其是当更新操作会发生阻塞或需要持续很久时。但异步更新也很复杂,尤其是在错误处理方面。
在 Clojure 中,一旦代理发生错误,就会进入失效状态,之后对代理数据的任何操作都会失败。
创建代理时其默认的错误处理模式为 fail。也可以将错误处理模式设置为 continue,这意味着失效状态的代理不再需要通过 restart-agent 重置就可以继续新的操作。如果设置了错误处理函数,错误处理模式会被默认设置为 continue,代理出现错误时则会调用错误处理函数。
软件事务内存
引用(ref)比原子变量或代理更加复杂,通过引用可以实现软件事务内存(STM)。通过原子变量和代理每次仅能修改一个变量,而通过 STM 可以多多个变量进程并发一致的修改,就像数据库中的事务可以对多行数据进行并发一致的修改一样。
引用也是包装了对一个值的引用,使用 deref 或 @ 获取值;使用 alter 函数来修改引用的值,但不同于 swap! 或 send,使用时不能只是简单的被调用。因为只能在一个事务中才能修改引用的值。
事务
STM 事务具有原子性、一致性、隔离性。
- 原子性:在其他事务看来,当前事务的副作用要么全部发生,要么都不发生。
- 一致性:事务保证全程遵守校验器定义的规范,如果事务的一系列修改中存在一个校验失败,那么所有的修改都不会发生。
- 隔离性:多个事务可以同时运行,但同时运行的事务的结果,与串行运行这些事务的结构应当完全一样。
这三个性质是许多数据库支持的 ACID 特性中的前三个,唯一遗漏的性质是——持久性,STM 的数据在电源故障或系统崩溃时会丢失。如果需要用到持久性则完全可以直接使用数据库。
隔离性选择
大多数场景适合使用完全隔离的事务,但对于有些场景来说,隔离性是个过强的约束。如果使用 commute 替换 alter,就可以得到不那么强的隔离性。
多个引用
事务通常会涉及多个引用,否则应该使用原子变量或代理。
对,你猜对了,又是银行转账的例子。
如果 STM 运行期间检测到多个并发事务的修改发生冲突,那其中一个或几个事务将进行重试。就像修改原子变量一样,需要保证事务没有副作用(除了更新引用的值意外的其操作)。
重试事务
基于无锁的重试,可以避免死锁。
事务的安全副作用
代理具有事务性。如果在事务中使用 send 来更新一个代理,那么 send 仅会在事务成功时生效。如果需要在事务成功时产生一些副作用,那 send 将是最佳选择。
适用场景
Clojure 对共享可变状态的三种支持机制:
- 原子变量:可以对一个值进行同步更新,同步的意思是当 swap! 调用返回时更新已经完成。无法对多个变量进行一致性更新。
- 代理:对一个值进行异步更新,异步的意思是更新可能在 send 返回后完成。对多个代理不能一致更新。
- 引用:可以对多个值进行一致的、同步的更新。
原子变量还是 STM
当解决一个涉及多个值需要一致更新的问题时,即可以使用多个引用并通过 STM 来保证一致性,也可以将这些值整合到一个数据结构中并用一个原子变量管理这个单个数据结构的访问一致性。
该如何选择呢?答案是因人而异,两种方案都正确,尽量选择简单的,比如数据结构肯能会很复杂。在性能上,根据使用场景的特点和数据访问模式的不同,肯定会存在差异,所以需要有效的压力测试进行评估。
虽然 STM 带有很多光芒,但就 Clojure 而言,由于语言的函数性减少了对可变量的使用,因此大部分问题都可以使用原子变量来解决。而更简单的方案通常会更有效。
总结
优点
传统的命令式语言混淆了标识与状态这两个概念,而 Clojure 的持久数据结构将可变量的标识与状态分离开来。这解决了基于锁的方案的大部分缺点。
缺点
基于 Clojure 方式的并发编程不支持分布式编程,因此也无法直接提供容错性。好在 Clojure 运行于 JVM,可以使用一些第三方库来解决该问题,比如 Akka。
其他语言
Haskell 提供了类似本章的功能,不过作为一种纯粹的函数式语言,它的风格会带来一种非常不同的编程体验。值得一提的是 Haskell 提供了完整的 STM 实现。可以参考 Beautiful Concurrency。
另外,大部分主流语言都提供了 STM 实现,包括 GCC 支持的编程语言。但是有证据表明,STM 模型并不适合于命令式编程语言。
3.4.5 - CH05-Actor
使用 Actor 就像租车——如果我们需要,可以很快速的租到一辆;如果车辆发生故障,也不需要自己修理,直接换一辆即可。
Actor 模型是一种适用性非常好的通用并发编程模型。它可以应用于共享内存架构和分布式内存架构,适合解决地理分布问题,同时还能提供很好的容错性。
更加面向对象
函数式编程使用可变状态,也就避免了共享可变状态带来的一系列问题。相比之下,使用 Actor 模型保留了可变状态,但不将其共享。
Actor 类似于 OOP 中的对象——其中封装了状态,并通过消息与其他 Actor 通信。两者的区别是所有 Actor 可以同时运行,而且,与 OO 式的“消息传递(实质上是方法调用)”不同,actor 之间是真实的在传递消息。
Actor 模型是一个通用的并发编程模型,几乎可以用在任何一种编程语言里,最典型的是 Erlang。而我们将使用 Elixir 来介绍 actor 模型,它是 Erlang 虚拟机(BEAM)上一种较新的编程语言。
与 Clojure 相比,Elixir 是一种不纯粹的、动态类型的函数式语言。
消息与信箱
在 Elixir 中,进程是一个轻量级的概念,比操作系统的线程还要轻,它消耗更少的资源且创建代价很低。Elixir 程序可以毫不困难的创建数千个进程,通常不需要依赖线程池技术。
对列式信箱
异步的发送消息是使用 actor 模型的重要特性之一。消息并非直接发送到一个 actor,而是发送到一个 mailbox。
这样的设计解耦了 actor 之间的关系——actor 都以自己的步调运行,发送消息时也不会被阻塞。
虽然所有 Actor 可以同时运行,但它们都按照信箱接收到消息的顺序来依次处理消息,且仅在当前消息处理完成之后才会开始处理下一条消息,因此我们只需要关心发送消息时的并发问题即可。
接收消息
def loop do
receive do
{:greet, name} -> IO.puts("Hello #{name}")
{:praise, name} -> IO.puts("#{name}, you're amazing")
{:celebrate, name, age} -> IO.puts("Here's to another #{age} years, #{name}")
end
loop
end
通常 actor 会进行无限循环,通过 receive 等待接收消息,并进行消息处理。在 Elixir 的 actor 实现中,内部的一个函数通过递归调用自己来进行无限循环,用 receive 来等待一个消息,通过模式匹配来决定如何处理消息。这
Elixir 实现了尾调用消除,即,如果函数在最后调用了自己,那么递归调用将被替换成一个简单的跳转,这样可以避免递归引起的堆栈移除。
连接到进程
为了彻底关闭一个 actor,需要满足两个条件。第一个是需要告诉 actor 在完成消息处理后就关闭;第二个是需要知道 actor 何时完成关闭。
首先,通过接收一个显式的关闭消息来满足第一个条件:
receive do
...
{:shutdown} -> exit(:normal)
...
然后,通过一个方法来获知 actor 是否完全关闭。下面的代码将 :trap_exit
设为 true,并用 spawn_link 替换 spawn 以连接到进程:
Process.flag(:trap_exit, true)
pid = spawn_link(&Talker.loop/0)
现在当创建的进程关闭时,就会得到一个通知(是一个系统产生的消息)。
双向通信
Actor 是以异步的方式发送消息的——发送者因此不会被阻塞。那么如何获得一个消息的回复呢?
Actor 模型没有提供直接回复消息的机制,但我们可以轻松实现:将发送进程的标示符包含在消息中,接收者接收到消息后提取其中的标识符,然后向该标识符表示的进程发送回复消息。
为进程命名
将一个消息发送给某个进程时,需要知道进程的标示符。当我们自己创建进程时没有问题,但如何向别人创建的进程发送消息呢?最简单的方式就是为进程命名。
错误处理与容错
错误检测
前面我们使用 spawn_link 建立了两个进程之间的连接,这样就可以检测到某个进程的终止。Linking 是 Elixir 编程中的一个重要概念。
- 进程的异常终止通过连接进行传播。
- 连接是双向的。
- 正常终止时不影响相连接的其他进程。
- 通过设置 trap_exit 标识可以让一个进程捕获到另一个进程的终止消息,即,将该进程转化为系统进程。
管理进程
可以创建一个系统进程来管理其他若干个进程。
错误处理内核模式
Tony Hoare 有一句名言: 软件设计有两种方式:一种是使软件过于简单,明显的没有缺陷;另一种是使软件过于复杂,没有明显的缺陷。
Actor 提供了一种容错的方式:错误处理内核模式。在两者之间找到了一种平衡。
一个软件系统如何应用了错误处理内核模式,那么使该系统正确运行的前提是其错误处理内核必须能够正确运行。程序的程序通常使用尽可能小的错误处理内核——小而简单到明显没有缺陷。
对于一个使用 actor 模型的程序,其错误处理内核是顶层的管理者,管理着子进程——对子进程进行启动、停止、重启等操作。
程序的每个模块都有自己的错误处理内核——模块正确运行的前提是其错误处理内核必须正确运行。子模块也会拥有自己的错误处理内核,依次类推。这就构成了一个错误处理的层级树,较危险的操作都会被下放给底层的 actor 执行。
错误处理内核机制主要解决了防御式编程中碰到的一些棘手问题。
任其崩溃
防御式编程主要通过预言可能出现的缺陷来实现容错性。使用 actor 模型并不需要使用防御式编程,而是遵循“任其崩溃”的哲学,让 actor 的上层管理者来处理这些问题。这样做的优势在于:
- 代码会变得更加简洁从而易于理解,可以清晰区分稳定代码和脆弱代码。
- 多个 actor 之间是相互独立的,并不共享状态,因此一个 actor 的崩溃不太会殃及到其他 actor。尤其重要的是一个 actor 的崩溃不会影响到其管理者,这样管理者才能正确处理此次崩溃。
- 管理者也可以选择不处理崩溃,而是记录崩溃的原因,这样我们就会得到崩溃通知并进行后续处理。
分布式
相比已经介绍过的并发模型,actor 模型的一个重大优点是它支持分布式——它可以将消息发送到另外一台计算机,就像发送到本地计算机上的 actor 一样。这被称为地理位置透明。
OTP
上面演示的代码过于底层,而 OTP 为使用 Actor 模型提供更多工具。
- 更简便的消息匹配。
- 进程管理。
- 更好的重启逻辑。
- 调试与日志。
- 代码热升级。
- 发布管理、故障切换、自动扩容等。
主要概念包括:
- 节点
- 连接节点
- 远程执行
- 远程消息
- 等等。
总结
优点
- 消息传递与封装
- 容错
- 分布式编程
缺点
Actor 除了优点,也会带来它独有的一些问题。
Actor 模型并没有直接提供并行支持,事实上可以自己构造,但由于 actor 之间不共享状态,仅通过消息传递进行交流会不太适合实施细粒度的并行。
个人认为应用 Actor 模型的最大障碍是开发者们的思维方式转变,尤其对于一个以 Java 这种命令式语言作为生产语言的团队来说。
另外,如果想要深入理解 Actor 模型,可以直接参考 Erlang/OTP,如果想要在生产中构建基于 Actor 模型的项目,推荐使用运行于 JVM 的 Akka,当然,如果同时使用 Scala 语言就更好了,因为 Java 中一贯的编程思路如共享可变状态、对象可变等不利于使用 Actor 模型。
3.4.6 - CH06-CSP
CSP 看上去类似于 Actor,但最大的区别在于:actor 模型的重点在于参与交流的实体,而 CSP 模型的重点在于用于交流的通道。
大家都在跌跌不休的争论涡轮增压与自然吸气孰优孰劣,让中置发动机布局与前置发动机布局一较高下,却忘记了最重要的方面其实与车辆本身无关。你能去往何方、能多快到达目的地,首要的决定因素是道路网络而不是车辆本身。
消息传递系统与之类似,决定其特性和功能的首要因素并不是用于传递消息的代码或消息的内容,而是消息的传输通道。
万物皆通信
使用 actor 模型的程序是由独立的、并发执行的实体组成,这些实体之间通过发送消息进行通信。每个 actor 都有一个信箱,用于保存已经收到但尚未被处理的消息。
与 actor 模型类似,CSP 模型也是由独立的、并发执行的实体组成,实体之间也是通过发送消息进行通信。但两种模型的重要差别在于:CSP 模型不关注发送消息的实体,而是关注发送消息时使用的 channel(通道)。通道是第一类对象,它不想 actor 的信箱一样与实体紧耦合,而是可以单独创建和读写,并在进程之间传递。
与函数式编程和 actor 模型类似,CSP 模型也是正在复兴的古董。由于近来 Go 语言的兴起,CSP 模型又流行了起来。
channel 与 go block
core.async 库将 Go 的并发模型引入了 Clojure,channel 与 go block 是其提供的主要工具。在大小有限的线程池中,go block 允许多个并发任务复用线程资源。
channel
一个 channel 就是一个线程安全的队列——任何任务只要持有 channel 的引用,就可以向其一端添加消息,也可以从另一端删除消息。在 actor 模型中,消息是从指定的 actor 发往指定的另一个 actor;与之不同,使用 channel 发送消息时发送者并不知道谁是接收者,反之亦然。
缓存区
默认情况下,channel 是同步的(或称无缓存的)——一个任务向 channel 写入消息的操作会一直阻塞到另一个任务从 channel 中删除该消息。
如果向创建 channel 的 chan 函数传入缓存区大小,就可以创建一个有缓存的 channel。当缓存没有被消息填满时,向其写入消息会理解返回,不会阻塞。
关闭
close!
可以关闭一个 channel。从已经关闭的空的 channel 中读出消息将会得到 nil;向已经关闭的 channel 写入消息时,消息将会被丢弃,写入 nil 则会报错。
缓存已满
默认情况下,向一个缓存已满的 channel 写入消息将会一直被阻塞。但通过向 chan 函数传入缓冲区来改变这个策略。
- default:阻塞
- dropping-buffer:满时丢弃,不再阻塞
- sliding-buffer:启用已有消息,使用新消息填充,不再阻塞
go block
线程创建与启动都会带来开销,这也正是使用线程池的原因。但是线程池并非总是适用,尤其是当线程可能会被阻塞时,使用线程池则可能会带来麻烦。
阻塞问题
线程池技术是处理 CPU 密集型任务的利器——任务进行时会占用某个线程,任务结束后将线程返还给线程池,以使得线程能够被复用。但涉及线程通信时使用线程池是否合适呢?如果线程被阻塞,那么它将被无限期占用,这就削弱了使用线程池技术的优势。
这种问题是存在解决方案的,但通常会对代码风格加以限制,使之变成事件驱动式编程。事件驱动是一种编程风格。
虽然这些方案能够解决问题,但破坏了控制流的自然表达形式,让代码变得难以阅读和理解。更糟的是,这些方案还会大量使用全局状态,因为事件处理器需要保存一些数据,以便之后的事件处理器使用。我们已经学习过这个结论了:状态与并发不要混用。
go block 提供了一种两全其美的解决方案——既可以写出事件驱动的代码来解决目前遇到的阻塞问题,又可以不牺牲代码的结构性和可读性。其原理是 go block 在底层将串行的代码透明的重写成了事件驱动的形式。
控制反转
与其他 Lisp 方言类似,Clojure 有一套强大的宏系统。如果你使用过其他语言的宏系统,就会觉得 Lisp 的宏更像是魔法,它可以进行神奇的代码变换。go 宏就是其中一个小魔法。
go block 中的代码会被转换成一个状态机。当从 channel 中读出消息或向 channel 中写入消息时,状态机将暂停,并释放它所占用的线程的控制权。当代码可以继续运行时,状态机进行一次状态转换,并可能在另一个线程中继续运行。
通过这样的控制反转,core.async 运行时可以在有限的线程池中高效的运行多个 go block。
状态机暂停
channels.core=> (def ch (chan))
#'channels.core/ch
channels.core=> (go
#_=> (let [x (<! ch)
#_=> y (<! ch)]
#_=> (println "Sum:" (+ x y))))
#<ManyToManyChannel clojure.core.async.impl.channels.ManyToManyChannel@13ac7b98>
channels.core=> (>!! ch 3)
nil
channels.core=> (>!! ch 4) nil
Sum: 7
这段代码首先创建了一个名为 ch 的 channel。然后创建了一个 go block,用来从 ch 中读取两个值,再输出两个值之和。虽然看上去 go block 从 channel 中读取数据时应当阻塞,实际上却发生了有趣的事情。
这段代码并没有使用 <!!
从 channel 中读取数据,而是使用了 <!
。单个谈好意味着本次读 channel 是进行暂停操作,而不是进行阻塞操作。
如下图所示,go block 将串行的代码转换成拥有 3 个状态的状态机:
该状态机包含以下 3 个状态:
- 初始状态会直接暂停,等待 ch 中有数据可以被读取。满足条件时进入状态 2。
- 状态机首先从将 ch 读取的值绑定到 x 上,然后暂停,等待 ch 中下一个可以被读取的数据。满足条件时,进入状态 3。
- 状态机将从 ch 中读取的值绑定到 y 上。输出计算结构,然后终止。
go block 的成本很低
go block 的只要意义在于其效率。与使用线程不同,使用 go block 的成本很低,因此可以创建很多个而不用担心耗尽资源。这看上去是个小小的改进,但实际上不用担心资源而能随意创建并发任务有着革命性的意义。
你可能已经注意到 go block 返回的是一个 channel,go block 运行完成时会将结果写入到这个 channel 中。
经过试验,创建并运行 10 万个 go block 仅需 3/4 秒。这意味着 go block 的性能比起 Elixir 的进程毫不逊色——该成绩非常优秀,因为 Elixir 运行在以并发性能为设计主旨的 Erlang 虚拟机中,而 Clojure 却运行于 JVM。
总结
优点
与 Actor 模型相比,CSP 模型的最大优点是灵活性。使用 actor 模型时,负责通信的媒介与执行单元是紧耦合的——即 actor 的信箱。而使用 CSP 模型时,channel 是第一类对象,可以被独立的创建、写入、读取,也可以在不同的执行单元中传递。
Clojure 语言的创始人 Rich Hickey 解释了他选择 CSP 而非 actor 的原因:
我个人对 actor 模型并不感兴趣。在 actor 模型中,生产者与消费者还是紧耦合在一起的。诚然,我们可以使用 actor 模型实现消息通信用的队列,但是 actor 模型本身就已经使用了队列,用它来实现基础的消息通信用的队列未免显得画蛇添足。
从更务实的角度来说,现在的 CSP 模型的实现,比如 core.async 库,使用了控制反转技术,不仅提高了异步程序的效率,还为原本使用回调函数来解决的应用领域提供了一种显著改进的编程模型。
缺点
基于 CSP 模型的编程语言也可以支持分布式和容错性,但与基于 actor 模型的编程语言不通,这两个主题没有得到足够多的重视和支持——也没有基于 CSP 模型实现的 OTP。
与使用线程锁模型和 actor 模型一样,CSP 模型也容易受到死锁影响,且没有提供直接的并行支持。使用 CSP 模型时,并行需要建立在并发的基础上,这也就引入了不确定性。
结语
CSP 模型和 Actor 模型各自的开发社区侧重点不同并各自发展,从而形成了两者之间的诸多差异。Actor 模型的开发社区侧重于容错性和分布式,而 CSP 模型的开发社区侧重于效率和代码表达的流畅性。
如果为 Actor 模型引入 CSP 形式的流畅性呢?
3.4.7 - CH07-数据并行
数据并行就像是八车道的高速公路,虽然每辆车的速度相对平缓,但由于多辆车可以同时行进,所以通过某一点的车流量可以很大。
到目前为止,我们讨论的每一项技术都可以用于解决多种编程问题。相比之下,数据并行只适用于很窄的范围。顾名思义,数据并行是并行编程技术,而不是并发编程技术。
GPGPU
图形处理单元(GPU)是隐藏在电脑中的超级计算机。现代 GPU 是一个强力的数据并行处理器,其用于数学计算时的性能超越 CPU,这种做法称为基于图形处理器的通用计算,即 GPGPU。
图形处理与数据并行
计算机图形学主要研究如何处理数据、如何处理大量数据以及如何快速处理大量数据。3D 游戏的一个场景是由无数个小三角构成的,每个三角形都需根据与视点的透视关系计算出其在屏幕上的位置,并进行裁剪、光照处理、修饰纹理等,这些操作每秒钟都要进行 25 次以上。
虽然需要处理的计算量是很大的,但它有一个非常好的特性:施加在数据上的操作都s’s是相对姜丹的向量操作或矩阵操作。因此这种场景非常适合数据并行——多个计算资源会在不同的数据上并行施加相同的操作。
现代 GPU 是十分复杂但非常强力的并行处理器,其 1 秒钟可以处理几十亿个三角形。虽然设计 GPU 的主要目的是为了满足图形计算的需要,但是 GPU 也可用于更广的领域。
数据并行可以通过多种方式来实现,我们要学习其中两种:流水线和多 ALU。
流水线
虽然看上去两数相乘是一个原子操作,但如果从芯片上的门电路角度看,这个操作实际上是分几步完成的。这些操作通常被排列成流水线型,如下图:
上图是一个拥有 5 个步骤的流水线,如果每一步需要一个时钟周期来完成,那将一组数(两个数)相乘就需要 5 个时钟周期。但如果有多组数相乘,就可以通过让流水线饱和来获得更好的性能,如下图:
如果需要将 1000 组数相乘,每组数需要 5 个时钟周期,看上去总共需要 5000 个时钟周期,而如上图所示,仅需要略多于 1000 个时钟周期即可完成。
多 ALU
CPU 中负责进行乘法运算的组件称为算术逻辑单元,即 ALU,如下图:
只需要搭配足够多的内存总线,多个 ALU 就可以同时获取多个操作数,这样施加在大量数据上的运算就可以并行了,如下图:
GPU 的内存总线通常有 256 位或更宽,也就是说一次可以获取 8 个或更多个 32 位的浮点数。
混乱的局面
为了获得更好的性能,现实中的 GPU 会综合使用流水线、多 ALU 以及许多本书尚未提及的技术,这就进一步增加了理解 GPU 的难度。更遗憾的是,不同的 GPU 之间的共性很少,如果必须针对某个 GPU 架构开发代码,GPGPU 编程并非最佳选择。
OpenCL 定义了一种类 C 的语言,可以针对多种架构抽象的进行编程。不过的 GPU 厂商会提供各自的编译器和驱动程序,使代码可以被编译并运行在对应的 GPU 上。
总结
优点
数据并行非常适用于处理大量数值数据,尤其适合用于科学计算、工程计算及仿真领域,比如流体力学、有限元分析、N 体模型、模拟退火、蚁群优化、神经网络等。
GPU 不仅是强大的数据并行处理器,在能耗方面也变现出众,比传统的 CPU 有更加优秀的 GFLOPS/watt 指标。世界上最快的超级计算机都广泛使用 GPU 或专用数据并行协处理器,其中能耗指标低是一个重要的原因。
缺点
数据并行编程,更准确的说是 GPGPU 编程,在其适合领域内所向披靡。但并不适用于所有问题领域。值得一提的是,虽然用数据并行可以解决一些非数值问题(如自然语言处理),但这样做并不容易——现今的工具集绝大多数关注的是数值处理。
对 OpenCL 内核的调优是个技术活,理解底层架构的细节才能有效的进行调优。如果要写出高效的跨平台代码,就会变得异常复杂。在解决某些问题时,从主机往设备上复制数据会消耗大量时间,这会减弱甚至低效我们从事并行计算中获得的收益…..
3.4.8 - CH08-Lambda 架构
如果需要将一大批货物从国家的一端运送到另一端,18 轮的打开车是不二之选。如果紧要运行一个包裹,大卡车就不太适用了,因此综合性的航运公司也会适用一些小型货车进行本地的货物收发。
Lambda 架构采用了类似的方法,既使用了可以进行大规模数据批处理的 MapReduce 技术,也使用了可以快速处理数据并及时反馈的流处理技术,这样混搭能够为大数据问题提供扩展性、响应性、容错性都很优秀的解决方案。
并行计算
不同于传统数据处理,大数据领域广泛使用了并行计算——只要有足够的计算资源就可以处理 TB 级别的数据。Lambda 架构是一种大数据处理技术。
与上一章讨论的 GPGPU 编程类似,Lambda 架构也使用了数据并行技术。与 GPGPU 不同的是,Lambda 架构站在大规模场景的角度来解决问题,它可以将数据和计算分布到几十台或几百台机器构建的集群上运行。这种技术不但解决了之间因为规模庞大而无法解决的问题,还可以构建出对硬件错误和认为错误进行容错的系统。
Lambda 架构包含了很多内如,本章只侧重于其并发和分布式特性(更多内如可以参考 Big Data 一书)。对于 Lambda 架构中的诸多组件,本书侧重介绍两个主要的层:批处理层和加速层。
批处理层使用 MapReduce 这类批处理技术从历史数据中对批处理视图进行预计算。这种计算效率高但延迟也高,所以又增加了一个加速层,使用流处理等低延迟技术从接收到的新数据中计算实时视图。合并这两种视图,就可以获得最终的计算结果。
这是本书中最复杂的专题。它以很多其他技术作为基石,其中最重要的就是 MapReduce。
MapReduce
MapReduce 是一个多语义的术语。它可以指代一类算法,这类算法分为两个步骤:对一个数据首先进行映射操作(map),然后进行化简(reduce)操作。
MapReduce 还可以指代一类系统:这类系统使用了上述算法,将计算过程高效的分布到一个集群上。这类系统不仅可以将数据和数据处理过程分不到集群的多台机器上,还可以在一台或多个计算机崩溃时继续运转。
当 MapReduce 指代一类系统时,可以说它是 Google 发明的。除了 Google,最流行的 MapReduce 框架是 Hadoop。
Hadoop 基础
Hadoop 就是用来处理大量数据的工具。如果你的数据不是以 GB 或更大的单位来度量的,那就不适用使用 Hadoop。Hadoop 的效率源自于它将数据分块后分别交给多台计算机进行处理。
我们很容易猜到,一个 MapReduce 任务由两种主要的组件构成:mapper 和 reducer。mapper 负责将某种输入格式映射为许多键值对;reducer 负责将这些键值对转换为最终的输出格式。mapper 和 reducer 可以分布在很多不同的机器上。
输入通常由一个或多个大文本文件构成。Hadoop 对这些文件进行分片(每一片的大小是可配置的),并将每个分片发送个一个 mapper,mapper 将输出一系列键值对,Hadoop 再将这些键值对发送给 reducer。
一个 mapper 产生的键值对可以发送给多个 reducer。键值对的键决定了那个 reducer 会接收这个键值对——Hadoop 确保具有相同键的键值对(无论由哪个 mapper 产生)都会发送给同一个 reducer 处理。这个阶段通常被称为洗牌(shuffle)。
Hadoop 为每个键调用一次 reducer,并传入所有与该键对应的值。reducer 将这些值合并,再生产最终的输出结果。
批处理层
传统数据系统的缺陷
数据系统不是一个新概念——从计算机发明之初,数据库就一直负责存储和处理数据。传统数据库适用于一台计算机,但随着要处理的数据量越来越大,数据库就必须使用多台机器。
扩展性
利用一些技术(如复制、分片等)可以将传统数据库扩展到多台机器上,但随着计算机数量和数据量的增长,这种方案会变得越来越困难。超过一定程度,增加计算机资源将无法继续提升性能。
维护成本
维护一个跨越多台计算机的数据库的成本是比较高的。如果要求维护时不能停机,那么维护将变得更加困难——比如对数据库进行重新分片。随着数据量和查询数量的增加,容错、备份、确保数据一致性等工作的难度会呈几何级数增长。
复杂度
复制和分片通常要求应用层提供一些支持——应用需要知道将请求发送给哪一台机器,以及应该更行哪一个数据分片。开发者习惯使用的许多特性(比如事务)在数据库分片后将无法使用。也就是说开发者必须显式处理失败的事务并进行重试。这都增加了传统数据库的复杂性,也增加了出错的可能。
认为错误
讨论容错性时很容易被忽略的就是认为错误。许多数据故障不是由于存储故障引起的,而是由于管理员或开发人员的认为错误引起的。如果运气比较好,这类错误可以被快速定位,并通过还原备份来恢复,但不是所有错误都可以被轻易解决。设想一下,如果有一个隐藏了几周的数据错误突然引发了大面积崩溃,我们又该如何修复数据库呢?
有时,我们可以分析错误影响的范围,并使用临时的脚本来修复数据库。有时我们可以通过重放数据库日志来回滚这个错误。有时,我们只能承认运气不佳。每次一来运气可不是长久之计。
报表与分析
传统数据库擅长于运营支持,即处理日常的业务数据。如果要处理历史数据,比如生成报表或进行数据分析,传统数据库的效率就比较低了。
典型的解决方案是在独立的数据仓库中使用另一种格式来维护历史数据。数据从业务数据库向数据仓库的迁移过程就是著名的 ETL。这种方案不仅复杂,而且需要准确预测将来需要什么样的信息。有时会碰到这种情况:由于缺乏必要的信息或者信息格式不对,无法生成所需报表或进行某些分析。
永恒的真相
我们可以将信息分为两类——原始数据和(从原始数据生成的)衍生数据。原始数据是永恒的真相,也是 Lambda 架构的基础。
加速层
上图展示了批处理层与加速层的协作方式。有新数据生成时,一方面给将其添加到原始数据中,这样批处理层就可以进行处理;另一方面将其传递给加速层,加速层会生成一个实时视图,实时视图会和批处理视图合并来满足对最新数据的查询。
实时视图仅包含最后一次生成批处理视图后产生的原始数据所对应的衍生信息,当这部分数据被批处理层处理后,该实时视图被弃用。
设计加速层
不同的应用对实时性的要求是不同的——有一些要求数据在妙级可用,有些甚至是毫秒。
由于加速层需要使用增量算法,因此要比构建批处理层复杂的多。这意味着加速层不能只处理原始数据,也就享受不到原始数据的原始特性了。我们必须重新面对传统数据库的特性:随机写、复杂的锁机制和事务机制。
从好的方面来看,加速层只需要处理一部分数据,就是那部分还未被批处理层处理的珊瑚橘。一旦批处理层赶上进度,旧的数据就会从加速层移除。
同步与异步
最容易想到的构建加速层的方式是模仿传统的同步数据库。其实可以将传统数据库看做是 Lambda 架构的一种退化特例(没有批处理层)。
在这种模型中,客户端直接和数据库通信,并在数据库进行更新操作时阻塞。这种模型非常合理,在某些场景下这是唯一能满足特定需求的方法。不过在另一些场景中,异步架构更合适一些。
在这种模型中,客户端将更新操作添加到队列(如 Kafka),这一步是无阻塞的。流处理器将串行的处理这些更新操作并对数据库进行更新。
用队列将客户端和数据库解耦,会使更新操作变得更加复杂。不过,根据异步的特性,如果可以接受异步方案,也会获得非常显著的好处:
- 客户端不会阻塞,所以少量的客户端就能处理大量数据,从而提高吞吐。
- 业务压力激增会导致客户端或数据库超载,也会导致同步系统超时或丢失一些更新。而异步系统则不同,只需要将未处理的更新操作保持在队列中,在业务压力恢复稳定后可逐渐赶上进度。
- 稍后我们将了解到:流处理器可以被并行化,也可以在多台计算机上进行分布式计算,即改善了性能也提供了容错。
如何让数据过期
假设批处理层需要两个小时处理数据,那么很容易就会认为加速层需要保留这两个小时以内的数据。实际上加速层需要保留两倍的数据,如下图所示。
假设 N-1 次批处理刚刚结束,第 N 次批处理正要开始。如果每次批处理需要运行两个小时,这意味着批处理视图会落后两个小时。因此加速层需要保持这落后的两个小时数据,还要保持批处理层运行的两个小时中所有的新数据,总共需要保持 4 个小时的数据。
当第 N 次批处理结束时,需要让最早两个小时的数据过期,但仍保存其后两个小时的数据。有多种方法可以达到目的,不过最容易的就是同时维护两个加速层,并交替使用它们,如下图:
当一次梳理完成时,批处理视图中的新数据就变得可用,就可以将当前用户处理请求的加速层换到另一个加速层上。切换后闲置的加速层会清理其数据库,并在新的批处理开始时重新建立视图。
这种做法的好处是,一方面不需要费心识别加速层的数据库中那些数据需要被过期清理,另一方面由于每次切换后加速层都是从一个空数据库开始运行,因此达到了更好的性能和可靠性。当然为此付出的代价是必须维护两份加速层的数据并且消耗两份计算资源,不过考虑到加速层仅仅处理总数据量中很小的一部分,因此付出的代价不那么大。
总结
Lambda 架构将我们已经学到的一些内容进行了融合:
- 原始数据是永恒的真相,这让我们想到分离状态与标识的做法。
- Hadoop 并行化解问题的方法是先将数据切分,在进行化简操作,这类似于并行函数式编程的做法。
- 类似于 actor 模型,Lambda 架构将处理过程分布大集群上,既改善了性能又提供了容错。
- Storm 的元组流类似于 Actor 和 CSP 中的消息机制。
优点
Lambda 架构非常适合报表与分析——以前则是使用数仓来完成这类任务。
缺点
仅适合大数据量。
显然本书仅涉及了 Lambda 架构,或者说是大数据处理问题的一些皮毛。但好的一点是将其他并发编程模型与 Lambda 架构进行了相似性关联,在构建大数据处理系统时可以借鉴已有的、微观的并发编程模型,从思路上、从形式上。
3.4.9 - CH09-未来方向
未来趋势
- 未来是“不变”的
- 未来是分布式的
未尽之路
- Fork/Join 模型和 Wokr-Stealing 算法
- PS. 这是 Akka 的核心
- 数据流
- Reactive Programming
- Functional Reactive Programming
- 网格计算
- 元组空间
4 - JVM 拆解
4.1 - CH01-开始运行
Java 代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行 jar 文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开 JRE,也就是 Java 运行时环境。
实际上,JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。我们 Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开发、诊断工具。
为什么需要虚拟机
Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实,需要进行一些转换。
当前的主流思路是,设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。叫做字节码是因为 Java 字节码指令的操作码(opcode)被固定为一个字节。
比如下面代码的中间列,正是用 Java 写的 Helloworld 程序编译而成的字节码。可以看到,它与 C 版本的编译结果一样,都是由一个个字节组成的。
我们同样可以将其反汇编为人类可读的代码格式(如下图的最右列所示)。不同的是,Java 版本的编译结果相对精简一些。这是因为 Java 虚拟机相对于物理机而言,抽象程度更高。
# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
0x00: b2 00 02 getstatic java.lang.System.out
0x03: 12 03 ldc "Hello, World!"
0x05: b6 00 04 invokevirtual java.io.PrintStream.println
0x08: b1 return
Java 虚拟机可以由硬件实现 [1],但更为常见的是在各个现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写,到处运行”。
虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,这部分内容甚至催生了一波垃圾回收调优的业务。
除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。
JVM 如何执行字节码
从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于**方法区(Method Area)**中。实际运行时,虚拟机会执行方法区内的代码。
如果你熟悉 X86 的话,你会发现这和段式内存管理中的代码段类似。而且,Java 虚拟机同样也在内存中划分出堆和栈来存储运行时数据。
不同的是,Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。
在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。
当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。
从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。
在 HotSpot 里面,上述翻译过程有两种形式:第一种是解释执行,即逐条将字节码翻译成机器码并执行;第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
JVM 运行效率
HotSpot 采用了多种技术来提升启动性能以及峰值性能,刚刚提到的即时编译便是其中最重要的技术之一。
即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。
对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。
理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。
举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。
这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。
为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:C1、C2 和 Graal。Graal 是 Java 10 正式引入的实验性即时编译器,在专栏的第四部分我会详细介绍,这里暂不做讨论。
之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。
C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。
从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。
为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。
在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。
总结与实践
之所以要在虚拟机中运行,是因为它提供了可移植性。一旦 Java 代码被编译为 Java 字节码,便可以在不同平台上的 Java 虚拟机实现上运行。此外,虚拟机还提供了一个代码托管的环境,代替我们处理部分冗长而且容易出错的事务,例如内存管理。
Java 虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC 寄存器、Java 方法栈和本地方法栈。Java 程序编译而成的 class 文件,需要先加载至方法区中,方能在 Java 虚拟机中运行。
为了提高运行效率,标准 JDK 中的 HotSpot 虚拟机采用的是一种混合执行的策略。
它会解释执行 Java 字节码,然后会将其中反复执行的热点代码,以方法为单位进行即时编译,翻译成机器码后直接运行在底层硬件之上。
HotSpot 装载了多个不同的即时编译器,以便在编译时间和生成代码的执行效率之间做取舍。
4.2 - CH02-基本类型
Java 引进了八个基本类型,来支持数值计算。Java 这么做的原因主要是工程上的考虑,因为使用基本类型能够在执行效率以及内存使用两方面提升软件性能。
boolean
在 Java 语言规范中,boolean 类型的值只有两种可能,它们分别用符号**“true”和“false”**来表示。显然,这两个符号是不能被虚拟机直接使用的。
在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。这个编码规则约束了 Java 字节码的具体实现。
举个例子,对于存储 boolean 数组的字节码,Java 虚拟机需保证实际存入的值是整数 1 或者 0。
Java 虚拟机规范同时也要求 Java 编译器遵守这个编码规则,并且用整数相关的字节码来实现逻辑运算,以及基于 boolean 类型的条件跳转。这样一来,在编译而成的 class 文件中,除了字段和传入参数外,基本看不出 boolean 类型的痕迹了。
对于 Java 虚拟机来说,它看到的 boolean 类型,早已被映射为整数类型。因此,将原本声明为 boolean 类型的局部变量,通过字节码修改工具赋值为除了 0、1 之外的整数值,在 Java 虚拟机看来是“合法”的。
基本类型
除了上面提到的 boolean 类型外,Java 的基本类型还包括整数类型 byte、short、char、int 和 long,以及浮点类型 float 和 double。
Java 的基本类型都有对应的值域和默认值。可以看到,byte、short、int、long、float 以及 double 的值域依次扩大,而且前面的值域被后面的值域所包含。因此,从前面的基本类型转换至后面的基本类型,无需强制转换。另外一点值得注意的是,尽管他们的默认值看起来不一样,但在内存中都是 0。
在这些基本类型中,boolean 和 char 是唯二的无符号类型。在不考虑违反规范的情况下,boolean 类型的取值范围是 0 或者 1。char 类型的取值范围则是 [0, 65535]。通常我们可以认定 char 类型的值为非负数。这种特性十分有用,比如说作为数组索引等。
在前面的例子中,我们能够将整数 2 存储到一个声明为 boolean 类型的局部变量中。那么,声明为 byte、char 以及 short 的局部变量,是否也能够存储超出它们取值范围的数值呢?
答案是可以的。而且,这些超出取值范围的数值同样会带来一些麻烦。比如说,声明为 char 类型的局部变量实际上有可能为负数。当然,在正常使用 Java 编译器的情况下,生成的字节码会遵守 Java 虚拟机规范对编译器的约束,因此你无须过分担心局部变量会超出它们的取值范围。
Java 的浮点类型采用 IEEE 754 浮点数格式。以 float 为例,浮点类型通常有两个 0,+0.0F 以及 -0.0F。
前者在 Java 里是 0,后者是符号位为 1、其他位均为 0 的浮点数,在内存中等同于十六进制整数 0x8000000(即 -0.0F 可通过 Float.intBitsToFloat(0x8000000) 求得)。尽管它们的内存数值不同,但是在 Java 中 +0.0F == -0.0F 会返回真。
在有了 +0.0F 和 -0.0F 这两个定义后,我们便可以定义浮点数中的正无穷及负无穷。正无穷就是任意正浮点数(不包括 +0.0F)除以 +0.0F 得到的值,而负无穷是任意正浮点数除以 -0.0F 得到的值。在 Java 中,正无穷和负无穷是有确切的值,在内存中分别等同于十六进制整数 0x7F800000 和 0xFF800000。
你也许会好奇,既然整数 0x7F800000 等同于正无穷,那么 0x7F800001 又对应什么浮点数呢?
这个数字对应的浮点数是 NaN(Not-a-Number)。
不仅如此,[0x7F800001, 0x7FFFFFFF] 和 [0xFF800001, 0xFFFFFFFF] 对应的都是 NaN。当然,一般我们计算得出的 NaN,比如说通过 +0.0F/+0.0F,在内存中应为 0x7FC00000。这个数值,我们称之为标准的 NaN,而其他的我们称之为不标准的 NaN。
NaN 有一个有趣的特性:除了“!=”始终返回 true 之外,所有其他比较结果都会返回 false。
举例来说,“NaN<1.0F”返回 false,而“NaN>=1.0F”同样返回 false。对于任意浮点数 f,不管它是 0 还是 NaN,“f!=NaN”始终会返回 true,而“f==NaN”始终会返回 false。
因此,我们在程序里做浮点数比较的时候,需要考虑上述特性。在本专栏的第二部分,我会介绍这个特性给向量化比较带来什么麻烦。
基本类型的大小
在第一篇中我曾经提到,Java 虚拟机每调用一个 Java 方法,便会创建一个栈帧。为了方便理解,这里我只讨论供解释器使用的解释栈帧(interpreted frame)。
这种栈帧有两个主要的组成部分,分别是局部变量区,以及字节码的操作数栈。这里的局部变量是广义的,除了普遍意义下的局部变量之外,它还包含实例方法的“this 指针”以及方法所接收的参数。
在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。
也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。
当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。
因此,当我们将一个 int 类型的值,存储到这些类型的字段或数组时,相当于做了一次隐式的掩码操作。举例来说,当我们把 0xFFFFFFFF(-1)存储到一个声明为 char 类型的字段里时,由于该字段仅占两字节,所以高两位的字节便会被截取掉,最终存入“\uFFFF”。
boolean 字段和 boolean 数组则比较特殊。在 HotSpot 中,boolean 字段占用一字节,而 boolean 数组则直接用 byte 数组来实现。为了保证堆中的 boolean 值是合法的,HotSpot 在存储时显式地进行掩码操作,也就是说,只取最后一位的值存入 boolean 字段或数组中。
讲完了存储,现在我来讲讲加载。Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。
对于 boolean、char 这两个无符号类型来说,加载伴随着零扩展。举个例子,char 的大小为两个字节。在加载时 char 的值会被复制到 int 类型的低二字节,而高二字节则会用 0 来填充。
对于 byte、short 这两个类型来说,加载伴随着符号扩展。举个例子,short 的大小为两个字节。在加载时 short 的值同样会被复制到 int 类型的低二字节。如果该 short 值为非负数,即最高位为 0,那么该 int 类型的值的高二字节会用 0 来填充,否则用 1 来填充。
总结与实践
其中,boolean 类型在 Java 虚拟机中被映射为整数类型:“true”被映射为 1,而“false”被映射为 0。Java 代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的。
除 boolean 类型之外,Java 还有另外 7 个基本类型。它们拥有不同的值域,但默认值在内存中均为 0。这些基本类型之中,浮点类型比较特殊。基于它的运算或比较,需要考虑 +0.0F、-0.0F 以及 NaN 的情况。
除 long 和 double 外,其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的,但它们在堆中占用的大小确不同。在将 boolean、byte、char 以及 short 的值存入字段或者数组单元时,Java 虚拟机会进行掩码操作。在读取时,Java 虚拟机则会将其扩展为 int 类型。
4.3 - CH03-类加载
我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。在上一篇中,我已经详细介绍过了 Java 的基本类型,它们是由 Java 虚拟机预先定义好的。
至于另一大类引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除(我会在专栏的第二部分详细介绍),因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。
说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。为了叙述方便,下面我就用“类”来统称它们。
无论是直接生成的数组类,还是加载的类,Java 虚拟机都需要对其进行链接和初始化。接下来,我会详细给你介绍一下每个步骤具体都在干些什么。
加载
加载,是指查找字节流,并且据此创建类的过程。前面提到,对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其他的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
以盖房子为例,村里的 Tony 要盖个房子,那么按照流程他得先找个建筑师,跟他说想要设计一个房型,比如说“一房、一厅、四卫”。你或许已经听出来了,这里的房型相当于类,而建筑师,就相当于类加载器。
村里有许多建筑师,他们等级森严,但有着共同的祖师爷,叫启动类加载器(boot class loader)。启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。换句话说,祖师爷不喜欢像 Tony 这样的小角色来打扰他,所以谁也没有祖师爷的联系方式。
除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。
村里的建筑师有一个潜规则,就是接到单子自己不能着手干,得先给师傅过过目。师傅不接手的情况下,才能自己来。在 Java 虚拟机中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。
在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。
扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
Java 9 引入了模块系统,并且略微更改了上述的类加载器1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。
除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。
除了加载功能之外,类加载器还提供了命名空间的作用。这个很好理解,打个比方,咱们这个村不讲究版权,如果你剽窃了另一个建筑师的设计作品,那么只要你标上自己的名字,这两个房型就是不同的。
在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。
链接
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
验证阶段的目的,在于确保被加载类能够满足 Java 虚拟机的约束条件。这就好比 Tony 需要将设计好的房型提交给市政部门审核。只有当审核通过,才能继续下面的建造工作。
通常而言,Java 编译器生成的类文件必然满足 Java 虚拟机的约束条件。因此,这部分我留到讲解字节码注入时再详细介绍。
准备阶段的目的,则是为被加载类的静态字段分配内存。Java 代码中对静态字段的具体初始化,则会在稍后的初始化阶段中进行。过了这个阶段,咱们算是盖好了毛坯房。虽然结构已经完整,但是在没有装修之前是不能住人的。
除了分配内存外,部分 Java 虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如说用来实现虚方法的动态绑定的方法表。
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。
举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。
解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
如果将这段话放在盖房子的语境下,那么符号引用就好比“Tony 的房子”这种说法,不管它存在不存在,我们都可以用这种说法来指代 Tony 的房子。实际引用则好比实际的通讯地址,如果我们想要与 Tony 通信,则需要启动盖房子的过程。
Java 虚拟机规范并没有要求在链接过程中完成解析。它仅规定了:如果某些字节码使用了符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。
初始化
在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。
如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。
类加载的最后一步是初始化,便是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
只有当初始化完成之后,类才正式成为可执行的状态。这放在我们盖房子的例子中就是,只有当房子装修过后,Tony 才能真正地住进去。
那么,类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:
- 当虚拟机启动时,初始化用户指定的主类;
- 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
- 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
- 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
- 子类的初始化会触发父类的初始化;
- 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
- 使用反射 API 对某个类进行反射调用时,初始化这个类;
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
我在文章中贴了一段代码,这段代码是在著名的单例延迟初始化例子中2,只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。
由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。
总结与实践
今天我介绍了 Java 虚拟机将字节流转化为 Java 类的过程。这个过程可分为加载、链接以及初始化三大步骤。
加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
初始化,则是为标记为常量值的字段赋值,以及执行 < clinit > 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
4.4 - CH04-方法调用-上
下面是一个可变长参数的方法重载问题:
void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }
invoke(null, 1); // 调用第二个 invoke 方法
invoke(null, 1, 2); // 调用第二个 invoke 方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
// 才能调用第一个 invoke 方法
重载与重写
在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。
小知识:这个限制可以通过字节码工具绕开。也就是说,在编译完成之后,我们可以再向 class 文件中添加方法名和参数类型相同,而返回类型不同的方法。
当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在 Java 编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?
当前版本的 Java 编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
- 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
简化后就是:
- 原始类型+定长参数
- 装箱类型+定长参数
- 装箱类型+变长参数
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
在开头的例子中,当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java 编译器会认为第二个方法更为贴切。
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
众所周知,Java 是一门面向对象的编程语言,它的一个重要特性便是多态。而方法重写,正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。
打个比方,如果你经常漫游,那么你可能知道,拨打 10086 会根据你当前所在地,连接到当地的客服。重写调用也是如此:它会根据调用者的动态类型,来选取实际的目标方法。
静态绑定和动态绑定
接下来,我们来看看 Java 虚拟机是怎么识别方法的。
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。前面两个就不做过多的解释了。至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。
可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。
Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。
对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法 [2] 来实现 Java 中的重写语义。
由于对重载方法的区分在编译阶段已经完成,我们可以认为 Java 虚拟机不存在重载这一概念。因此,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。
这个说法在 Java 虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此 Java 编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。
确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
具体来说,Java 字节码中与调用相关的指令共有五种。
- invokestatic:用于调用静态方法。
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
- invokevirtual:用于调用非私有实例方法。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于调用动态方法。
由于 invokedynamic 指令较为复杂,我将在后面的篇章中单独介绍。这里我们只讨论前四种。
我在文章中贴了一段代码,展示了编译生成这四种调用指令的情况。
interface 客户 {
boolean isVIP();
}
class 商户 {
public double 折后价格 (double 原价, 客户 某客户) {
return 原价 * 0.8d;
}
}
class 奸商 extends 商户 {
@Override
public double 折后价格 (double 原价, 客户 某客户) {
if (某客户.isVIP()) { // invokeinterface
return 原价 * 价格歧视 (); // invokestatic
} else {
return super. 折后价格 (原价, 某客户); // invokespecial
}
}
public static double 价格歧视 () {
// 咱们的杀熟算法太粗暴了,应该将客户城市作为随机数生成器的种子。
return new Random() // invokespecial
.nextDouble() // invokevirtual
+ 0.8d;
}
}
在代码中,“商户”类定义了一个成员方法,叫做“折后价格”,它将接收一个 double 类型的参数,以及一个“客户”类型的参数。这里“客户”是一个接口,它定义了一个接口方法,叫“isVIP”。
我们还定义了另一个叫做“奸商”的类,它继承了“商户”类,并且重写了“折后价格”这个方法。如果客户是 VIP,那么它会被给到一个更低的折扣。
在这个方法中,我们首先会调用“客户”接口的”isVIP“方法。该调用会被编译为 invokeinterface 指令。
如果客户是 VIP,那么我们会调用奸商类的一个名叫“价格歧视”的静态方法。该调用会被编译为 invokestatic 指令。如果客户不是 VIP,那么我们会通过 super 关键字调用父类的“折后价格”方法。该调用会被编译为 invokespecial 指令。
在静态方法“价格歧视”中,我们会调用 Random 类的构造器。该调用会被编译为 invokespecial 指令。然后我们会以这个新建的 Random 对象为调用者,调用 Random 类中的 nextDouble 方法。该调用会被编译为 invokevirutal 指令。
对于 invokestatic 以及 invokespecial 而言,Java 虚拟机能够直接识别具体的目标方法。
而对于 invokevirtual 以及 invokeinterface 而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。
唯一的例外在于,如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为 final[3][4],那么它可以不通过动态类型,直接确定目标方法。
调用指令的符号引用
在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。我在文章中贴了一个例子,利用“javap -v”打印某个类的常量池,如果你感兴趣的话可以到文章中查看。
// 在奸商.class 的常量池中,#16 为接口符号引用,指向接口方法 " 客户.isVIP()"。而 #22 为非接口符号引用,指向静态方法 " 奸商. 价格歧视 ()"。
$ javap -v 奸商.class ...
Constant pool:
...
#16 = InterfaceMethodref #27.#29 // 客户.isVIP:()Z
...
#22 = Methodref #1.#33 // 奸商. 价格歧视:()D
...
上一篇中我曾提到过,在执行使用了符号引用的字节码前,Java 虚拟机需要解析这些符号引用,并替换为实际引用。
对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。
- 在 C 中查找符合名字及描述符的方法。
- 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
- 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。
- 在 I 中查找符合名字及描述符的方法。
- 如果没有找到,在 Object 类中的公有实例方法中搜索。
- 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。具体什么是方法表,我会在下一篇中做出解答。
总结与实践
今天我介绍了 Java 以及 Java 虚拟机是如何识别目标方法的。
在 Java 中,方法存在重载以及重写的概念,重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。
Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型。
在 Java 虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载。
在 class 文件中,Java 编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。
4.5 - CH05-方法调用-下
虚方法调用
在上一篇中我曾经提到,Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方法调用。
在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。
在 Java 虚拟机中,静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令。如果虚方法调用指向一个标记为 final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。
Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。那么方法表具体是怎样实现的呢?
方法表
在介绍那篇类加载机制的链接部分中,我曾提到类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。
这个数据结构,便是 Java 虚拟机实现动态绑定的关键所在。下面我将以 **invokevirtual 所使用的虚方法表(virtual method table,vtable)**为例介绍方法表的用法。**invokeinterface 所使用的接口方法表(interface method table,itable)**稍微复杂些,但是原理其实是类似的。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
一个模拟出国边检的小例子:
abstract class 乘客 {
abstract void 出境 ();
@Override
public String toString() { ... }
}
class 外国人 extends 乘客 {
@Override
void 出境 () { /* 进外国人通道 */ }
}
class 中国人 extends 乘客 {
@Override
void 出境 () { /* 进中国人通道 */ }
void 买买买 () { /* 逛免税店 */ }
}
乘客 某乘客 = ...
某乘客. 出境 ();
在我们的例子中,“乘客”类的方法表包括两个方法:“toString”以及“出境”,分别对应 0 号和 1 号。
之所以方法表调换了“toString”方法和“出境”方法的位置,是因为“toString”方法的索引值需要与 Object 类中同名方法的索引值一致。为了保持简洁,这里我就不考虑 Object 类中的其他方法。
“外国人”的方法表同样有两行。其中,0 号方法指向继承而来的“乘客”类的“toString”方法。1 号方法则指向自己重写的“出境”方法。
“中国人”的方法表则包括三个方法,除了继承而来的“乘客”类的“toString“方法,自己重写的“出境”方法之外,还包括独有的“买买买”方法。
乘客 某乘客 = ...
某乘客. 出境 ();
这里,Java 虚拟机的工作可以想象为导航员。每当来了一个乘客需要出境,导航员会先问是中国人还是外国人(获取动态类型),然后翻出中国人 / 外国人对应的小册子(获取动态类型的方法表),小册子的第 1 页便写着应该到哪条通道办理出境手续(用 1 作为索引来查找方法表所对应的目标方法)。
实际上,**使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。**相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。
那么我们是否可以认为虚方法调用对性能没有太大影响呢?
其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。下面我便来介绍第一种内联缓存。
内联缓存
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
在我们的例子中,这相当于导航员记住了上一个出境乘客的国籍和对应的通道,例如中国人,走了左边通道出境。那么下一个乘客想要出境的时候,导航员会先问是不是中国人,是的话就走左边通道。如果不是的话,只好拿出外国人的小册子,翻到第 1 页,再告知查询结果:右边。
在针对多态的优化手段中,我们通常会提及以下三个术语。
- 单态(monomorphic)指的是仅有一种状态的情况。
- 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
- 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。
对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。
一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。
前面提到,当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。
因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。
另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。
具体到我们的例子,如果来了一队乘客,其中外国人和中国人依次隔开,那么在重复使用的单态内联缓存中,导航员需要反复记住上个出境的乘客,而且记住的信息在处理下一乘客时又会被替换掉。因此,倒不如一直不记,以此来节省脑细胞。
虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
对于极其简单的方法而言,比如说 getter/setter,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性,我们会在专栏的第二部分详细介绍方法内联的内容。
总结与实践
今天我介绍了虚方法调用在 Java 虚拟机中的实现方式。
虚方法调用包括 invokevirtual 指令和 invokeinterface 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。
Java 虚拟机的动态绑定是通过方法表这一数据结构来实现的。方法表中每一个重写方法的索引值,与父类方法表中被重写的方法的索引值一致。在解析虚方法调用时,Java 虚拟机会纪录下所声明的目标方法的索引值,并且在运行过程中根据这个索引值查找具体的目标方法。
Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。
当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定。
4.6 - CH06-异常处理
异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。
抛出异常可分为显式和隐式两种。显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。
隐式抛异常的主体则是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。举例来说,Java 虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常(ArrayIndexOutOfBoundsException)。
捕获异常则涉及了如下三种代码块。
- try 代码块:用来标记需要进行异常监控的代码。
- catch 代码块:跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。
- finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。
在程序正常执行的情况下,这段代码会在 try 代码块之后运行。否则,也就是 try 代码块触发异常的情况下,如果该异常没有被捕获,finally 代码块会直接运行,并且在运行之后重新抛出该异常。
如果该异常被 catch 代码块捕获,finally 代码块则在 catch 代码块之后运行。在某些不幸的情况下,catch 代码块也触发了异常,那么 finally 代码块同样会运行,并会抛出 catch 代码块触发的异常。在某些极端不幸的情况下,finally 代码块也触发了异常,那么只好中断当前 finally 代码块的执行,并往外抛异常。
上面这段听起来有点绕,但是等我讲完 Java 虚拟机的异常处理机制之后,你便会明白这其中的道理。
异常的基本概念
在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并且处理的异常。
Exception 有一个特殊的子类 RuntimeException,用来表示“程序虽然无法继续执行,但是还能抢救一下”的情况。前边提到的数组索引越界便是其中的一种。
RuntimeException 和 Error 属于 Java 里的非检查异常(unchecked exception)。其他异常则属于检查异常(checked exception)。在 Java 语法中,所有的检查异常都需要程序显式地捕获,或者在方法声明中用 throws 关键字标注。通常情况下,程序中自定义的异常应为检查异常,以便最大化利用 Java 编译器的编译时检查。
异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。
当然,在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法(Throwable.fillInStackTrace),直接从新建异常位置开始算起。此外,Java 虚拟机还会忽略标记为不可见的 Java 方法栈帧。我们在介绍 Lambda 的时候会看到具体的例子。
既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非 throw 语句的位置,而是新建异常的位置。
因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。
Java 虚拟机是如何捕获异常的?
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
其中,from 指针和 to 指针标示了该异常处理器所监控的范围,例如 try 代码块所覆盖的范围。target 指针则指向异常处理器的起始位置,例如 catch 代码块的起始位置。
public static void main(String[] args) {
try {
mayThrowException();
} catch (Exception e) {
e.printStackTrace();
}
}
// 对应的 Java 字节码
public static void main(java.lang.String[]);
Code:
0: invokestatic mayThrowException:()V
3: goto 11
6: astore_1
7: aload_1
8: invokevirtual java.lang.Exception.printStackTrace
11: return
Exception table:
from to target type
0 3 6 Class java/lang/Exception // 异常表条目
举个例子,在上图的 main 方法中,我定义了一段 try-catch 代码。其中,catch 代码块所捕获的异常类型为 Exception。
编译过后,该方法的异常表拥有一个条目。其 from 指针和 to 指针分别为 0 和 3,代表它的监控范围从索引为 0 的字节码开始,到索引为 3 的字节码结束(不包括 3)。该条目的 target 指针是 6,代表这个异常处理器从索引为 6 的字节码开始。条目的最后一列,代表该异常处理器所捕获的异常类型正是 Exception。
当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。
如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。
finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。
针对异常执行路径,Java 编译器会生成一个或多个异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常(在 javap 中以 any 指代)。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。
如果你感兴趣的话,可以用 javap 工具来查看下面这段包含了 try-catch-finally 代码块的编译结果。为了更好地区分每个代码块,我定义了四个实例字段:tryBlock、catchBlock、finallyBlock、以及 methodExit,并且仅在对应的代码块中访问这些字段。
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
$ javap -c Foo
...
public void test();
Code:
0: aload_0
1: iconst_0
2: putfield #20 // Field tryBlock:I
5: goto 30
8: astore_1
9: aload_0
10: iconst_1
11: putfield #22 // Field catchBlock:I
14: aload_0
15: iconst_2
16: putfield #24 // Field finallyBlock:I
19: goto 35
22: astore_2
23: aload_0
24: iconst_2
25: putfield #24 // Field finallyBlock:I
28: aload_2
29: athrow
30: aload_0
31: iconst_2
32: putfield #24 // Field finallyBlock:I
35: aload_0
36: iconst_3
37: putfield #26 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 8 Class java/lang/Exception
0 14 22 any
...
可以看到,编译结果包含三份 finally 代码块。其中,前两份分别位于 try 代码块和 catch 代码块的正常执行路径出口。最后一份则作为异常处理器,监控 try 代码块以及 catch 代码块。它将捕获 try 代码块触发的、未被 catch 代码块捕获的异常,以及 catch 代码块触发的异常。
这里有一个小问题,如果 catch 代码块捕获了异常,并且触发了另一个异常,那么 finally 捕获并且重抛的异常是哪个呢?答案是后者。也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。
Java 7 的 Supressed 异常以及语法糖
Java 7 引入了 Supressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。
然而,Java 层面的 finally 代码块缺少指向所捕获异常的引用,所以这个新特性使用起来非常繁琐。
为此,Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Supressed 异常。当然,该语法糖的主要目的并不是使用 Supressed 异常,而是精简资源打开关闭的用法。
在 Java 7 之前,对于打开的资源,我们需要定义一个 finally 代码块,来确保该资源在正常或者异常执行状况下都能关闭。
资源的关闭操作本身容易触发异常。因此,如果同时打开多个资源,那么每一个资源都要对应一个独立的 try-finally 代码块,以保证每个资源都能够关闭。这样一来,代码将会变得十分繁琐。
FileInputStream in0 = null;
FileInputStream in1 = null;
FileInputStream in2 = null;
...
try {
in0 = new FileInputStream(new File("in0.txt"));
...
try {
in1 = new FileInputStream(new File("in1.txt"));
...
try {
in2 = new FileInputStream(new File("in2.txt"));
...
} finally {
if (in2 != null) in2.close();
}
} finally {
if (in1 != null) in1.close();
}
} finally {
if (in0 != null) in0.close();
}
Java 7 的 try-with-resources 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Supressed 异常的功能,来避免原异常“被消失”。
public class Foo implements AutoCloseable {
private final String name;
public Foo(String name) { this.name = name; }
@Override
public void close() {
throw new RuntimeException(name);
}
public static void main(String[] args) {
try (Foo foo0 = new Foo("Foo0"); // try-with-resources
Foo foo1 = new Foo("Foo1");
Foo foo2 = new Foo("Foo2")) {
throw new RuntimeException("Initial");
}
}
}
// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
at Foo.main(Foo.java:18)
Suppressed: java.lang.RuntimeException: Foo2
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo1
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo0
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
除了 try-with-resources 语法糖之外,Java 7 还支持在同一 catch 代码块中捕获多种异常。实际实现非常简单,生成多个异常表条目即可。
// 在同一 catch 代码块中捕获多种异常
try {
...
} catch (SomeException | OtherException e) {
...
}
总结与实践
今天我介绍了 Java 虚拟机的异常处理机制。
Java 的异常分为 Exception 和 Error 两种,而 Exception 又分为 RuntimeException 和其他类型。RuntimeException 和 Error 属于非检查异常。其他的 Exception 皆属于检查异常,在触发时需要显式捕获,或者在方法头用 throws 关键字声明。
Java 字节码中,每个方法对应一个异常表。当程序触发异常时,Java 虚拟机将查找异常表,并依此决定需要将控制流转移至哪个异常处理器之中。Java 代码中的 catch 代码块和 finally 代码块都会生成异常表条目。
Java 7 引入了 Supressed 异常、try-with-resources,以及多异常捕获。后两者属于语法糖,能够极大地精简我们的代码。
4.7 - CH07-实现反射
反射在 Java 中的应用十分广泛。开发人员日常接触到的 Java 集成开发环境(IDE)便运用了这一功能:每当我们敲入点号时,IDE 便会根据点号前的内容,动态展示可以访问的字段或者方法。
另一个日常应用则是 Java 调试器,它能够在调试过程中枚举某一对象所有字段的值。
在 Web 开发中,我们经常能够接触到各种可配置的通用框架。为了保证框架的可扩展性,它们往往借助 Java 的反射机制,根据配置文件来加载不同的类。举例来说,Spring 框架的依赖反转(IoC),便是依赖于反射机制。
然而,我相信不少开发人员都嫌弃反射机制比较慢。甚至是甲骨文关于反射的教学网页 [1],也强调了反射性能开销大的缺点。
反射调用的实现
首先,我们来看看方法的反射调用,也就是 Method.invoke,是怎么实现的。
public final class Method extends Executable {
...
public Object invoke(Object obj, Object... args) throws ... {
... // 权限检查
MethodAccessor ma = methodAccessor;
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
}
如果你查阅 Method.invoke 的源代码,那么你会发现,它实际上委派给 MethodAccessor 来处理。MethodAccessor 是一个接口,它有两个已有的具体实现:一个通过本地方法来实现反射调用,另一个则使用了委派模式。为了方便记忆,我便用“本地实现”和“委派实现”来指代这两者。
每个 Method 实例的第一次反射调用都会生成一个委派实现,它所委派的具体实现便是一个本地实现。本地实现非常容易理解。当进入了 Java 虚拟机内部之后,我们便拥有了 Method 实例所指向方法的具体地址。这时候,反射调用无非就是将传入的参数准备好,然后调用进入目标方法。
// v0 版本
import java.lang.reflect.Method;
public class Test {
public static void target(int i) {
new Exception("#" + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
method.invoke(null, 0);
}
}
# 不同版本的输出略有不同,这里我使用了 Java 10。
$ java Test
java.lang.Exception: #0
at Test.target(Test.java:5)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
a t java.base/jdk.internal.reflect.NativeMethodAccessorImpl. .invoke(NativeMethodAccessorImpl.java:62)
t java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.i .invoke(DelegatingMethodAccessorImpl.java:43)
java.base/java.lang.reflect.Method.invoke(Method.java:564)
t Test.main(Test.java:131
为了方便理解,我们可以打印一下反射调用到目标方法时的栈轨迹。在上面的 v0 版本代码中,我们获取了一个指向 Test.target 方法的 Method 对象,并且用它来进行反射调用。在 Test.target 中,我会打印出栈轨迹。
可以看到,反射调用先是调用了 Method.invoke,然后进入委派实现(DelegatingMethodAccessorImpl),再然后进入本地实现(NativeMethodAccessorImpl),最后到达目标方法。
这里你可能会疑问,为什么反射调用还要采取委派实现作为中间层?直接交给本地实现不可以么?
其实,Java 的反射调用机制还设立了另一种动态生成字节码的实现(下称动态实现),直接使用 invoke 指令来调用目标方法。之所以采用委派实现,便是为了能够在本地实现以及动态实现中切换。
// 动态实现的伪代码,这里只列举了关键的调用逻辑,其实它还包括调用者检测、参数检测的字节码。
package jdk.internal.reflect;
public class GeneratedMethodAccessor1 extends ... {
@Overrides
public Object invoke(Object obj, Object[] args) throws ... {
Test.target((int) args[0]);
return null;
}
}
动态实现和本地实现相比,其运行效率要快上 20 倍 [2] 。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍 [3]。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold= 来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。
为了观察这个过程,我将刚才的例子更改为下面的 v1 版本。它会将反射调用循环 20 次。
// v1 版本
import java.lang.reflect.Method;
public class Test {
public static void target(int i) {
new Exception("#" + i).printStackTrace();
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
for (int i = 0; i < 20; i++) {
method.invoke(null, i);
}
}
}
# 使用 -verbose:class 打印加载的类
$ java -verbose:class Test
...
java.lang.Exception: #14
at Test.target(Test.java:5)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:564)
at Test.main(Test.java:12)
[0.158s][info][class,load] ...
...
[0.160s][info][class,load] jdk.internal.reflect.GeneratedMethodAccessor1 source: __JVM_DefineClass__
java.lang.Exception: #15
at Test.target(Test.java:5)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl .invoke(NativeMethodAccessorImpl.java:62)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:564)
at Test.main(Test.java:12)
java.lang.Exception: #16
at Test.target(Test.java:5)
at jdk.internal.reflect.GeneratedMethodAccessor1 .invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl .invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:564)
at Test.main(Test.java:12)
...
可以看到,在第 15 次(从 0 开始数)反射调用时,我们便触发了动态实现的生成。这时候,Java 虚拟机额外加载了不少类。其中,最重要的当属 GeneratedMethodAccessor1(第 30 行)。并且,从第 16 次反射调用开始,我们便切换至这个刚刚生成的动态实现(第 40 行)。
反射调用的 Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现。
反射调用的开销
下面,我们便来拆解反射调用的性能开销。
在刚才的例子中,我们先后进行了 Class.forName,Class.getMethod 以及 Method.invoke 三个操作。其中,**Class.forName 会调用本地方法,Class.getMethod 则会遍历该类的公有方法。如果没有匹配到,它还将遍历父类的公有方法。**可想而知,这两个操作都非常费时。
值得注意的是,以 getMethod 为代表的查找方法操作,会返回查找得到结果的一份拷贝。因此,我们应当避免在热点代码中使用返回 Method 数组的 getMethods 或者 getDeclaredMethods 方法,以减少不必要的堆空间消耗。
在实践中,我们往往会在应用程序中缓存 Class.forName 和 Class.getMethod 的结果。因此,下面我就只关注反射调用本身的性能开销。
为了比较直接调用和反射调用的性能差距,我将前面的例子改为下面的 v2 版本。它会将反射调用循环二十亿次。此外,它还将记录下每跑一亿次的时间。
我将取最后五个记录的平均值,作为预热后的峰值性能。(注:这种性能评估方式并不严谨,我会在专栏的第三部分介绍如何用 JMH 来测性能。)
在我这个老笔记本上,一亿次直接调用耗费的时间大约在 120ms。这和不调用的时间是一致的。其原因在于这段代码属于热循环,同样会触发即时编译。并且,即时编译会将对 Test.target 的调用内联进来,从而消除了调用的开销。
// v2 版本
mport java.lang.reflect.Method;
public class Test {
public static void target(int i) {
// 空方法
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
method.invoke(null, 128);
}
}
}
下面我将以 120ms 作为基准,来比较反射调用的性能开销。
由于目标方法 Test.target 接收一个 int 类型的参数,因此我传入 128 作为反射调用的参数,测得的结果约为基准的 2.7 倍。我们暂且不管这个数字是高是低,先来看看在反射调用之前字节码都做了什么。
59: aload_2 // 加载 Method 对象
60: aconst_null // 反射调用的第一个参数 null
61: iconst_1
62: anewarray Object // 生成一个长度为 1 的 Object 数组
65: dup
66: iconst_0
67: sipush 128
70: invokestatic Integer.valueOf // 将 128 自动装箱成 Integer
73: aastore // 存入 Object 数组中
74: invokevirtual Method.invoke // 反射调用
这里我截取了循环中反射调用编译而成的字节码。可以看到,这段字节码除了反射调用外,还额外做了两个操作。
第一,由于 Method.invoke 是一个变长参数方法,在字节码层面它的最后一个参数会是 Object 数组(感兴趣的同学私下可以用 javap 查看)。Java 编译器会在方法调用处生成一个长度为传入参数数量的 Object 数组,并将传入参数一一存储进该数组中。
第二,由于 Object 数组不能存储基本类型,Java 编译器会对传入的基本类型参数进行自动装箱。
这两个操作除了带来性能开销外,还可能占用堆内存,使得 GC 更加频繁。(如果你感兴趣的话,可以用虚拟机参数 -XX:+PrintGC 试试。)那么,如何消除这部分开销呢?
关于第二个自动装箱,Java 缓存了 [-128, 127] 中所有整数所对应的 Integer 对象。当需要自动装箱的整数在这个范围之内时,便返回缓存的 Integer,否则需要新建一个 Integer 对象。
因此,我们可以将这个缓存的范围扩大至覆盖 128(对应参数 -Djava.lang.Integer.IntegerCache.high=128),便可以避免需要新建 Integer 对象的场景。
或者,我们可以在循环外缓存 128 自动装箱得到的 Integer 对象,并且直接传入反射调用中。这两种方法测得的结果差不多,约为基准的 1.8 倍。
现在我们再回来看看第一个因变长参数而自动生成的 Object 数组。既然每个反射调用对应的参数个数是固定的,那么我们可以选择在循环外新建一个 Object 数组,设置好参数,并直接交给反射调用。改好的代码可以参照文稿中的 v3 版本。
// v3 版本
import java.lang.reflect.Method;
public class Test {
public static void target(int i) {
// 空方法
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
Object[] arg = new Object[1]; // 在循环外构造参数数组
arg[0] = 128;
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
method.invoke(null, arg);
}
}
}
测得的结果反而更糟糕了,为基准的 2.9 倍。这是为什么呢?
如果你在上一步解决了自动装箱之后查看运行时的 GC 状况,你会发现这段程序并不会触发 GC。其原因在于,原本的反射调用被内联了,从而使得即时编译器中的逃逸分析将原本新建的 Object 数组判定为不逃逸的对象。
如果一个对象不逃逸,那么即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间。具体我会在本专栏的第二部分详细解释。
如果在循环外新建数组,即时编译器无法确定这个数组会不会中途被更改,因此无法优化掉访问数组的操作,可谓是得不偿失。
到目前为止,我们的最好记录是 1.8 倍。那能不能再进一步提升呢?
刚才我曾提到,可以关闭反射调用的 Inflation 机制,从而取消委派实现,并且直接使用动态实现。此外,每次反射调用都会检查目标方法的权限,而这个检查同样可以在 Java 代码里关闭,在关闭了这两项机制之后,也就得到了我们的 v4 版本,它测得的结果约为基准的 1.3 倍。
// v4 版本
import java.lang.reflect.Method;
// 在运行指令中添加如下两个虚拟机参数:
// -Djava.lang.Integer.IntegerCache.high=128
// -Dsun.reflect.noInflation=true
public class Test {
public static void target(int i) {
// 空方法
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
method.setAccessible(true); // 关闭权限检查
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
method.invoke(null, 128);
}
}
}
到这里,我们基本上把反射调用的水分都榨干了。接下来,我来把反射调用的性能开销给提回去。
首先,在这个例子中,之所以反射调用能够变得这么快,主要是因为即时编译器中的方法内联。在关闭了 Inflation 的情况下,内联的瓶颈在于 Method.invoke 方法中对 MethodAccessor.invoke 方法的调用。
我会在后面的文章中介绍方法内联的具体实现,这里先说个结论:在生产环境中,我们往往拥有多个不同的反射调用,对应多个 GeneratedMethodAccessor,也就是动态实现。
由于 Java 虚拟机的关于上述调用点的类型 profile(注:对于 invokevirtual 或者 invokeinterface,Java 虚拟机会记录下调用者的具体类型,我们称之为类型 profile)无法同时记录这么多个类,因此可能造成所测试的反射调用没有被内联的情况。
// v5 版本
import java.lang.reflect.Method;
public class Test {
public static void target(int i) {
// 空方法
}
public static void main(String[] args) throws Exception {
Class<?> klass = Class.forName("Test");
Method method = klass.getMethod("target", int.class);
method.setAccessible(true); // 关闭权限检查
polluteProfile();
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
method.invoke(null, 128);
}
}
public static void polluteProfile() throws Exception {
Method method1 = Test.class.getMethod("target1", int.class);
Method method2 = Test.class.getMethod("target2", int.class);
for (int i = 0; i < 2000; i++) {
method1.invoke(null, 0);
method2.invoke(null, 0);
}
}
public static void target1(int i) { }
public static void target2(int i) { }
}
在上面的 v5 版本中,我在测试循环之前调用了 polluteProfile 的方法。该方法将反射调用另外两个方法,并且循环上 2000 遍。
而测试循环则保持不变。测得的结果约为基准的 6.7 倍。也就是说,只要误扰了 Method.invoke 方法的类型 profile,性能开销便会从 1.3 倍上升至 6.7 倍。
之所以这么慢,除了没有内联之外,另外一个原因是逃逸分析不再起效。这时候,我们便可以采用刚才 v3 版本中的解决方案,在循环外构造参数数组,并直接传递给反射调用。这样子测得的结果约为基准的 5.2 倍。
除此之外,我们还可以提高 Java 虚拟机关于每个调用能够记录的类型数目(对应虚拟机参数 -XX:TypeProfileWidth,默认值为 2,这里设置为 3)。最终测得的结果约为基准的 2.8 倍,尽管它和原本的 1.3 倍还有一定的差距,但总算是比 6.7 倍好多了。
总结与实践
今天我介绍了 Java 里的反射机制。
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过 15 次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联。
附录:反射 API 简介
通常来说,使用反射 API 的第一步便是获取 Class 对象。在 Java 中常见的有这么三种。
- 使用静态方法 Class.forName 来获取。
- 调用对象的 getClass() 方法。
- 直接用类名 +“.class”访问。对于基本类型来说,它们的包装类型(wrapper classes)拥有一个名为“TYPE”的 final 静态字段,指向该基本类型对应的 Class 对象。
例如,Integer.TYPE 指向 int.class。对于数组类型来说,可以使用类名 +“[ ].class”来访问,如 int[ ].class。
除此之外,Class 类和 java.lang.reflect 包中还提供了许多返回 Class 对象的方法。例如,对于数组类的 Class 对象,调用 Class.getComponentType() 方法可以获得数组元素的类型。
一旦得到了 Class 对象,我们便可以正式地使用反射功能了。下面我列举了较为常用的几项。
- 使用 newInstance() 来生成一个该类的实例。它要求该类中拥有一个无参数的构造器。
- 使用 isInstance(Object) 来判断一个对象是否该类的实例,语法上等同于 instanceof 关键字(JIT 优化时会有差别,我会在本专栏的第二部分详细介绍)。
- 使用 Array.newInstance(Class,int) 来构造该类型的数组。
- 使用 getFields()/getConstructors()/getMethods() 来访问该类的成员。除了这三个之外,Class 类还提供了许多其他方法,详见 [4]。需要注意的是,方法名中带 Declared 的不会返回父类的成员,但是会返回私有成员;而不带 Declared 的则相反。
当获得了类成员之后,我们可以进一步做如下操作。
- 使用 Constructor/Field/Method.setAccessible(true) 来绕开 Java 语言的访问限制。
- 使用 Constructor.newInstance(Object[]) 来生成该类的实例。
- 使用 Field.get/set(Object) 来访问字段的值。
- 使用 Method.invoke(Object, Object[]) 来调用方法。
4.8 - CH08-invokedynamic-上
前不久,“虚拟机”赛马俱乐部来了个年轻人,标榜自己是动态语言,是先进分子。
这一天,先进分子牵着一头鹿进来,说要参加赛马。咱部里的老学究 Java 就不同意了呀,鹿又不是马,哪能参加赛马。
当然了,这种墨守成规的调用方式,自然是先进分子所不齿的。现在年轻人里流行的是鸭子类型(duck typing)[1],只要是跑起来像只马的,它就是一只马,也就能够参加赛马比赛。
class Horse {
public void race() {
System.out.println("Horse.race()");
}
}
class Deer {
public void race() {
System.out.println("Deer.race()");
}
}
class Cobra {
public void race() {
System.out.println("How do you turn this on?");
}
}
(如何用同一种方式调用他们的赛跑方法?)
说到了这里,如果我们将赛跑定义为对赛跑方法(对应上述代码中的 race())的调用的话,那么这个故事的关键,就在于能不能在马场中调用非马类型的赛跑方法。
为了解答这个问题,我们先来回顾一下 Java 里的方法调用。在 Java 中,方法调用会被编译为 invokestatic,invokespecial,invokevirtual 以及 invokeinterface 四种指令。这些指令与包含目标方法类名、方法名以及方法描述符的符号引用捆绑。在实际运行之前,Java 虚拟机将根据这个符号引用链接到具体的目标方法。
可以看到,在这四种调用指令中,Java 虚拟机明确要求方法调用需要提供目标方法的类名。在这种体系下,我们有两个解决方案。一是调用其中一种类型的赛跑方法,比如说马类的赛跑方法。对于非马的类型,则给它套一层马甲,当成马来赛跑。
另外一种解决方式,是通过反射机制,来查找并且调用各个类型中的赛跑方法,以此模拟真正的赛跑。
显然,比起直接调用,这两种方法都相当复杂,执行效率也可想而知。为了解决这个问题,Java 7 引入了一条新的指令 invokedynamic。该指令的调用机制抽象出调用点这一个概念,并允许应用程序将调用点链接至任意符合条件的方法上。
public static void startRace(java.lang.Object)
0: aload_0 // 加载一个任意对象
1: invokedynamic race // 调用赛跑方法
(理想的调用方式)
作为 invokedynamic 的准备工作,Java 7 引入了更加底层、更加灵活的方法抽象 :方法句柄(MethodHandle)。
方法句柄的概念
方法句柄是一个强类型的,能够被直接执行的引用 [2]。该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的 getter 或者 setter 方法。
这里需要注意的是,它并不会直接指向目标字段所在类中的 getter/setter,毕竟你无法保证已有的 getter/setter 方法就是在访问目标字段。
方法句柄的类型(MethodType)是由所指向方法的参数类型以及返回类型组成的。它是用来确认方法句柄是否适配的唯一关键。当使用方法句柄时,我们其实并不关心方法句柄所指向方法的类名或者方法名。
打个比方,如果兔子的“赛跑”方法和“睡觉”方法的参数类型以及返回类型一致,那么对于兔子递过来的一个方法句柄,我们并不知道会是哪一个方法。
方法句柄的创建是通过 MethodHandles.Lookup 类来完成的。它提供了多个 API,既可以使用反射 API 中的 Method 来查找,也可以根据类、方法名以及方法句柄类型来查找。
当使用后者这种查找方式时,用户需要区分具体的调用类型,比如说对于用 invokestatic 调用的静态方法,我们需要使用 Lookup.findStatic 方法;对于用 invokevirutal 调用的实例方法,以及用 invokeinterface 调用的接口方法,我们需要使用 findVirtual 方法;对于用 invokespecial 调用的实例方法,我们则需要使用 findSpecial 方法。
调用方法句柄,和原本对应的调用指令是一致的。也就是说,对于原本用 invokevirtual 调用的方法句柄,它也会采用动态绑定;而对于原本用 invkespecial 调用的方法句柄,它会采用静态绑定。
class Foo {
private static void bar(Object o) {
..
}
public static Lookup lookup() {
return MethodHandles.lookup();
}
}
// 获取方法句柄的不同方式
MethodHandles.Lookup l = Foo.lookup(); // 具备 Foo 类的访问权限
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, "bar", t);
方法句柄同样也有权限问题。但它与反射 API 不同,其权限检查是在句柄的创建阶段完成的。在实际调用过程中,Java 虚拟机并不会检查方法句柄的权限。如果该句柄被多次调用的话,那么与反射调用相比,它将省下重复权限检查的开销。
需要注意的是,方法句柄的访问权限不取决于方法句柄的创建位置,而是取决于 Lookup 对象的创建位置。
举个例子,对于一个私有字段,如果 Lookup 对象是在私有字段所在类中获取的,那么这个 Lookup 对象便拥有对该私有字段的访问权限,即使是在所在类的外边,也能够通过该 Lookup 对象创建该私有字段的 getter 或者 setter。
由于方法句柄没有运行时权限检查,因此,应用程序需要负责方法句柄的管理。一旦它发布了某些指向私有方法的方法句柄,那么这些私有方法便被暴露出去了。
方法句柄的操作
方法句柄的调用可分为两种,一是需要严格匹配参数类型的 invokeExact。它有多严格呢?假设一个方法句柄将接收一个 Object 类型的参数,如果你直接传入 String 作为实际参数,那么方法句柄的调用会在运行时抛出方法类型不匹配的异常。正确的调用方式是将该 String 显式转化为 Object 类型。
在普通 Java 方法调用中,我们只有在选择重载方法时,才会用到这种显式转化。这是因为经过显式转化后,参数的声明类型发生了改变,因此有可能匹配到不同的方法描述符,从而选取不同的目标方法。调用方法句柄也是利用同样的原理,并且涉及了一个签名多态性(signature polymorphism)的概念。(在这里我们暂且认为签名等同于方法描述符。)
public final native @PolymorphicSignature Object invokeExact(Object... args) throws Throwable;
方法句柄 API 有一个特殊的注解类 @PolymorphicSignature。在碰到被它注解的方法调用时,Java 编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明的描述符。
在刚才的例子中,当传入的参数是 String 时,对应的方法描述符包含 String 类;而当我们转化为 Object 时,对应的方法描述符则包含 Object 类。
public void test(MethodHandle mh, String s) throws Throwable {
mh.invokeExact(s);
mh.invokeExact((Object) s);
}
// 对应的 Java 字节码
public void test(MethodHandle, String) throws java.lang.Throwable;
Code:
0: aload_1
1: aload_2
2: invokevirtual MethodHandle.invokeExact:(Ljava/lang/String;)V
5: aload_1
6: aload_2
7: invokevirtual MethodHandle.invokeExact:(Ljava/lang/Object;)V
10: return
invokeExact 会确认该 invokevirtual 指令对应的方法描述符,和该方法句柄的类型是否严格匹配。在不匹配的情况下,便会在运行时抛出异常。
如果你需要自动适配参数类型,那么你可以选取方法句柄的第二种调用方式 invoke。它同样是一个签名多态性的方法。invoke 会调用 MethodHandle.asType 方法,生成一个适配器方法句柄,对传入的参数进行适配,再调用原方法句柄。调用原方法句柄的返回值同样也会先进行适配,然后再返回给调用者。
方法句柄还支持增删改参数的操作,这些操作都是通过生成另一个方法句柄来实现的。这其中,改操作就是刚刚介绍的 MethodHandle.asType 方法。删操作指的是将传入的部分参数就地抛弃,再调用另一个方法句柄。它对应的 API 是 MethodHandles.dropArguments 方法。
增操作则非常有意思。它会往传入的参数中插入额外的参数,再调用另一个方法句柄,它对应的 API 是 MethodHandle.bindTo 方法。Java 8 中捕获类型的 Lambda 表达式便是用这种操作来实现的,下一篇我会详细进行解释。
增操作还可以用来实现方法的柯里化 [3]。举个例子,有一个指向 f(x, y) 的方法句柄,我们可以通过将 x 绑定为 4,生成另一个方法句柄 g(y) = f(4, y)。在执行过程中,每当调用 g(y) 的方法句柄,它会在参数列表最前面插入一个 4,再调用指向 f(x, y) 的方法句柄。
方法句柄的实现
下面我们来看看 HotSpot 虚拟机中方法句柄调用的具体实现。(由于篇幅原因,这里只讨论 DirectMethodHandle。)
前面提到,调用方法句柄所使用的 invokeExact 或者 invoke 方法具备签名多态性的特性。它们会根据具体的传入参数来生成方法描述符。那么,拥有这个描述符的方法实际存在吗?对 invokeExact 或者 invoke 的调用具体会进入哪个方法呢?
import java.lang.invoke.*;
public class Foo {
public static void bar(Object o) {
new Exception().printStackTrace();
}
public static void main(String[] args) throws Throwable {
MethodHandles.Lookup l = MethodHandles.lookup();
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh = l.findStatic(Foo.class, "bar", t);
mh.invokeExact(new Object());
}
}
和查阅反射调用的方式一样,我们可以通过新建异常实例来查看栈轨迹。打印出来的占轨迹如下所示:
$ java Foo
java.lang.Exception
at Foo.bar(Foo.java:5)
at Foo.main(Foo.java:12)
也就是说,invokeExact 的目标方法竟然就是方法句柄指向的方法。
先别高兴太早。我刚刚提到过,invokeExact 会对参数的类型进行校验,并在不匹配的情况下抛出异常。如果它直接调用了方法句柄所指向的方法,那么这部分参数类型校验的逻辑将无处安放。因此,唯一的可能便是 Java 虚拟机隐藏了部分栈信息。
当我们启用了 -XX:+ShowHiddenFrames 这个参数来打印被 Java 虚拟机隐藏了的栈信息时,你会发现 main 方法和目标方法中间隔着两个貌似是生成的方法。
$ java -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames Foo
java.lang.Exception
at Foo.bar(Foo.java:5)
at java.base/java.lang.invoke.DirectMethodHandle$Holder. invokeStatic(DirectMethodHandle$Holder:1000010)
at java.base/java.lang.invoke.LambdaForm$MH000/766572210. invokeExact_MT000_LLL_V(LambdaForm$MH000:1000019)
at Foo.main(Foo.java:12)
实际上,Java 虚拟机会对 invokeExact 调用做特殊处理,调用至一个共享的、与方法句柄类型相关的特殊适配器中。这个适配器是一个 LambdaForm,我们可以通过添加虚拟机参数将之导出成 class 文件(-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)。
final class java.lang.invoke.LambdaForm$MH000 { static void invokeExact_MT000_LLLLV(jeava.lang.bject, jjava.lang.bject, jjava.lang.bject);
Code:
: aload_0
1 : checkcast #14 //Mclass java/lang/invoke/ethodHandle
: dup
5 : astore_0
: aload_32 : checkcast #16 //Mclass java/lang/invoke/ethodType
10: invokestatic I#22 // Method java/lang/invoke/nvokers.checkExactType:(MLjava/lang/invoke/ethodHandle,;Ljava/lang/invoke/ethodType);V
13: aload_0
14: invokestatic #26 I // Method java/lang/invoke/nvokers.checkCustomized:(MLjava/lang/invoke/ethodHandle);V
17: aload_0
18: aload_1
19: ainvakevirtudl #30 2 // Methodijava/lang/nvokev/ethodHandle.invokeBasic:(LLeava/lang/bject;;V
23 return
可以看到,在这个适配器中,它会调用 Invokers.checkExactType 方法来检查参数类型,然后调用 Invokers.checkCustomized 方法。后者会在方法句柄的执行次数超过一个阈值时进行优化(对应参数 -Djava.lang.invoke.MethodHandle.CUSTOMIZE_THRESHOLD,默认值为 127)。最后,它会调用方法句柄的 invokeBasic 方法。
Java 虚拟机同样会对 invokeBasic 调用做特殊处理,这会将调用至方法句柄本身所持有的适配器中。这个适配器同样是一个 LambdaForm,你可以通过反射机制将其打印出来。
// 该方法句柄持有的 LambdaForm 实例的 toString() 结果
DMH.invokeStatic_L_V=Lambda(a0:L,a1:L)=>{
t2:L=DirectMethodHandle.internalMemberName(a0:L);
t3:V=MethodHandle.linkToStatic(a1:L,t2:L);void}
这个适配器将获取方法句柄中的 MemberName 类型的字段,并且以它为参数调用 linkToStatic 方法。估计你已经猜到了,Java 虚拟机也会对 linkToStatic 调用做特殊处理,它将根据传入的 MemberName 参数所存储的方法地址或者方法表索引,直接跳转至目标方法。
final class MemberName implements Member, Cloneable {
...
//@Injected JVM_Method* vmtarget;
//@Injected int vmindex;
...
那么前面那个适配器中的优化又是怎么回事?实际上,方法句柄一开始持有的适配器是共享的。当它被多次调用之后,Invokers.checkCustomized 方法会为该方法句柄生成一个特有的适配器。这个特有的适配器会将方法句柄作为常量,直接获取其 MemberName 类型的字段,并继续后面的 linkToStatic 调用。
final class java.lang.invoke.LambdaForm$DMH000 {
static void invokeStatic000_LL_V(java.lang.Object, java.lang.Object);
Code:
0: ldc #14 // String CONSTANT_PLACEHOLDER_1 <<Foo.bar(Object)void/invokeStatic>>
2: checkcast #16 // class java/lang/invoke/MethodHandle
5: astore_0 // 上面的优化代码覆盖了传入的方法句柄
6: aload_0 // 从这里开始跟初始版本一致
7: invokestatic #22 // Method java/lang/invoke/DirectMethodHandle.internalMemberName:(Ljava/lang/Object;)Ljava/lang/Object;
10: astore_2
11: aload_1
12: aload_2
13: checkcast #24 // class java/lang/invoke/MemberName
16: invokestatic #28 // Method java/lang/invoke/MethodHandle.linkToStatic:(Ljava/lang/Object;Ljava/lang/invoke/MemberName;)V
19: return
可以看到,方法句柄的调用和反射调用一样,都是间接调用。因此,它也会面临无法内联的问题。不过,与反射调用不同的是,方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量。具体内容我会在下一篇中进行详细的解释。
总结与实践
今天我介绍了 invokedynamic 底层机制的基石:方法句柄。
方法句柄是一个强类型的、能够被直接执行的引用。它仅关心所指向方法的参数类型以及返回类型,而不关心方法所在的类以及方法名。方法句柄的权限检查发生在创建过程中,相较于反射调用节省了调用时反复权限检查的开销。
方法句柄可以通过 invokeExact 以及 invoke 来调用。其中,invokeExact 要求传入的参数和所指向方法的描述符严格匹配。方法句柄还支持增删改参数的操作,这些操作是通过生成另一个充当适配器的方法句柄来实现的。
方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。
4.9 - CH09-invokedynamic-下
上回讲到,为了让所有的动物都能参加赛马,Java 7 引入了 invokedynamic 机制,允许调用任意类的“赛跑”方法。不过,我们并没有讲解 invokedynamic,而是深入地探讨了它所依赖的方法句柄。
今天,我便来正式地介绍 invokedynamic 指令,讲讲它是如何生成调用点,并且允许应用程序自己决定链接至哪一个方法中的。
invokedynamic 指令
invokedynamic 是 Java 7 引入的一条新指令,用以支持动态语言的方法调用。具体来说,它将调用点(CallSite)抽象成一个 Java 类,并且将原本由 Java 虚拟机控制的方法调用以及方法链接暴露给了应用程序。在运行过程中,每一条 invokedynamic 指令将捆绑一个调用点,并且会调用该调用点所链接的方法句柄。
在第一次执行 invokedynamic 指令时,Java 虚拟机会调用该指令所对应的启动方法(BootStrap Method),来生成前面提到的调用点,并且将之绑定至该 invokedynamic 指令中。在之后的运行过程中,Java 虚拟机则会直接调用绑定的调用点所链接的方法句柄。
在字节码中,启动方法是用方法句柄来指定的。这个方法句柄指向一个返回类型为调用点的静态方法。该方法必须接收三个固定的参数,分别为一个 Lookup 类实例,一个用来指代目标方法名字的字符串,以及该调用点能够链接的方法句柄的类型。
除了这三个必需参数之外,启动方法还可以接收若干个其他的参数,用来辅助生成调用点,或者定位所要链接的目标方法。
import java.lang.invoke.*;
class Horse {
public void race() {
System.out.println("Horse.race()");
}
}
class Deer {
public void race() {
System.out.println("Deer.race()");
}
}
// javac Circuit.java
// java Circuit
public class Circuit {
public static void startRace(Object obj) {
// aload obj
// invokedynamic race()
}
public static void main(String[] args) {
startRace(new Horse());
// startRace(new Deer());
}
public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
MethodHandle mh = l.findVirtual(Horse.class, name, MethodType.methodType(void.class));
return new ConstantCallSite(mh.asType(callSiteType));
}
}
我在文稿中贴了一段代码,其中便包含一个启动方法。它将接收前面提到的三个固定参数,并且返回一个链接至 Horse.race 方法的 ConstantCallSite。
这里的 ConstantCallSite 是一种不可以更改链接对象的调用点。除此之外,Java 核心类库还提供多种可以更改链接对象的调用点,比如 MutableCallSite 和 VolatileCallSite。
这两者的区别就好比正常字段和 volatile 字段之间的区别。此外,应用程序还可以自定义调用点类,来满足特定的重链接需求。
由于 Java 暂不支持直接生成 invokedynamic 指令 [1],所以接下来我会借助之前介绍过的字节码工具 ASM 来实现这一目的。
import java.io.IOException;
import java.lang.invoke.*;
import java.nio.file.*;
import org.objectweb.asm.*;
// javac -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper.java
// java -cp /path/to/asm-all-6.0_BETA.jar:. ASMHelper
// java Circuit
public class ASMHelper implements Opcodes {
private static class MyMethodVisitor extends MethodVisitor {
private static final String BOOTSTRAP_CLASS_NAME = Circuit.class.getName().replace('.', '/');
private static final String BOOTSTRAP_METHOD_NAME = "bootstrap";
private static final String BOOTSTRAP_METHOD_DESC = MethodType
.methodType(CallSite.class, MethodHandles.Lookup.class, String.class, MethodType.class)
.toMethodDescriptorString();
private static final String TARGET_METHOD_NAME = "race";
private static final String TARGET_METHOD_DESC = "(Ljava/lang/Object;)V";
public final MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
Handle h = new Handle(H_INVOKESTATIC, BOOTSTRAP_CLASS_NAME, BOOTSTRAP_METHOD_NAME, BOOTSTRAP_METHOD_DESC, false);
mv.visitInvokeDynamicInsn(TARGET_METHOD_NAME, TARGET_METHOD_DESC, h);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 1);
mv.visitEnd();
}
}
public static void main(String[] args) throws IOException {
ClassReader cr = new ClassReader("Circuit");
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new ClassVisitor(ASM6, cw) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if ("startRace".equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
};
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Circuit.class"), cw.toByteArray());
}
}
你无需理解上面这段代码的具体含义,只须了解它会更改同一目录下 Circuit 类的 startRace(Object) 方法,使之包含 invokedynamic 指令,执行所谓的赛跑方法。
public static void startRace(java.lang.Object);
0: aload_0
1: invokedynamic #80, 0 // race:(Ljava/lang/Object;)V
6: return
如果你足够细心的话,你会发现该指令所调用的赛跑方法的描述符,和 Horse.race 方法或者 Deer.race 方法的描述符并不一致。这是因为 invokedynamic 指令最终调用的是方法句柄,而方法句柄会将调用者当成第一个参数。因此,刚刚提到的那两个方法恰恰符合这个描述符所对应的方法句柄类型。
到目前为止,我们已经可以通过 invokedynamic 调用 Horse.race 方法了。为了支持调用任意类的 race 方法,我实现了一个简单的单态内联缓存。如果调用者的类型命中缓存中的类型,便直接调用缓存中的方法句柄,否则便更新缓存。
// 需要更改 ASMHelper.MyMethodVisitor 中的 BOOTSTRAP_CLASS_NAME
import java.lang.invoke.*;
public class MonomorphicInlineCache {
private final MethodHandles.Lookup lookup;
private final String name;
public MonomorphicInlineCache(MethodHandles.Lookup lookup, String name) {
this.lookup = lookup;
this.name = name;
}
private Class<?> cachedClass = null;
private MethodHandle mh = null;
public void invoke(Object receiver) throws Throwable {
if (cachedClass != receiver.getClass()) {
cachedClass = receiver.getClass();
mh = lookup.findVirtual(cachedClass, name, MethodType.methodType(void.class));
}
mh.invoke(receiver);
}
public static CallSite bootstrap(MethodHandles.Lookup l, String name, MethodType callSiteType) throws Throwable {
MonomorphicInlineCache ic = new MonomorphicInlineCache(l, name);
MethodHandle mh = l.findVirtual(MonomorphicInlineCache.class, "invoke", MethodType.methodType(void.class, Object.class));
return new ConstantCallSite(mh.bindTo(ic));
}
}
可以看到,尽管 invokedynamic 指令调用的是所谓的 race 方法,但是实际上我返回了一个链接至名为“invoke”的方法的调用点。由于调用点仅要求方法句柄的类型能够匹配,因此这个链接是合法的。
不过,这正是 invokedynamic 的目的,也就是将调用点与目标方法的链接交由应用程序来做,并且依赖于应用程序对目标方法进行验证。所以,如果应用程序将赛跑方法链接至兔子的睡觉方法,那也只能怪应用程序自己了。
Java 8 的 Lambda 表达式
在 Java 8 中,Lambda 表达式也是借助 invokedynamic 来实现的。
具体来说,Java 编译器利用 invokedynamic 指令来生成实现了函数式接口的适配器。这里的函数式接口指的是仅包括一个非 default 接口方法的接口,一般通过 @FunctionalInterface 注解。不过就算是没有使用该注解,Java 编译器也会将符合条件的接口辨认为函数式接口。
int x = ..
IntStream.of(1, 2, 3).map(i -> i * 2).map(i -> i * x);
举个例子,上面这段代码会对 IntStream 中的元素进行两次映射。我们知道,映射方法 map 所接收的参数是 IntUnaryOperator(这是一个函数式接口)。也就是说,在运行过程中我们需要将 i->i2 和 i->ix 这两个 Lambda 表达式转化成 IntUnaryOperator 的实例。这个转化过程便是由 invokedynamic 来实现的。
在编译过程中,Java 编译器会对 Lambda 表达式进行解语法糖(desugar),生成一个方法来保存 Lambda 表达式的内容。该方法的参数列表不仅包含原本 Lambda 表达式的参数,还包含它所捕获的变量。(注:方法引用,如 Horse::race,则不会生成生成额外的方法。)
在上面那个例子中,第一个 Lambda 表达式没有捕获其他变量,而第二个 Lambda 表达式(也就是 i->i*x)则会捕获局部变量 x。这两个 Lambda 表达式对应的方法如下所示。可以看到,所捕获的变量同样也会作为参数传入生成的方法之中。
// i -> i * 2
private static int lambda$0(int);
Code:
0: iload_0
1: iconst_2
2: imul
3: ireturn
// i -> i * x
private static int lambda$1(int, int);
Code:
0: iload_1
1: iload_0
2: imul
3: ireturn
第一次执行 invokedynamic 指令时,它所对应的启动方法会通过 ASM 来生成一个适配器类。这个适配器类实现了对应的函数式接口,在我们的例子中,也就是 IntUnaryOperator。启动方法的返回值是一个 ConstantCallSite,其链接对象为一个返回适配器类实例的方法句柄。
根据 Lambda 表达式是否捕获其他变量,启动方法生成的适配器类以及所链接的方法句柄皆不同。
如果该 Lambda 表达式没有捕获其他变量,那么可以认为它是上下文无关的。因此,启动方法将新建一个适配器类的实例,并且生成一个特殊的方法句柄,始终返回该实例。
如果该 Lambda 表达式捕获了其他变量,那么每次执行该 invokedynamic 指令,我们都要更新这些捕获了的变量,以防止它们发生了变化。
另外,为了保证 Lambda 表达式的线程安全,我们无法共享同一个适配器类的实例。因此,在每次执行 invokedynamic 指令时,所调用的方法句柄都需要新建一个适配器类实例。
在这种情况下,启动方法生成的适配器类将包含一个额外的静态方法,来构造适配器类的实例。该方法将接收这些捕获的参数,并且将它们保存为适配器类实例的实例字段。
你可以通过虚拟机参数 -Djdk.internal.lambda.dumpProxyClasses=/DUMP/PATH 导出这些具体的适配器类。这里我导出了上面这个例子中两个 Lambda 表达式对应的适配器类。
// i->i*2 对应的适配器类
final class LambdaTest$$Lambda$1 implements IntUnaryOperator {
private LambdaTest$$Lambda$1();
Code:
0: aload_0
1: invokespecial java/lang/Object."<init>":()V
4: return
public int applyAsInt(int);
Code:
0: iload_1
1: invokestatic LambdaTest.lambda$0:(I)I
4: ireturn
}
// i->i*x 对应的适配器类
final class LambdaTest$$Lambda$2 implements IntUnaryOperator {
private final int arg$1;
private LambdaTest$$Lambda$2(int);
Code:
0: aload_0
1: invokespecial java/lang/Object."<init>":()V
4: aload_0
5: iload_1
6: putfield arg$1:I
9: return
private static java.util.function.IntUnaryOperator get$Lambda(int);
Code:
0: new LambdaTest$$Lambda$2
3: dup
4: iload_0
5: invokespecial "<init>":(I)V
8: areturn
public int applyAsInt(int);
Code:
0: aload_0
1: getfield arg$1:I
4: iload_1
5: invokestatic LambdaTest.lambda$1:(II)I
8: ireturn
}
可以看到,捕获了局部变量的 Lambda 表达式多出了一个 get$Lambda 的方法。启动方法便会将所返回的调用点链接至指向该方法的方法句柄。也就是说,每次执行 invokedynamic 指令时,都会调用至这个方法中,并构造一个新的适配器类实例。
这个多出来的新建实例会对程序性能造成影响吗?
Lambda 以及方法句柄的性能分析
我再次请出测试反射调用性能开销的那段代码,并将其改造成使用 Lambda 表达式的 v6 版本。
// v6 版本
import java.util.function.IntConsumer;
public class Test {
public static void target(int i) { }
public static void main(String[] args) throws Exception {
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -> Test.target(j)).accept(128);
// ((IntConsumer) Test::target.accept(128);
}
}
}
测量结果显示,它与直接调用的性能并无太大的区别。也就是说,即时编译器能够将转换 Lambda 表达式所使用的 invokedynamic,以及对 IntConsumer.accept 方法的调用统统内联进来,最终优化为空操作。
这个其实不难理解:Lambda 表达式所使用的 invokedynamic 将绑定一个 ConstantCallSite,其链接的目标方法无法改变。因此,即时编译器会将该目标方法直接内联进来。对于这类没有捕获变量的 Lambda 表达式而言,目标方法只完成了一个动作,便是加载缓存的适配器类常量。
另一方面,对 IntConsumer.accept 方法的调用实则是对适配器类的 accept 方法的调用。
如果你查看了 accept 方法对应的字节码的话,你会发现它仅包含一个方法调用,调用至 Java 编译器在解 Lambda 语法糖时生成的方法。
该方法的内容便是 Lambda 表达式的内容,也就是直接调用目标方法 Test.target。将这几个方法调用内联进来之后,原本对 accept 方法的调用则会被优化为空操作。
下面我将之前的代码更改为带捕获变量的 v7 版本。理论上,每次调用 invokedynamic 指令,Java 虚拟机都会新建一个适配器类的实例。然而,实际运行结果还是与直接调用的性能一致。
// v7 版本
import java.util.function.IntConsumer;
public class Test {
public static void target(int i) { }
public static void main(String[] args) throws Exception {
int x = 2;
long current = System.currentTimeMillis();
for (int i = 1; i <= 2_000_000_000; i++) {
if (i % 100_000_000 == 0) {
long temp = System.currentTimeMillis();
System.out.println(temp - current);
current = temp;
}
((IntConsumer) j -> Test.target(x + j)).accept(128);
}
}
}
显然,即时编译器的逃逸分析又将该新建实例给优化掉了。我们可以通过虚拟机参数 -XX:-DoEscapeAnalysis 来关闭逃逸分析。果然,这时候测得的值约为直接调用的 2.5 倍。
尽管逃逸分析能够去除这些额外的新建实例开销,但是它也不是时时奏效。它需要同时满足两件事:invokedynamic 指令所执行的方法句柄能够内联,和接下来的对 accept 方法的调用也能内联。
只有这样,逃逸分析才能判定该适配器实例不逃逸。否则,我们会在运行过程中不停地生成适配器类实例。所以,我们应当尽量使用非捕获的 Lambda 表达式。
总结与实践
今天我介绍了 invokedynamic 指令以及 Lambda 表达式的实现。
invokedymaic 指令抽象出调用点的概念,并且将调用该调用点所链接的方法句柄。在第一次执行 invokedynamic 指令时,Java 虚拟机将执行它所对应的启动方法,生成并且绑定一个调用点。之后如果再次执行该指令,Java 虚拟机则直接调用已经绑定了的调用点所链接的方法。
Lambda 表达式到函数式接口的转换是通过 invokedynamic 指令来实现的。该 invokedynamic 指令对应的启动方法将通过 ASM 生成一个适配器类。
对于没有捕获其他变量的 Lambda 表达式,该 invokedynamic 指令始终返回同一个适配器类的实例。对于捕获了其他变量的 Lambda 表达式,每次执行 invokedynamic 指令将新建一个适配器类实例。
不管是捕获型的还是未捕获型的 Lambda 表达式,它们的性能上限皆可以达到直接调用的性能。其中,捕获型 Lambda 表达式借助了即时编译器中的逃逸分析,来避免实际的新建适配器类实例的操作。
4.10 - CH10-对象内存布局
在 Java 程序中,我们拥有多种新建对象的方式。除了最为常见的 new 语句之外,我们还可以通过反射机制、Object.clone 方法、反序列化以及 Unsafe.allocateInstance 方法来新建对象。
其中,Object.clone 方法和反序列化通过直接复制已有的数据,来初始化新建对象的实例字段。Unsafe.allocateInstance 方法则没有初始化实例字段,而 new 语句和反射机制,则是通过调用构造器来初始化实例字段。
以 new 语句为例,它编译而成的字节码将包含用来请求内存的 new 指令,以及用来调用构造器的 invokespecial 指令。
// Foo foo = new Foo(); 编译而成的字节码
0 new Foo
3 dup
4 invokespecial Foo()
7 astore_1
提到构造器,就不得不提到 Java 对构造器的诸多约束。首先,如果一个类没有定义任何构造器的话, Java 编译器会自动添加一个无参数的构造器。
// Foo 类构造器会调用其父类 Object 的构造器
public Foo();
0 aload_0 [this]
1 invokespecial java.lang.Object() [8]
4 return
然后,子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
显式调用又可分为两种,一是直接使用“super”关键字调用父类构造器,二是使用“this”关键字调用同一个类中的其他构造器。无论是直接的显式调用,还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(不过这可以通过调用其他生成参数的方法,或者字节码注入来绕开。)
总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象。
你应该已经发现了其中的玄机:通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
这些字段在内存中的具体分布是怎么样的呢?今天我们就来看看对象的内存布局。
压缩指针
在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。
在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。因此,每一个 Integer 对象的额外内存开销至少是 400%。这也是为什么 Java 要引入基本类型的原因之一。
为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针 [1] 的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。
这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。
那么压缩指针是什么原理呢?
打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1 号车,依次类推。
原本的内存寻址用的是车位号。比如说我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找 3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车。
这样一来,32 位压缩指针最多可以标记 2 的 32 次方辆车,对应着 2 的 33 次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号 *2 的寻址系统。
上述模型有一个前提,你应该已经想到了,就是每辆车都从偶数号车位停起。这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)。
默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。
在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB 的地址空间(超过 32GB 则会关闭压缩指针)。
在对压缩指针解引用时,我们需要将其左移 3 位,再加上一个固定偏移量,便可以得到能够寻址 32GB 地址空间的伪 64 位指针了。
此外,我们可以通过配置刚刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
举例来说,如果规定每辆车都需要从偶数车位号停起,那么对于占据两个车位的小房车来说刚刚好,而对于需要三个车位的大房车来说,也仅是浪费一个车位。
但是如果规定需要从 4 的倍数号车位停起,那么小房车则会浪费两个车位,而大房车至多可能浪费三个车位。
当然,就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。
**字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。**这两种情况对程序的执行效率而言都是不利的。
下面我来介绍一下对象内存布局另一个有趣的特性:字段重排列。
字段重排列
字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1),但都会遵循如下两个规则。
其一,如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。
以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。
其二,子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
class A {
long l;
int i;
}
class B extends A {
long l;
int i;
}
我在文中贴了一段代码,里边定义了两个类 A 和 B,其中 B 继承 A。A 和 B 各自定义了一个 long 类型的实例字段和一个 int 类型的实例字段。下面我分别打印了 B 类在启用压缩指针和未启用压缩指针时,各个字段的偏移量。
# 启用压缩指针时,B 类的字段分布
B object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 int A.i 0
16 8 long A.l 0
24 8 long B.l 0
32 4 int B.i 0
36 4 (loss due to the next object alignment)
当启用压缩指针时,可以看到 Java 虚拟机将 A 类的 int 字段放置于 long 字段之前,以填充因为 long 字段对齐造成的 4 字节缺口。由于对象整体大小需要对齐至 8N,因此对象的最后会有 4 字节的空白填充。
# 关闭压缩指针时,B 类的字段分布
B object internals:
OFFSET SIZE TYPE DESCRIPTION
0 4 (object header)
4 4 (object header)
8 4 (object header)
12 4 (object header)
16 8 long A.l
24 4 int A.i
28 4 (alignment/padding gap)
32 8 long B.l
40 4 int B.i
44 4 (loss due to the next object alignment)
当关闭压缩指针时,B 类字段的起始位置需对齐至 8N。这么一来,B 类字段的前后各有 4 字节的空白。那么我们可不可以将 B 类的 int 字段移至前面的空白中,从而节省这 8 字节呢?
我认为是可以的,并且我修改过后的 Java 虚拟机也没有跑崩。由于 HotSpot 中的这块代码年久失修,公司的同事也已经记不得是什么原因了,那么姑且先认为是一些历史遗留问题吧。
Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题 [2]。这个注释也会影响到字段的排列。
虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。
然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。(volatile 字段和缓存行的故事我会在之后的篇章中详细介绍。)
Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,随着 Java 版本的变动也比较大,因此这里就不做阐述了。
如果你感兴趣,可以利用实践环节的工具,来查阅 Contended 字段的内存布局。注意使用虚拟机选项 -XX:-RestrictContended。如果你在 Java 9 以上版本试验的话,在使用 javac 编译时需要添加 –add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAME
总结和实践
今天我介绍了 Java 虚拟机构造对象的方式,所构造对象的大小,以及对象的内存布局。
常见的 new 语句会被编译为 new 指令,以及对构造器的调用。每个类的构造器皆会直接或者间接调用父类的构造器,并且在同一个实例中初始化相应的字段。
Java 虚拟机引入了压缩指针的概念,将原本的 64 位指针压缩成 32 位。压缩指针要求 Java 虚拟机堆中对象的起始地址要对齐至 8 的倍数。Java 虚拟机还会对每个类的字段进行重排列,使得字段也能够内存对齐。
4.11 - CH11-垃圾回收-上
Java 虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。不过既然是自动机制,肯定没法做到像手动回收那般精准高效 [1] ,而且还会带来不少与垃圾回收实现相关的问题。
引用计数法与可达性分析
垃圾回收,顾名思义,便是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?
我们先来讲一种古老的辨别方法:引用计数法(reference counting)。它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。
它的具体实现是这样子的:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。
除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
举个例子,假设对象 a 与 b 相互引用,除此之外没有其他引用指向 a 或者 b。在这种情况下,a 和 b 实际上已经死了,但由于它们的引用计数器皆不为 0,在引用计数法的心中,这两个对象还活着。因此,这些循环引用对象所占据的空间将不可回收,从而造成了内存泄露。
目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
那么什么是 GC Roots 呢?我们可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots 包括(但不限于)如下几种:
- Java 方法栈桢中的局部变量;
- 已加载类的静态变量;
- JNI handles;
- 已启动且未停止的 Java 线程。
可达性分析可以解决引用计数法所不能解决的循环引用问题。举例来说,即便对象 a 和 b 相互引用,只要从 GC Roots 出发无法到达 a 或者 b,那么可达性分析便不会将它们加入存活对象合集之中。
虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。
比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。
误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。
Stop-the-world 以及安全点
怎么解决这个问题呢?在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。
Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
这篇博客 [2] 还提到了一种比较另类的解释:安全词。一旦垃圾回收线程喊出了安全词,其他非垃圾回收线程便会一一停下。
当然,安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。
举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。
只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
由于本地代码需要通过 JNI 的 API 来完成上述三个操作,因此 Java 虚拟机仅需在 API 的入口处进行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,便可以在必要的时候挂起当前线程。
除了执行 JNI 本地代码外,Java 线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。
其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。
对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。
执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受 Java 虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。
那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个。
第一,安全点检测本身也有一定的开销。不过 HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起。
第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots。
由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。
不过,不同的即时编译器插入安全点检测的位置也可能不同。以 Graal 为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。
不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。
除了垃圾回收之外,Java 虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。我会在涉及的时侯再进行具体的讲解。
垃圾回收的三种方式
当标记完所有的存活对象时,我们便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种。
第一种是清除(sweep),即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
清除这种回收方式的原理及其简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
另一个则是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
第二种是压缩(compact),即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
第三种则是复制(copy),即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。
当然,现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。在下一篇中我们会详细介绍 Java 虚拟机中垃圾回收算法的具体实现。
总结与实践
今天我介绍了垃圾回收的一些基础知识。
Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列 GC Roots 出发,边标记边探索所有被引用的对象。
为了防止在标记过程中堆栈的状态发生改变,Java 虚拟机采取安全点机制来实现 Stop-the-world 操作,暂停其他非垃圾回收线程。
回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除、性能开销较大的压缩、以及堆使用效率较低的复制。
4.12 - CH12-垃圾回收-下
在读博士的时候,我曾经写过一个统计 Java 对象生命周期的动态分析,并且用它来跑了一些基准测试。
其中一些程序的结果,恰好验证了许多研究人员的假设,即大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。
(pmd 中 Java 对象生命周期的直方图,红色的表示被逃逸分析优化掉的对象)
之所以要提到这个假设,是因为它造就了 Java 虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
Java 虚拟机可以给不同代使用不同的回收算法。对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。
这时候,Java 虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)
今天这一篇我们来关注一下针对新生代的 Minor GC。首先,我们来看看 Java 虚拟机中的堆具体是怎么划分的。
Java 虚拟机的堆划分
前面提到,Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。
默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。
当然,你也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高。
通常来说,当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。
否则,将有可能出现两个对象共用一段内存的事故。如果你还记得前两篇我用“停车位”打的比方的话,这里就相当于两个司机(线程)同时将车停入同一个停车位,因而发生剐蹭事故。
Java 虚拟机的解决方法是为每个司机预先申请多个停车位,并且只允许该司机停在自己的停车位上。那么当司机的停车位用完了该怎么办呢(假设这个司机代客泊车)?
答案是:再申请多个停车位便可以了。这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。
具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。
我猜测会有留言问为什么不把 bump the pointer 翻译成指针碰撞。这里先解释一下,在英语中我们通常省略了 bump up the pointer 中的 up。在这个上下文中 bump 的含义应为“提高”。另外一个例子是当我们发布软件的新版本时,也会说 bump the version number。
如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
当 Eden 区的空间耗尽了怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。
前面提到,新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。
当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
总而言之,当发生 Minor GC 时,我们应用了标记 - 复制算法,将 Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和 Eden 区的存活对象复制到另一个 Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记 - 复制算法的效果极好。
Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。
这样一来,岂不是又做了一次全堆扫描呢?
卡表
HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。
在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。
首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。
这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)。
写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。
因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。
这么一来,写屏障便可精简为下面的伪代码 [1]。这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种方式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。
CARD_TABLE [this address >> 9] = DIRTY;
虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题 [2]。
在介绍对象内存布局中我曾提到虚共享问题,讲的是几个 volatile 字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。
在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载部分卡表,那么它将对应 64 张卡,也就是 32KB 的内存。
如果同时有两个 Java 线程,在这 32KB 内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。
为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:
if (CARD_TABLE [this address >> 9] != DIRTY)
CARD_TABLE [this address >> 9] = DIRTY;
总结与实践
今天我介绍了 Java 虚拟机中垃圾回收具体实现的一些通用知识。
Java 虚拟机将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。其中,新生代分为 Eden 区和两个大小一致的 Survivor 区,并且其中一个 Survivor 区是空的。
在指针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。
因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。
垃圾回收器
针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New。这三个采用的都是标记 - 复制算法。其中,Serial 是一个单线程的,Parallel New 可以看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用。
针对老年代的垃圾回收器也有三个:刚刚提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本。
CMS 采用的是标记 - 清除算法,并且是并发的。除了少数几个操作需要 Stop-the-world 之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,Java 虚拟机会使用其他两个压缩型垃圾回收器进行一次垃圾回收。由于 G1 的出现,CMS 在 Java 9 中已被废弃 [3]。
G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。
G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来。
即将到来的 Java 11 引入了 ZGC,宣称暂停时间不超过 10ms。
4.13 - CH13-内存模型
我们先来看一个反常识的例子。
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
这里我定义了两个共享变量 a 和 b,以及两个方法。第一个方法将局部变量 r2 赋值为 a,然后将共享变量 b 赋值为 1。第二个方法将局部变量 r1 赋值为 b,然后将共享变量 a 赋值为 2。请问(r1,r2)的可能值都有哪些?
在单线程环境下,我们可以先调用第一个方法,最终(r1,r2)为(1,0);也可以先调用第二个方法,最终为(0,2)。
在多线程环境下,假设这两个方法分别跑在两个不同的线程之上,如果 Java 虚拟机在执行了任一方法的第一条赋值语句之后便切换线程,那么最终结果将可能出现(0,0)的情况。
除上述三种情况之外,Java 语言规范第 17.4 小节 [1] 还介绍了一种看似不可能的情况(1,2)。
造成这一情况的原因有三个,分别为即时编译器的重排序,处理器的乱序执行,以及内存系统的重排序。由于后两种原因涉及具体的体系架构,我们暂且放到一边。下面我先来讲一下编译器优化的重排序是怎么一回事。
首先需要说明一点,即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
另外,如果两个操作之间存在数据依赖,那么即时编译器(和处理器)不能调整它们的顺序,否则将会造成程序语义的改变。
int a=0, b=0;
public void method1() {
int r2 = a;
b = 1;
.. // Code uses b
if (r2 == 2) {
..
}
}
在上面这段代码中,我扩展了先前例子中的第一个方法。新增的代码会先使用共享变量 b 的值,然后再使用局部变量 r2 的值。
此时,即时编译器有两种选择。
第一,在一开始便将 a 加载至某一寄存器中,并且在接下来 b 的赋值操作以及使用 b 的代码中避免使用该寄存器。第二,在真正使用 r2 时才将 a 加载至寄存器中。这么一来,在执行使用 b 的代码时,我们不再霸占一个通用寄存器,从而减少需要借助栈空间的情况。
int a=0, b=0;
public void method1() {
for (..) {
int r2 = a;
b = 1;
.. // Code uses r2 and rewrites a
}
}
另一个例子则是将第一个方法的代码放入一个循环中。除了原本的两条赋值语句之外,我只在循环中添加了使用 r2,并且更新 a 的代码。由于对 b 的赋值是循环无关的,即时编译器很有可能将其移出循环之前,而对 r2 的赋值语句还停留在循环之中。
如果想要复现这两个场景,你可能需要添加大量有意义的局部变量,来给寄存器分配算法施加压力。
可以看到,即时编译器的优化可能将原本字段访问的执行顺序打乱。在单线程环境下,由于 as-if-serial 的保证,我们无须担心顺序执行不可能发生的情况,如(r1,r2)=(1,2)。
然而,在多线程情况下,这种数据竞争(data race)的情况是有可能发生的。而且,Java 语言规范将其归咎于应用程序没有作出恰当的同步操作。
Java 内存模型与 happens-before 关系
为了让应用程序能够免于数据竞争的干扰,Java 5 引入了明确定义的 Java 内存模型。其中最为重要的一个概念便是 happens-before 关系。happens-before 关系是用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。
在同一个线程中,字节码的先后顺序(program order)也暗含了 happens-before 关系:在程序控制流路径中靠前的字节码 happens-before 靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。
除了线程内的 happens-before 关系之外,Java 内存模型还定义了下述线程间的 happens-before 关系。
- 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
- volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
- 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。
- 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
- 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
- 构造器中的最后一个操作 happens-before 析构器的第一个操作。
happens-before 关系还具备传递性。如果操作 X happens-before 操作 Y,而操作 Y happens-before 操作 Z,那么操作 X happens-before 操作 Z。
在文章开头的例子中,程序没有定义任何 happens-before 关系,仅拥有默认的线程内 happens-before 关系。也就是 r2 的赋值操作 happens-before b 的赋值操作,r1 的赋值操作 happens-before a 的赋值操作。
Thread1 Thread2
| |
b=1 |
| r1=b
| a=2
r2=a |
拥有 happens-before 关系的两对赋值操作之间没有数据依赖,因此即时编译器、处理器都可能对其进行重排序。举例来说,只要将 b 的赋值操作排在 r2 的赋值操作之前,那么便可以按照赋值 b,赋值 r1,赋值 a,赋值 r2 的顺序得到(1,2)的结果。
那么如何解决这个问题呢?答案是,将 a 或者 b 设置为 volatile 字段。
比如说将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。
int a=0;
volatile int b=0;
public void method1() {
int r2 = a;
b = 1;
}
public void method2() {
int r1 = b;
a = 2;
}
根据同一个线程中,字节码顺序所暗含的 happens-before 关系,以及 happens-before 关系的传递性,我们可以轻易得出 r2 的赋值操作 happens-before a 的赋值操作。
这也就意味着,当对 a 进行赋值时,对 r2 的赋值操作已经完成了。因此,在 b 为 volatile 字段的情况下,程序不可能出现(r1,r2)为(1,2)的情况。
由此可以看出,解决这种数据竞争问题的关键在于构造一个跨线程的 happens-before 关系 :操作 X happens-before 操作 Y,使得操作 X 之前的字节码的结果对操作 Y 之后的字节码可见。
Java 内存模型的底层实现
在理解了 Java 内存模型的概念之后,我们现在来看看它的底层实现。Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。
对于即时编译器来说,它会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。
这些内存屏障会限制即时编译器的重排序操作。以 volatile 字段访问为例,所插入的内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也将不允许 volatile 字段读操作之后的内存访问被重排序至其之前。
然后,即时编译器将根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。以我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令 [2]。
在文章开头的例子中,method1 和 method2 之中的代码均属于先读后写(假设 r1 和 r2 被存储在寄存器之中)。X86_64 架构的处理器并不能将读操作重排序至写操作之后,具体可参考 Intel Software Developer Manual Volumn 3,8.2.3.3 小节。因此,我认为例子中的重排序必然是即时编译器造成的。
举例来说,对于 volatile 字段,即时编译器将在 volatile 字段的读写操作前后各插入一些内存屏障。
然而,在 X86_64 架构上,只有 volatile 字段写操作之后的写读内存屏障需要用具体指令来替代。(HotSpot 所选取的具体指令是 lock add DWORD PTR [rsp],0x0,而非 mfence[3]。)
该具体指令的效果,可以简单理解为强制刷新处理器的写缓存。写缓存是处理器用来加速内存存储效率的一项技术。
在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。
强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。
由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。
锁,volatile 字段,final 字段与安全发布
下面我来讲讲 Java 内存模型涉及的几个关键词。
前面提到,锁操作同样具备 happens-before 关系。具体来说,解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。
需要注意的是,锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。
因此也就不再强制刷新缓存。举个例子,即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存。
volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。
在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存。因此,理想情况下对 volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作。
volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。
final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其他线程只能看到已初始化的 final 实例字段。
因此,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在 X86_64 平台上,写写屏障是空操作。
新建对象的安全发布(safe publication)问题不仅仅包括 final 实例字段的可见性,还包括其他实例字段的可见性。
当发布一个已初始化的对象时,我们希望所有已初始化的实例字段对其他线程可见。否则,其他线程可能见到一个仅部分初始化的新建对象,从而造成程序错误。这里我就不展开了。如果你感兴趣的话,可以参考这篇博客 [4]。
总结与实践
今天我主要介绍了 Java 的内存模型。
Java 内存模型通过定义了一系列的 happens-before 操作,让应用程序开发者能够轻易地表达不同线程的操作之间的内存可见性。
在遵守 Java 内存模型的前提下,即时编译器以及底层体系架构能够调整内存访问操作,以达到性能优化的效果。如果开发者没有正确地利用 happens-before 规则,那么将可能导致数据竞争。
Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。
4.14 - CH14-Synchronized
在 Java 程序中,我们可以利用 synchronized 关键字来对程序进行加锁。它既可以用来声明一个 synchronized 代码块,也可以直接标记静态方法或者实例方法。
当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁解锁的锁对象。
public void foo(Object lock) {
synchronized (lock) {
lock.hashCode();
}
}
// 上面的 Java 代码将编译为下面的字节码
public void foo(java.lang.Object);
Code:
0: aload_1
1: dup
2: astore_2
3: monitorenter
4: aload_1
5: invokevirtual java/lang/Object.hashCode:()I
8: pop
9: aload_2
10: monitorexit
11: goto 19
14: astore_3
15: aload_2
16: monitorexit
17: aload_3
18: athrow
19: return
Exception table:
from to target type
4 11 14 any
14 17 14 any
我在文稿中贴了一段包含 synchronized 代码块的 Java 代码,以及它所编译而成的字节码。你可能会留意到,上面的字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。
你可以根据我在介绍异常处理时介绍过的知识,对照字节码和异常处理表来构造所有可能的执行路径,看看在执行了 monitorenter 指令之后,是否都有执行 monitorexit 指令。
当用 synchronized 标记方法时,你会看到字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。
public synchronized void foo(Object lock) {
lock.hashCode();
}
// 上面的 Java 代码将编译为下面的字节码
public synchronized void foo(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: invokevirtual java/lang/Object.hashCode:()I
4: pop
5: return
这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。
关于 monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。
在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。
当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。
之所以采用这种计数器的方式,是为了允许同一个线程重复获取同一把锁。举个例子,如果一个 Java 类中拥有多个 synchronized 方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操作。因此,我们需要设计这么一个可重入的特性,来避免编程里的隐式约束。
说完抽象的锁算法,下面我们便来介绍 HotSpot 虚拟机中具体的锁实现。
重量级锁
重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合 posix 接口的操作系统(如 macOS 和绝大部分的 Linux),上述操作是通过 pthread 的互斥锁(mutex)来实现的。此外,这些操作将涉及系统调用,需要从操作系统的用户态切换至内核态,其开销非常之大。
为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。
与线程阻塞相比,自旋状态可能会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。
我们可以用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如说我们在 synchronized 代码块里只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更加合适。
然而,对于 Java 虚拟机来说,它并不能看到红灯的剩余时间,也就没办法根据等待时间的长短来选择自旋还是阻塞。Java 虚拟机给出的方案是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。
就我们的例子来说,如果之前不熄火等到了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等到绿灯,那么这次不熄火的时间就短一点。
自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
轻量级锁
你可能见到过深夜的十字路口,四个方向都闪黄灯的情况。由于深夜十字路口的车辆来往可能比较少,如果还设置红绿灯交替,那么很有可能出现四个方向仅有一辆车在等红灯的情况。
因此,红绿灯可能被设置为闪黄灯的情况,代表车辆可以自由通过,但是司机需要注意观察(个人理解,实际意义请咨询交警部门)。
Java 虚拟机也存在着类似的情形:多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。
在介绍轻量级锁的原理之前,我们先来了解一下 Java 虚拟机是怎么区分轻量级锁和重量级锁的。
(你可以参照HotSpot Wiki里这张图阅读。)
在对象内存布局那一篇中我曾经介绍了对象头中的标记字段(mark word)。它的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。
当进行加锁操作时,Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。
然后,Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。这里解释一下,CAS 是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值。
假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01。如果是,则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00。此时,该线程已成功获得这把锁,可以继续执行了。
如果不是 X…X01,那么有两种可能。第一,该线程重复获取同一把锁。此时,Java 虚拟机会将锁记录清零,以代表该锁被重复获取。第二,其他线程持有该锁。此时,Java 虚拟机会将这把锁膨胀为重量级锁,并且阻塞当前线程。
当进行解锁操作时,如果当前锁记录(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录)的值为 0,则代表重复进入同一把锁,直接返回即可。
否则,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
如果不是,则意味着这把锁已经被膨胀为重量级锁。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程。
偏向锁
如果说轻量级锁针对的情况很乐观,那么接下来的偏向锁针对的情况则更加乐观:从始至终只有一个线程请求某一把锁。
这就好比你在私家庄园里装了个红绿灯,并且庄园里只有你在开车。偏向锁的做法便是在红绿灯处识别来车的车牌号。如果匹配到你的车牌号,那么直接亮绿灯。
具体来说,在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101。
在接下来的运行过程中,每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:最后三位是否为 101,是否包含当前线程的地址,以及 epoch 值是否和锁对象的类的 epoch 值相同。如果都满足,那么当前线程持有该偏向锁,可以直接返回。
这里的 epoch 值是一个什么概念呢?
我们先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java 虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁。
如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。
具体的做法便是在每个类中维护一个 epoch 值,你可以理解为第几代偏向锁。当设置偏向锁时,Java 虚拟机需要将该 epoch 值复制到锁对象的标记字段中。
在宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需要复制新的 epoch 值。
为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。
如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。
总结与实践
今天我介绍了 Java 虚拟机中 synchronized 关键字的实现,按照代价由高至低可分为重量级锁、轻量级锁和偏向锁三种。
重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。Java 虚拟机采取了自适应自旋,来避免线程在面对非常小的 synchronized 代码块时,仍会被阻塞、唤醒的情况。
轻量级锁采用 CAS 操作,将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。它针对的是多个线程在不同时间段申请同一把锁的情况。
偏向锁只会在第一次请求时采用 CAS 操作,在锁对象的标记字段中记录下当前线程的地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。
4.15 - CH15-语法糖
在前面的篇章中,我们多次提到了 Java 语法和 Java 字节码的差异之处。这些差异之处都是通过 Java 编译器来协调的。今天我们便来列举一下 Java 编译器的协调工作。
自动装箱与自动拆箱
首先要提到的便是 Java 的自动装箱(auto-boxing)和自动拆箱(auto-unboxing)。
我们知道,Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。
之所以需要包装类型,是因为许多 Java 核心类库的 API 都是面向对象的。举个例子,Java 核心类库中的容器类,就只支持引用类型。
当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。
对于基本类型的数值来说,我们需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式,也可以是隐式的,后者正是 Java 中的自动装箱。
public int foo() {
ArrayList<Integer> list = new ArrayList<>();
list.add(0);
int result = list.get(0);
return result;
}
以上图中的 Java 代码为例。我构造了一个 Integer 类型的 ArrayList,并且向其中添加一个 int 值 0。然后,我会获取该 ArrayList 的第 0 个元素,并作为 int 值返回给调用者。这段代码对应的 Java 字节码如下所示:
public int foo();
Code:
0: new java/util/ArrayList
3: dup
4: invokespecial java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: iconst_0
10: invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
16: pop
17: aload_1
18: iconst_0
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
25: invokevirtual java/lang/Integer.intValue:()I
28: istore_2
29: iload_2
30: ireturn
当向泛型参数为 Integer 的 ArrayList 添加 int 值时,便需要用到自动装箱了。在上面字节码偏移量为 10 的指令中,我们调用了 Integer.valueOf 方法,将 int 类型的值转换为 Integer 类型,再存储至容器类中。
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这是 Integer.valueOf 的源代码。可以看到,当请求的 int 值在某个范围内时,我们会返回缓存了的 Integer 对象;而当所请求的 int 值在范围之外时,我们则会新建一个 Integer 对象。
在介绍反射的那一篇中,我曾经提到参数 java.lang.Integer.IntegerCache.high。这个参数将影响这里面的 IntegerCache.high。
也就是说,我们可以通过配置该参数,扩大 Integer 缓存的范围。Java 虚拟机参数 -XX:+AggressiveOpts 也会将 IntegerCache.high 调整至 20000。
奇怪的是,Java 并不支持对 IntegerCache.low 的更改,也就是说,对于小于 -128 的整数,我们无法直接使用由 Java 核心类库所缓存的 Integer 对象。
25: invokevirtual java/lang/Integer.intValue:()I
当从泛型参数为 Integer 的 ArrayList 取出元素时,我们得到的实际上也是 Integer 对象。如果应用程序期待的是一个 int 值,那么就会发生自动拆箱。
在我们的例子中,自动拆箱对应的是字节码偏移量为 25 的指令。该指令将调用 Integer.intValue 方法。这是一个实例方法,直接返回 Integer 对象所存储的 int 值。
泛型与类型擦除
你可能已经留意到了,在前面例子生成的字节码中,往 ArrayList 中添加元素的 add 方法,所接受的参数类型是 Object;而从 ArrayList 中获取元素的 get 方法,其返回类型同样也是 Object。
前者还好,但是对于后者,在字节码中我们需要进行向下转换,将所返回的 Object 强制转换为 Integer,方能进行接下来的自动拆箱。
13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
...
19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
22: checkcast java/lang/Integer
之所以会出现这种情况,是因为 Java 泛型的类型擦除。这是个什么概念呢?简单地说,那便是 Java 程序里的泛型信息,在 Java 虚拟机里全部都丢失了。这么做主要是为了兼容引入泛型之前的代码。
当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。
class GenericTest<T extends Number> {
T foo(T t) {
return t;
}
}
举个例子,在上面这段 Java 代码中,我定义了一个 T extends Number 的泛型参数。它所对应的字节码如下所示。可以看到,foo 方法的方法描述符所接收参数的类型以及返回类型都为 Number。方法描述符是 Java 虚拟机识别方法调用的目标方法的关键。
T foo(T);
descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
flags: (0x0000)
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: areturn
Signature: (TT;)TT;
不过,字节码中仍存在泛型参数的信息,如方法声明里的 T foo(T),以及方法签名(Signature)中的 “(TT;)TT;”。这类信息主要由 Java 编译器在编译类时使用。
既然泛型会被类型擦除,那么我们还有必要用它吗?
我认为是有必要的。Java 编译器可以根据泛型参数判断程序中的语法是否正确。举例来说,尽管经过类型擦除后,ArrayList.add 方法所接收的参数是 Object 类型,但是往泛型参数为 Integer 类型的 ArrayList 中添加字符串对象,Java 编译器是会报错的。
ArrayList<Integer> list = new ArrayList<>();
list.add("0"); // 编译出错
桥接方法
泛型的类型擦除带来了不少问题。其中一个便是方法重写。在第四篇的课后实践中,我留了这么一段代码:
class Merchant<T extends Customer> {
public double actionPrice(T customer) {
return 0.0d;
}
}
class VIPOnlyMerchant extends Merchant<VIP> {
@Override
public double actionPrice(VIP customer) {
return 0.0d;
}
}
VIPOnlyMerchant 中的 actionPrice 方法是符合 Java 语言的方法重写的,毕竟都使用 @Override 来注解了。然而,经过类型擦除后,父类的方法描述符为 (LCustomer;)D,而子类的方法描述符为 (LVIP;)D。这显然不符合 Java 虚拟机关于方法重写的定义。
为了保证编译而成的 Java 字节码能够保留重写的语义,Java 编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法,并将调用子类的方法。
class VIPOnlyMerchant extends Merchant<VIP>
...
public double actionPrice(VIP);
descriptor: (LVIP;)D
flags: (0x0001) ACC_PUBLIC
Code:
0: dconst_0
1: dreturn
public double actionPrice(Customer);
descriptor: (LCustomer;)D
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
0: aload_0
1: aload_1
2: checkcast class VIP
5: invokevirtual actionPrice:(LVIP;)D
8: dreturn
// 这个桥接方法等同于
public double actionPrice(Customer customer) {
return actionPrice((VIP) customer);
}
在我们的例子中,VIPOnlyMerchant 类将包含一个桥接方法 actionPrice(Customer),它重写了父类的同名同方法描述符的方法。该桥接方法将传入的 Customer 参数强制转换为 VIP 类型,再调用原本的 actionPrice(VIP) 方法。
当一个声明类型为 Merchant,实际类型为 VIPOnlyMerchant 的对象,调用 actionPrice 方法时,字节码里的符号引用指向的是 Merchant.actionPrice(Customer) 方法。Java 虚拟机将动态绑定至 VIPOnlyMerchant 类的桥接方法之中,并且调用其 actionPrice(VIP) 方法。
需要注意的是,在 javap 的输出中,该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有 ACC_SYNTHETIC。它表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Customer 的对象作为参数,调用 VIPOnlyMerchant 类的 actionPrice 方法时,Java 编译器会报错,并且提示参数类型不匹配。
Customer customer = new VIP();
new VIPOnlyMerchant().actionPrice(customer); // 编译出错
当然,如果你实在想要调用这个桥接方法,那么你可以选择使用反射机制。
class Merchant {
public Number actionPrice(Customer customer) {
return 0;
}
}
class NaiveMerchant extends Merchant {
@Override
public Double actionPrice(Customer customer) {
return 0.0D;
}
}
除了前面介绍的泛型重写会生成桥接方法之外,如果子类定义了一个与父类参数类型相同的方法,其返回类型为父类方法返回类型的子类,那么 Java 编译器也会为其生成桥接方法。
class NaiveMerchant extends Merchant
public java.lang.Double actionPrice(Customer);
descriptor: (LCustomer;)Ljava/lang/Double;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: dconst_0
1: invokestatic Double.valueOf:(D)Ljava/lang/Double;
4: areturn
public java.lang.Number actionPrice(Customer);
descriptor: (LCustomer;)Ljava/lang/Number;
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: invokevirtual actionPrice:(LCustomer;)Ljava/lang/Double;
5: areturn
我之前曾提到过,class 文件里允许出现两个同名、同参数类型但是不同返回类型的方法。这里的原方法和桥接方法便是其中一个例子。由于该桥接方法同样标注了 ACC_SYNTHETIC,因此,当在 Java 程序中调用 NaiveMerchant.actionPrice 时,我们只会调用到原方法。
其他语法糖
在前面的篇章中,我已经介绍过了变长参数、try-with-resources 以及在同一 catch 代码块中捕获多种异常等语法糖。下面我将列举另外两个常见的语法糖。
foreach 循环允许 Java 程序在 for 循环里遍历数组或者 Iterable 对象。对于数组来说,foreach 循环将从 0 开始逐一访问数组中的元素,直至数组的末尾。其等价的代码如下面所示:
public void foo(int[] array) {
for (int item : array) {
}
}
// 等同于
public void bar(int[] array) {
int[] myArray = array;
int length = myArray.length;
for (int i = 0; i < length; i++) {
int item = myArray[i];
}
}
对于 Iterable 对象来说,foreach 循环将调用其 iterator 方法,并且用它的 hasNext 以及 next 方法来遍历该 Iterable 对象中的元素。其等价的代码如下面所示:
public void foo(ArrayList<Integer> list) {
for (Integer item : list) {
}
}
// 等同于
public void bar(ArrayList<Integer> list) {
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer item = iterator.next();
}
}
字符串 switch 编译而成的字节码看起来非常复杂,但实际上就是一个哈希桶。由于每个 case 所截获的字符串都是常量值,因此,Java 编译器会将原来的字符串 switch 转换为 int 值 switch,比较所输入的字符串的哈希值。
由于字符串哈希值很容易发生碰撞,因此,我们还需要用 String.equals 逐个比较相同哈希值的字符串。
如果你感兴趣的话,可以自己利用 javap 分析字符串 switch 编译而成的字节码。
总结与实践
今天我主要介绍了 Java 编译器对几个语法糖的处理。
基本类型和其包装类型之间的自动转换,也就是自动装箱、自动拆箱,是通过加入 [Wrapper].valueOf(如 Integer.valueOf)以及 [Wrapper].[primitive]Value(如 Integer.intValue)方法调用来实现的。
Java 程序中的泛型信息会被擦除。具体来说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的具体类。
由于 Java 语义与 Java 字节码中关于重写的定义并不一致,因此 Java 编译器会生成桥接方法作为适配器。此外,我还介绍了 foreach 循环以及字符串 switch 的编译。
4.16 - CH16-即时编译-上
通常而言,代码会先被 Java 虚拟机解释执行,之后反复执行的热点代码则会被即时编译成为机器码,直接运行在底层硬件之上。
分层编译模式
HotSpot 虚拟机包含多个即时编译器 C1、C2 和 Graal。
其中,Graal 是一个实验性质的即时编译器,可以通过参数 -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用,并且替换 C2。
在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,我们采用编译效率较快的 C1,对应参数 -client。
对于执行时间较长的,或者对峰值性能有要求的程序,我们采用生成代码执行效率较快的 C2,对应参数 -server。
Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。
分层编译将 Java 虚拟机的执行状态分为了五个层次。为了方便阐述,我用“C1 代码”来指代由 C1 生成的机器码,“C2 代码”来指代由 C2 生成的机器码。五个层级分别是:
- 解释执行;
- 执行不带 profiling 的 C1 代码;
- 执行仅带方法调用次数以及循环回边执行次数 profiling 的 C1 代码;
- 执行带所有 profiling 的 C1 代码;
- 执行 C2 代码。
通常情况下,C2 代码的执行效率要比 C1 代码的高出 30% 以上。然而,对于 C1 代码的三种状态,按执行效率从高至低则是 1 层 > 2 层 > 3 层。
其中 1 层的性能比 2 层的稍微高一些,而 2 层的性能又比 3 层高出 30%。这是因为 profiling 越多,其额外的性能开销越大。
这里解释一下,profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。
你可能已经接触过许许多多的 profiler,例如 JDK 附带的 hprof。这些 profiler 大多通过注入(instrumentation)或者 JVMTI 事件来实现的。Java 虚拟机也内置了 profiling。我会在下一篇中具体介绍 Java 虚拟机的 profiling 都在做些什么。
在 5 个层次的执行状态中,1 层和 4 层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的。
不同的编译路径,图片来源于我之前一篇介绍 Graal 的博客。
这里我列举了 4 个不同的编译路径(Igor 的演讲列举了更多的编译路径)。通常情况下,热点方法会被 3 层的 C1 编译,然后再被 4 层的 C2 编译。
如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。
那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。
在 C1 忙碌的情况下,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由 4 层的 C2 编译。在 C2 忙碌的情况下,方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。
Java 8 默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用 C2。
如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。
即时编译的触发
Java 虚拟机是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。前面提到,Java 虚拟机在 0 层、2 层和 3 层执行状态时进行 profiling,其中就包含方法的调用次数和循环回边的执行次数。
这里的循环回边是一个控制流图中的概念。在字节码中,我们可以简单理解为往回跳转的指令。(注意,这并不一定符合循环回边的定义。)
public static void foo(Object obj) {
int sum = 0;
for (int i = 0; i < 200; i++) {
sum += i;
}
}
举例来说,上面这段代码将被编译为下面的字节码。其中,偏移量为 18 的字节码将往回跳至偏移量为 7 的字节码中。在解释执行时,每当运行一次该指令,Java 虚拟机便会将该方法的循环回边计数器加 1。
public static void foo(java.lang.Object);
Code:
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: goto 14
7: iload_1 <--
8: iload_2
9: iadd
10: istore_1
11: iinc 2, 1
14: iload_2
15: sipush 200
18: if_icmplt 7 -->
21: return
在即时编译过程中,我们会识别循环的头部和尾部。在上面这段字节码中,循环的头部是偏移量为 14 的字节码,尾部为偏移量为 11 的字节码。
循环尾部到循环头部的控制流边就是真正意义上的循环回边。也就是说,C1 将在这个位置插入增加循环回边计数器的代码。
解释执行和 C1 代码中增加循环回边计数器的位置并不相同,但这并不会对程序造成影响。
实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。
具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。
当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。
所谓的动态调整其实并不复杂:在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。
系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
其中 X 是执行层次,可取 3 或者 4;
queue_size_X 是执行层次为 X 的待编译方法的数目;
TierXLoadFeedback 是预设好的参数,其中 Tier3LoadFeedback 为 5,Tier4LoadFeedback 为 3;
compiler_count_X 是层次 X 的编译线程数目。
在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的(对应参数 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount=N 强制设定总编译线程数目时,CICompilerCountPerCPU 将被设置为 false)。
Java 虚拟机会将这些编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。举个例子,对于一个四核机器来说,总的编译线程数目为 3,其中包含一个 C1 编译线程和两个 C2 编译线程。
对于四核及以上的机器,总的编译线程的数目为:
n = log2(N) * log2(log2(N)) * 3 / 2
其中 N 为 CPU 核心数目。
当启用分层编译时,即时编译具体的触发条件如下。
当方法调用次数大于由参数 -XX:TierXInvocationThreshold 指定的阈值乘以系数,或者当方法调用次数大于由参数 -XX:TierXMINInvocationThreshold 指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数 -XX:TierXCompileThreshold 指定的阈值乘以系数时,便会触发 X 层即时编译。
触发条件为:
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)
其中 i 为调用次数,b 为循环回边次数。
OSR 编译
可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。为什么 Java 虚拟机需要维护两个不同的计数器呢?
实际上,除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。
OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化(deoptimization)采用的技术也可以称之为 OSR。
在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。
该倍数的计算方法为:
(OnStackReplacePercentage - InterpreterProfilePercentage)/100
其中 -XX:InterpreterProfilePercentage 的默认值为 33,当使用 C1 时 -XX:OnStackReplacePercentage 为 933,当使用 C2 时为 140。
也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。
在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。
OSR 编译在正常的应用程序中并不多见。它只在基准测试时比较常见,因此并不需要过多了解。
总结与实践
今天我详细地介绍了 Java 虚拟机中的即时编译。
从 Java 8 开始,Java 虚拟机默认采用分层编译的方式。它将执行分为五个层次,分为为 0 层解释执行,1 层执行没有 profiling 的 C1 代码,2 层执行部分 profiling 的 C1 代码,3 层执行全部 profiling 的 C1 代码,和 4 层执行 C2 代码。
通常情况下,方法会首先被解释执行,然后被 3 层的 C1 编译,最后被 4 层的 C2 编译。
即时编译是由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目和可用的编译现成数量动态调整的。
OSR 是一种能够在非方法入口处进行解释执行和编译后代码之间切换的技术。OSR 编译可以用来解决单次调用方法包含热循环的性能优化问题。
4.17 - CH17-即时编译-下
Profiling
上篇提到,分层编译中的 0 层、2 层和 3 层都会进行 profiling,收集能够反映程序执行状态的数据。其中,最为基础的便是方法的调用次数以及循环回边的执行次数。它们被用于触发即时编译。
此外,0 层和 3 层还会收集用于 4 层 C2 编译的数据,比如说分支跳转字节码的分支 profile(branch profile),包括跳转次数和不跳转次数,以及非私有实例方法调用指令、强制类型转换 checkcast 指令、类型测试 instanceof 指令,和引用类型的数组存储 aastore 指令的类型 profile(receiver type profile)。
分支 profile 和类型 profile 的收集将给应用程序带来不少的性能开销。据统计,正是因为这部分额外的 profiling,使得 3 层 C1 代码的性能比 2 层 C1 代码的低 30%。
在通常情况下,我们不会在解释执行过程中收集分支 profile 以及类型 profile。只有在方法触发 C1 编译后,Java 虚拟机认为该方法有可能被 C2 编译,方才在该方法的 C1 代码中收集这些 profile。
只有在比较极端的情况下,例如等待 C1 编译的方法数目太多时,Java 虚拟机才会开始在解释执行过程中收集这些 profile。
那么这些耗费巨大代价收集而来的 profile 具体有什么作用呢?
答案是,C2 可以根据收集得到的数据进行猜测,假设接下来的执行同样会按照所收集的 profile 进行,从而作出比较激进的优化。
基于分支 profile 的优化
举个例子,下面这段代码中包含两个条件判断。第一个条件判断将测试所输入的 boolean 值。
如果为 true,则将局部变量 v 设置为所输入的 int 值。如果为 false,则将所输入的 int 值经过一番运算之后,再存入局部变量 v 之中。
第二个条件判断则测试局部变量 v 是否和所输入的 int 值相等。如果相等,则返回 0。如果不等,则将局部变量 v 经过一番运算之后,再将之返回。显然,当所输入的 boolean 值为 true 的情况下,这段代码将返回 0。
public static int foo(boolean f, int in) {
int v;
if (f) {
v = in;
} else {
v = (int) Math.sin(in);
}
if (v == in) {
return 0;
} else {
return (int) Math.cos(v);
}
}
// 编译而成的字节码:
public static int foo(boolean, int);
Code:
0: iload_0
1: ifeq 9
4: iload_1
5: istore_2
6: goto 16
9: iload_1
10: i2d
11: invokestatic java/lang/Math.sin:(D)D
14: d2i
15: istore_2
16: iload_2
17: iload_1
18: if_icmpne 23
21: iconst_0
22: ireturn
23: iload_2
24: i2d
25: invokestatic java/lang/Math.cos:(D)D
28: d2i
29: ireturn
假设应用程序调用该方法时,所传入的 boolean 值皆为 true。那么,偏移量为 1 以及偏移量为 18 的条件跳转指令所对应的分支 profile 中,跳转的次数都为 0。
C2 可以根据这两个分支 profile 作出假设,在接下来的执行过程中,这两个条件跳转指令仍旧不会发生跳转。基于这个假设,C2 便不再编译这两个条件跳转语句所对应的 false 分支了。
我们暂且不管当假设错误的时候会发生什么,先来看一看剩下来的代码。经过“剪枝”之后,在第二个条件跳转处,v 的值只有可能为所输入的 int 值。因此,该条件跳转可以进一步被优化掉。最终的结果是,在第一个条件跳转之后,C2 代码将直接返回 0。
这里我打印了 C2 的编译结果。可以看到,在地址为 2cee 的指令处进行过一次比较之后,该机器码便直接返回 0。
Compiled method (c2) 95 16 4 CompilationTest::foo (30 bytes)
...
CompilationTest.foo [0x0000000104fb2ce0, 0x0000000104fb2d38] 88 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x000000012629e380} 'foo' '(ZI)I' in 'CompilationTest'
# parm0: rsi = boolean
# parm1: rdx = int
# [sp+0x30] (sp of caller)
0x0000000104fb2ce0: mov DWORD PTR [rsp-0x14000],eax
0x0000000104fb2ce7: push rbp
0x0000000104fb2ce8: sub rsp,0x20
0x0000000104fb2cec: test esi,esi
0x0000000104fb2cee: je 0x0000000104fb2cfe // 跳转至?
0x0000000104fb2cf0: xor eax,eax // 将返回值设置为 0
0x0000000104fb2cf2: add rsp,0x20
0x0000000104fb2cf6: pop rbp
0x0000000104fb2cf7: test DWORD PTR [rip+0xfffffffffca32303],eax // safepoint
0x0000000104fb2cfd: ret
...
总结一下,根据条件跳转指令的分支 profile,即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间。此外,“剪枝”将精简程序的数据流,从而触发更多的优化。
在现实中,分支 profile 出现仅跳转或者仅不跳转的情况并不多见。当然,即时编译器对分支 profile 的利用也不仅限于“剪枝”。它还会根据分支 profile,计算每一条程序执行路径的概率,以便某些编译器优化优先处理概率较高的路径。
基于类型 profile 的优化
另外一个例子则是关于 instanceof 以及方法调用的类型 profile。下面这段代码将测试所传入的对象是否为 Exception 的实例,如果是,则返回它的系统哈希值;如果不是,则返回它的哈希值。
public static int hash(Object in) {
if (in instanceof Exception) {
return System.identityHashCode(in);
} else {
return in.hashCode();
}
}
// 编译而成的字节码:
public static int hash(java.lang.Object);
Code:
0: aload_0
1: instanceof java/lang/Exception
4: ifeq 12
7: aload_0
8: invokestatic java/lang/System.identityHashCode:(Ljava/lang/Object;)I
11: ireturn
12: aload_0
13: invokevirtual java/lang/Object.hashCode:()I
16: ireturn
假设应用程序调用该方法时,所传入的 Object 皆为 Integer 实例。那么,偏移量为 1 的 instanceof 指令的类型 profile 仅包含 Integer,偏移量为 4 的分支跳转语句的分支 profile 中不跳转的次数为 0,偏移量为 13 的方法调用指令的类型 profile 仅包含 Integer。
在 Java 虚拟机中,instanceof 测试并不简单。如果 instanceof 的目标类型是 final 类型,那么 Java 虚拟机仅需比较测试对象的动态类型是否为该 final 类型。
在讲解对象的内存分布那一篇中,我曾经提到过,对象头存有该对象的动态类型。因此,获取对象的动态类型仅为单一的内存读指令。
如果目标类型不是 final 类型,比如说我们例子中的 Exception,那么 Java 虚拟机需要从测试对象的动态类型开始,依次测试该类,该类的父类、祖先类,该类所直接实现或者间接实现的接口是否与目标类型一致。
不过,在我们的例子中,instanceof 指令的类型 profile 仅包含 Integer。根据这个信息,即时编译器可以假设,在接下来的执行过程中,所输入的 Object 对象仍为 Integer 实例。
因此,生成的代码将测试所输入的对象的动态类型是否为 Integer。如果是的话,则继续执行接下来的代码。(该优化源自 Graal,采用 C2 可能无法复现。)
然后,即时编译器会采用和第一个例子中一致的针对分支 profile 的优化,以及对方法调用的条件去虚化内联。
我会在接下来的篇章中详细介绍内联,这里先说结果:生成的代码将测试所输入的对象动态类型是否为 Integer。如果是的话,则执行 Integer.hashCode() 方法的实质内容,也就是返回该 Integer 实例的 value 字段。
public final class Integer ... {
...
@Override
public int hashCode() {
return Integer.hashCode(value);
}
public static int hashCode(int value) {
return value;
}
...
}
和第一个例子一样,根据数据流分析,上述代码可以最终优化为极其简单的形式。
这里我打印了 Graal 的编译结果。可以看到,在地址为 1ab7 的指令处进行过一次比较之后,该机器码便直接返回所传入的 Integer 对象的 value 字段。
Compiled method (JVMCI) 600 23 4
...
----------------------------------------------------------------------
CompilationTest.hash (CompilationTest.hash(Object)) [0x000000011d811aa0, 0x000000011d811b00] 96 bytes
[Entry Point]
[Verified Entry Point]
[Constants]
# {method} {0x00000001157053c8} 'hash' '(Ljava/lang/Object;)I' in 'CompilationTest'
# parm0: rsi:rsi = 'java/lang/Object'
# [sp+0x20] (sp of caller)
0x000000011d811aa0: mov DWORD PTR [rsp-0x14000],eax
0x000000011d811aa7: sub rsp,0x18
0x000000011d811aab: mov QWORD PTR [rsp+0x10],rbp
// 比较 [rsi+0x8],也就是所传入的 Object 参数的动态类型,是否为 Integer。这里 0xf80022ad 是 Integer 类的内存地址。
0x000000011d811ab0: cmp DWORD PTR [rsi+0x8],0xf80022ad
// 如果不是,跳转至?
0x000000011d811ab7: jne 0x000000011d811ad3
// 加载 Integer.value。在启用压缩指针时,该字段的偏移量为 12,也就是 0xc
0x000000011d811abd: mov eax,DWORD PTR [rsi+0xc]
0x000000011d811ac0: mov rbp,QWORD PTR [rsp+0x10]
0x000000011d811ac5: add rsp,0x18
0x000000011d811ac9: test DWORD PTR [rip+0xfffffffff272f537],eax
0x000000011d811acf: vzeroupper
0x000000011d811ad2: ret
和基于分支 profile 的优化一样,基于类型 profile 的优化同样也是作出假设,从而精简控制流以及数据流。这两者的核心都是假设。
对于分支 profile,即时编译器假设的是仅执行某一分支;对于类型 profile,即时编译器假设的是对象的动态类型仅为类型 profile 中的那几个。
那么,当假设失败的情况下,程序将何去何从?我们继续往下看。
去优化
Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行。
在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,并不再返回即时编译器生成的机器码中。
在上面的程序控制流图中,我画了很多红色方框的问号。这些问号便代表着一个个的陷阱。一旦踏入这些陷阱,便将发生去优化,并切换至解释执行。
去优化的过程相当复杂。由于即时编译器采用了许多优化方式,其生成的代码和原本的字节码的差异非常之大。
在去优化的过程中,需要将当前机器码的执行状态转换至某一字节码之前的执行状态,并从该字节码开始执行。这便要求即时编译器在编译过程中记录好这两种执行状态的映射。
举例来说,经过逃逸分析之后,机器码可能并没有实际分配对象,而是在各个寄存器中存储该对象的各个字段(标量替换,具体我会在之后的篇章中进行介绍)。在去优化过程中,Java 虚拟机需要还原出这个对象,以便解释执行时能够使用该对象。
当根据映射关系创建好对应的解释执行栈桢后,Java 虚拟机便会采用 OSR 技术,动态替换栈上的内容,并在目标字节码处开始解释执行。
此外,在调用 Java 虚拟机的去优化方法时,即时编译器生成的机器码可以根据产生去优化的原因来决定是否保留这一份机器码,以及何时重新编译对应的 Java 方法。
如果去优化的原因与优化无关,即使重新编译也不会改变生成的机器码,那么生成的机器码可以在调用去优化方法时传入 Action_None,表示保留这一份机器码,在下一次调用该方法时重新进入这一份机器码。
如果去优化的原因与静态分析的结果有关,例如类层次分析,那么生成的机器码可以在调用去优化方法时传入 Action_Recompile,表示不保留这一份机器码,但是可以不经过重新 profile,直接重新编译。
如果去优化的原因与基于 profile 的激进优化有关,那么生成的机器码需要在调用去优化方法时传入 Action_Reinterpret,表示不保留这一份机器码,而且需要重新收集程序的 profile。
这是因为基于 profile 的优化失败的时候,往往代表这程序的执行状态发生改变,因此需要更正已收集的 profile,以更好地反映新的程序执行状态。
总结与实践
今天我介绍了 Java 虚拟机的 profiling 以及基于所收集的数据的优化和去优化。
通常情况下,解释执行过程中仅收集方法的调用次数以及循环回边的执行次数。
当方法被 3 层 C1 所编译时,生成的 C1 代码将收集条件跳转指令的分支 profile,以及类型相关指令的类型 profile。在部分极端情况下,Java 虚拟机也会在解释执行过程中收集这些 profile。
基于分支 profile 的优化以及基于类型 profile 的优化都将对程序今后的执行作出假设。这些假设将精简所要编译的代码的控制流以及数据流。在假设失败的情况下,Java 虚拟机将采取去优化,退回至解释执行并重新收集相关的 profile。
4.18 - CH18-即时编译中间表达
在上一章中,我利用了程序控制流图以及伪代码,来展示即时编译器中基于 profile 的优化。不过,这并非实际的优化过程。
1. 中间表达形式(IR)
在编译原理课程中,我们通常将编译器分为前端和后端。其中,前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式,也就是 IR(Intermediate Representation )。后端会对 IR 进行优化,然后生成目标代码。
如果不考虑解释执行的话,从 Java 源代码到最终的机器码实际上经过了两轮编译:Java 编译器将 Java 源代码编译成 Java 字节码,而即时编译器则将 Java 字节码编译成机器码。
对于即时编译器来说,所输入的 Java 字节码剥离了很多高级的 Java 语法,而且其采用的基于栈的计算模型非常容易建模。因此,即时编译器并不需要重新进行词法分析、语法分析以及语义分析,而是直接将 Java 字节码作为一种 IR。
不过,Java 字节码本身并不适合直接作为可供优化的 IR。这是因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR。这种 IR 的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。
y = 1;
y = 2;
x = y;
举个例子(来源),上面这段代码所对应的 SSA 形式伪代码是下面这段:
y1 = 1;
y2 = 2;
x1 = y2;
在源代码中,我们可以轻易地发现第一个对 y 的赋值是冗余的,但是编译器不能。传统的编译器需要借助数据流分析(具体的优化叫reaching definition),从后至前依次确认哪些变量的值被覆盖(kill)掉。
不过,如果借助了 SSA IR,编译器则可以通过查找赋值了但是没有使用的变量,来识别冗余赋值。
除此之外,SSA IR 对其他优化方式也有很大的帮助,例如常量折叠(constant folding)、常量传播(constant propagation)、强度削减(strength reduction)以及死代码删除(dead code elimination)等。
示例:
x1=4*1024 经过常量折叠后变为 x1=4096
x1=4; y1=x1 经过常量传播后变为 x1=4; y1=4
y1=x1*3 经过强度削减后变为 y1=(x1<<1)+x1
if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为 y1=1
部分同学可能会手动进行上述优化,以期望能够达到更高的运行效率。实际上,对于这些简单的优化,编译器会代为执行,以便程序员专注于代码的可读性。
SSA IR 会带来一个问题,那便是不同执行路径可能会对同一变量设置不同的值。例如下面这段代码 if 语句的两个分支中,变量 y 分别被赋值为 0 或 1,并且在接下来的代码中读取 y 的值。此时,根据不同的执行路径,所读取到的值也很有可能不同。
x = ..;
if (x > 0) {
y = 0;
} else {
y = 1;
}
x = y;
为了解决这个问题,我们需要引入一个 Phi 函数的概念,能够根据不同的执行路径选择不同的值。于是,上面这段代码便可以转换为下面这段 SSA 伪代码。这里的 Phi 函数将根据前面两个分支分别选择 y1、y2 的值,并赋值给 y3。
x1 = ..;
if (x1 > 0) {
y1 = 0;
} else {
y2 = 1;
}
y3 = Phi(y1, y2);
x2 = y3;
总之,即时编译器会将 Java 字节码转换成 SSA IR。更确切的说,是一张包含控制流和数据流的 IR 图,每个字节码对应其中的若干个节点(注意,有些字节码并没有对应的 IR 节点)。然后,即时编译器在 IR 图上面进行优化。
我们可以将每一种优化看成一个独立的图算法,它接收一个 IR 图,并输出经过转换后的 IR 图。整个编译器优化过程便是一个个优化串联起来的。
2. Sea-of-nodes
HotSpot 里的 C2 采用的是一种名为 Sea-of-Nodes 的 SSA IR。它的最大特点,便是去除了变量的概念,直接采用变量所指向的值,来进行运算。
在上面这段 SSA 伪代码中,我们使用了多个变量名 x1、x2、y1 和 y2。这在 Sea-of-Nodes 将不复存在。
取而代之的则是对应的值,比如说 Phi(y1, y2) 变成 Phi(0, 1),后者本身也是一个值,被其他 IR 节点所依赖。正因如此,常量传播在 Sea-of-Nodes 中变成了一个 no-op。
Graal 的 IR 同样也是 Sea-of-Nodes 类型的,并且可以认为是 C2 IR 的精简版本。由于 Graal 的 IR 系统更加容易理解,而且工具支持相对来说也比较全、比较新,所以下面我将围绕着 Graal 的 IR 系统来讲解。
尽管 IR 系统不同,C2 和 Graal 所实现的优化大同小异。对于那小部分不同的地方,它们也在不停地相互“借鉴”。所以你无须担心不通用的问题。
为了方便你理解今天的内容,我将利用 IR 可视化工具Ideal Graph Visualizer(IGV),来展示具体的 IR 图。(这里 Ideal 是 C2 中 IR 的名字。)
public static int foo(int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += i;
}
return sum;
}
上面这段代码所对应的 IR 图如下所示:
IR 图
这里面,0 号 Start 节点是方法入口,21 号 Return 节点是方法出口。红色加粗线条为控制流,蓝色线条为数据流,而其他颜色的线条则是特殊的控制流或数据流。被控制流边所连接的是固定节点,其他的皆属于浮动节点。若干个顺序执行的节点将被包含在同一个基本块之中,如图中的 B0、B1 等。
基本块直接的控制流关系
基本块是仅有一个入口和一个出口的指令序列(IR 节点序列)。一个基本块的出口可以和若干个基本块的入口相连接,反之亦然。
在我们的例子中,B0 和 B2 的出口与 B1 的入口连接,代表在执行完 B0 或 B2 后可以跳转至 B1,并继续执行 B1 中的内容。而 B1 的出口则与 B2 和 B3 的入口连接。
可以看到,上面的 IR 图已经没有 sum 或者 i 这样的变量名了,取而代之的是一个个的值,例如源程序中的 i<count 被转换为 10 号 < 节点,其接收两个值,分别为代表 i 的 8 号 Phi 节点,以及代表输入第 0 个参数的 1 号 P(0) 节点。
关于 8 号 Phi 节点,前面讲过,它将根据不同的执行路径选择不同的值。如果是从 5 号 End 节点进入的,则选择常量 0;如果是从 20 号 LoopEnd 节点跳转进入的,则选择 19 号 + 节点。
你可以自己分析一下代表 sum 的 7 号 Phi 节点,根据不同的执行路径都选择了哪些值。
浮动节点的位置并不固定。在编译过程中,编译器需要(多次)计算浮动节点具体的排布位置。这个过程我们称之为节点调度(node scheduling)。
节点调度是根据节点之间的依赖关系来进行的。举个例子,在前面的 IR 图中,10 号 < 节点是 16 号 if 节点用来判断是否跳转的条件,因此它需要排布在 16 号 if 节点(注意这是一个固定节点)之前。同时它又依赖于 8 号 Phi 节点的值以及 1 号 P(0) 节点的值,因此它需要排布在这两个节点之后。
需要注意的是,C2 没有固定节点这一概念,所有的 IR 节点都是浮动节点。它将根据各个基本块头尾之间的控制依赖,以及数据依赖和内存依赖,来进行节点调度。
这里的内存依赖是什么一个概念呢?假设一段程序往内存中存储了一个值,而后又读取同一内存,那么显然程序希望读取到的是所存储的值。即时编译器不能任意调度对同一内存地址的读写,因为它们之间存在依赖关系。
C2 的做法便是将这种时序上的先后记录为内存依赖,并让节点调度算法在进行调度时考虑这些内存依赖关系。Graal 则将内存读写转换成固定节点。由于固定节点存在先后关系,因此无须额外记录内存依赖。
3. Gloval Value Numbering
下面介绍一种因 Sea-of-Nodes 而变得非常容易的优化技术 —— Gloval Value Numbering(GVN)。
GVN 是一种发现并消除等价计算的优化技术。举例来说,如果一段程序中出现了多次操作数相同的乘法,那么即时编译器可以将这些乘法并为一个,从而降低输出机器码的大小。如果这些乘法出现在同一执行路径上,那么 GVN 还将省下冗余的乘法操作。
在 Sea-of-Nodes 中,由于只存在值的概念,因此 GVN 算法将非常简单:如果一个浮动节点本身不存在内存副作用(由于 GVN 可能影响节点调度,如果有内存副作用的话,那么将引发一些源代码中不可能出现的情况) ,那么即时编译器只需判断该浮动节点是否与已存在的浮动节点的类型相同,所输入的 IR 节点是否一致,便可以将这两个浮动节点归并成一个。
public static int foo(int a, int b) {
int sum = a * b;
if (a > 0) {
sum += a * b;
}
if (b > 0) {
sum += a * b;
}
return sum;
}
我们来看一个实际的案例。在上面这段代码中,如果 a 和 b 都大于 0,那么我们需要做三次乘法。通过 GVN 之后,我们只会在 B0 中做一次乘法,并且在接下来的代码中直接使用乘法的结果,也就是 4 号 * 节点所代表的值。
我们可以将 GVN 理解为在 IR 图上的公共子表达式消除(Common Subexpression Elimination,CSE)。
这两者的区别在于,GVN 直接比较值的相同与否,而 CSE 则是借助词法分析器来判断两个表达式相同与否。因此,在不少情况下,CSE 还需借助常量传播来达到消除的效果。
总结与实践
今天我介绍了即时编译器的内部构造。
即时编译器将所输入的 Java 字节码转换成 SSA IR,以便更好地进行优化。
具体来说,C2 和 Graal 采用的是一种名为 Sea-of-Nodes 的 IR,其特点用 IR 节点来代表程序中的值,并且将源程序中基于变量的计算转换为基于值的计算。
此外,我还介绍了 C2 和 Graal 的 IR 的可视化工具 IGV,以及基于 IR 的优化 GVN。
4.19 - CH19-字节码基础
操作数栈
我们知道,Java 字节码是 Java 虚拟机所使用的指令集。因此,它与 Java 虚拟机基于栈的计算模型是密不可分的。
在解释执行过程中,每当为 Java 方法分配栈桢时,Java 虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。
具体来说便是:执行每一条指令之前,Java 虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java 虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中。
以加法指令 iadd 为例。假设在执行该指令前,栈顶的两个元素分别为 int 值 1 和 int 值 2,那么 iadd 指令将弹出这两个 int,并将求得的和 int 值 3 压入栈中。
由于 iadd 指令只消耗栈顶的两个元素,因此,对于离栈顶距离为 2 的元素,即图中的问号,iadd 指令并不关心它是否存在,更加不会对其进行修改。
Java 字节码中有好几条指令是直接作用在操作数栈上的。最为常见的便是 dup: 复制栈顶元素,以及 pop:舍弃栈顶元素。
dup 指令常用于复制 new 指令所生成的未经初始化的引用。例如在下面这段代码的 foo 方法中,当执行 new 指令时,Java 虚拟机将指向一块已分配的、未初始化的内存的引用压入操作数栈中。
public void foo() {
Object o = new Object();
}
// 对应的字节码如下:
public void foo();
0 new java.lang.Object [3]
3 dup
4 invokespecial java.lang.Object() [8]
7 astore_1 [o]
8 return
接下来,我们需要以这个引用为调用者,调用其构造器,也就是上面字节码中的 invokespecial 指令。要注意,该指令将消耗操作数栈上的元素,作为它的调用者以及参数(不过 Object 的构造器不需要参数)。
因此,我们需要利用 dup 指令复制一份 new 指令的结果,并用来调用构造器。当调用返回之后,操作数栈上仍有原本由 new 指令生成的引用,可用于接下来的操作(即偏移量为 7 的字节码,下面会介绍到)。
pop 指令则常用于舍弃调用指令的返回结果。例如在下面这段代码的 foo 方法中,我将调用静态方法 bar,但是却不用其返回值。
由于对应的 invokestatic 指令仍旧会将返回值压入 foo 方法的操作数栈中,因此 Java 虚拟机需要额外执行 pop 指令,将返回值舍弃。
public static boolean bar() {
return false;
}
public void foo() {
bar();
}
// foo 方法对应的字节码如下:
public void foo();
0 invokestatic FooTest.bar() : boolean [24]
3 pop
4 return
需要注意的是,上述两条指令只能处理非 long 或者非 double 类型的值,这是因为 long 类型或者 double 类型的值,需要占据两个栈单元。当遇到这些值时,我们需要同时复制栈顶两个单元的 dup2 指令,以及弹出栈顶两个单元的 pop2 指令。
除此之外,不算常见但也是直接作用于操作数栈的还有 swap 指令,它将交换栈顶两个元素的值。
在 Java 字节码中,有一部分指令可以直接将常量加载到操作数栈上。以 int 类型为例,Java 虚拟机既可以通过 iconst 指令加载 -1 至 5 之间的 int 值,也可以通过 bipush、sipush 加载一个字节、两个字节所能代表的 int 值。
Java 虚拟机还可以通过 ldc 加载常量池中的常量值,例如 ldc #18 将加载常量池中的第 18 项。
这些常量包括 int 类型、long 类型、float 类型、double 类型、String 类型以及 Class 类型的常量。
常数加载指令表
正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外情况是在抛异常时,Java 虚拟机会清除操作数栈上的所有内容,而后将异常实例压入操作数栈上。
局部变量区
Java 方法栈桢的另外一个重要组成部分则是局部变量区,字节码程序可以将计算的结果缓存在局部变量区之中。
实际上,Java 虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法),所传入的参数,以及字节码中的局部变量。
和操作数栈一样,long 类型以及 double 类型的值将占据两个单元,其余类型仅占据一个单元。
public void foo(long l, float f) {
{
int i = 0;
}
{
String s = "Hello, World";
}
}
以上面这段代码中的 foo 方法为例,由于它是一个实例方法,因此局部变量数组的第 0 个单元存放着 this 指针。
第一个参数为 long 类型,于是数组的 1、2 两个单元存放着所传入的 long 类型参数的值。第二个参数则是 float 类型,于是数组的第 3 个单元存放着所传入的 float 类型参数的值。
在方法体里的两个代码块中,我分别定义了两个局部变量 i 和 s。由于这两个局部变量的生命周期没有重合之处,因此,Java 编译器可以将它们编排至同一单元中。也就是说,局部变量数组的第 4 个单元将为 i 或者 s。
存储在局部变量区的值,通常需要加载至操作数栈中,方能进行计算,得到计算结果后再存储至局部变量数组中。这些加载、存储指令是区分类型的。例如,int 类型的加载指令为 iload,存储指令为 istore。
局部变量区访问指令表
局部变量数组的加载、存储指令都需要指明所加载单元的下标。举例来说,aload 0 指的是加载第 0 个单元所存储的引用,在前面示例中的 foo 方法里指的便是加载 this 指针。
在我印象中,Java 字节码中唯一能够直接作用于局部变量区的指令是 iinc M N(M 为非负整数,N 为整数)。该指令指的是将局部变量数组的第 M 个单元中的 int 值增加 N,常用于 for 循环中自增量的更新。
public void foo() {
for (int i = 100; i>=0; i--) {}
}
// 对应的字节码如下:
public void foo();
0 bipush 100
2 istore_1 [i]
3 goto 9
6 iinc 1 -1 [i] // i--
9 iload_1 [i]
10 ifge 6
13 return
综合示例
下面我们来看一个综合的例子:
public static int bar(int i) {
return ((i + 1) - 2) * 3 / 4;
}
// 对应的字节码如下:
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_1
2: iadd
3: iconst_2
4: isub
5: iconst_3
6: imul
7: iconst_4
8: idiv
9: ireturn
这里我定义了一个 bar 方法。它将接收一个 int 类型的参数,进行一系列计算之后再返回。
对应的字节码中的 stack=2, locals=1 代表该方法需要的操作数栈空间为 2,局部变量数组空间为 1。当调用 bar(5) 时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下:
Java 字节码简介
前面我已经介绍了加载常量指令、操作数栈专用指令以及局部变量区访问指令。下面我们来看看其他的类别。
Java 相关指令,包括各类具备高层语义的字节码,即 new(后跟目标类,生成该类的未初始化的对象),instanceof(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。是则压入 1,否则压入 0),checkcast(后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。如果不是便抛出异常),athrow(将栈顶异常抛出),以及 monitorenter(为栈顶对象加锁)和 monitorexit(为栈顶对象解锁)。
此外,该类型的指令还包括字段访问指令,即静态字段访问指令 getstatic、putstatic,和实例字段访问指令 getfield、putfield。这四条指令均附带用以定位目标字段的信息,但所消耗的操作数栈元素皆不同。
以 putfield 为例,在上图中,它会把值 v 存储至对象 obj 的目标字段之中。
方法调用指令,包括 invokestatic,invokespecial,invokevirtual,invokeinterface 以及 invokedynamic。这几条字节码我们已经反反复复提及了,就不再具体介绍各自的含义了。
除 invokedynamic 外,其他的方法调用指令所消耗的操作数栈元素是根据调用类型以及目标方法描述符来确定的。在进行方法调用之前,程序需要依次压入调用者(invokestatic 不需要),以及各个参数。
public int neg(int i) {
return -i;
}
public int foo(int i) {
return neg(neg(i));
}
// foo 方法对应的字节码如下:foo 方法对应的字节码如下:
public int foo(int i);
0 aload_0 [this]
1 aload_0 [this]
2 iload_1 [i]
3 invokevirtual FooTest.neg(int) : int [25]
6 invokevirtual FooTest.neg(int) : int [25]
9 ireturn
以上面这段代码为例,当调用 foo(2) 时,每条指令执行前后局部变量数组空间以及操作数栈的分布如下所示:
数组相关指令,包括新建基本类型数组的 newarray,新建引用类型数组的 anewarray,生成多维数组的 multianewarray,以及求数组长度的 arraylength。另外,它还包括数组的加载指令以及存储指令。这些指令是区分类型的。例如,int 数组的加载指令为 iaload,存储指令为 iastore。
数组访问指令表
控制流指令,包括无条件跳转 goto,条件跳转指令,tableswitch 和 lookupswtich(前者针对密集的 cases,后者针对稀疏的 cases),返回指令,以及被废弃的 jsr,ret 指令。其中返回指令是区分类型的。例如,返回 int 值的指令为 ireturn。
返回指令表
除返回指令外,其他的控制流指令均附带一个或者多个字节码偏移量,代表需要跳转到的位置。例如下面的 abs 方法中偏移量为 1 的条件跳转指令,当栈顶元素小于 0 时,跳转至偏移量为 6 的字节码。
public int abs(int i) {
if (i >= 0) {
return i;
}
return -i;
}
// 对应的字节码如下所示:
public int abs(int i);
0 iload_1 [i]
1 iflt 6
4 iload_1 [i]
5 ireturn
6 iload_1 [i]
7 ineg
8 ireturn
剩余的 Java 字节码几乎都和计算相关,这里就不再详细阐述了。
总结与实践
今天我简单介绍了各种类型的 Java 字节码。
Java 方法的栈桢分为操作数栈和局部变量区。通常来说,程序需要将变量从局部变量区加载至操作数栈中,进行一番运算之后再存储回局部变量区中。
Java 字节码可以划分为很多种类型,如加载常量指令,操作数栈专用指令,局部变量区访问指令,Java 相关指令,方法调用指令,数组相关指令,控制流指令,以及计算相关指令。
4.20 - CH20-方法内联-上
在前面的篇章中,我多次提到了方法内联这项技术。它指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。
以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作仅剩字段访问。
在 C2 中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。
复习一下:即时编译器首先解析字节码,并生成 IR 图,然后在该 IR 图上进行优化。优化是由一个个独立的优化阶段(optimization phase)串联起来的。每个优化阶段都会对 IR 图进行转换。最后即时编译器根据 IR 图的节点以及调度顺序生成机器码。
同 C2 一样,Graal 也会在解析字节码的过程中进行方法调用的内联。此外,Graal 还拥有一个独立的优化阶段,来寻找指代方法调用的 IR 节点,并将之替换为目标方法的 IR 图。这个过程相对来说比较形象一些,因此,今天我就利用它来给你讲解一下方法内联。
// 方法内联的过程
public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}
上面这段代码中的 foo 方法将接收一个 int 类型的参数,而 bar 方法将接收一个 boolean 类型的参数。其中,foo 方法会读取静态字段 flag 的值,并作为参数调用 bar 方法。
foo 方法的 IR 图(内联前)
在编译 foo 方法时,其对应的 IR 图中将出现对 bar 方法的调用,即上图中的 5 号 Invoke 节点。如果内联算法判定应当内联对 bar 方法的调用时,那么即时编译器将开始解析 bar 方法的字节码,并生成对应的 IR 图,如下图所示。
bar 方法的 IR 图
接下来,即时编译器便可以进行方法内联,把 bar 方法所对应的 IR 图纳入到对 foo 方法的编译中。具体的操作便是将 foo 方法的 IR 图中 5 号 Invoke 节点替换为 bar 方法的 IR 图。
foo 方法的 IR 图(内联后)
除了将被调用方法的 IR 图节点复制到调用者方法的 IR 图中,即时编译器还需额外完成下述三项操作。
第一,被调用方法的传入参数节点,将被替换为调用者方法进行方法调用时所传入参数对应的节点。在我们的例子中,就是将 bar 方法 IR 图中的 1 号 P(0) 节点替换为 foo 方法 IR 图中的 3 号 LoadField 节点。
第二,在调用者方法的 IR 图中,所有指向原方法调用节点的数据依赖将重新指向被调用方法的返回节点。如果被调用方法存在多个返回节点,则生成一个 Phi 节点,将这些返回值聚合起来,并作为原方法调用节点的替换对象。
在我们的例子中,就是将 8 号 == 节点,以及 12 号 Return 节点连接到原 5 号 Invoke 节点的边,重新指向新生成的 24 号 Phi 节点中。
第三,如果被调用方法将抛出某种类型的异常,而调用者方法恰好有该异常类型的处理器,并且该异常处理器覆盖这一方法调用,那么即时编译器需要将被调用方法抛出异常的路径,与调用者方法的异常处理器相连接。
经过方法内联之后,即时编译器将得到一个新的 IR 图,并且在接下来的编译过程中对这个新的 IR 图进行进一步的优化。不过在上面这个例子中,方法内联后的 IR 图并没有能够进一步优化的地方。
public final static boolean flag = true;
public final static int value0 = 0;
public final static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}
不过,如果我们将代码中的三个静态字段标记为 final,那么 Java 编译器(注意不是即时编译器)会将它们编译为常量值(ConstantValue),并且在字节码中直接使用这些常量值,而非读取静态字段。举例来说,bar 方法对应的字节码如下所示。
public static int bar(boolean);
Code:
0: iload_0
1: ifeq 8
4: iconst_0
5: goto 9
8: iconst_1
9: ireturn
在编译 foo 方法时,一旦即时编译器决定要内联对 bar 方法的调用,那么它会将调用 bar 方法所使用的参数,也就是常数 1,替换 bar 方法 IR 图中的参数。经过死代码消除之后,bar 方法将直接返回常数 0,所需复制的 IR 图也只有常数 0 这么一个节点。
经过方法内联之后,foo 方法的 IR 图将变成如下所示:
该 IR 图可以进一步优化(死代码消除),并最终得到这张极为简单的 IR 图:
方法内联的条件
方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。
此外,内联越多也将导致生成的机器码越长。在 Java 虚拟机里,编译生成的机器码会被部署到 Code Cache 之中。这个 Code Cache 是有大小限制的(由 Java 虚拟机参数 -XX:ReservedCodeCacheSize 控制)。
这就意味着,生成的机器码越长,越容易填满 Code Cache,从而出现 Code Cache 已满,即时编译已被关闭的警告信息(CodeCache is full. Compiler has been disabled)。
因此,即时编译器不会无限制地进行方法内联。下面我便列举即时编译器的部分内联规则。(其他的特殊规则,如自动拆箱总会被内联、Throwable 类的方法不能被其他类中的方法所内联,你可以直接参考JDK 的源代码。)
首先,由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法),会被强制内联。 而由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法),则始终不会被内联。
其次,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。
再次,C2 不支持内联超过 9 层的调用(可以通过虚拟机参数 -XX:MaxInlineLevel 调整),以及 1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel 调整)。
如果方法 a 调用了方法 b,而方法 b 调用了方法 c,那么我们称 b 为 a 的 1 层调用,而 c 为 a 的 2 层调用。
最后,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。
我在上面的表格列举了一些 C2 相关的虚拟机参数。总体来说,即时编译器中的内联算法更青睐于小方法。
总结与实践
今天我介绍了方法内联的过程以及条件。
方法内联是指,在编译过程中,当遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
即时编译器既可以在解析过程中替换方法调用字节码,也可以在 IR 图中替换方法调用 IR 节点。这两者都需要将目标方法的参数以及返回值映射到当前方法来。
方法内联有许多规则。除了一些强制内联以及强制不内联的规则外,即时编译器会根据方法调用的层数、方法调用指令所在的程序路径的热度、目标方法的调用次数及大小,以及当前 IR 图的大小来决定方法调用能否被内联。
4.21 - CH21-方法内联-下
在上一篇中,我举的例子都是静态方法调用,即时编译器可以轻易地确定唯一的目标方法。
然而,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。
即时编译器的去虚化方式可分为完全去虚化以及条件去虚化(guarded devirtualization)。
完全去虚化是通过类型推导或者类层次分析(class hierarchy analysis),识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。
在介绍具体的去虚化方式之前,我们先来看一段代码。这里我定义了一个抽象类 BinaryOp,其中包含一个抽象方法 apply。BinaryOp 类有两个子类 Add 和 Sub,均实现了 apply 方法。
abstract class BinaryOp {
public abstract int apply(int a, int b);
}
class Add extends BinaryOp {
public int apply(int a, int b) {
return a + b;
}
}
class Sub extends BinaryOp {
public int apply(int a, int b) {
return a - b;
}
}
下面我便用这个例子来逐一讲解这几种去虚化方式。
基于类型推导的完全去虚化
基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。
public static int foo() {
BinaryOp op = new Add();
return op.apply(2, 1);
}
public static int bar(BinaryOp op) {
op = (Add) op;
return op.apply(2, 1);
}
举个例子,上面这段代码中的 foo 方法和 bar 方法均会调用 apply 方法,且调用者的声明类型皆为 BinaryOp。这意味着 Java 编译器会将其编译为 invokevirtual 指令,调用 BinaryOp.apply 方法。
前两篇中我曾提到过,在 Sea-of-Nodes 的 IR 系统中,变量不复存在,取而代之的是具体值。这些具体值的类型往往要比变量的声明类型精确。
foo 方法的 IR 图(方法内联前)
bar 方法的 IR 图(方法内联前)
在上面两张 IR 图中,方法调用的调用者(即 8 号 CallTarget 节点的第一个依赖值)分别为 2 号 New 节点,以及 5 号 Pi 节点。后者可以简单看成强制转换后的精确类型。由于这两个节点的类型均被精确为 Add 类,因此,原 invokevirtual 指令对应的 9 号 invoke 节点都被识别对 Add.apply 方法的调用。
经过对该具体方法的内联之后,对应的 IR 图如下所示:
foo 方法的 IR 图(方法内联及逃逸分析后)
bar 方法的 IR 图(方法内联后)
可以看到,通过将字节码转换为 Sea-of-Nodes IR 之后,即时编译器便可以直接去虚化,并将唯一的目标方法进一步内联进来。
public static int notInlined(BinaryOp op) {
if (op instanceof Add) {
return op.apply(2, 1);
}
return 0;
}
不过,对于上面这段代码中的 notInlined 方法,尽管理论上即时编译器能够推导出调用者的动态类型为 Add,但是 C2 和 Graal 都没有这么做。
其原因在于类型推导属于全局优化,本身比较浪费时间;另一方面,就算不进行基于类型推导的完全去虚化,也有接下来的基于类层次分析的去虚化,以及条件去虚化兜底,覆盖大部分的代码情况。
notInlined 方法的 IR 图(方法内联失败后)
因此,C2 和 Graal 决定,如果生成 Sea-of-Nodes IR 后,调用者的动态类型已能够直接确定,那么就进行这项去虚化。如果需要额外的数据流分析方能确定,那么干脆不做,以节省编译时间,并依赖接下来的去虚化手段进行优化。
基于类层次分析的完全去虚化
基于类层次分析的完全去虚化通过分析 Java 虚拟机中所有已被加载的类,判断某个抽象方法或者接口方法是否仅有一个实现。如果是,那么对这些方法的调用将只能调用至该具体实现中。
在上面的例子中,假设在编译 foo、bar 或 notInlined 方法时,Java 虚拟机仅加载了 Add。那么,BinaryOp.apply 方法只有 Add.apply 这么一个具体实现。因此,当即时编译器碰到对 BinaryOp.apply 的调用时,便可直接内联 Add.apply 的内容。
那么问题来了,即时编译器如何保证在今后的执行过程中,BinaryOp.apply 方法还是只有 Add.apply 这么一个具体实现呢?
事实上,它无法保证。因为 Java 虚拟机有可能在上述编译完成之后加载 Sub 类,从而引入另一个 BinaryOp.apply 方法的具体实现 Sub.apply。
Java 虚拟机的做法是为当前编译结果注册若干个假设(assumption),假定某抽象类只有一个子类,或者某抽象方法只有一个具体实现,又或者某类没有子类等。
之后,每当新的类被加载,Java 虚拟机便会重新验证这些假设。如果某个假设不再成立,那么 Java 虚拟机便会对其所属的编译结果进行去优化。
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
以上面这段代码中的 test 方法为例。假设即时编译的时候,如果类层次分析得出 BinaryOp 类只有 Add 一个子类的结论,那么即时编译器可以注册一个假设,假定抽象方法 BinaryOp.apply 有且仅有 Add.apply 这个具体实现。
基于这个假设,原虚方法调用便可直接被去虚化为对 Add.apply 方法的调用。如果在之后的运行过程中,Java 虚拟机又加载了 Sub 类,那么该假设失效,Java 虚拟机需要触发 test 方法编译结果的去优化。
public static int test(Add op) {
return op.apply(2, 1); // 仍需添加假设
}
事实上,即便调用者的声明类型为 Add,即时编译器仍需为之添加假设。这是因为 Java 虚拟机不能保证没有重写了 apply 方法的 Add 类的子类。
为了保证这里 apply 方法的语义,即时编译器需要假设 Add 类没有子类。当然,通过将 Add 类标注为 final,可以避开这个问题。
可以看到,即时编译器并不要求目标方法使用 final 修饰符。只要目标方法事实上是 final 的(effective final),便可以进行相应的去虚化以及内联。
不过,如果使用了 final 修饰符,即时编译器便可以不用生成对应的假设。这将使编译结果更加精简,并减少类加载时所需验证的内容。
test 方法的 IR 图(方法内联后)
让我们回到原本的例子中。从 test 方法的 IR 图可以看出,生成的代码无须检测调用者的动态类型是否为 Add,便直接执行内联之后的 Add.apply 方法中的内容(2+1 经过常量折叠之后得到 3,对应 13 号常数节点)。这是因为动态类型检测已被移至假设之中了。
然而,对于接口方法调用,该去虚化手段则不能移除动态类型检测。这是因为在执行 invokeinterface 指令时,Java 虚拟机必须对调用者的动态类型进行测试,看它是否实现了目标接口方法所在的接口。
Java 类验证器将接口类型直接看成 Object 类型,所以有可能出现声明类型为接口,实际类型没有继承该接口的情况,如下例所示。
// A.java
interface I {}
public class A {
public static void test(I obj) {
System.out.println("Hello World");
}
public static void main(String[] args) {
test(new B());
}
}
// B.java
public class B implements I { }
// Step 1: compile A.java and B.java
// Step 2: remove "implements I" from B.java, and compile B.java
// Step 3: run A
既然这一类型测试无法避免,C2 干脆就不对接口方法调用进行基于类层次分析的完全去虚化,而是依赖于接下来的条件去虚化。
条件去虚化
前面提到,条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。
具体的原理非常简单,是将调用者的动态类型,依次与 Java 虚拟机所收集的类型 Profile 中记录的类型相比较。如果匹配,则直接调用该记录类型所对应的目标方法。
public static int test(BinaryOp op) {
return op.apply(2, 1);
}
我们继续使用前面的例子。假设编译时类型 Profile 记录了调用者的两个类型 Sub 和 Add,那么即时编译器可以据此进行条件去虚化,依次比较调用者的动态类型是否为 Sub 或者 Add,并内联相应的方法。其伪代码如下所示:
public static int test(BinaryOp op) {
if (op.getClass() == Sub.class) {
return 2 - 1; // inlined Sub.apply
} else if (op.getClass() == Add.class) {
return 2 + 1; // inlined Add.apply
} else {
... // 当匹配不到类型 Profile 中的类型怎么办?
}
}
如果遍历完类型 Profile 中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择。
第一,如果类型 Profile 是完整的,也就是说,所有出现过的动态类型都被记录至类型 Profile 之中,那么即时编译器可以让程序进行去优化,重新收集类型 Profile,对应的 IR 图如下所示(这里 27 号 TypeSwitch 节点等价于前面伪代码中的多个 if 语句):
当匹配不到动态类型时进行去优化
第二,如果类型 Profile 是不完整的,也就是说,某些出现过的动态类型并没有记录至类型 Profile 之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方法表进行动态绑定。对应的 IR 图如下所示:
当匹配不到动态类型时进行虚调用(仅在 Graal 中使用。)
在 C2 中,如果类型 Profile 是不完整的,即时编译器压根不会进行条件去虚化,而是直接使用内联缓存或者方法表。
总结与实践
今天我介绍了即时编译器去虚化的几种方法。
完全去虚化通过类型推导或者类层次分析,将虚方法调用转换为直接调用。它的关键在于证明虚方法调用的目标方法是唯一的。
条件去虚化通过向代码中增添类型比较,将虚方法调用转换为一个个的类型测试以及对应该类型的直接调用。它将借助 Java 虚拟机所收集的类型 Profile。
4.22 - CH22-HotSpot-intrinsic
前不久,有同学问我,String.indexOf
方法和自己实现的indexOf
方法在字节码层面上差不多,为什么执行效率却有天壤之别呢?今天我们就来看一看。
public int indexOf(String str) {
if (coder() == str.coder()) {
return isLatin1() ? StringLatin1.indexOf(value, str.value)
: StringUTF16.indexOf(value, str.value);
}
if (coder() == LATIN1) { // str.coder == UTF16
return -1;
}
return StringUTF16.indexOfLatin1(value, str.value);
}
为了解答这个问题,我们来读一下String.indexOf
方法的源代码(上面的代码截取自 Java 10.0.2)。
在 Java 9 之前,字符串是用 char 数组来存储的,主要为了支持非英文字符。然而,大多数 Java 程序中的字符串都是由 Latin1 字符组成的。也就是说每个字符仅需占据一个字节,而使用 char 数组的存储方式将极大地浪费内存空间。
Java 9 引入了 Compact Strings[1] 的概念,当字符串仅包含 Latin1 字符时,使用一个字节代表一个字符的编码格式,使得内存使用效率大大提高。
假设我们调用String.indexOf
方法的调用者以及参数均为只包含 Latin1 字符的字符串,那么该方法的关键在于对StringLatin1.indexOf
方法的调用。
下面我列举了StringLatin1.indexOf
方法的源代码。你会发现,它并没有使用特别高明的算法,唯一值得注意的便是方法声明前的@HotSpotIntrinsicCandidate
注解。
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, byte[] str) {
if (str.length == 0) {
return 0;
}
if (value.length == 0) {
return -1;
}
return indexOf(value, value.length, str, str.length, 0);
}
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
byte first = str[0];
int max = (valueCount - strCount);
for (int i = fromIndex; i <= max; i++) {
// Look for first character.
if (value[i] != first) {
while (++i <= max && value[i] != first);
}
// Found first character, now look at the rest of value
if (i <= max) {
int j = i + 1;
int end = j + strCount - 1;
for (int k = 1; j < end && value[j] == str[k]; j++, k++);
if (j == end) {
// Found whole string.
return i;
}
}
}
return -1;
}
在 HotSpot 虚拟机中,所有被该注解标注的方法都是 HotSpot intrinsic。对这些方法的调用,会被 HotSpot 虚拟机替换成高效的指令序列。而原本的方法实现则会被忽略掉。
换句话说,HotSpot 虚拟机将为标注了@HotSpotIntrinsicCandidate
注解的方法额外维护一套高效实现。如果 Java 核心类库的开发者更改了原本的实现,那么虚拟机中的高效实现也需要进行相应的修改,以保证程序语义一致。
需要注意的是,其他虚拟机未必维护了这些 intrinsic 的高效实现,它们可以直接使用原本的较为低效的 JDK 代码。同样,不同版本的 HotSpot 虚拟机所实现的 intrinsic 数量也大不相同。通常越新版本的 Java,其 intrinsic 数量越多。
你或许会产生这么一个疑问:为什么不直接在源代码中使用这些高效实现呢?
这是因为高效实现通常依赖于具体的 CPU 指令,而这些 CPU 指令不好在 Java 源程序中表达。再者,换了一个体系架构,说不定就没有对应的 CPU 指令,也就无法进行 intrinsic 优化了。
下面我们便来看几个具体的例子。
intrinsic 与 CPU 指令
在文章开头的例子中,StringLatin1.indexOf
方法将在一个字符串(byte 数组)中查找另一个字符串(byte 数组),并且返回命中时的索引值,或者 -1(未命中)。
“恰巧”的是,X86_64 体系架构的 SSE4.2 指令集就包含一条指令 PCMPESTRI,让它能够在 16 字节以下的字符串中,查找另一个 16 字节以下的字符串,并且返回命中时的索引值。
因此,HotSpot 虚拟机便围绕着这一指令,开发出 X86_64 体系架构上的高效实现,并替换原本对StringLatin1.indexOf
方法的调用。
另外一个例子则是整数加法的溢出处理。一般我们在做整数加法时,需要考虑结果是否会溢出,并且在溢出的情况下作出相应的处理,以保证程序的正确性。
Java 核心类库提供了一个Math.addExact
方法。它将接收两个 int 值(或 long 值)作为参数,并返回这两个 int 值的和。当这两个 int 值之和溢出时,该方法将抛出ArithmeticException
异常。
@HotSpotIntrinsicCandidate
public static int addExact(int x, int y) {
int r = x + y;
// HD 2-12 Overflow iff both arguments have the opposite sign of the result
if (((x ^ r) & (y ^ r)) < 0) {
throw new ArithmeticException("integer overflow");
}
return r;
}
在 Java 层面判断 int 值之和是否溢出比较费事。我们需要分别比较两个 int 值与它们的和的符号是否不同。如果都不同,那么我们便认为这两个 int 值之和溢出。对应的实现便是两个异或操作,一个与操作,以及一个比较操作。
在 X86_64 体系架构中,大部分计算指令都会更新状态寄存器(FLAGS register),其中就有表示指令结果是否溢出的溢出标识位(overflow flag)。因此,我们只需在加法指令之后比较溢出标志位,便可以知道 int 值之和是否溢出了。对应的伪代码如下所示:
public static int addExact(int x, int y) {
int r = x + y;
jo LABEL_OVERFLOW; // jump if overflow flag set
return r;
LABEL_OVERFLOW:
throw new ArithmeticException("integer overflow");
// or deoptimize
}
最后一个例子则是Integer.bitCount
方法,它将统计所输入的 int 值的二进制形式中有多少个 1。
@HotSpotIntrinsicCandidate
public static int bitCount(int i) {
// HD, Figure 5-2
i = i - ((i >>> 1) & 0x55555555);
i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
i = (i + (i >>> 4)) & 0x0f0f0f0f;
i = i + (i >>> 8);
i = i + (i >>> 16);
return i & 0x3f;
}
我们可以看到,Integer.bitCount
方法的实现还是很巧妙的,但是它需要的计算步骤也比较多。在 X86_64 体系架构中,我们仅需要一条指令popcnt
,便可以直接统计出 int 值中 1 的个数。
intrinsic 与方法内联
HotSpot 虚拟机中,intrinsic 的实现方式分为两种。
一种是独立的桩程序。它既可以被解释执行器利用,直接替换对原方法的调用;也可以被即时编译器所利用,它把代表对原方法的调用的 IR 节点,替换为对这些桩程序的调用的 IR 节点。以这种形式实现的 intrinsic 比较少,主要包括Math
类中的一些方法。
另一种则是特殊的编译器 IR 节点。显然,这种实现方式仅能够被即时编译器所利用。
在编译过程中,即时编译器会将对原方法的调用的 IR 节点,替换成特殊的 IR 节点,并参与接下来的优化过程。最终,即时编译器的后端将根据这些特殊的 IR 节点,生成指定的 CPU 指令。大部分的 intrinsic 都是通过这种方式实现的。
这个替换过程是在方法内联时进行的。当即时编译器碰到方法调用节点时,它将查询目标方法是不是 intrinsic。
如果是,则插入相应的特殊 IR 节点;如果不是,则进行原本的内联工作。(即判断是否需要内联目标方法的方法体,并在需要内联的情况下,将目标方法的 IR 图纳入当前的编译范围之中。)
也就是说,如果方法调用的目标方法是 intrinsic,那么即时编译器会直接忽略原目标方法的字节码,甚至根本不在乎原目标方法是否有字节码。即便是 native 方法,只要它被标记为 intrinsic,即时编译器便能够将之 " 内联 " 进来,并插入特殊的 IR 节点。
事实上,不少被标记为 intrinsic 的方法都是 native 方法。原本对这些 native 方法的调用需要经过 JNI(Java Native Interface),其性能开销十分巨大。但是,经过即时编译器的 intrinsic 优化之后,这部分 JNI 开销便直接消失不见,并且最终的结果也十分高效。
举个例子,我们可以通过Thread.currentThread
方法来获取当前线程。这是一个 native 方法,同时也是一个 HotSpot intrinsic。在 X86_64 体系架构中,R13 寄存器存放着当前线程的指针。因此,对该方法的调用将被即时编译器替换为一个特殊 IR 节点,并最终生成读取 R13 寄存器指令。
已有 intrinsic 简介
最新版本的 HotSpot 虚拟机定义了三百多个 intrinsic。
在这三百多个 intrinsic 中,有三成以上是Unsafe
类的方法。不过,我们一般不会直接使用Unsafe
类的方法,而是通过java.util.concurrent
包来间接使用。
举个例子,Unsafe
类中经常会被用到的便是compareAndSwap
方法(Java 9+ 更名为compareAndSet
或compareAndExchange
方法)。在 X86_64 体系架构中,对这些方法的调用将被替换为lock cmpxchg
指令,也就是原子性更新指令。
除了Unsafe
类的方法之外,HotSpot 虚拟机中的 intrinsic 还包括下面的几种。
StringBuilder
和StringBuffer
类的方法。HotSpot 虚拟机将优化利用这些方法构造字符串的方式,以尽量减少需要复制内存的情况。String
类、StringLatin1
类、StringUTF16
类和Arrays
类的方法。HotSpot 虚拟机将使用 SIMD 指令(single instruction multiple data,即用一条指令处理多个数据)对这些方法进行优化。 举个例子,Arrays.equals(byte[], byte[])
方法原本是逐个字节比较,在使用了 SIMD 指令之后,可以放入 16 字节的 XMM 寄存器中(甚至是 64 字节的 ZMM 寄存器中)批量比较。- 基本类型的包装类、
Object
类、Math
类、System
类中各个功能性方法,反射 API、MethodHandle
类中与调用机制相关的方法,压缩、加密相关方法。这部分 intrinsic 则比较简单,这里就不详细展开了。如果你有感兴趣的,可以自行查阅资料,或者在文末留言。
如果你想知道 HotSpot 虚拟机定义的所有 intrinsic,那么你可以直接查阅 OpenJDK 代码 [2]。(该链接是 Java 12 的 intrinsic 列表。Java 8 的 intrinsic 列表可以查阅这一链接 [3]。)
总结与实践
今天我介绍了 HotSpot 虚拟机中的 intrinsic。
HotSpot 虚拟机将对标注了@HotSpotIntrinsicCandidate
注解的方法的调用,替换为直接使用基于特定 CPU 指令的高效实现。这些方法我们便称之为 intrinsic。
具体来说,intrinsic 的实现有两种。一是不大常见的桩程序,可以在解释执行或者即时编译生成的代码中使用。二是特殊的 IR 节点。即时编译器将在方法内联过程中,将对 intrinsic 的调用替换为这些特殊的 IR 节点,并最终生成指定的 CPU 指令。
HotSpot 虚拟机定义了三百多个 intrinsic。其中比较特殊的有Unsafe
类的方法,基本上使用 java.util.concurrent 包便会间接使用到Unsafe
类的 intrinsic。除此之外,String
类和Arrays
类中的 intrinsic 也比较特殊。即时编译器将为之生成非常高效的 SIMD 指令。
4.23 - CH23-逃逸分析
我们知道,Java 中Iterable
对象的 foreach 循环遍历是一个语法糖,Java 编译器会将该语法糖编译为调用Iterable
对象的iterator
方法,并用所返回的Iterator
对象的hasNext
以及next
方法,来完成遍历。
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
for (Object obj : list) {
f.accept(obj);
}
}
举个例子,上面的 Java 代码将使用 foreach 循环来遍历一个ArrayList
对象,其等价的代码如下所示:
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
Iterator<Object> iter = list.iterator();
while (iter.hasNext()) {
Object obj = iter.next();
f.accept(obj);
}
}
这里我也列举了所涉及的ArrayList
代码。我们可以看到,ArrayList.iterator
方法将创建一个ArrayList$Itr
实例。
public class ArrayList ... {
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
...
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
...
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
}
因此,有同学认为我们应当避免在热点代码中使用 foreach 循环,并且直接使用基于ArrayList.size
以及ArrayList.get
的循环方式(如下所示),以减少对 Java 堆的压力。
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
for (int i = 0; i < list.size(); i++) {
f.accept(list.get(i));
}
}
实际上,Java 虚拟机中的即时编译器可以将ArrayList.iterator
方法中的实例创建操作给优化掉。不过,这需要方法内联以及逃逸分析的协作。
在前面几篇中我们已经深入学习了方法内联,今天我便来介绍一下逃逸分析。
逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”(出处参见 [1])。
在 Java 虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
关于后者,由于 Java 虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。
通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。
回到文章开头的例子。理想情况下,即时编译器能够内联对ArrayList$Itr
构造器的调用,对hasNext
以及next
方法的调用,以及当内联了Itr.next
方法后,对checkForComodification
方法的调用。
如果这些方法调用均能够被内联,那么结果将近似于下面这段伪代码:
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
Itr iter = new Itr; // 注意这里是 new 指令
iter.cursor = 0;
iter.lastRet = -1;
iter.expectedModCount = list.modCount;
while (iter.cursor < list.size) {
if (list.modCount != iter.expectedModCount)
throw new ConcurrentModificationException();
int i = iter.cursor;
if (i >= list.size)
throw new NoSuchElementException();
Object[] elementData = list.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
iter.cursor = i + 1;
iter.lastRet = i;
Object obj = elementData[i];
f.accept(obj);
}
}
可以看到,这段代码所新建的ArrayList$Itr
实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸。
基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。
在介绍 Java 内存模型时,我曾提过synchronized (new Object()) {}
会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的 happens-before 规则。
synchronized (escapedObject) {}
则不然。由于其他线程可能会对逃逸了的对象escapedObject
进行加锁操作,从而构造了两个线程之间的 happens-before 关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。
不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。
我们知道,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。
不过,由于实现起来需要更改大量假设了“对象只能堆分配”的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。
所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。
标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。举例来说,前面经过内联之后的 forEach 代码可以被转换为如下代码:
public void forEach(ArrayList<Object> list, Consumer<Object> f) {
// Itr iter = new Itr; // 经过标量替换后该分配无意义,可以被优化掉
int cursor = 0; // 标量替换
int lastRet = -1; // 标量替换
int expectedModCount = list.modCount; // 标量替换
while (cursor < list.size) {
if (list.modCount != expectedModCount)
throw new ConcurrentModificationException();
int i = cursor;
if (i >= list.size)
throw new NoSuchElementException();
Object[] elementData = list.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
lastRet = i;
Object obj = elementData[i];
f.accept(obj);
}
}
可以看到,原本需要在内存中连续分布的对象,现已被拆散为一个个单独的字段cursor
,lastRet
,以及expectedModCount
。这些字段既可以存储在栈上,也可以直接存储在寄存器中。而该对象的对象头信息则直接消失了,不再被保存至内存之中。
由于该对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。
部分逃逸分析
C2 的逃逸分析与控制流无关,相对来说比较简单。Graal 则引入了一个与控制流有关的逃逸分析,名为部分逃逸分析(partial escape analysis)[2]。它解决了所新建的实例仅在部分程序路径中逃逸的情况。
举个例子,在下面这段代码中,新建实例只会在进入 if-then 分支时逃逸。(对hashCode
方法的调用是一个 HotSpot intrinsic,将被替换为一个无法内联的本地方法调用。)
public static void bar(boolean cond) {
Object foo = new Object();
if (cond) {
foo.hashCode();
}
}
// 可以手工优化为:
public static void bar(boolean cond) {
if (cond) {
Object foo = new Object();
foo.hashCode();
}
}
假设 if 语句的条件成立的可能性只有 1%,那么在 99% 的情况下,程序没有必要新建对象。其手工优化的版本正是部分逃逸分析想要自动达到的成果。
部分逃逸分析将根据控制流信息,判断出新建对象仅在部分分支中逃逸,并且将对象的新建操作推延至对象逃逸的分支中。这将使得原本因对象逃逸而无法避免的新建对象操作,不再出现在只执行 if-else 分支的程序路径之中。
综上,与 C2 所使用的逃逸分析相比,Graal 所使用的部分逃逸分析能够优化更多的情况,不过它编译时间也更长一些。
总结与实践
今天我介绍了 Java 虚拟机中即时编译器的逃逸分析,以及基于逃逸分析的优化。
在 Java 虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否会逃逸。即时编译器判断对象逃逸的依据有两个:一是看对象是否被存入堆中,二是看对象是否作为方法调用的调用者或者参数。
即时编译器会根据逃逸分析的结果进行优化,如锁消除以及标量替换。后者指的是将原本连续分配的对象拆散为一个个单独的字段,分布在栈上或者寄存器中。
部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分支。
4.24 - CH24-字段访问优化
在上一篇文章中,我介绍了逃逸分析,也介绍了基于逃逸分析的优化方式锁消除、栈上分配以及标量替换等内容。
其中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对对象字段的访问,替换为对一个个局部变量的访问。
class Foo {
int a = 0;
}
static int bar(int x) {
Foo foo = new Foo();
foo.a = x;
return foo.a;
}
举个例子,上面这段代码中的bar
方法,经过逃逸分析以及标量替换后,其优化结果如下所示。(确切地说,是指所生成的 IR 图与下述代码所生成的 IR 图类似。之后不再重复解释。)
static int bar(int x) {
int a = x;
return a;
}
由于 Sea-of-Nodes IR 的特性,局部变量不复存在,取而代之的是一个个值。在例子对应的 IR 图中,返回节点将直接返回所输入的参数。
经过标量替换的bar
方法
下面我列举了bar
方法经由 C2 即时编译生成的机器码(这里略去了指令地址的前 48 位)。
# {method} 'bar' '(I)I' in 'FieldAccessTest'
# parm0: rsi = int // 参数 x
# [sp+0x20] (sp of caller)
0x06a0: sub rsp,0x18 // 创建方法栈桢
0x06a7: mov QWORD PTR [rsp+0x10],rbp // 无关指令
0x06ac: mov eax,esi // 将参数 x 存入返回值 eax 中
0x06ae: add rsp,0x10 // 弹出方法栈桢
0x06b2: pop rbp // 无关指令
0x06b3: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x06b7: test DWORD PTR [r10],eax // 安全点测试
0x06ba: ret
在 X86_64 的机器码中,每当使用 call 指令进入目标方法的方法体中时,我们需要在栈上为当前方法分配一块内存作为其栈桢。而在退出该方法时,我们需要弹出当前方法所使用的栈桢。
由于寄存器 rsp 维护着当前线程的栈顶指针,因此这些操作都是通过增减寄存器 rsp 来实现的,即上面这段机器码中偏移量为 0x06a0 以及 0x06ae 的指令。
在介绍安全点(safepoint)时我曾介绍过,HotSpot 虚拟机的即时编译器将在方法返回时插入安全点测试指令,即图中偏移量为 0x06b3 以及 0x06ba 的指令。其中真正的安全点测试是 0x06b7 指令。
如果虚拟机需要所有线程都到达安全点,那么该 test 指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发 segfault,并借由 segfault 处理器进入安全点之中。通常,该指令会附带
; {poll_return}
这样子的注释,这里被我略去了。
在 X8_64 中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在 rax 寄存器中。有时候你会看到返回值被存入 eax 寄存器中,这其实是同一个寄存器,只不过 rax 表示 64 位寄存器,而 eax 表示 32 位寄存器。具体可以参考 x86 calling conventions[1]。
当忽略掉创建、弹出方法栈桢,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为 0x06ac 的 mov 指令,以及 0x06ba 的 ret 指令。前者将所传入的 int 型参数 x 移至代表返回值的 eax 寄存器中,后者是退出当前方法并返回至调用者中。
虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是 Java 虚拟机的银色子弹。
在现实中,Java 程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要起来。
static int bar(Foo o, int x) {
o.a = x;
return o.a;
}
在上面这段代码中,对象o
是传入参数,不属于逃逸分析的范围(Java 虚拟机中的逃逸分析针对的是新建对象)。该方法会将所传入的 int 型参数x
的值存储至实例字段Foo.a
中,然后再读取并返回同一字段的值。
这段代码将涉及两次内存访问操作:存储以及读取实例字段Foo.a
。我们可以轻易地将其手工优化为直接读取并返回传入参数 x 的值。由于这段代码较为简单,因此它极大可能被编译为寄存器之间的移动指令(即将输入参数x
的值移至寄存器 eax 中)。这与原本的内存访问指令相比,显然要高效得多。
static int bar(Foo o, int x) {
o.a = x;
return x;
}
那么即时编译器是否能够作出类似的自动优化呢?
字段读取优化
答案是可以的。即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。
当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。
在前面的例子中,我们见识了缓存字段存储节点的情况。下面我们来看一下缓存字段读取节点的情况。
static int bar(Foo o, int x) {
int y = o.a + x;
return o.a + y;
}
在上面这段代码中,实例字段Foo.a
将被读取两次。即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。
static int bar(Foo o, int x) {
int t = o.a;
int y = t + x;
return t + y;
}
如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化。
static int bar(Foo o, int x) {
o.a = 1;
if (o.a >= 0)
return x;
else
return -x;
}
例如在上面这段代码中,实例字段Foo.a
会被赋值为 1。接下来的 if 语句将判断同一实例字段是否不小于 0。经过字段读取优化之后,>=
节点的两个输入参数分别为常数 1 和 0,因此可以直接替换为具体结果true
。如此一来,else 分支将变成不可达代码,可以直接删除,其优化结果如下所示。
static int bar(Foo o, int x) {
o.a = 1;
return x;
}
我们再来看另一个例子。下面这段代码的bar
方法中,实例字段a
会被赋值为true
,后面紧跟着一个以a
为条件的 while 循环。
class Foo {
boolean a;
void bar() {
a = true;
while (a) {}
}
void whatever() { a = false; }
}
同样,即时编译器会将 while 循环中读取实例字段a
的操作直接替换为常量true
,即下面代码所示的死循环。
void bar() {
a = true;
while (true) {}
}
// 生成的机器码将陷入这一死循环中
0x066b: mov r11,QWORD PTR [r15+0x70] // 安全点测试
0x066f: test DWORD PTR [r11],eax // 安全点测试
0x0672: jmp 0x066b // while (true)
在介绍 Java 内存模型时,我们便知道可以通过 volatile 关键字标记实例字段a
,以此强制对它的读取。
实际上,即时编译器将在 volatile 字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。
就我们的例子而言,尽管在 X86_64 平台上,volatile 字段读取操作前后的内存屏障是 no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段Foo.a
的最新值。
0x00e0: movzx r11d,BYTE PTR [rbx+0xc] // 读取 a
0x00e5: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x00e9: test DWORD PTR [r10],eax // 安全点测试
0x00ec: test r11d,r11d // while (a)
0x00ef: jne 0x00e0 // while (a)
同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。
字段存储优化
除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。
class Foo {
int a = 0;
void bar() {
a = 1;
a = 2;
}
}
举例来说,上面这段代码中的bar
方法先后存储了两次Foo.a
实例字段。由于第一次存储之后没有读取Foo.a
的值,因此,即时编译器会将其看成冗余存储,并将之消除掉,生成如下代码:
void bar() {
a = 2;
}
实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。
class Foo {
int a = 0;
void bar() {
a = 1;
int t = a;
a = t + 2;
}
}
// 优化为
class Foo {
int a = 0;
void bar() {
a = 1;
int t = 1;
a = t + 2;
}
}
// 进一步优化为
class Foo {
int a = 0;
void bar() {
a = 3;
}
}
当然,如果所存储的字段被标记为 volatile,那么即时编译器也不能将冗余的存储操作消除掉。
这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。
死代码消除
除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于 Sea-of-Nodes IR 的特性,死存储的优化无须额外代价。
int bar(int x, int y) {
int t = x*y;
t = x+y;
return t;
}
上面这段代码涉及两个存储局部变量操作。当即时编译器将其转换为 Sea-of-Nodes IR 之后,没有节点依赖于 t 的第一个值x*y
。因此,该乘法运算将被消除,其结果如下所示:
int bar(int x, int y) {
return x+y;
}
死存储还有一种变体,即在部分程序路径上有冗余存储。
int bar(boolean f, int x, int y) {
int t = x*y;
if (f)
t = x+y;
return t;
}
举个例子,上面这段代码中,如果所传入的 boolean 类型的参数f
是true
,那么在程序执行路径上将先后进行两次对局部变量t
的存储。
同样,经过 Sea-of-Nodes IR 转换之后,返回节点所依赖的值是一个 phi 节点,将根据程序路径选择x+y
或者x*y
。也就是说,当f
为true
的程序路径上的乘法运算会被消除,其结果如下所示:
int bar(boolean f, int x, int y) {
int t;
if (f)
t = x+y;
else
t = x*y;
return t;
}
另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支,我们之前已经多次接触过了。
在即时编译过程中,我们经常因为方法内联、常量传播以及基于 profile 的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
int bar(int x) {
if (false)
return x;
else
return -x;
}
举个例子,在上面的代码中,if 语句将一直跳转至 else 分支之中。因此,另一不可达分支可以直接消除掉,形成下面的代码:
int bar(int x) {
return -x;
}
总结与实践
今天我介绍了即时编译器关于字段访问的优化方式,以及死代码消除。
即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值。
这要求生成缓存值的访问以及使用缓存值的读取之间没有方法调用、内存屏障,或者其他可能存储该字段的节点。
即时编译器还会优化冗余的字段存储操作。如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。
此外,我还介绍了死代码消除的两种形式。第一种是局部变量的死存储消除以及部分死存储消除。它们可以通过转换为 Sea-of-Nodes IR 来完成。第二种则是不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
4.25 - CH25-循环优化
在许多应用程序中,循环都扮演着非常重要的角色。为了提升循环的运行效率,研发编译器的工程师提出了不少面向循环的编译优化方式,如循环无关代码外提,循环展开等。
今天,我们便来了解一下,Java 虚拟机中的即时编译器都应用了哪些面向循环的编译优化。
循环无关代码外提
所谓的循环无关代码(Loop-invariant Code),指的是循环中值不变的表达式。如果能够在不改变程序语义的情况下,将这些循环无关代码提出循环之外,那么程序便可以避免重复执行这些表达式,从而达到性能提升的效果。
int foo(int x, int y, int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += x * y + a[i];
}
return sum;
}
// 对应的字节码
int foo(int, int, int[]);
Code:
0: iconst_0
1: istore 4
3: iconst_0
4: istore 5
6: goto 25
// 循环体开始
9: iload 4 // load sum
11: iload_1 // load x
12: iload_2 // load y
13: imul // x*y
14: aload_3 // load a
15: iload 5 // load i
17: iaload // a[i]
18: iadd // x*y + a[i]
19: iadd // sum + (x*y + a[i])
20: istore 4 // sum = sum + (x*y + a[i])
22: iinc 5, 1 // i++
25: iload 5 // load i
27: aload_3 // load a
28: arraylength // a.length
29: if_icmplt 9 // i < a.length
// 循环体结束
32: iload 4
34: ireturn
举个例子,在上面这段代码中,循环体中的表达式x*y
,以及循环判断条件中的a.length
均属于循环不变代码。前者是一个整数乘法运算,而后者则是内存访问操作,读取数组对象a
的长度。(数组的长度存放于数组对象的对象头中,可通过 arraylength 指令来访问。)
理想情况下,上面这段代码经过循环无关代码外提之后,等同于下面这一手工优化版本。
int fooManualOpt(int x, int y, int[] a) {
int sum = 0;
int t0 = x * y;
int t1 = a.length;
for (int i = 0; i < t1; i++) {
sum += t0 + a[i];
}
return sum;
}
我们可以看到,无论是乘法运算x*y
,还是内存访问a.length
,现在都在循环之前完成。原本循环中需要执行这两个表达式的地方,现在直接使用循环之前这两个表达式的执行结果。
在 Sea-of-Nodes IR 的帮助下,循环无关代码外提的实现并不复杂。
上图我截取了 Graal 为前面例子中的foo
方法所生成的 IR 图(局部)。其中 B2 基本块位于循环之前,B3 基本块为循环头。
x*y
所对应的 21 号乘法节点,以及a.length
所对应的 47 号读取节点,均不依赖于循环体中生成的数据,而且都为浮动节点。节点调度算法会将它们放置于循环之前的 B2 基本块中,从而实现这些循环无关代码的外提。
0x02f0: mov edi,ebx // ebx 存放着 x*y 的结果
0x02f2: add edi,DWORD PTR [r8+r9*4+0x10]
// [r8+r9*4+0x10] 即 a[i]
// r8 指向 a,r9d 存放着 i
0x02f7: add eax,edi // eax 存放着 sum
0x02f9: inc r9d // i++
0x02fc: cmp r9d,r10d // i < a.length
// r10d 存放着 a.length
0x02ff: jl 0x02f0
上面这段机器码是foo
方法的编译结果中的循环。这里面没有整数乘法指令,也没有读取数组长度的内存访问指令。它们的值已在循环之前计算好了,并且分别保存在寄存器ebx
以及r10d
之中。在循环之中,代码直接使用寄存器ebx
以及r10d
所保存的值,而不用在循环中反复计算。
从生成的机器码中可以看出,除了x*y
和a.length
的外提之外,即时编译器还外提了 int 数组加载指令iaload
所暗含的 null 检测(null check)以及下标范围检测(range check)。
如果将iaload
指令想象成一个接收数组对象以及下标作为参数,并且返回对应数组元素的方法,那么它的伪代码如下所示:
int iaload(int[] arrayRef, int index) {
if (arrayRef == null) { // null 检测
throw new NullPointerException();
}
if (index < 0 || index >= arrayRef.length) { // 下标范围检测
throw new ArrayIndexOutOfBoundsException();
}
return arrayRef[index];
}
foo
方法中的 null 检测属于循环无关代码。这是因为它始终检测作为输入参数的 int 数组是否为 null,而这与第几次循环无关。
为了更好地阐述具体的优化,我精简了原来的例子,并将iaload
展开,最终形成如下所示的代码。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
if (a == null) { // null check
throw new NullPointerException();
}
if (i < 0 || i >= a.length) { // range check
throw new ArrayIndexOutOfBoundsException();
}
sum += a[i];
}
return sum;
}
在这段代码中,null 检测涉及了控制流依赖,因而无法通过 Sea-of-Nodes IR 转换以及节点调度来完成外提。
在 C2 中,null 检测的外提是通过额外的编译优化,也就是循环预测(Loop Prediction,对应虚拟机参数-XX:+UseLoopPredicate
)来实现的。该优化的实际做法是在循环之前插入同样的检测代码,并在命中的时候进行去优化。这样一来,循环中的检测代码便会被归纳并消除掉。
int foo(int[] a) {
int sum = 0;
if (a == null) {
deoptimize(); // never returns
}
for (int i = 0; i < a.length; i++) {
if (a == null) { // now evluate to false
throw new NullPointerException();
}
if (i < 0 || i >= a.length) { // range check
throw new ArrayIndexOutOfBoundsException();
}
sum += a[i];
}
return sum;
}
除了 null 检测之外,其他循环无关检测都能够按照这种方式外提至循环之前。甚至是循环有关的下标范围检测,都能够借助循环预测来外提,只不过具体的转换要复杂一些。
之所以说下标范围检测是循环有关的,是因为在我们的例子中,该检测的主体是循环控制变量i
(检测它是否在[0, a.length)
之间),它的值将随着循环次数的增加而改变。
由于外提该下标范围检测之后,我们无法再引用到循环变量i
,因此,即时编译器需要转换检测条件。具体的转换方式如下所示:
for (int i = INIT; i < LIMIT; i += STRIDE) {
if (i < 0 || i >= a.length) { // range check
throw new ArrayIndexOutOfBoundsException();
}
sum += a[i];
}
----------
// 经过下标范围检测外提之后:
if (INIT < 0 || IMAX >= a.length) {
// IMAX 是 i 所能达到的最大值,注意它不一定是 LIMIT-1
detopimize(); // never returns
}
for (int i = INIT; i < LIMIT; i += STRIDE) {
sum += a[i]; // 不包含下标范围检测
}
循环展开
另外一项非常重要的循环优化是循环展开(Loop Unrolling)。它指的是在循环体中重复多次循环迭代,并减少循环次数的编译优化。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i++) {
sum += (i % 2 == 0) ? a[i] : -a[i];
}
return sum;
}
举个例子,上面的代码经过一次循环展开之后将形成下面的代码:
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i += 2) { // 注意这里的步数是 2
sum += (i % 2 == 0) ? a[i] : -a[i];
sum += ((i + 1) % 2 == 0) ? a[i + 1] : -a[i + 1];
}
return sum;
}
在 C2 中,只有计数循环(Counted Loop)才能被展开。所谓的计数循环需要满足如下四个条件。
- 维护一个循环计数器,并且基于计数器的循环出口只有一个(但可以有基于其他判断条件的出口)。
- 循环计数器的类型为 int、short 或者 char(即不能是 byte、long,更不能是 float 或者 double)。
- 每个迭代循环计数器的增量为常数。
- 循环计数器的上限(增量为正数)或下限(增量为负数)是循环无关的数值。
for (int i = START; i < LIMIT; i += STRIDE) { .. }
// 等价于
int i = START;
while (i < LIMIT) {
..
i += STRIDE;
}
在上面两种循环中,只要LIMIT
是循环无关的数值,STRIDE
是常数,而且循环中除了i < LIMIT
之外没有其他基于循环变量i
的循环出口,那么 C2 便会将该循环识别为计数循环。
循环展开的缺点显而易见:它可能会增加代码的冗余度,导致所生成机器码的长度大幅上涨。
不过,随着循环体的增大,优化机会也会不断增加。一旦循环展开能够触发进一步的优化,总体的代码复杂度也将降低。比如前面的例子经过循环展开之后便可以进一步优化为如下所示的代码:
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i += 2) {
sum += a[i];
sum += -a[i + 1];
}
return sum;
}
循环展开有一种特殊情况,那便是完全展开(Full Unroll)。当循环的数目是固定值而且非常小时,即时编译器会将循环全部展开。此时,原本循环中的循环判断语句将不复存在,取而代之的是若干个顺序执行的循环体。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 4; i++) {
sum += a[i];
}
return sum;
}
举个例子,上述代码将被完全展开为下述代码:
int foo(int[] a) {
int sum = 0;
sum += a[0];
sum += a[1];
sum += a[2];
sum += a[3];
return sum;
}
即时编译器会在循环体的大小与循环展开次数之间做出权衡。例如,对于仅迭代三次(或以下)的循环,即时编译器将进行完全展开;对于循环体 IR 节点数目超过阈值的循环,即时编译器则不会进行任何循环展开。
其他循环优化
除了循环无关代码外提以及循环展开之外,即时编译器还有两个比较重要的循环优化技术:循环判断外提(loop unswitching)以及循环剥离(loop peeling)。
循环判断外提指的是将循环中的 if 语句外提至循环之前,并且在该 if 语句的两个分支中分别放置一份循环代码。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
if (a.length > 4) {
sum += a[i];
}
}
return sum;
}
举个例子,上面这段代码经过循环判断外提之后,将变成下面这段代码:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
} else {
for (int i = 0; i < a.length; i++) {
}
}
return sum;
}
// 进一步优化为:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
}
return sum;
}
循环判断外提与循环无关检测外提所针对的代码模式比较类似,都是循环中的 if 语句。不同的是,后者在检查失败时会抛出异常,中止当前的正常执行路径;而前者所针对的是更加常见的情况,即通过 if 语句的不同分支执行不同的代码逻辑。
循环剥离指的是将循环的前几个迭代或者后几个迭代剥离出循环的优化方式。一般来说,循环的前几个迭代或者后几个迭代都包含特殊处理。通过将这几个特殊的迭代剥离出去,可以使原本的循环体的规律性更加明显,从而触发进一步的优化。
int foo(int[] a) {
int j = 0;
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += a[j];
j = i;
}
return sum;
}
举个例子,上面这段代码剥离了第一个迭代后,将变成下面这段代码:
int foo(int[] a) {
int sum = 0;
if (0 < a.length) {
sum += a[0];
for (int i = 1; i < a.length; i++) {
sum += a[i - 1];
}
}
return sum;
}
总结与实践
今天我介绍了即时编译器所使用的循环优化。
循环无关代码外提将循环中值不变的表达式,或者循环无关检测外提至循环之前,以避免在循环中重复进行冗余计算。前者是通过 Sea-of-Nodes IR 以及节点调度来共同完成的,而后者则是通过一个独立优化 —— 循环预测来完成的。循环预测还可以外提循环有关的数组下标范围检测。
循环展开是一种在循环中重复多次迭代,并且相应地减少循环次数的优化方式。它是一种以空间换时间的优化方式,通过增大循环体来获取更多的优化机会。循环展开的特殊形式是完全展开,将原本的循环转换成若干个循环体的顺序执行。
此外,我还简单地介绍了另外两种循环优化方式:循环判断外提以及循环剥离。
4.26 - CH26-向量化
在上一篇的实践环节中,我给你留了一个题目:如何进一步优化下面这段代码。
void foo(byte[] dst, byte[] src) {
for (int i = 0; i < dst.length - 4; i += 4) {
dst[i] = src[i];
dst[i+1] = src[i+1];
dst[i+2] = src[i+2];
dst[i+3] = src[i+3];
}
... // post-loop
}
由于 X86_64 平台不支持内存间的直接移动,上面代码中的dst[i] = src[i]
通常会被编译为两条内存访问指令:第一条指令把src[i]
的值读取至寄存器中,而第二条指令则把寄存器中的值写入至dst[i]
中。
因此,上面这段代码中的一个循环迭代将会执行四条内存读取指令,以及四条内存写入指令。
由于数组元素在内存中是连续的,当从src[i]
的内存地址处读取 32 位的内容时,我们将一并读取src[i]
至src[i+3]
的值。同样,当向dst[i]
的内存地址处写入 32 位的内容时,我们将一并写入dst[i]
至dst[i+3]
的值。
通过综合这两个批量操作,我们可以使用一条内存读取指令以及一条内存写入指令,完成上面代码中循环体内的全部工作。如果我们用x[i:i+3]
来指代x[i]
至x[i+3]
合并后的值,那么上述优化可以被表述成如下所示的代码:
void foo(byte[] dst, byte[] src) {
for (int i = 0; i < dst.length - 4; i += 4) {
dst[i:i+3] = src[i:i+3];
}
... // post-loop
}
SIMD 指令
在前面的示例中,我们使用的是 byte 数组,四个数组元素并起来也才 4 个字节。如果换成 int 数组,或者 long 数组,那么四个数组元素并起来将会是 16 字节或 32 字节。
我们知道,X86_64 体系架构上通用寄存器的大小为 64 位(即 8 个字节),无法暂存这些超长的数据。因此,即时编译器将借助长度足够的 XMM 寄存器,来完成 int 数组与 long 数组的向量化读取和写入操作。(为了实现方便,byte 数组的向量化读取、写入操作同样使用了 XMM 寄存器。)
所谓的 XMM 寄存器,是由 SSE(Streaming SIMD Extensions)指令集所引入的。它们一开始仅为 128 位。自从 X86 平台上的 CPU 开始支持 AVX(Advanced Vector Extensions)指令集后(2011 年),XMM 寄存器便升级为 256 位,并更名为 YMM 寄存器。原本使用 XMM 寄存器的指令,现将使用 YMM 寄存器的低 128 位。
前几年推出的 AVX512 指令集,更是将 YMM 寄存器升级至 512 位,并更名为 ZMM 寄存器。HotSpot 虚拟机也紧跟时代,更新了不少基于 AVX512 指令集以及 ZMM 寄存器的优化。不过,支持 AVX512 指令集的 CPU 都比较贵,目前在生产环境中很少见到。
SSE 指令集以及之后的 AVX 指令集都涉及了一个重要的概念,那便是单指令流多数据流(Single Instruction Multiple Data,SIMD),即通过单条指令操控多组数据的计算操作。这些指令我们称之为 SIMD 指令。
SIMD 指令将 XMM 寄存器(或 YMM 寄存器、ZMM 寄存器)中的值看成多个整数或者浮点数组成的向量,并且批量进行计算。
举例来说,128 位 XMM 寄存器里的值可以看成 16 个 byte 值组成的向量,或者 8 个 short 值组成的向量,4 个 int 值组成的向量,两个 long 值组成的向量;而 SIMD 指令PADDB
、PADDW
、PADDD
以及PADDQ
,将分别实现 byte 值、short 值、int 值或者 long 值的向量加法。
void foo(int[] a, int[] b, int[] c) {
for (int i = 0; i < c.length; i++) {
c[i] = a[i] + b[i];
}
}
上面这段代码经过向量化优化之后,将使用PADDD
指令来实现c[i:i+3] = a[i:i+3] + b[i:i+3]
。其执行过程中的数据流如下图所示,图片源自 Vladimir Ivanov 的演讲 [1]。下图中内存的右边是高位,寄存器的左边是高位,因此数组元素的顺序是反过来的。
也就是说,原本需要c.length
次加法操作的代码,现在最少只需要c.length/4
次向量加法即可完成。因此,SIMD 指令也被看成 CPU 指令级别的并行。
这里
c.length/4
次是理论值。现实中,C2 还将考虑缓存行对齐等因素,导致能够应用向量化加法的仅有数组中间的部分元素。
使用 SIMD 指令的 HotSpot Intrinsic
SIMD 指令虽然非常高效,但是使用起来却很麻烦。这主要是因为不同的 CPU 所支持的 SIMD 指令可能不同。一般来说,越新的 SIMD 指令,它所支持的寄存器长度越大,功能也越强。
目前几乎所有的 X86_64 平台上的 CPU 都支持 SSE 指令集,绝大部分支持 AVX 指令集,三四年前量产的 CPU 支持 AVX2 指令集,最近少数服务器端 CPU 支持 AVX512 指令集。AVX512 指令集的提升巨大,因为它不仅将寄存器长度增大至 512 字节,而且引入了非常多的新指令。
为了能够尽量利用新的 SIMD 指令,我们需要提前知道程序会被运行在支持哪些指令集的 CPU 上,并在编译过程中选择所支持的 SIMD 指令中最新的那些。
或者,我们可以在编译结果中纳入同一段代码的不同版本,每个版本使用不同的 SIMD 指令。在运行过程中,程序将根据 CPU 所支持的指令集,来选择执行哪一个版本。
虽然程序中包含当前 CPU 可能不支持的指令,但是只要不执行到这些指令,程序便不会出问题。如果不小心执行到这些不支持的指令,CPU 会触发一个中断,并向当前进程发出
sigill
信号。
不过,这对于使用即时编译技术的 Java 虚拟机来说,并不是一个大问题。
我们知道,Java 虚拟机所执行的 Java 字节码是平台无关的。它首先会被解释执行,而后反复执行的部分才会被 Java 虚拟机即时编译为机器码。换句话说,在进行即时编译的时候,Java 虚拟机已经运行在目标 CPU 之上,可以轻易地得知其所支持的指令集。
然而,Java 字节码的平台无关性却引发了另一个问题,那便是 Java 程序无法像 C++ 程序那样,直接使用由 Intel 提供的,将被替换为具体 SIMD 指令的 intrinsic 方法 [2]。
HotSpot 虚拟机提供的替代方案是 Java 层面的 intrinsic 方法,这些 intrinsic 方法的语义要比单个 SIMD 指令复杂得多。在运行过程中,HotSpot 虚拟机将根据当前体系架构来决定是否将对该 intrinsic 方法的调用替换为另一高效的实现。如果不,则使用原本的 Java 实现。
举个例子,Java 8 中Arrays.equals(int[], int[])
的实现将逐个比较 int 数组中的元素。
public static boolean equals(int[] a, int[] a2) {
if (a==a2)
return true;
if (a==null || a2==null)
return false;
int length = a.length;
if (a2.length != length)
return false;
// 关键循环
for (int i=0; i<length; i++)
if (a[i] != a2[i])
return false;
return true;
}
对应的 intrinsic 高效实现会将数组的多个元素加载至 XMM/YMM/ZMM 寄存器中,然后进行按位比较。如果两个数组相同,那么其中若干个元素合并而成的值也相同,其按位比较也应成功。反过来,如果按位比较失败,则说明两个数组不同。
使用 SIMD 指令的 HotSpot intrinsic 是虚拟机开发人员根据其语义定制的,因而性能相当优越。
不过,由于开发成本及维护成本较高,这种类型的 intrinsic 屈指可数,如用于复制数组的System.arraycopy
和Arrays.copyOf
,用于比较数组的Arrays.equals
,以及 Java 9 新加入的Arrays.compare
和Arrays.mismatch
,以及字符串相关的一些方法String.indexOf
、StringLatin1.inflate
。
Arrays.copyOf
将调用System.arraycopy
,实际上只有后者是 intrinsic。在 Java 9 之后,数组比较真正的 intrinsic 是ArraySupports.vectorizedMismatch
方法,而Arrays.equals
、Arrays.compare
和Arrays.mismatch
将调用至该方法中。
另外,这些 intrinsic 方法只能做到点覆盖,在不少情况下,应用程序并不会用到这些 intrinsic 的语义,却又存在向量化优化的机会。这个时候,我们便需要借助即时编译器中的自动向量化(auto vectorization)。
自动向量化
即时编译器的自动向量化将针对能够展开的计数循环,进行向量化优化。如前面介绍过的这段代码,即时编译器便能够自动将其展开优化成使用PADDD
指令的向量加法。
void foo(int[] a, int[] b, int[] c) {
for (int i = 0; i < c.length; i++) {
c[i] = a[i] + b[i];
}
}
关于计数循环的判定,我在上一篇介绍循环优化时已经讲解过了,这里我补充几点自动向量化的条件。
- 循环变量的增量应为 1,即能够遍历整个数组。
- 循环变量不能为 long 类型,否则 C2 无法将循环识别为计数循环。
- 循环迭代之间最好不要有数据依赖,例如出现类似于
a[i] = a[i-1]
的语句。当循环展开之后,循环体内存在数据依赖,那么 C2 无法进行自动向量化。 - 循环体内不要有分支跳转。
- 不要手工进行循环展开。如果 C2 无法自动展开,那么它也将无法进行自动向量化。
我们可以看到,自动向量化的条件较为苛刻。而且,C2 支持的整数向量化操作并不多,据我所致只有向量加法,向量减法,按位与、或、异或,以及批量移位和批量乘法。C2 还支持向量点积的自动向量化,即两两相乘再求和,不过这需要多条 SIMD 指令才能完成,因此并不是十分高效。
为了解决向量化 intrinsic 以及自动向量化覆盖面过窄的问题,我们在 OpenJDK 的 Paname 项目 [3] 中尝试引入开发人员可控的向量化抽象。
该抽象将提供一套通用的跨平台 API,让 Java 程序能够定义诸如IntVector<S256Bits>
的向量,并使用由它提供的一系列向量化 intrinsic 方法。即时编译器负责将这些 intrinsic 的调用转换为符合当前体系架构 /CPU 的 SIMD 指令。如果你感兴趣的话,可以参考 Vladimir Ivanov 今年在 JVMLS 上的演讲 [4]。
总结与实践
今天我介绍了即时编译器中的向量化优化。
向量化优化借助的是 CPU 的 SIMD 指令,即通过单条指令控制多组数据的运算。它被称为 CPU 指令级别的并行。
HotSpot 虚拟机运用向量化优化的方式有两种。第一种是使用 HotSpot intrinsic,在调用特定方法的时候替换为使用了 SIMD 指令的高效实现。Intrinsic 属于点覆盖,只有当应用程序明确需要这些 intrinsic 的语义,才能够获得由它带来的性能提升。
第二种是依赖即时编译器进行自动向量化,在循环展开优化之后将不同迭代的运算合并为向量运算。自动向量化的触发条件较为苛刻,因此也无法覆盖大多数用例。
4.27 - CH27-注解处理器
注解(annotation)是 Java 5 引入的,用来为类、方法、字段、参数等 Java 结构提供额外信息的机制。我先举个例子,比如,Java 核心类库中的@Override
注解是被用来声明某个实例方法重写了父类的同名同参数类型的方法。
package java.lang;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
@Override
注解本身被另外两个元注解(即作用在注解上的注解)所标注。其中,@Target
用来限定目标注解所能标注的 Java 结构,这里@Override
便只能被用来标注方法。
@Retention
则用来限定当前注解生命周期。注解共有三种不同的生命周期:SOURCE
,CLASS
或RUNTIME
,分别表示注解只出现在源代码中,只出现在源代码和字节码中,以及出现在源代码、字节码和运行过程中。
这里@Override
便只能出现在源代码中。一旦标注了@Override
的方法所在的源代码被编译为字节码,该注解便会被擦除。
我们不难猜到,@Override
仅对 Java 编译器有用。事实上,它会为 Java 编译器引入了一条新的编译规则,即如果所标注的方法不是 Java 语言中的重写方法,那么编译器会报错。而当编译完成时,它的使命也就结束了。
我们知道,Java 的注解机制允许开发人员自定义注解。这些自定义注解同样可以为 Java 编译器添加编译规则。不过,这种功能需要由开发人员提供,并且以插件的形式接入 Java 编译器中,这些插件我们称之为注解处理器(annotation processor)。
除了引入新的编译规则之外,注解处理器还可以用于修改已有的 Java 源文件(不推荐),或者生成新的 Java 源文件。下面,我将用几个案例来详细阐述注解处理器的这些功能,以及它背后的原理。
注解处理器的原理
在介绍注解处理器之前,我们先来了解一下 Java 编译器的工作流程。
如上图所示 出处 [1],Java 源代码的编译过程可分为三个步骤:
- 将源文件解析为抽象语法树;
- 调用已注册的注解处理器;
- 生成字节码。
如果在第 2 步调用注解处理器过程中生成了新的源文件,那么编译器将重复第 1、2 步,解析并且处理新生成的源文件。每次重复我们称之为一轮(Round)。
也就是说,第一轮解析、处理的是输入至编译器中的已有源文件。如果注解处理器生成了新的源文件,则开始第二轮、第三轮,解析并且处理这些新生成的源文件。当注解处理器不再生成新的源文件,编译进入最后一轮,并最终进入生成字节码的第 3 步。
package foo;
import java.lang.annotation.*;
@Target({ ElementType.TYPE, ElementType.FIELD })
@Retention(RetentionPolicy.SOURCE)
public @interface CheckGetter {
}
在上面这段代码中,我定义了一个注解@CheckGetter
。它既可以用来标注类,也可以用来标注字段。此外,它和@Override
相同,其生命周期被限定在源代码中。
下面我们来实现一个处理@CheckGetter
注解的处理器。它将遍历被标注的类中的实例字段,并检查有没有相应的getter
方法。
public interface Processor {
void init(ProcessingEnvironment processingEnv);
Set<String> getSupportedAnnotationTypes();
SourceVersion getSupportedSourceVersion();
boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv);
...
}
所有的注解处理器类都需要实现接口Processor
。该接口主要有四个重要方法。其中,init
方法用来存放注解处理器的初始化代码。之所以不用构造器,是因为在 Java 编译器中,注解处理器的实例是通过反射 API 生成的。也正是因为使用反射 API,每个注解处理器类都需要定义一个无参数构造器。
通常来说,当编写注解处理器时,我们不声明任何构造器,并依赖于 Java 编译器,为之插入一个无参数构造器。而具体的初始化代码,则放入init
方法之中。
在剩下的三个方法中,getSupportedAnnotationTypes
方法将返回注解处理器所支持的注解类型,这些注解类型只需用字符串形式表示即可。
getSupportedSourceVersion
方法将返回该处理器所支持的 Java 版本,通常,这个版本需要与你的 Java 编译器版本保持一致;而process
方法则是最为关键的注解处理方法。
JDK 提供了一个实现Processor
接口的抽象类AbstractProcessor
。该抽象类实现了init
、getSupportedAnnotationTypes
和getSupportedSourceVersion
方法。
它的子类可以通过@SupportedAnnotationTypes
和@SupportedSourceVersion
注解来声明所支持的注解类型以及 Java 版本。
下面这段代码便是@CheckGetter
注解处理器的实现。由于我使用了 Java 10 的编译器,因此将支持版本设置为SourceVersion.RELEASE_10
。
package bar;
import java.util.Set;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.util.ElementFilter;
import javax.tools.Diagnostic.Kind;
import foo.CheckGetter;
@SupportedAnnotationTypes("foo.CheckGetter")
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class CheckGetterProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
// TODO: annotated ElementKind.FIELD
for (TypeElement annotatedClass : ElementFilter.typesIn(roundEnv.getElementsAnnotatedWith(CheckGetter.class))) {
for (VariableElement field : ElementFilter.fieldsIn(annotatedClass.getEnclosedElements())) {
if (!containsGetter(annotatedClass, field.getSimpleName().toString())) {
processingEnv.getMessager().printMessage(Kind.ERROR,
String.format("getter not found for '%s.%s'.", annotatedClass.getSimpleName(), field.getSimpleName()));
}
}
}
return true;
}
private static boolean containsGetter(TypeElement typeElement, String name) {
String getter = "get" + name.substring(0, 1).toUpperCase() + name.substring(1).toLowerCase();
for (ExecutableElement executableElement : ElementFilter.methodsIn(typeElement.getEnclosedElements())) {
if (!executableElement.getModifiers().contains(Modifier.STATIC)
&& executableElement.getSimpleName().toString().equals(getter)
&& executableElement.getParameters().isEmpty()) {
return true;
}
}
return false;
}
}
该注解处理器仅重写了process
方法。这个方法将接收两个参数,分别代表该注解处理器所能处理的注解类型,以及囊括当前轮生成的抽象语法树的RoundEnvironment
。
由于该处理器针对的注解仅有@CheckGetter
一个,而且我们并不会读取注解中的值,因此第一个参数并不重要。在代码中,我直接使用了
`roundEnv.getElementsAnnotatedWith(CheckGetter.class)`
来获取所有被@CheckGetter
注解的类(以及字段)。
process
方法涉及各种不同类型的Element
,分别指代 Java 程序中的各个结构。如TypeElement
指代类或者接口,VariableElement
指代字段、局部变量、enum 常量等,ExecutableElement
指代方法或者构造器。
package foo; // PackageElement
class Foo { // TypeElement
int a; // VariableElement
static int b; // VariableElement
Foo () {} // ExecutableElement
void setA ( // ExecutableElement
int newA // VariableElement
) {}
}
这些结构之间也有从属关系,如上面这段代码所示 (出处 [2])。我们可以通过TypeElement.getEnclosedElements
方法,获得上面这段代码中Foo
类的字段、构造器以及方法。
我们也可以通过ExecutableElement.getParameters
方法,获得setA
方法的参数。具体这些Element
类都有哪些 API,你可以参考它们的 Javadoc[3]。
在将该注解处理器编译成 class 文件后,我们便可以将其注册为 Java 编译器的插件,并用来处理其他源代码。注册的方法主要有两种。第一种是直接使用 javac 命令的-processor
参数,如下所示:
$ javac -cp /CLASSPATH/TO/CheckGetterProcessor -processor bar.CheckGetterProcessor Foo.java
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
1 error
第二种则是将注解处理器编译生成的 class 文件压缩入 jar 包中,并在 jar 包的配置文件中记录该注解处理器的包名及类名,即bar.CheckGetterProcessor
。
(具体路径及配置文件名为`META-INF/services/javax.annotation.processing.Processor`)
当启动 Java 编译器时,它会寻找 classpath 路径上的 jar 包是否包含上述配置文件,并自动注册其中记录的注解处理器。
$ javac -cp /PATH/TO/CheckGetterProcessor.jar Foo.java
error: Class 'Foo' is annotated as @CheckGetter, but field 'a' is without getter
1 error
此外,我们还可以在 IDE 中配置注解处理器。这里我就不过多演示了,感兴趣的同学可以自行搜索。
利用注解处理器生成源代码
前面提到,注解处理器可以用来修改已有源代码或者生成源代码。
确切地说,注解处理器并不能真正地修改已有源代码。这里指的是修改由 Java 源代码生成的抽象语法树,在其中修改已有树节点或者插入新的树节点,从而使生成的字节码发生变化。
对抽象语法树的修改涉及了 Java 编译器的内部 API,这部分很可能随着版本变更而失效。因此,我并不推荐这种修改方式。
如果你感兴趣的话,可以参考 [Project Lombok][4]。这个项目自定义了一系列注解,并根据注解的内容来修改已有的源代码。例如它提供了@Getter
和@Setter
注解,能够为程序自动添加getter
以及setter
方法。有关对使用内部 API 的讨论,你可以参考 [这篇博客][5],以及 [Lombok 的回应][6]。
用注解处理器来生成源代码则比较常用。我们以前介绍过的压力测试 jcstress,以及接下来即将介绍的 JMH 工具,都是依赖这种方式来生成测试代码的。
package foo;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Adapt {
Class<?> value();
}
在上面这段代码中,我定义了一个注解@Adapt
。这个注解将接收一个Class
类型的参数value
(如果注解类仅包含一个名为value
的参数时,那么在使用注解时,我们可以省略value=
),具体用法如这段代码所示。
// Bar.java
package test;
import java.util.function.IntBinaryOperator;
import foo.Adapt;
public class Bar {
@Adapt(IntBinaryOperator.class)
public static int add(int a, int b) {
return a + b;
}
}
接下来,我们来实现一个处理@Adapt
注解的处理器。该处理器将生成一个新的源文件,实现参数value
所指定的接口,并且调用至被该注解所标注的方法之中。具体的实现代码比较长,建议你在网页端观看。
package bar;
import java.io.*;
import java.util.Set;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import javax.tools.JavaFileObject;
import javax.tools.Diagnostic.Kind;
@SupportedAnnotationTypes("foo.Adapt")
@SupportedSourceVersion(SourceVersion.RELEASE_10)
public class AdaptProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
if (!"foo.Adapt".equals(annotation.getQualifiedName().toString())) {
continue;
}
ExecutableElement targetAsKey = getExecutable(annotation, "value");
for (ExecutableElement annotatedMethod : ElementFilter.methodsIn(roundEnv.getElementsAnnotatedWith(annotation))) {
if (!annotatedMethod.getModifiers().contains(Modifier.PUBLIC)) {
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt on non-public method");
continue;
}
if (!annotatedMethod.getModifiers().contains(Modifier.STATIC)) {
// TODO support non-static methods
continue;
}
TypeElement targetInterface = getAnnotationValueAsTypeElement(annotatedMethod, annotation, targetAsKey);
if (targetInterface.getKind() != ElementKind.INTERFACE) {
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt with non-interface input");
continue;
}
TypeElement enclosingType = getTopLevelEnclosingType(annotatedMethod);
createAdapter(enclosingType, annotatedMethod, targetInterface);
}
}
return true;
}
private void createAdapter(TypeElement enclosingClass, ExecutableElement annotatedMethod,
TypeElement targetInterface) {
PackageElement packageElement = (PackageElement) enclosingClass.getEnclosingElement();
String packageName = packageElement.getQualifiedName().toString();
String className = enclosingClass.getSimpleName().toString();
String methodName = annotatedMethod.getSimpleName().toString();
String adapterName = className + "_" + methodName + "Adapter";
ExecutableElement overriddenMethod = getFirstNonDefaultExecutable(targetInterface);
try {
Filer filer = processingEnv.getFiler();
JavaFileObject sourceFile = filer.createSourceFile(packageName + "." + adapterName, new Element[0]);
try (PrintWriter out = new PrintWriter(sourceFile.openWriter())) {
out.println("package " + packageName + ";");
out.println("import " + targetInterface.getQualifiedName() + ";");
out.println();
out.println("public class " + adapterName + " implements " + targetInterface.getSimpleName() + " {");
out.println(" @Override");
out.println(" public " + overriddenMethod.getReturnType() + " " + overriddenMethod.getSimpleName()
+ formatParameter(overriddenMethod, true) + " {");
out.println(" return " + className + "." + methodName + formatParameter(overriddenMethod, false) + ";");
out.println(" }");
out.println("}");
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private ExecutableElement getExecutable(TypeElement annotation, String methodName) {
for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
if (methodName.equals(method.getSimpleName().toString())) {
return method;
}
}
processingEnv.getMessager().printMessage(Kind.ERROR, "Incompatible @Adapt.");
return null;
}
private ExecutableElement getFirstNonDefaultExecutable(TypeElement annotation) {
for (ExecutableElement method : ElementFilter.methodsIn(annotation.getEnclosedElements())) {
if (!method.isDefault()) {
return method;
}
}
processingEnv.getMessager().printMessage(Kind.ERROR,
"Target interface should declare at least one non-default method.");
return null;
}
private TypeElement getAnnotationValueAsTypeElement(ExecutableElement annotatedMethod, TypeElement annotation,
ExecutableElement annotationFunction) {
TypeMirror annotationType = annotation.asType();
for (AnnotationMirror annotationMirror : annotatedMethod.getAnnotationMirrors()) {
if (processingEnv.getTypeUtils().isSameType(annotationMirror.getAnnotationType(), annotationType)) {
AnnotationValue value = annotationMirror.getElementValues().get(annotationFunction);
if (value == null) {
processingEnv.getMessager().printMessage(Kind.ERROR, "Unknown @Adapt target");
continue;
}
TypeMirror targetInterfaceTypeMirror = (TypeMirror) value.getValue();
return (TypeElement) processingEnv.getTypeUtils().asElement(targetInterfaceTypeMirror);
}
}
processingEnv.getMessager().printMessage(Kind.ERROR, "@Adapt should contain target()");
return null;
}
private TypeElement getTopLevelEnclosingType(ExecutableElement annotatedMethod) {
TypeElement enclosingType = null;
Element enclosing = annotatedMethod.getEnclosingElement();
while (enclosing != null) {
if (enclosing.getKind() == ElementKind.CLASS) {
enclosingType = (TypeElement) enclosing;
} else if (enclosing.getKind() == ElementKind.PACKAGE) {
break;
}
enclosing = enclosing.getEnclosingElement();
}
return enclosingType;
}
private String formatParameter(ExecutableElement method, boolean includeType) {
StringBuilder builder = new StringBuilder();
builder.append('(');
String separator = "";
for (VariableElement parameter : method.getParameters()) {
builder.append(separator);
if (includeType) {
builder.append(parameter.asType());
builder.append(' ');
}
builder.append(parameter.getSimpleName());
separator = ", ";
}
builder.append(')');
return builder.toString();
}
}
在这个注解处理器实现中,我们将读取注解中的值,因此我将使用process
方法的第一个参数,并通过它获得被标注方法对应的@Adapt
注解中的value
值。
之所以采用这种麻烦的方式,是因为value
值属于Class
类型。在编译过程中,被编译代码中的Class
常量未必被加载进 Java 编译器所在的虚拟机中。因此,我们需要通过process
方法的第一个参数,获得value
所指向的接口的抽象语法树,并据此生成源代码。
生成源代码的方式实际上非常容易理解。我们可以通过Filer.createSourceFile
方法获得一个类似于文件的概念,并通过PrintWriter
将具体的内容一一写入即可。
当将该注解处理器作为插件接入 Java 编译器时,编译前面的test/Bar.java
将生成下述代码,并且触发新一轮的编译。
package test;
import java.util.function.IntBinaryOperator;
public class Bar_addAdapter implements IntBinaryOperator {
@Override
public int applyAsInt(int arg0, int arg1) {
return Bar.add(arg0, arg1);
}
}
注意,该注解处理器没有处理所编译的代码包名为空的情况。
总结与实践
今天我介绍了 Java 编译器的注解处理器。
注解处理器主要有三个用途。一是定义编译规则,并检查被编译的源文件。二是修改已有源代码。三是生成新的源代码。其中,第二种涉及了 Java 编译器的内部 API,因此并不推荐。第三种较为常见,是 OpenJDK 工具 jcstress,以及 JMH 生成测试代码的方式。
Java 源代码的编译过程可分为三个步骤,分别为解析源文件生成抽象语法树,调用已注册的注解处理器,和生成字节码。如果在第 2 步中,注解处理器生成了新的源代码,那么 Java 编译器将重复第 1、2 步,直至不再生成新的源代码。
4.28 - CH28-JMH-上
大家或许都看到过一些不严谨的性能测试,以及基于这些测试结果得出的令人匪夷所思的结论。
static int foo() {
int i = 0;
while (i < 1_000_000_000) {
i++;
}
return i;
}
举个例子,上面这段代码中的foo
方法,将进行 10^9 次加法操作及跳转操作。
不少开发人员,包括我在介绍反射调用那一篇中所做的性能测试,都使用了下面这段代码的测量方式,即通过System.nanoTime
或者System.currentTimeMillis
来测量每若干个操作(如连续调用 1000 次foo
方法)所花费的时间。
public class LoopPerformanceTest {
static int foo() { ... }
public static void main(String[] args) {
// warmup
for (int i = 0; i < 20_000; i++) {
foo();
}
// measurement
long current = System.nanoTime();
for (int i = 1; i <= 10_000; i++) {
foo();
if (i % 1000 == 0) {
long temp = System.nanoTime();
System.out.println(temp - current);
current = System.nanoTime();
}
}
}
}
这种测量方式实际上过于理性化,忽略了 Java 虚拟机、操作系统,乃至硬件系统所带来的影响。
性能测试的坑
关于 Java 虚拟机所带来的影响,我们在前面的篇章中已经介绍过不少,如 Java 虚拟机堆空间的自适配,即时编译等。
在上面这段代码中,真正进行测试的代码(即// measurement
后的代码)由于循环次数不多,属于冷循环,没有能触发 OSR 编译。
也就是说,我们会在main
方法中解释执行,然后调用foo
方法即时编译生成的机器码中。这种混杂了解释执行以及即时编译生成代码的测量方式,其得到的数据含义不明。
有同学认为,我们可以假设foo
方法耗时较长(毕竟 10^9 次加法),因此main
方法的解释执行并不会对最终计算得出的性能数据造成太大影响。上面这段代码在我的机器上测出的结果是,每 1000 次foo
方法调用在 20 微秒左右。
这是否意味着,我这台机器的 CPU 已经远超它的物理限制,其频率达到 100,000,000 GHz 了。(假设循环主体就两条指令,每时钟周期指令数 [1] 为 1。)这显然是不可能的,目前 CPU 单核的频率大概在 2-5 GHz 左右,再怎么超频也不可能提升七八个数量级。
你应该能够猜到,这和即时编译器的循环优化有关。下面便是foo
方法的编译结果。我们可以看到,它将直接返回 10^9,而不是循环 10^9 次,并在循环中重复进行加法。
0x8aa0: sub rsp,0x18 // 创建方法栈桢
0x8aa7: mov QWORD PTR [rsp+0x10],rbp // 无关指令
0x8aac: mov eax,0x3b9aca00 // return 10^9
0x8ab1: add rsp,0x10 // 弹出方法栈桢
0x8ab5: pop rbp // 无关指令
0x8ab6: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x8aba: test DWORD PTR [r10],eax // 安全点测试
0x8abd: ret
之前我忘记解释所谓的”无关指令“是什么意思。我指的是该指令和具体的代码逻辑无关。即时编译器生成的代码可能会将 RBP 寄存器作为通用寄存器,从而是寄存器分配算法有更多的选择。由于调用者(caller)未必保存了 RBP 寄存器的值,所以即时编译器会在进入被调用者(callee)时保存 RBP 的值,并在退出被调用者时复原 RBP 的值。
static int foo() {
int i = 0;
while (i < 1_000_000_000) {
i++;
}
return i;
}
// 优化为
static int foo() {
return 1_000_000_000;
}
该循环优化并非循环展开。在默认情况下,即时编译器仅能将循环展开 60 次(对应虚拟机参数-XX:LoopUnrollLimit
)。实际上,在介绍循环优化那篇文章中,我并没有提及这个优化。因为该优化实在是太过于简单,几乎所有开发人员都能够手工对其进行优化。
在即时编译器中,它是一个基于计数循环的优化。我们也已经学过计数循环的知识。也就是说,只要将循环变量i
改为 long 类型,便可以“避免”这个优化。
关于操作系统和硬件系统所带来的影响,一个较为常见的例子便是电源管理策略。在许多机器,特别是笔记本上,操作系统会动态配置 CPU 的频率。而 CPU 的频率又直接影响到性能测试的数据,因此短时间的性能测试得出的数据未必可靠。
例如我的笔记本,在刚开始进行性能评测时,单核频率可以达到 4.0 GHz。而后由于 CPU 温度升高,频率便被限制在 3.0 GHz 了。
除了电源管理之外,CPU 缓存、分支预测器 [2],以及超线程技术 [3],都会对测试结果造成影响。
就 CPU 缓存而言,如果程序的数据本地性较好,那么它的性能指标便会非常好;如果程序存在 false sharing 的问题,即几个线程写入内存中属于同一缓存行的不同部分,那么它的性能指标便会非常糟糕。
超线程技术是另一个可能误导性能测试工具的因素。我们知道,超线程技术将为每个物理核心虚拟出两个虚拟核心,从而尽可能地提高物理核心的利用率。如果性能测试的两个线程被安排在同一物理核心上,那么得到的测试数据显然要比被安排在不同物理核心上的数据糟糕得多。
总而言之,性能基准测试存在着许多深坑(pitfall)。然而,除了性能测试专家外,大多数开发人员都没有足够全面的知识,能够绕开这些坑,因而得出的性能测试数据很有可能是有偏差的(biased)。
下面我将介绍 OpenJDK 中的开源项目 JMH[4](Java Microbenchmark Harness)。JMH 是一个面向 Java 语言或者其他 Java 虚拟机语言的性能基准测试框架。它针对的是纳秒级别(出自官网介绍,个人觉得精确度没那么高)、微秒级别、毫秒级别,以及秒级别的性能测试。
由于许多即时编译器的开发人员参与了该项目,因此 JMH 内置了许多功能来控制即时编译器的优化。对于其他影响性能评测的因素,JMH 也提供了不少策略来降低影响,甚至是彻底解决。
因此,使用这个性能基准测试框架的开发人员,可以将精力完全集中在所要测试的业务逻辑,并以最小的代价控制除了业务逻辑之外的可能影响性能的因素。
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts. Do not assume the numbers tell you what you want them to tell.
不过,JMH 也不能完美解决性能测试数据的偏差问题。它甚至会在每次运行的输出结果中打印上述语句,所以,JMH 的开发人员也给出了一个小忠告:我们开发人员不要轻信 JMH 的性能测试数据,不要基于这些数据乱下结论。
通常来说,性能基准测试的结果反映的是所测试的业务逻辑在所运行的 Java 虚拟机,操作系统,硬件系统这一组合上的性能指标,而根据这些性能指标得出的通用结论则需要经过严格论证。
在理解(或忽略)了 JMH 的忠告后,我们下面便来看看如何使用 JMH。
生成 JMH 项目
JMH 的使用方式并不复杂。我们可以借助 JMH 部署在 maven 上的 archetype,生成预设好依赖关系的 maven 项目模板。具体的命令如下所示:
$ mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.21
$ cd test
该命令将在当前目录下生成一个test
文件夹(对应参数-DartifactId=test
,可更改),其中便包含了定义该 maven 项目依赖的pom.xml
文件,以及自动生成的测试文件src/main/org/sample/MyBenchmark.java
(这里org/sample
对应参数-DgroupId=org.sample
,可更改)。后者的内容如下所示:
/*
* Copyright ...
*/
package org.sample;
import org.openjdk.jmh.annotations.Benchmark;
public class MyBenchmark {
@Benchmark
public void testMethod() {
// This is a demo/sample template for building your JMH benchmarks. Edit as needed.
// Put your benchmark code here.
}
}
这里面,类名MyBenchmark
以及方法名testMethod
并不重要,你可以随意更改。真正重要的是@Benchmark
注解。被它标注的方法,便是 JMH 基准测试的测试方法。该测试方法默认是空的。我们可以填入需要进行性能测试的业务逻辑。
举个例子,我们可以测量新建异常对象的性能,如下述代码所示:
@Benchmark
public void testMethod() {
new Exception();
}
通常来说,我们不应该使用这种貌似会被即时编译器优化掉的代码(在下篇中我会介绍 JMH 的Blackhole
功能)。
不过,我们已经学习过逃逸分析了,知道 native 方法调用的调用者或者参数会被识别为逃逸。而Exception
的构造器将间接调用至 native 方法fillInStackTrace
中,并且该方法调用的调用者便是新建的Exception
对象。因此,逃逸分析将判定该新建对象逃逸,而即时编译器也无法优化掉原本的新建对象操作。
当Exception
的构造器返回时,Java 虚拟机将不再拥有指向这一新建对象的引用。因此,该新建对象可以被垃圾回收。
编译和运行 JMH 项目
在上一篇介绍注解处理器时,我曾提到过,JMH 正是利用注解处理器 [5] 来自动生成性能测试的代码。实际上,除了@Benchmark
之外,JMH 的注解处理器还将处理所有位于org.openjdk.jmh.annotations
包 [6] 下的注解。(其他注解我们会在下一篇中详细介绍。)
我们可以运行mvn compile
命令来编译这个 maven 项目。该命令将生成target
文件夹,其中的generated-sources
目录便存放着由 JMH 的注解处理器所生成的 Java 源代码:
$ mvn compile
$ ls target/generated-sources/annotations/org/sample/generated/
MyBenchmark_jmhType.java MyBenchmark_jmhType_B1.java MyBenchmark_jmhType_B2.java MyBenchmark_jmhType_B3.java MyBenchmark_testMethod_jmhTest.java
在这些源代码里,所有以MyBenchmark_jmhType
为前缀的 Java 类都继承自MyBenchmark
。这是注解处理器的常见用法,即通过生成子类来将注解所带来的额外语义扩张成方法。
具体来说,它们之间的继承关系是MyBenchmark_jmhType -> B3 -> B2 -> B1 -> MyBenchmark
(这里A -> B
代表 A 继承 B)。其中,B2 存放着 JMH 用来控制基准测试的各项字段。
为了避免这些控制字段对MyBenchmark
类中的字段造成 false sharing 的影响,JMH 生成了 B1 和 B3,分别存放了 256 个 boolean 字段,从而避免 B2 中的字段与MyBenchmark
类、MyBenchmark_jmhType
类中的字段(或内存里下一个对象中的字段)会出现在同一缓存行中。
之所以不能在同一类中安排这些字段,是因为 Java 虚拟机的字段重排列。而类之间的继承关系,便可以避免不同类所包含的字段之间的重排列。
除了这些jmhType
源代码外,generated-sources
目录还存放着真正的性能测试代码MyBenchmark_testMethod_jmhTest.java
。当进行性能测试时,Java 虚拟机所运行的代码很有可能便是这一个源文件中的热循环经过 OSR 编译过后的代码。
在通过 CompileCommand 分析即时编译后的机器码时,我们需要关注的其实是
MyBenchmark_testMethod_jmhTest
中的方法。
由于这里面的内容过于复杂,我将在下一篇中介绍影响该生成代码的众多功能性注解,这里就不再详细进行介绍了。
接下来,我们可以运行mvn package
命令,将编译好的 class 文件打包成 jar 包。生成的 jar 包同样位于target
目录下,其名字为benchmarks.jar
。jar 包里附带了一系列配置文件,如下所示:
$ mvn package
$ jar tf target/benchmarks.jar META-INF
META-INF/MANIFEST.MF
META-INF/
META-INF/BenchmarkList
META-INF/CompilerHints
META-INF/maven/
META-INF/maven/org.sample/
META-INF/maven/org.sample/test/
META-INF/maven/org.sample/test/pom.xml
META-INF/maven/org.sample/test/pom.properties
META-INF/maven/org.openjdk.jmh/
META-INF/maven/org.openjdk.jmh/jmh-core/
META-INF/maven/org.openjdk.jmh/jmh-core/pom.xml
META-INF/maven/org.openjdk.jmh/jmh-core/pom.properties
META-INF/maven/net.sf.jopt-simple/
META-INF/maven/net.sf.jopt-simple/jopt-simple/
META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.xml
META-INF/maven/net.sf.jopt-simple/jopt-simple/pom.properties
META-INF/LICENSE.txt
META-INF/NOTICE.txt
META-INF/maven/org.apache.commons/
META-INF/maven/org.apache.commons/commons-math3/
META-INF/maven/org.apache.commons/commons-math3/pom.xml
META-INF/maven/org.apache.commons/commons-math3/pom.properties
$ unzip -c target/benchmarks.jar META-INF/MANIFEST.MF
Archive: target/benchmarks.jar
inflating: META-INF/MANIFEST.MF
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Apache Maven 3.5.4
Built-By: zhengy
Build-Jdk: 10.0.2
Main-Class: org.openjdk.jmh.Main
$ unzip -c target/benchmarks.jar META-INF/BenchmarkList
Archive: target/benchmarks.jar
inflating: META-INF/BenchmarkList
JMH S 22 org.sample.MyBenchmark S 51 org.sample.generated.MyBenchmark_testMethod_jmhTest S 10 testMethod S 10 Throughput E A 1 1 1 E E E E E E E E E E E E E E E E E
$ unzip -c target/benchmarks.jar META-INF/CompilerHints
Archive: target/benchmarks.jar
inflating: META-INF/CompilerHints
dontinline,*.*_all_jmhStub
dontinline,*.*_avgt_jmhStub
dontinline,*.*_sample_jmhStub
dontinline,*.*_ss_jmhStub
dontinline,*.*_thrpt_jmhStub
inline,org/sample/MyBenchmark.testMethod
这里我展示了其中三个比较重要的配置文件。
MANIFEST.MF
中指定了该 jar 包的默认入口,即org.openjdk.jmh.Main
[7]。BenchmarkList
中存放了测试配置。该配置是根据MyBenchmark.java
里的注解自动生成的,具体我会在下一篇中详细介绍源代码中如何配置。CompilerHints
中存放了传递给 Java 虚拟机的-XX:CompileCommandFile
参数的内容。它规定了无法内联以及必须内联的几个方法,其中便有存放业务逻辑的测试方法testMethod
。
在编译MyBenchmark_testMethod_jmhTest
类中的测试方法时,JMH 会让即时编译器强制内联对MyBenchmark.testMethod
的方法调用,以避免调用开销。
打包生成的 jar 包可以直接运行。具体指令如下所示:
$ java -jar target/benchmarks.jar
WARNING: An illegal reflective access operation has occurred
...
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 25 1004801,393 ± 4055,462 ops/s
这里 JMH 会有非常多的输出,具体内容我会在下一篇中进行讲解。
输出的最后便是本次基准测试的结果。其中比较重要的两项指标是Score
和Error
,分别代表本次基准测试的平均吞吐量(每秒运行testMethod
方法的次数)以及误差范围。例如,这里的结果说明本次基准测试平均每秒生成 10^6 个异常实例,误差范围大致在 4000 个异常实例。
总结与实践
今天我介绍了 OpenJDK 的性能基准测试项目 JMH。
Java 程序的性能测试存在着许多深坑,有来自 Java 虚拟机的,有来自操作系统的,甚至有来自硬件系统的。如果没有足够的知识,那么性能测试的结果很有可能是有偏差的。
性能基准测试框架 JMH 是 OpenJDK 中的其中一个开源项目。它内置了许多功能,来规避由 Java 虚拟机中的即时编译器或者其他优化对性能测试造成的影响。此外,它还提供了不少策略来降低来自操作系统以及硬件系统的影响。
开发人员仅需将所要测试的业务逻辑通过@Benchmark
注解,便可以让 JMH 的注解处理器自动生成真正的性能测试代码,以及相应的性能测试配置文件。
4.29 - CH29-JMH-下
@Fork 和 @BenchmarkMode
在上一篇的末尾,我们已经运行过由 JMH 项目编译生成的 jar 包了。下面是它的输出结果:
$ java -jar target/benchmarks.jar
...
# JMH version: 1.21
# VM version: JDK 10.0.2, Java HotSpot(TM) 64-Bit Server VM, 10.0.2+13
# VM invoker: /Library/Java/JavaVirtualMachines/jdk-10.0.2.jdk/Contents/Home/bin/java
# VM options: <none>
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: org.sample.MyBenchmark.testMethod
# Run progress: 0,00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration 1: 1023500,647 ops/s
# Warmup Iteration 2: 1030767,909 ops/s
# Warmup Iteration 3: 1018212,559 ops/s
# Warmup Iteration 4: 1002045,519 ops/s
# Warmup Iteration 5: 1004210,056 ops/s
Iteration 1: 1010251,342 ops/s
Iteration 2: 1005717,344 ops/s
Iteration 3: 1004751,523 ops/s
Iteration 4: 1003034,640 ops/s
Iteration 5: 997003,830 ops/s
# Run progress: 20,00% complete, ETA 00:06:41
# Fork: 2 of 5
...
# Run progress: 80,00% complete, ETA 00:01:40
# Fork: 5 of 5
# Warmup Iteration 1: 988321,959 ops/s
# Warmup Iteration 2: 999486,531 ops/s
# Warmup Iteration 3: 1004856,886 ops/s
# Warmup Iteration 4: 1004810,860 ops/s
# Warmup Iteration 5: 1002332,077 ops/s
Iteration 1: 1011871,670 ops/s
Iteration 2: 1002653,844 ops/s
Iteration 3: 1003568,030 ops/s
Iteration 4: 1002724,752 ops/s
Iteration 5: 1001507,408 ops/s
Result "org.sample.MyBenchmark.testMethod":
1004801,393 ±(99.9%) 4055,462 ops/s [Average]
(min, avg, max) = (992193,459, 1004801,393, 1014504,226), stdev = 5413,926
CI (99.9%): [1000745,931, 1008856,856] (assumes normal distribution)
# Run complete. Total time: 00:08:22
...
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 25 1004801,393 ± 4055,462 ops/s
在上面这段输出中,我们暂且忽略最开始的 Warning 以及打印出来的配置信息,直接看接下来貌似重复的五段输出。
# Run progress: 0,00% complete, ETA 00:08:20
# Fork: 1 of 5
# Warmup Iteration 1: 1023500,647 ops/s
# Warmup Iteration 2: 1030767,909 ops/s
# Warmup Iteration 3: 1018212,559 ops/s
# Warmup Iteration 4: 1002045,519 ops/s
# Warmup Iteration 5: 1004210,056 ops/s
Iteration 1: 1010251,342 ops/s
Iteration 2: 1005717,344 ops/s
Iteration 3: 1004751,523 ops/s
Iteration 4: 1003034,640 ops/s
Iteration 5: 997003,830 ops/s
你应该已经留意到Fork: 1 of 5
的字样。这里指的是 JMH 会 Fork 出一个新的 Java 虚拟机,来运行性能基准测试。
之所以另外启动一个 Java 虚拟机进行性能基准测试,是为了获得一个相对干净的虚拟机环境。
在介绍反射的那篇文章中,我就已经演示过因为类型 profile 被污染,而导致无法内联的情况。使用新的虚拟机,将极大地降低被上述情况干扰的可能性,从而保证更加精确的性能数据。
在介绍虚方法内联的那篇文章中,我讲解过基于类层次分析的完全内联。新启动的 Java 虚拟机,其加载的与测试无关的抽象类子类或接口实现相对较少。因此,具体是否进行完全内联将交由开发人员来决定。
关于这种情况,JMH 提供了一个性能测试案例 [1]。如果你感兴趣的话,可以下载下来自己跑一遍。
除了对即时编译器的影响之外,Fork 出新的 Java 虚拟机还会提升性能数据的准确度。
这主要是因为不少 Java 虚拟机的优化会带来不确定性,例如 TLAB 内存分配(TLAB 的大小会变化),偏向锁、轻量锁算法,并发数据结构等。这些不确定性都可能导致不同 Java 虚拟机中运行的性能测试的结果不同,例如 JMH 这一性能的测试案例 [2]。
在这种情况下,通过运行更多的 Fork,并将每个 Java 虚拟机的性能测试结果平均起来,可以增强最终数据的可信度,使其误差更小。在 JMH 中,你可以通过@Fork
注解来配置,具体如下述代码所示:
@Fork(10)
public class MyBenchmark {
//...
}
让我们回到刚刚的输出结果。每个 Fork 包含了 5 个预热迭代(warmup iteration,如# Warmup Iteration 1: 1023500,647 ops/s
)以及 5 个测试迭代(measurement iteration,如Iteration 1: 1010251,342 ops/s
)。
每个迭代后都跟着一个数据,代表本次迭代的吞吐量,也就是每秒运行了多少次操作(operations/s,或 ops/s)。默认情况下,一次操作指的是调用一次测试方法testMethod
。
除了吞吐量之外,我们还可以输出其他格式的性能数据,例如运行一次操作的平均时间。具体的配置方法以及对应参数如下述代码以及下表所示:
@BenchmarkMode(Mode.AverageTime)
public class MyBenchmark {
//...
}
一般来说,默认使用的吞吐量已足够满足大多数测试需求了。
@Warmup 和 @Measurement
之所以区分预热迭代和测试迭代,是为了在记录性能数据之前,将 Java 虚拟机带至一个稳定状态。
这里的稳定状态,不仅包括测试方法被即时编译成机器码,还包括 Java 虚拟机中各种自适配优化算法能够稳定下来,如前面提到的 TLAB 大小,亦或者是使用传统垃圾回收器时的 Eden 区、Survivor 区和老年代的大小。
一般来说,预热迭代的数目以及每次预热迭代的时间,需要由你根据所要测试的业务逻辑代码来调配。通常的做法便是在首次运行时配置较多次迭代,并监控性能数据达到稳定状态时的迭代数目。
不少性能评测框架都会自动检测稳定状态。它们所采用的算法是计算迭代之间的差值,如果连续几个迭代与前一迭代的差值均小于某个值,便将这几个迭代以及之后的迭代当成稳定状态。
这种做法有一个缺陷,那便是在达到最终稳定状态前,程序可能拥有多个中间稳定状态。例如通过 Java 上的 JavaScript 引擎 Nashorn 运行 JavaScript 代码,便可能出现多个中间稳定状态的情况。(具体可参考 Aleksey Shipilev 的 devoxx 2013 演讲 [3] 的第 21 页。)
总而言之,开发人员需要自行决定预热迭代的次数以及每次迭代的持续时间。
通常来说,我会在保持 5-10 个预热迭代的前提下(这样可以看出是否达到稳定状况),将总的预热时间优化至最少,以便节省性能测试的机器时间。(这在持续集成 / 回归测试的硬件资源跟不上代码提交速度的团队中非常重要。)
当确定了预热迭代的次数以及每次迭代的持续时间之后,我们便可以通过@Warmup
注解来进行配置,如下述代码所示:
@Warmup(iterations=10, time=100, timeUnit=TimeUnit.MILLISECONDS, batchSize=10)
public class MyBenchmark {
//...
}
@Warmup
注解有四个参数,分别为预热迭代的次数iterations
,每次迭代持续的时间time
和timeUnit
(前者是数值,后者是单位。例如上面代码代表的是每次迭代持续 100 毫秒),以及每次操作包含多少次对测试方法的调用batchSize
。
测试迭代可通过@Measurement
注解来进行配置。它的可配置选项和@Warmup
的一致,这里就不再重复了。与预热迭代不同的是,每个 Fork 中测试迭代的数目越多,我们得到的性能数据也就越精确。
@State、@Setup 和 @TearDown
通常来说,我们所要测试的业务逻辑只是整个应用程序中的一小部分,例如某个具体的 web app 请求。这要求在每次调用测试方法前,程序处于准备接收请求的状态。
我们可以把上述场景抽象一下,变成程序从某种状态到另一种状态的转换,而性能测试,便是在收集该转换的性能数据。
JMH 提供了@State
注解,被它标注的类便是程序的状态。由于 JMH 将负责生成这些状态类的实例,因此,它要求状态类必须拥有无参数构造器,以及当状态类为内部类时,该状态类必须是静态的。
JMH 还将程序状态细分为整个虚拟机的程序状态,线程私有的程序状态,以及线程组私有的程序状态,分别对应@State
注解的参数Scope.Benchmark
,Scope.Thread
和Scope.Group
。
需要注意的是,这里的线程组并非 JDK 中的那个概念,而是 JMH 自己定义的概念。具体可以参考@GroupThreads
注解 [4],以及这个案例 [5]。
@State
的配置方法以及状态类的用法如下所示:
public class MyBenchmark {
@State(Scope.Benchmark)
public static class MyBenchmarkState {
String message = "exception";
}
@Benchmark
public void testMethod(MyBenchmarkState state) {
new Exception(state.message);
}
}
我们可以看到,状态类是通过方法参数的方式传入测试方法之中的。JMH 将负责把所构造的状态类实例传入该方法之中。
不过,如果MyBenchmark
被标注为@State
,那么我们可以不用在测试方法中定义额外的参数,而是直接访问MyBenchmark
类中的实例变量。
和 JUnit 测试一样,我们可以在测试前初始化程序状态,在测试后校验程序状态。这两种操作分别对应@Setup
和@TearDown
注解,被它们标注的方法必须是状态类中的方法。
而且,JMH 并不限定状态类中@Setup
方法以及@TearDown
方法的数目。当存在多个@Setup
方法或者@TearDown
方法时,JMH 将按照定义的先后顺序执行。
JMH 对@Setup
方法以及@TearDown
方法的调用时机是可配置的。可供选择的粒度有在整个性能测试前后调用,在每个迭代前后调用,以及在每次调用测试方法前后调用。其中,最后一个粒度将影响测试数据的精度。
这三种粒度分别对应@Setup
和@TearDown
注解的参数Level.Trial
,Level.Iteration
,以及Level.Invocation
。具体的用法如下所示:
public class MyBenchmark {
@State(Scope.Benchmark)
public static class MyBenchmarkState {
int count;
@Setup(Level.Invocation)
public void before() {
count = 0;
}
@TearDown(Level.Invocation)
public void after() {
// Run with -ea
assert count == 1 : "ERROR";
}
}
@Benchmark
public void testMethod(MyBenchmarkState state) {
state.count++;
}
}
即时编译相关功能
JMH 还提供了不少控制即时编译的功能,例如可以控制每个方法内联与否的@CompilerControl
注解 [6]。
另外一个更小粒度的功能则是Blackhole
类。它里边的consume
方法可以防止即时编译器将所传入的值给优化掉。
具体的使用方法便是为被@Benchmark
注解标注了的测试方法增添一个类型为Blackhole
的参数,并且在测试方法的代码中调用其实例方法Blackhole.consume
,如下述代码所示:
@Benchmark
public void testMethod(Blackhole bh) {
bh.consume(new Object()); // prevents escape analysis
}
需要注意的是,它并不会阻止对传入值的计算的优化。举个例子,在下面这段代码中,我将3+4
的值传入Blackhole.consume
方法中。即时编译器仍旧会进行常量折叠,而Blackhole
将阻止即时编译器把所得到的常量值 7 给优化消除掉。
@Benchmark
public void testMethod(Blackhole bh) {
bh.consume(3+4);
}
除了防止死代码消除的consume
之外,Blackhole
类还提供了一个静态方法consumeCPU
,来消耗 CPU 时间。该方法将接收一个 long 类型的参数,这个参数与所消耗的 CPU 时间呈线性相关。
总结与实践
今天我介绍了基准测试框架 JMH 的进阶功能。我们来回顾一下。
@Fork
允许开发人员指定所要 Fork 出的 Java 虚拟机的数目。@BenchmarkMode
允许指定性能数据的格式。@Warmup
和@Measurement
允许配置预热迭代或者测试迭代的数目,每个迭代的时间以及每个操作包含多少次对测试方法的调用。@State
允许配置测试程序的状态。测试前对程序状态的初始化以及测试后对程序状态的恢复或者校验可分别通过@Setup
和@TearDown
来实现。
4.30 - CH30-诊断-CLI
今天,我们来一起了解一下 JDK 中用于监控及诊断工具。本篇中我将使用刚刚发布的 Java 11 版本的工具进行示范。
jps
你可能用过ps
命令,打印所有正在运行的进程的相关信息。JDK 中的jps
命令(帮助文档)沿用了同样的概念:它将打印所有正在运行的 Java 进程的相关信息。
在默认情况下,jps
的输出信息包括 Java 进程的进程 ID 以及主类名。我们还可以通过追加参数,来打印额外的信息。例如,-l
将打印模块名以及包名;-v
将打印传递给 Java 虚拟机的参数(如-XX:+UnlockExperimentalVMOptions -XX:+UseZGC
);-m
将打印传递给主类的参数。
具体的示例如下所示:
$ jps -mlv
18331 org.example.Foo Hello World
18332 jdk.jcmd/sun.tools.jps.Jps -mlv -Dapplication.home=/Library/Java/JavaVirtualMachines/jdk-11.jdk/Contents/Home -Xms8m -Djdk.module.main=jdk.jcmd
需要注意的是,如果某 Java 进程关闭了默认开启的UsePerfData
参数(即使用参数-XX:-UsePerfData
),那么jps
命令(以及下面介绍的jstat
)将无法探知该 Java 进程。
当获得 Java 进程的进程 ID 之后,我们便可以调用接下来介绍的各项监控及诊断工具了。
jstat
jstat
命令(帮助文档)可用来打印目标 Java 进程的性能数据。它包括多条子命令,如下所示:
$ jstat -options
-class
-compiler
-gc
-gccapacity
-gccause
-gcmetacapacity
-gcnew
-gcnewcapacity
-gcold
-gcoldcapacity
-gcutil
-printcompilation
在这些子命令中,-class
将打印类加载相关的数据,-compiler
和-printcompilation
将打印即时编译相关的数据。剩下的都是以-gc
为前缀的子命令,它们将打印垃圾回收相关的数据。
默认情况下,jstat
只会打印一次性能数据。我们可以将它配置为每隔一段时间打印一次,直至目标 Java 进程终止,或者达到我们所配置的最大打印次数。具体示例如下所示:
# Usage: jstat -outputOptions [-t] [-hlines] VMID [interval [count]]
$ jstat -gc 22126 1s 4
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
17472,0 17472,0 0,0 0,0 139904,0 47146,4 349568,0 21321,0 30020,0 28001,8 4864,0 4673,4 22 0,080 3 0,270 0 0,000 0,350
17472,0 17472,0 420,6 0,0 139904,0 11178,4 349568,0 21321,0 30020,0 28090,1 4864,0 4674,2 28 0,084 3 0,270 0 0,000 0,354
17472,0 17472,0 0,0 403,9 139904,0 139538,4 349568,0 21323,4 30020,0 28137,2 4864,0 4674,2 34 0,088 4 0,359 0 0,000 0,446
17472,0 17472,0 0,0 0,0 139904,0 0,0 349568,0 21326,1 30020,0 28093,6 4864,0 4673,4 38 0,091 5 0,445 0 0,000 0,536
当监控本地环境的 Java 进程时,VMID 可以简单理解为 PID。如果需要监控远程环境的 Java 进程,你可以参考 jstat 的帮助文档。
在上面这个示例中,22126 进程是一个使用了 CMS 垃圾回收器的 Java 进程。我们利用jstat
的-gc
子命令,来打印该进程垃圾回收相关的数据。命令最后的1s 4
表示每隔 1 秒打印一次,共打印 4 次。
在-gc
子命令的输出中,前四列分别为两个 Survivor 区的容量(Capacity)和已使用量(Utility)。我们可以看到,这两个 Survivor 区的容量相等,而且始终有一个 Survivor 区的内存使用量为 0。
当使用默认的 G1 GC 时,输出结果则有另一些特征:
$ jstat -gc 22208 1s
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
0,0 16384,0 0,0 16384,0 210944,0 192512,0 133120,0 5332,5 28848,0 26886,4 4864,0 4620,5 19 0,067 1 0,016 2 0,002 0,084
0,0 16384,0 0,0 16384,0 210944,0 83968,0 133120,0 5749,9 29104,0 27132,8 4864,0 4621,0 21 0,078 1 0,016 2 0,002 0,095
0,0 0,0 0,0 0,0 71680,0 18432,0 45056,0 20285,1 29872,0 27952,4 4864,0 4671,6 23 0,089 2 0,063 2 0,002 0,153
0,0 2048,0 0,0 2048,0 69632,0 28672,0 45056,0 18608,1 30128,0 28030,4 4864,0 4672,4 32 0,093 2 0,063 2 0,002 0,158
...
在上面这个示例中,jstat
每隔 1s 便会打印垃圾回收的信息,并且不断重复下去。
你可能已经留意到,S0C
和S0U
始终为 0,而且另一个 Survivor 区的容量(S1C)可能会下降至 0。
这是因为,当使用 G1 GC 时,Java 虚拟机不再设置 Eden 区、Survivor 区,老年代区的内存边界,而是将堆划分为若干个等长内存区域。
每个内存区域都可以作为 Eden 区、Survivor 区以及老年代区中的任一种,并且可以在不同区域类型之间来回切换。(参考链接)
换句话说,逻辑上我们只有一个 Survivor 区。当需要迁移 Survivor 区中的数据时(即 Copying GC),我们只需另外申请一个或多个内存区域,作为新的 Survivor 区。
因此,Java 虚拟机决定在使用 G1 GC 时,将所有 Survivor 内存区域的总容量以及已使用量存放至 S1C 和 S1U 中,而 S0C 和 S0U 则被设置为 0。
当发生垃圾回收时,Java 虚拟机可能出现 Survivor 内存区域内的对象全被回收或晋升的现象。
在这种情况下,Java 虚拟机会将这块内存区域回收,并标记为可分配的状态。这样子做的结果是,堆中可能完全没有 Survivor 内存区域,因而相应的 S1C 和 S1U 将会是 0。
jstat
还有一个非常有用的参数-t
,它将在每行数据之前打印目标 Java 进程的启动时间。例如,在下面这个示例中,第一列代表该 Java 进程已经启动了 10.7 秒。
$ jstat -gc -t 22407
Timestamp S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
10,7 0,0 0,0 0,0 0,0 55296,0 45056,0 34816,0 20267,8 30128,0 27975,3 4864,0 4671,6 33 0,086 3 0,111 2 0,001 0,198
我们可以比较 Java 进程的启动时间以及总 GC 时间(GCT 列),或者两次测量的间隔时间以及总 GC 时间的增量,来得出 GC 时间占运行时间的比例。
如果该比例超过 20%,则说明目前堆的压力较大;如果该比例超过 90%,则说明堆里几乎没有可用空间,随时都可能抛出 OOM 异常。
jstat
还可以用来判断是否出现内存泄漏。在长时间运行的 Java 程序中,我们可以运行jstat
命令连续获取多行性能数据,并取这几行数据中 OU 列(即已占用的老年代内存)的最小值。
然后,我们每隔一段较长的时间重复一次上述操作,来获得多组 OU 最小值。如果这些值呈上涨趋势,则说明该 Java 程序的老年代内存已使用量在不断上涨,这意味着无法回收的对象在不断增加,因此很有可能存在内存泄漏。
上面没有涉及的列(或者其他子命令的输出),你可以查阅帮助文档了解具体含义。至于文档中漏掉的 CGC 和 CGCT,它们分别代表并发 GC Stop-The-World 的次数和时间。
jmap
在这种情况下,我们便可以请jmap
命令(帮助文档)出马,分析 Java 虚拟机堆中的对象。
jmap
同样包括多条子命令。
-clstats
,该子命令将打印被加载类的信息。-finalizerinfo
,该子命令将打印所有待 finalize 的对象。-histo
,该子命令将统计各个类的实例数目以及占用内存,并按照内存使用量从多至少的顺序排列。此外,-histo:live
只统计堆中的存活对象。-dump
,该子命令将导出 Java 虚拟机堆的快照。同样,-dump:live
只保存堆中的存活对象。
我们通常会利用jmap -dump:live,format=b,file=filename.bin
命令,将堆中所有存活对象导出至一个文件之中。
这里format=b
将使jmap
导出与hprof(在 Java 9 中已被移除)、-XX:+HeapDumpAfterFullGC
、-XX:+HeapDumpOnOutOfMemoryError
格式一致的文件。这种格式的文件可以被其他 GUI 工具查看,具体我会在下一篇中进行演示。
下面我贴了一段-histo
子命令的输出:
$ jmap -histo 22574
num #instances #bytes class name (module)
-------------------------------------------------------
1: 500004 20000160 org.python.core.PyComplex
2: 570866 18267712 org.python.core.PyFloat
3: 360295 18027024 [B (java.base@11)
4: 339394 11429680 [Lorg.python.core.PyObject;
5: 308637 11194264 [Ljava.lang.Object; (java.base@11)
6: 301378 9291664 [I (java.base@11)
7: 225103 9004120 java.math.BigInteger (java.base@11)
8: 507362 8117792 org.python.core.PySequence$1
9: 285009 6840216 org.python.core.PyLong
10: 282908 6789792 java.lang.String (java.base@11)
...
2281: 1 16 traceback$py
2282: 1 16 unicodedata$py
Total 5151277 167944400
由于jmap
将访问堆中的所有对象,为了保证在此过程中不被应用线程干扰,jmap
需要借助安全点机制,让所有线程停留在不改变堆中数据的状态。
也就是说,由jmap
导出的堆快照必定是安全点位置的。这可能导致基于该堆快照的分析结果存在偏差。举个例子,假设在编译生成的机器码中,某些对象的生命周期在两个安全点之间,那么:live
选项将无法探知到这些对象。
另外,如果某个线程长时间无法跑到安全点,jmap
将一直等下去。上一小节的jstat
则不同。这是因为垃圾回收器会主动将jstat
所需要的摘要数据保存至固定位置之中,而jstat
只需直接读取即可。
关于这种长时间等待的情况,你可以通过下面这段程序来复现:
// 暂停时间较长,约为二三十秒,可酌情调整。
// CTRL+C 的 SIGINT 信号无法停止,需要 SIGKILL。
static double sum = 0;
public static void main(String[] args) {
for (int i = 0; i < 0x77777777; i++) { // counted loop
sum += Math.log(i); // Math.log is an intrinsic
}
}
jmap
(以及接下来的jinfo
、jstack
和jcmd
)依赖于 Java 虚拟机的Attach API,因此只能监控本地 Java 进程。
一旦开启 Java 虚拟机参数DisableAttachMechanism
(即使用参数-XX:+DisableAttachMechanism
),基于 Attach API 的命令将无法执行。反过来说,如果你不想被其他进程监控,那么你需要开启该参数。
jinfo
jinfo
命令(帮助文档)可用来查看目标 Java 进程的参数,如传递给 Java 虚拟机的-X
(即输出中的 jvm_args)、-XX
参数(即输出中的 VM Flags),以及可在 Java 层面通过System.getProperty
获取的-D
参数(即输出中的 System Properties)。
具体的示例如下所示:
$ jinfo 31185
Java System Properties:
gopherProxySet=false
awt.toolkit=sun.lwawt.macosx.LWCToolkit
java.specification.version=11
sun.cpu.isalist=
sun.jnu.encoding=UTF-8
...
VM Flags:
-XX:CICompilerCount=4 -XX:ConcGCThreads=3 -XX:G1ConcRefinementThreads=10 -XX:G1HeapRegionSize=2097152 -XX:GCDrainStackTargetSize=64 -XX:InitialHeapSize=536870912 -XX:MarkStackSize=4194304 -XX:MaxHeapSize=8589934592 -XX:MaxNewSize=5152702464 -XX:MinHeapDeltaBytes=2097152 -XX:NonNMethodCodeHeapSize=5835340 -XX:NonProfiledCodeHeapSize=122911450 -XX:ProfiledCodeHeapSize=122911450 -XX:ReservedCodeCacheSize=251658240 -XX:+SegmentedCodeCache -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseG1GC
VM Arguments:
jvm_args: -Xlog:gc -Xmx1024m
java_command: org.example.Foo
java_class_path (initial): .
Launcher Type: SUN_STANDARD
jinfo
还可以用来修改目标 Java 进程的“manageable”虚拟机参数。
举个例子,我们可以使用jinfo -flag +HeapDumpAfterFullGC <PID>
命令,开启<PID>
所指定的 Java 进程的HeapDumpAfterFullGC
参数。
你可以通过下述命令查看其他 “manageable” 虚拟机参数:
$ java -XX:+PrintFlagsFinal -version | grep manageable
intx CMSAbortablePrecleanWaitMillis = 100 {manageable} {default}
intx CMSTriggerInterval = -1 {manageable} {default}
intx CMSWaitDuration = 2000 {manageable} {default}
bool HeapDumpAfterFullGC = false {manageable} {default}
bool HeapDumpBeforeFullGC = false {manageable} {default}
bool HeapDumpOnOutOfMemoryError = false {manageable} {default}
ccstr HeapDumpPath = {manageable} {default}
uintx MaxHeapFreeRatio = 70 {manageable} {default}
uintx MinHeapFreeRatio = 40 {manageable} {default}
bool PrintClassHistogram = false {manageable} {default}
bool PrintConcurrentLocks = false {manageable} {default}
java version "11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)
jstack
jstack
命令(帮助文档)可以用来打印目标 Java 进程中各个线程的栈轨迹,以及这些线程所持有的锁。
jstack
的其中一个应用场景便是死锁检测。这里我用jstack
获取一个已经死锁了的 Java 程序的栈信息。具体输出如下所示:
$ jstack 31634
...
"Thread-0" #12 prio=5 os_prio=31 cpu=1.32ms elapsed=34.24s tid=0x00007fb08601c800 nid=0x5d03 waiting for monitor entry [0x000070000bc7e000]
java.lang.Thread.State: BLOCKED (on object monitor)
at DeadLock.foo(DeadLock.java:18)
- waiting to lock <0x000000061ff904c0> (a java.lang.Object)
- locked <0x000000061ff904b0> (a java.lang.Object)
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
"Thread-1" #13 prio=5 os_prio=31 cpu=1.43ms elapsed=34.24s tid=0x00007fb08601f800 nid=0x5f03 waiting for monitor entry [0x000070000bd81000]
java.lang.Thread.State: BLOCKED (on object monitor)
at DeadLock.bar(DeadLock.java:33)
- waiting to lock <0x000000061ff904b0> (a java.lang.Object)
- locked <0x000000061ff904c0> (a java.lang.Object)
at DeadLock$$Lambda$2/0x0000000800063040.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
...
JNI global refs: 6, weak refs: 0
Found one Java-level deadlock:
=============================
"Thread-0":
waiting to lock monitor 0x00007fb083015900 (object 0x000000061ff904c0, a java.lang.Object),
which is held by "Thread-1"
"Thread-1":
waiting to lock monitor 0x00007fb083015800 (object 0x000000061ff904b0, a java.lang.Object),
which is held by "Thread-0"
Java stack information for the threads listed above:
===================================================
"Thread-0":
at DeadLock.foo(DeadLock.java:18)
- waiting to lock <0x000000061ff904c0> (a java.lang.Object)
- locked <0x000000061ff904b0> (a java.lang.Object)
at DeadLock$$Lambda$1/0x0000000800060840.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
"Thread-1":
at DeadLock.bar(DeadLock.java:33)
- waiting to lock <0x000000061ff904b0> (a java.lang.Object)
- locked <0x000000061ff904c0> (a java.lang.Object)
at DeadLock$$Lambda$2/0x0000000800063040.run(Unknown Source)
at java.lang.Thread.run(java.base@11/Thread.java:834)
Found 1 deadlock.
我们可以看到,jstack
不仅会打印线程的栈轨迹、线程状态(BLOCKED)、持有的锁(locked …)以及正在请求的锁(waiting to lock …),而且还会分析出具体的死锁。
jcmd
你还可以直接使用jcmd
命令(帮助文档),来替代前面除了jstat
之外的所有命令。具体的替换规则你可以参考下表。
至于jstat
的功能,虽然jcmd
复制了jstat
的部分代码,并支持通过PerfCounter.print
子命令来打印所有的 Performance Counter,但是它没有保留jstat
的输出格式,也没有重复打印的功能。因此,感兴趣的同学可以自行整理。
另外,我们将在下一篇中介绍jcmd
中 Java Flight Recorder 相关的子命令。
总结与实践
今天我介绍了 JDK 中用于监控及诊断的命令行工具。我们再来回顾一下。
jps
将打印所有正在运行的 Java 进程。jstat
允许用户查看目标 Java 进程的类加载、即时编译以及垃圾回收相关的信息。它常用于检测垃圾回收问题以及内存泄漏问题。jmap
允许用户统计目标 Java 进程的堆中存放的 Java 对象,并将它们导出成二进制文件。jinfo
将打印目标 Java 进程的配置参数,并能够改动其中 manageabe 的参数。jstack
将打印目标 Java 进程中各个线程的栈轨迹、线程状态、锁状况等信息。它还将自动检测死锁。jcmd
则是一把瑞士军刀,可以用来实现前面除了jstat
之外所有命令的功能。
4.31 - CH31-诊断-GUI
eclipse MAT
在上一篇中,我介绍了jmap
工具,它支持导出 Java 虚拟机堆的二进制快照。eclipse 的MAT 工具便是其中一个能够解析这类二进制快照的工具。
MAT 本身也能够获取堆的二进制快照。该功能将借助jps
列出当前正在运行的 Java 进程,以供选择并获取快照。由于jps
会将自己列入其中,因此你会在列表中发现一个已经结束运行的jps
进程。
MAT 获取二进制快照的方式有三种,一是使用 Attach API,二是新建一个 Java 虚拟机来运行 Attach API,三是使用jmap
工具。
这三种本质上都是在使用 Attach API。不过,在目标进程启用了DisableAttachMechanism
参数时,前两者将不在选取列表中显示,后者将在运行时报错。
当加载完堆快照之后,MAT 的主界面将展示一张饼状图,其中列举占据的 Retained heap 最多的几个对象。
这里讲一下 MAT 计算对象占据内存的两种方式。第一种是 Shallow heap,指的是对象自身所占据的内存。第二种是 Retained heap,指的是当对象不再被引用时,垃圾回收器所能回收的总内存,包括对象自身所占据的内存,以及仅能够通过该对象引用到的其他对象所占据的内存。上面的饼状图便是基于 Retained heap 的。
MAT 包括了两个比较重要的视图,分别是直方图(histogram)和支配树(dominator tree)。
MAT 的直方图和jmap
的-histo
子命令一样,都能够展示各个类的实例数目以及这些实例的 Shallow heap 总和。但是,MAT 的直方图还能够计算 Retained heap,并支持基于实例数目或 Retained heap 的排序方式(默认为 Shallow heap)。此外,MAT 还可以将直方图中的类按照超类、类加载器或者包名分组。
当选中某个类时,MAT 界面左上角的 Inspector 窗口将展示该类的 Class 实例的相关信息,如类加载器等。(下图中的ClassLoader @ 0x0
指的便是启动类加载器。)
支配树的概念源自图论。在一则流图(flow diagram)中,如果从入口节点到 b 节点的所有路径都要经过 a 节点,那么 a 支配(dominate)b。
在 a 支配 b,且 a 不同于 b 的情况下(即 a 严格支配 b),如果从 a 节点到 b 节点的所有路径中不存在支配 b 的其他节点,那么 a 直接支配(immediate dominate)b。这里的支配树指的便是由节点的直接支配节点所组成的树状结构。
我们可以将堆中所有的对象看成一张对象图,每个对象是一个图节点,而 GC Roots 则是对象图的入口,对象之间的引用关系则构成了对象图中的有向边。这样一来,我们便能够构造出该对象图所对应的支配树。
MAT 将按照每个对象 Retained heap 的大小排列该支配树。如下图所示:
根据 Retained heap 的定义,只要能够回收上图右侧的表中的第一个对象,那么垃圾回收器便能够释放出 13.6MB 内存。
需要注意的是,对象的引用型字段未必对应支配树中的父子节点关系。假设对象 a 拥有两个引用型字段,分别指向 b 和 c。而 b 和 c 各自拥有一个引用型字段,但都指向 d。如果没有其他引用指向 b、c 或 d,那么 a 直接支配 b、c 和 d,而 b(或 c)和 d 之间不存在支配关系。
当在支配树视图中选中某一对象时,我们还可以通过 Path To GC Roots 功能,反向列出该对象到 GC Roots 的引用路径。如下图所示:
MAT 还将自动匹配内存泄漏中的常见模式,并汇报潜在的内存泄漏问题。具体可参考该帮助文档以及这篇博客。
Java Mission Control
注意:自 Java 11 开始,本节介绍的 JFR 已经开源。但在之前的 Java 版本,JFR 属于 Commercial Feature,需要通过 Java 虚拟机参数
-XX:+UnlockCommercialFeatures
开启。我个人不清楚也不能回答关于 Java 11 之前的版本是否仍需要商务许可(Commercial License)的问题。请另行咨询后再使用,或者直接使用 Java 11。
Java Mission Control(JMC)是 Java 虚拟机平台上的性能监控工具。它包含一个 GUI 客户端,以及众多用来收集 Java 虚拟机性能数据的插件,如 JMX Console(能够访问用来存放虚拟机各个子系统运行数据的MXBeans),以及虚拟机内置的高效 profiling 工具 Java Flight Recorder(JFR)。
JFR 的性能开销很小,在默认配置下平均低于 1%。与其他工具相比,JFR 能够直接访问虚拟机内的数据,并且不会影响虚拟机的优化。因此,它非常适用于生产环境下满负荷运行的 Java 程序。
当启用时,JFR 将记录运行过程中发生的一系列事件。其中包括 Java 层面的事件,如线程事件、锁事件,以及 Java 虚拟机内部的事件,如新建对象、垃圾回收和即时编译事件。
按照发生时机以及持续时间来划分,JFR 的事件共有四种类型,它们分别为以下四种。
- 瞬时事件(Instant Event),用户关心的是它们发生与否,例如异常、线程启动事件。
- 持续事件(Duration Event),用户关心的是它们的持续时间,例如垃圾回收事件。
- 计时事件(Timed Event),是时长超出指定阈值的持续事件。
- 取样事件(Sample Event),是周期性取样的事件。 取样事件的其中一个常见例子便是方法抽样(Method Sampling),即每隔一段时间统计各个线程的栈轨迹。如果在这些抽样取得的栈轨迹中存在一个反复出现的方法,那么我们可以推测该方法是热点方法。
JFR 的取样事件要比其他工具更加精确。以方法抽样为例,其他工具通常基于 JVMTI(Java Virtual Machine Tool Interface)的GetAllStackTraces
API。该 API 依赖于安全点机制,其获得的栈轨迹总是在安全点上,由此得出的结论未必精确。JFR 则不然,它不依赖于安全点机制,因此其结果相对来说更加精确。
JFR 的启用方式主要有三种。
第一种是在运行目标 Java 程序时添加-XX:StartFlightRecording=
参数。关于该参数的配置详情,你可以参考该帮助文档(请在页面中搜索StartFlightRecording
)。
下面我列举三种常见的配置方式。
- 在下面这条命令中,JFR 将会在 Java 虚拟机启动 5s 后(对应
delay=5s
)收集数据,持续 20s(对应duration=20s
)。当收集完毕后,JFR 会将收集得到的数据保存至指定的文件中(对应filename=myrecording.jfr
)。
# Time fixed
$ java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile MyApp
settings=profile
指定了 JFR 所收集的事件类型。默认情况下,JFR 将加载配置文件$JDK/lib/jfr/default.jfc
,并识别其中所包含的事件类型。当使用了settings=profile
配置时,JFR 将加载配置文件$JDK/lib/jfr/profile.jfc
。该配置文件所包含的事件类型要多于默认的default.jfc
,因此性能开销也要大一些(约为 2%)。
default.jfc
以及profile.jfc
均为 XML 文件。后面我会介绍如何利用 JMC 来进行修改。
- 在下面这条命令中,JFR 将在 Java 虚拟机启动之后持续收集数据,直至进程退出。在进程退出时(对应
dumponexit=true
),JFR 会将收集得到的数据保存至指定的文件中。
# Continuous, dump on exit
$ java -XX:StartFlightRecording=dumponexit=true,filename=myrecording.jfr MyApp
- 在下面这条命令中,JFR 将在 Java 虚拟机启动之后持续收集数据,直至进程退出。该命令不会主动保存 JFR 收集得到的数据。
# Continuous, dump on demand
$ java -XX:StartFlightRecording=maxage=10m,maxsize=100m,name=SomeLabel MyApp
Started recording 1.
Use jcmd 38502 JFR.dump name=SomeLabel filename=FILEPATH to copy recording data to file.
...
由于 JFR 将持续收集数据,如果不加以限制,那么 JFR 可能会填满硬盘的所有空间。因此,我们有必要对这种模式下所收集的数据进行限制。
在这条命令中,maxage=10m
指的是仅保留 10 分钟以内的事件,maxsize=100m
指的是仅保留 100MB 以内的事件。一旦所收集的事件达到其中任意一个限制,JFR 便会开始清除不合规格的事件。
然而,为了保持较小的性能开销,JFR 并不会频繁地校验这两个限制。因此,在实践过程中你往往会发现指定文件的大小超出限制,或者文件中所存储事件的时间超出限制。具体解释请参考这篇帖子。
前面提到,该命令不会主动保存 JFR 收集得到的数据。用户需要运行jcmd <PID> JFR.dump
命令方能保存。
这便是 JFR 的第二种启用方式,即通过jcmd
来让 JFR 开始收集数据、停止收集数据,或者保存所收集的数据,对应的子命令分别为JFR.start
,JFR.stop
,以及JFR.dump
。
JFR.start
子命令所接收的配置及格式和-XX:StartFlightRecording=
参数的类似。这些配置包括delay
、duration
、settings
、maxage
、maxsize
以及name
。前几个参数我们都已经介绍过了,最后一个参数name
就是一个标签,当同一进程中存在多个 JFR 数据收集操作时,我们可以通过该标签来辨别。
在启动目标进程时,我们不再添加-XX:StartFlightRecording=
参数。在目标进程运行过程中,我们可以运行JFR.start
子命令远程启用目标进程的 JFR 功能。具体用法如下所示:
$ jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
复制代码
上述命令运行过后,目标进程中的 JFR 已经开始收集数据。此时,我们可以通过下述命令来导出已经收集到的数据:
$ jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
复制代码
最后,我们可以通过下述命令关闭目标进程中的 JFR:
$ jcmd <PID> JFR.stop name=SomeLabel
复制代码
关于JFR.start
、JFR.dump
和JFR.stop
的其他用法,你可以参考该帮助文档。
第三种启用 JFR 的方式则是 JMC 中的 JFR 插件。
在 JMC GUI 客户端左侧的 JVM 浏览器中,我们可以看到所有正在运行的 Java 程序。当点击右键弹出菜单中的Start Flight Recording...
时,JMC 便会弹出另一个窗口,用来配置 JFR 的启动参数,如下图所示:
这里的配置参数与前两种启动 JFR 的方式并无二致,同样也包括标签名、收集数据的持续时间、缓存事件的时间及空间限制,以及配置所要监控事件的Event settings
。
(这里对应前两种启动方式的settings=default|profile
)
JMC 提供了两个选择:Continuous 和 Profiling,分别对应
$JDK/lib/jfr/
里的default.jfc
和profile.jfc
。
我们可以通过 JMC 的Flight Recording Template Manager
导入这些 jfc 文件,并在 GUI 界面上进行更改。更改完毕后,我们可以导出为新的 jfc 文件,以便在服务器端使用。
当收集完成时,JMC 会自动打开所生成的 jfr 文件,并在主界面中列举目标进程在收集数据的这段时间内的潜在问题。例如,Parallel Threads
一节,便汇报了没有完整利用 CPU 资源的问题。
客户端的左边则罗列了 Java 虚拟机的各个子系统。JMC 将根据 JFR 所收集到的每个子系统的事件来进行可视化,转换成图或者表。
这里我简单地介绍其中两个。
垃圾回收子系统所对应的选项卡展示了 JFR 所收集到的 GC 事件,以及基于这些 GC 事件的数据生成的堆已用空间的分布图,Metaspace 大小的分布图,最长暂停以及总暂停的直方分布图。
即时编译子系统所对应的选项卡则展示了方法编译时间的直方图,以及按编译时间排序的编译任务表。
后者可能出现同方法名同方法描述符的编译任务。其原因主要有两个,一是不同编译层次的即时编译,如 3 层的 C1 编译以及 4 层的 C2 编译。二是去优化后的重新编译。
总结与实践
今天我介绍了两个 GUI 工具:eclipse MAT 以及 JMC。
eclipse MAT 可用于分析由jmap
命令导出的 Java 堆快照。它包括两个相对比较重要的视图,分别为直方图和支配树。直方图展示了各个类的实例数目以及这些实例的 Shallow heap 或 Retained heap 的总和。支配树则展示了快照中每个对象所直接支配的对象。
Java Mission Control 是 Java 虚拟机平台上的性能监控工具。Java Flight Recorder 是 JMC 的其中一个组件,能够以极低的性能开销收集 Java 虚拟机的性能数据。
JFR 的启用方式有三种,分别为在命令行中使用-XX:StartFlightRecording=
参数,使用jcmd
的JFR.*
子命令,以及 JMC 的 JFR 插件。JMC 能够加载 JFR 的输出结果,并且生成各种信息丰富的图表。
4.32 - CH32-JNI
我们经常会遇见 Java 语言较难表达,甚至是无法表达的应用场景。比如我们希望使用汇编语言(如 X86_64 的 SIMD 指令)来提升关键代码的性能;再比如,我们希望调用 Java 核心类库无法提供的,某个体系架构或者操作系统特有的功能。
在这种情况下,我们往往会牺牲可移植性,在 Java 代码中调用 C/C++ 代码(下面简述为 C 代码),并在其中实现所需功能。这种跨语言的调用,便需要借助 Java 虚拟机的 Java Native Interface(JNI)机制。
关于 JNI 的例子,你应该特别熟悉 Java 中标记为native
的、没有方法体的方法(下面统称为 native 方法)。当在 Java 代码中调用这些 native 方法时,Java 虚拟机将通过 JNI,调用至对应的 C 函数(下面将 native 方法对应的 C 实现统称为 C 函数)中。
public class Object {
public native int hashCode();
}
举个例子,Object.hashCode
方法便是一个 native 方法。它对应的 C 函数将计算对象的哈希值,并缓存在对象头、栈上锁记录(轻型锁)或对象监视锁(重型锁所使用的 monitor)中,以确保该值在对象的生命周期之内不会变更。
native 方法的链接
在调用 native 方法前,Java 虚拟机需要将该 native 方法链接至对应的 C 函数上。
链接方式主要有两种。第一种是让 Java 虚拟机自动查找符合默认命名规范的 C 函数,并且链接起来。
事实上,我们并不需要记住所谓的命名规范,而是采用javac -h
命令,便可以根据 Java 程序中的 native 方法声明,自动生成包含符合命名规范的 C 函数的头文件。
举个例子,在下面这段代码中,Foo
类有三个 native 方法,分别为静态方法foo
以及两个重载的实例方法bar
。
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
}
通过执行javac -h . org/example/Foo.java
命令,我们将在当前文件夹(对应-h
后面跟着的.
)生成名为org_example_Foo.h
的头文件。其内容如下所示:
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class org_example_Foo */
#ifndef _Included_org_example_Foo
#define _Included_org_example_Foo
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: org_example_Foo
* Method: foo
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (IJ)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
/*
* Class: org_example_Foo
* Method: bar
* Signature: (Ljava/lang/String;Ljava/lang/Object;)V
*/
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *, jobject, jstring, jobject);
#ifdef __cplusplus
}
#endif
#endif
这里我简单讲解一下该命名规范。
首先,native 方法对应的 C 函数都需要以Java_
为前缀,之后跟着完整的包名和方法名。由于 C 函数名不支持/
字符,因此我们需要将/
转换为_
,而原本方法名中的_
符号,则需要转换为_1
。
举个例子,org.example
包下Foo
类的foo
方法,Java 虚拟机会将其自动链接至名为Java_org_example_Foo_foo
的 C 函数中。
当某个类出现重载的 native 方法时,Java 虚拟机还会将参数类型纳入自动链接对象的考虑范围之中。具体的做法便是在前面 C 函数名的基础上,追加__
以及方法描述符作为后缀。
方法描述符的特殊符号同样会被替换掉,如引用类型所使用的;
会被替换为_2
,数组类型所使用的[
会被替换为_3
。
基于此命名规范,你可以手动拼凑上述代码中,Foo
类的两个bar
方法所能自动链接的 C 函数名,并用javac -h
命令所生成的结果来验证一下。
第二种链接方式则是在 C 代码中主动链接。
这种链接方式对 C 函数名没有要求。通常我们会使用一个名为registerNatives
的 native 方法,并按照第一种链接方式定义所能自动链接的 C 函数。在该 C 函数中,我们将手动链接该类的其他 native 方法。
举个例子,Object
类便拥有一个registerNatives
方法,所对应的 C 代码如下所示:
// 注:Object 类的 registerNatives 方法的实现位于 java.base 模块里的 C 代码中
static JNINativeMethod methods[] = {
{"hashCode", "()I", (void *)&JVM_IHashCode},
{"wait", "(J)V", (void *)&JVM_MonitorWait},
{"notify", "()V", (void *)&JVM_MonitorNotify},
{"notifyAll", "()V", (void *)&JVM_MonitorNotifyAll},
{"clone", "()Ljava/lang/Object;", (void *)&JVM_Clone},
};
JNIEXPORT void JNICALL
Java_java_lang_Object_registerNatives(JNIEnv *env, jclass cls)
{
(*env)->RegisterNatives(env, cls,
methods, sizeof(methods)/sizeof(methods[0]));
}
我们可以看到,上面这段代码中的 C 函数将调用RegisterNatives
API,注册Object
类中其他 native 方法所要链接的 C 函数。并且,这些 C 函数的名字并不符合默认命名规则。
当使用第二种方式进行链接时,我们需要在其他 native 方法被调用之前完成链接工作。因此,我们往往会在类的初始化方法里调用该registerNatives
方法。具体示例如下所示:
public class Object {
private static native void registerNatives();
static {
registerNatives();
}
}
下面我们采用第一种链接方式,并且实现其中的bar(String, Object)
方法。如下所示:
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
printf("Hello, World\n");
return;
}
然后,我们可以通过 gcc 命令将其编译成为动态链接库:
# 该命令仅适用于 macOS
$ gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -o libfoo.dylib -shared foo.c
这里需要注意的是,动态链接库的名字须以lib
为前缀,以.dylib
(或 Linux 上的.so
)为扩展名。在 Java 程序中,我们可以通过System.loadLibrary("foo")
方法来加载libfoo.dylib
,如下述代码所示:
package org.example;
public class Foo {
public static native void foo();
public native void bar(int i, long j);
public native void bar(String s, Object o);
int i = 0xDEADBEEF;
public static void main(String[] args) {
try {
System.loadLibrary("foo");
} catch (UnsatisfiedLinkError e) {
e.printStackTrace();
System.exit(1);
}
new Foo().bar("", "");
}
}
如果libfoo.dylib
不在当前路径下,我们可以在启动 Java 虚拟机时配置java.library.path
参数,使其指向包含libfoo.dylib
的文件夹。具体命令如下所示:
$ java -Djava.library.path=/PATH/TO/DIR/CONTAINING/libfoo.dylib org.example.Foo
Hello, World
JNI 的 API
在 C 代码中,我们也可以使用 Java 的语言特性,如 instanceof 测试等。这些功能都是通过特殊的 JNI 函数(JNI Functions)来实现的。
Java 虚拟机会将所有 JNI 函数的函数指针聚合到一个名为JNIEnv
的数据结构之中。
这是一个线程私有的数据结构。Java 虚拟机会为每个线程创建一个JNIEnv
,并规定 C 代码不能将当前线程的JNIEnv
共享给其他线程,否则 JNI 函数的正确性将无法保证。
这么设计的原因主要有两个。一是给 JNI 函数提供一个单独命名空间。二是允许 Java 虚拟机通过更改函数指针替换 JNI 函数的具体实现,例如从附带参数类型检测的慢速版本,切换至不做参数类型检测的快速版本。
在 HotSpot 虚拟机中,JNIEnv
被内嵌至 Java 线程的数据结构之中。部分虚拟机代码甚至会从JNIEnv
的地址倒推出 Java 线程的地址。因此,如果在其他线程中使用当前线程的JNIEnv
,会使这部分代码错误识别当前线程。
JNI 会将 Java 层面的基本类型以及引用类型映射为另一套可供 C 代码使用的数据结构。其中,基本类型的对应关系如下表所示:
引用类型对应的数据结构之间也存在着继承关系,具体如下所示:
jobject
|- jclass (java.lang.Class objects)
|- jstring (java.lang.String objects)
|- jthrowable (java.lang.Throwable objects)
|- jarray (arrays)
|- jobjectArray (object arrays)
|- jbooleanArray (boolean arrays)
|- jbyteArray (byte arrays)
|- jcharArray (char arrays)
|- jshortArray (short arrays)
|- jintArray (int arrays)
|- jlongArray (long arrays)
|- jfloatArray (float arrays)
|- jdoubleArray (double arrays)
我们回头看看Foo
类 3 个 native 方法对应的 C 函数的参数。
JNIEXPORT void JNICALL Java_org_example_Foo_foo
(JNIEnv *, jclass);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__IJ
(JNIEnv *, jobject, jint, jlong);
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2 (JNIEnv *, jobject, jstring, jobject);
静态 native 方法foo
将接收两个参数,分别为存放 JNI 函数的JNIEnv
指针,以及一个jclass
参数,用来指代定义该 native 方法的类,即Foo
类。
两个实例 native 方法bar
的第二个参数则是jobject
类型的,用来指代该 native 方法的调用者,也就是Foo
类的实例。
如果 native 方法声明了参数,那么对应的 C 函数将接收这些参数。在我们的例子中,第一个bar
方法声明了 int 型和 long 型的参数,对应的 C 函数则接收 jint 和 jlong 类型的参数;第二个bar
方法声明了 String 类型和 Object 类型的参数,对应的 C 函数则接收 jstring 和 jobject 类型的参数。
下面,我们继续修改上一小节中的foo.c
,并在 C 代码中获取Foo
类实例的i
字段。
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
printf("Hello, World 0x%x\n", value);
return;
}
我们可以看到,在 JNI 中访问字段类似于反射 API:我们首先需要通过类实例获得FieldID
,然后再通过FieldID
获得某个实例中该字段的值。不过,与 Java 代码相比,上述代码貌似不用处理异常。事实果真如此吗?
下面我就尝试获取了不存在的字段j
,运行结果如下所示:
$ java org.example.Foo
Hello, World 0x5
Exception in thread "main" java.lang.NoSuchFieldError: j
at org.example.Foo.bar(Native Method)
at org.example.Foo.main(Foo.java:20)
我们可以看到,printf
语句照常执行并打印出Hello, World 0x5
,但这个数值明显是错误的。当从 C 函数返回至 main 方法时,Java 虚拟机又会抛出NoSuchFieldError
异常。
实际上,当调用 JNI 函数时,Java 虚拟机便已生成异常实例,并缓存在内存中的某个位置。与 Java 编程不一样的是,它并不会显式地跳转至异常处理器或者调用者中,而是继续执行接下来的 C 代码。
因此,当从可能触发异常的 JNI 函数返回时,我们需要通过 JNI 函数ExceptionOccurred
检查是否发生了异常,并且作出相应的处理。如果无须抛出该异常,那么我们需要通过 JNI 函数ExceptionClear
显式地清空已缓存的异常。
具体示例如下所示(为了控制代码篇幅,我仅在第一个GetFieldID
后检查异常以及清空异常):
// foo.c
#include <stdio.h>
#include "org_example_Foo.h"
JNIEXPORT void JNICALL Java_org_example_Foo_bar__Ljava_lang_String_2Ljava_lang_Object_2
(JNIEnv *env, jobject thisObject, jstring str, jobject obj) {
jclass cls = (*env)->GetObjectClass(env, thisObject);
jfieldID fieldID = (*env)->GetFieldID(env, cls, "j", "I");
if((*env)->ExceptionOccurred(env)) {
printf("Exception!\n");
(*env)->ExceptionClear(env);
}
fieldID = (*env)->GetFieldID(env, cls, "i", "I");
jint value = (*env)->GetIntField(env, thisObject, fieldID);
// we should put an exception guard here as well.
printf("Hello, World 0x%x\n", value);
return;
}
局部引用与全局引用
在 C 代码中,我们可以访问所传入的引用类型参数,也可以通过 JNI 函数创建新的 Java 对象。
这些 Java 对象显然也会受到垃圾回收器的影响。因此,Java 虚拟机需要一种机制,来告知垃圾回收算法,不要回收这些 C 代码中可能引用到的 Java 对象。
这种机制便是 JNI 的局部引用(Local Reference)和全局引用(Global Reference)。垃圾回收算法会将被这两种引用指向的对象标记为不可回收。
事实上,无论是传入的引用类型参数,还是通过 JNI 函数(除NewGlobalRef
及NewWeakGlobalRef
之外)返回的引用类型对象,都属于局部引用。
不过,一旦从 C 函数中返回至 Java 方法之中,那么局部引用将失效。也就是说,垃圾回收器在标记垃圾时不再考虑这些局部引用。
这就意味着,我们不能缓存局部引用,以供另一 C 线程或下一次 native 方法调用时使用。
对于这种应用场景,我们需要借助 JNI 函数NewGlobalRef
,将该局部引用转换为全局引用,以确保其指向的 Java 对象不会被垃圾回收。
相应的,我们还可以通过 JNI 函数DeleteGlobalRef
来消除全局引用,以便回收被全局引用指向的 Java 对象。
此外,当 C 函数运行时间极其长时,我们也应该考虑通过 JNI 函数DeleteLocalRef
,消除不再使用的局部引用,以便回收被引用的 Java 对象。
另一方面,由于垃圾回收器可能会移动对象在内存中的位置,因此 Java 虚拟机需要另一种机制,来保证局部引用或者全局引用将正确地指向移动过后的对象。
HotSpot 虚拟机是通过句柄(handle)来完成上述需求的。这里句柄指的是内存中 Java 对象的指针的指针。当发生垃圾回收时,如果 Java 对象被移动了,那么句柄指向的指针值也将发生变动,但句柄本身保持不变。
实际上,无论是局部引用还是全局引用,都是句柄。其中,局部引用所对应的句柄有两种存储方式,一是在本地方法栈帧中,主要用于存放 C 函数所接收的来自 Java 层面的引用类型参数;另一种则是线程私有的句柄块,主要用于存放 C 函数运行过程中创建的局部引用。
当从 C 函数返回至 Java 方法时,本地方法栈帧中的句柄将会被自动清除。而线程私有句柄块则需要由 Java 虚拟机显式清理。
进入 C 函数时对引用类型参数的句柄化,和调整参数位置(C 调用和 Java 调用传参的方式不一样),以及从 C 函数返回时清理线程私有句柄块,共同造就了 JNI 调用的额外性能开销(具体可参考该 stackoverflow 上的回答)。
总结与实践
今天我介绍了 JNI 的运行机制。
Java 中的 native 方法的链接方式主要有两种。一是按照 JNI 的默认规范命名所要链接的 C 函数,并依赖于 Java 虚拟机自动链接。另一种则是在 C 代码中主动链接。
JNI 提供了一系列 API 来允许 C 代码使用 Java 语言特性。这些 API 不仅使用了特殊的数据结构来表示 Java 类,还拥有特殊的异常处理模式。
JNI 中的引用可分为局部引用和全局引用。这两者都可以阻止垃圾回收器回收被引用的 Java 对象。不同的是,局部引用在 native 方法调用返回之后便会失效。传入参数以及大部分 JNI API 函数的返回值都属于局部引用。
4.33 - CH33-Agent
关于 Java agent,大家可能都听过大名鼎鼎的premain
方法。顾名思义,这个方法指的就是在main
方法之前执行的方法。
package org.example;
public class MyAgent {
public static void premain(String args) {
System.out.println("premain");
}
}
我在上面这段代码中定义了一个premain
方法。这里需要注意的是,Java 虚拟机所能识别的premain
方法接收的是字符串类型的参数,而并非类似于main
方法的字符串数组。
为了能够以 Java agent 的方式运行该premain
方法,我们需要将其打包成 jar 包,并在其中的 MANIFEST.MF 配置文件中,指定所谓的Premain-class
。具体的命令如下所示:
# 注意第一条命令会向 manifest.txt 文件写入两行数据,其中包括一行空行
$ echo 'Premain-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java -javaagent:myagent.jar HelloWorld
premain
Hello, World
除了在命令行中指定 Java agent 之外,我们还可以通过 Attach API 远程加载。具体用法如下面的代码所示:
import java.io.IOException;
import com.sun.tools.attach.*;
public class AttachTest {
public static void main(String[] args)
throws AttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException {
if (args.length <= 1) {
System.out.println("Usage: java AttachTest <PID> /PATH/TO/AGENT.jar");
return;
}
VirtualMachine vm = VirtualMachine.attach(args[0]);
vm.loadAgent(args[1]);
}
}
使用 Attach API 远程加载的 Java agent 不会再先于main
方法执行,这取决于另一虚拟机调用 Attach API 的时机。并且,它运行的也不再是premain
方法,而是名为agentmain
的方法。
public class MyAgent {
public static void agentmain(String args) {
System.out.println("agentmain");
}
}
相应的,我们需要更新 jar 包中的 manifest 文件,使其包含Agent-Class
的配置,例如Agent-Class: org.example.MyAgent
。
$ echo 'Agent-Class: org.example.MyAgent
' > manifest.txt
$ jar cvmf manifest.txt myagent.jar org/
$ java HelloWorld
Hello, World
$ jps
$ java AttachTest <pid> myagent.jar
agentmain
// 最后一句输出来自于运行 HelloWorld 的 Java 进程
Java 虚拟机并不限制 Java agent 的数量。你可以在 java 命令后附上多个-javaagent
参数,或者远程 attach 多个 Java agent,Java 虚拟机会按照定义顺序,或者 attach 的顺序逐个执行这些 Java agent。
在premain
方法或者agentmain
方法中打印一些字符串并不出奇,我们完全可以将其中的逻辑并入main
方法,或者其他监听端口的线程中。除此之外,Java agent 还提供了一套 instrumentation 机制,允许应用程序拦截类加载事件,并且更改该类的字节码。
接下来,我们来了解一下基于这一机制的字节码注入。
字节码注入
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.printf("Loaded %s: 0x%X%X%X%X\n", className, classfileBuffer[0], classfileBuffer[1],
classfileBuffer[2], classfileBuffer[3]);
return null;
}
}
}
我们先来看一个例子。在上面这段代码中,premain
方法多出了一个Instrumentation
类型的参数,我们可以通过它来注册类加载事件的拦截器。该拦截器需要实现ClassFileTransformer
接口,并重写其中的transform
方法。
transform
方法将接收一个 byte 数组类型的参数,它代表的是正在被加载的类的字节码。在上面这段代码中,我将打印该数组的前四个字节,也就是 Java class 文件的魔数(magic number)0xCAFEBABE。
transform
方法将返回一个 byte 数组,代表更新过后的类的字节码。当方法返回之后,Java 虚拟机会使用所返回的 byte 数组,来完成接下来的类加载工作。不过,如果transform
方法返回 null 或者抛出异常,那么 Java 虚拟机将使用原来的 byte 数组完成类加载工作。
基于这一类加载事件的拦截功能,我们可以实现字节码注入(bytecode instrumentation),往正在被加载的类中插入额外的字节码。
在工具篇中我曾经介绍过字节码工程框架 ASM 的用法。下面我将演示它的tree 包(依赖于基础包),用面向对象的方式注入字节码。
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
if ("main".equals(methodNode.name)) {
InsnList instrumentation = new InsnList();
instrumentation.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
instrumentation.add(new LdcInsnNode("Hello, Instrumentation!"));
instrumentation
.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false));
methodNode.instructions.insert(instrumentation);
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
上面这段代码不难理解。我们将使用ClassReader
读取所传入的 byte 数组,并将其转换成ClassNode
。然后我们将遍历ClassNode
中的MethodNode
节点,也就是该类中的构造器和方法。
当遇到名字为"main"
的方法时,我们会在方法的入口处注入System.out.println("Hello, Instrumentation!");
。运行结果如下所示:
$ java -javaagent:myagent.jar -cp .:/PATH/TO/asm-7.0-beta.jar:/PATH/TO/asm-tree-7.0-beta.jar HelloWorld
Hello, Instrumentation!
Hello, World!
Java agent 还提供了另外两个功能redefine
和retransform
。这两个功能针对的是已加载的类,并要求用户传入所要redefine
或者retransform
的类实例。
其中,redefine
指的是舍弃原本的字节码,并替换成由用户提供的 byte 数组。该功能比较危险,一般用于修复出错了的字节码。
retransform
则将针对所传入的类,重新调用所有已注册的ClassFileTransformer
的transform
方法。它的应用场景主要有如下两个。
第一,在执行premain
或者agentmain
方法前,Java 虚拟机早已加载了不少类,而这些类的加载事件并没有被拦截,因此也没有被注入。使用retransform
功能可以注入这些已加载但未注入的类。
第二,在定义了多个 Java agent,多个注入的情况下,我们可能需要移除其中的部分注入。当调用Instrumentation.removeTransformer
去除某个注入类后,我们可以调用retransform
功能,重新从原始 byte 数组开始进行注入。
Java agent 的这些功能都是通过 JVMTI agent,也就是 C agent 来实现的。JVMTI 是一个事件驱动的工具实现接口,通常,我们会在 C agent 加载后的入口方法Agent_OnLoad
处注册各个事件的钩子(hook)方法。当 Java 虚拟机触发了这些事件时,便会调用对应的钩子方法。
JNIEXPORT jint JNICALL
Agent_OnLoad(JavaVM *vm, char *options, void *reserved);
举个例子,我们可以为 JVMTI 中的ClassFileLoadHook
事件设置钩子,从而在 C 层面拦截所有的类加载事件。关于 JVMTI 的其他事件,你可以参考该链接。
基于字节码注入的 profiler
我们可以利用字节码注入来实现代码覆盖工具(例如JaCoCo),或者各式各样的 profiler。
通常,我们会定义一个运行时类,并在某一程序行为的周围,注入对该运行时类中方法的调用,以表示该程序行为正要发生或者已经发生。
package org.example;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class MyProfiler {
public static ConcurrentHashMap<Class<?>, AtomicInteger> data = new ConcurrentHashMap<>();
public static void fireAllocationEvent(Class<?> klass) {
data.computeIfAbsent(klass, kls -> new AtomicInteger())
.incrementAndGet();
}
public static void dump() {
data.forEach((kls, counter) -> {
System.err.printf("%s: %d\n", kls.getName(), counter.get());
});
}
static {
Runtime.getRuntime().addShutdownHook(new Thread(MyProfiler::dump));
}
}
举个例子,上面这段代码便是一个运行时类。该类维护了一个HashMap
,用来统计每个类所新建实例的数目。当程序退出时,我们将逐个打印出每个类的名字,以及其新建实例的数目。
在 Java agent 中,我们会截获正在加载的类,并且在每条new
字节码之后插入对fireAllocationEvent
方法的调用,以表示当前正在新建某个类的实例。具体的注入代码如下所示:
package org.example;
import java.lang.instrument.*;
import java.security.ProtectionDomain;
import org.objectweb.asm.*;
import org.objectweb.asm.tree.*;
public class MyAgent {
public static void premain(String args, Instrumentation instrumentation) {
instrumentation.addTransformer(new MyTransformer());
}
static class MyTransformer implements ClassFileTransformer, Opcodes {
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (className.startsWith("java") ||
className.startsWith("javax") ||
className.startsWith("jdk") ||
className.startsWith("sun") ||
className.startsWith("com/sun") ||
className.startsWith("org/example")) {
// Skip JDK classes and profiler classes
return null;
}
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode classNode = new ClassNode(ASM7);
cr.accept(classNode, ClassReader.SKIP_FRAMES);
for (MethodNode methodNode : classNode.methods) {
for (AbstractInsnNode node : methodNode.instructions.toArray()) {
if (node.getOpcode() == NEW) {
TypeInsnNode typeInsnNode = (TypeInsnNode) node;
InsnList instrumentation = new InsnList();
instrumentation.add(new LdcInsnNode(Type.getObjectType(typeInsnNode.desc)));
instrumentation.add(new MethodInsnNode(INVOKESTATIC, "org/example/MyProfiler", "fireAllocationEvent",
"(Ljava/lang/Class;)V", false));
methodNode.instructions.insert(node, instrumentation);
}
}
}
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
classNode.accept(cw);
return cw.toByteArray();
}
}
}
你或许已经留意到,我们不得不排除对 JDK 类以及该运行时类的注入。这是因为,对这些类的注入很可能造成死循环调用,并最终抛出StackOverflowException
异常。
举个例子,假设我们在PrintStream.println
方法入口处注入System.out.println("blahblah")
,由于out
是PrintStream
的实例,因此当执行注入代码时,我们又会调用PrintStream.println
方法,从而造成死循环。
解决这一问题的关键在于设置一个线程私有的标识位,用以区分应用代码的上下文以及注入代码的上下文。当即将执行注入代码时,我们将根据标识位判断是否已经位于注入代码的上下文之中。如果不是,则设置标识位并正常执行注入代码;如果是,则直接返回,不再执行注入代码。
字节码注入的另一个技术难点则是命名空间。举个例子,不少应用程序都依赖于字节码工程库 ASM。当我们的注入逻辑依赖于 ASM 时,便有可能出现注入使用最新版本的 ASM,而应用程序使用较低版本的 ASM 的问题。
JDK 本身也使用了 ASM 库,如用来生成 Lambda 表达式的适配器类。JDK 的做法是重命名整个 ASM 库,为所有类的包名添加jdk.internal
前缀。我们显然不好直接更改 ASM 的包名,因此需要借助自定义类加载器来隔离命名空间。
除了上述技术难点之外,基于字节码注入的工具还有另一个问题,那便是观察者效应(observer effect)对所收集的数据造成的影响。
举个利用字节码注入收集每个方法的运行时间的例子。假设某个方法调用了另一个方法,而这两个方法都被注入了,那么统计被调用者运行时间的注入代码所耗费的时间,将不可避免地被计入至调用者方法的运行时间之中。
再举一个统计新建对象数目的例子。我们知道,即时编译器中的逃逸分析可能会优化掉新建对象操作,但它不会消除相应的统计操作,比如上述例子中对fireAllocationEvent
方法的调用。在这种情况下,我们将统计没有实际发生的新建对象操作。
另一种情况则是,我们所注入的对fireAllocationEvent
方法的调用,将影响到方法内联的决策。如果该新建对象的构造器调用恰好因此没有被内联,从而造成对象逃逸。在这种情况下,原本能够被逃逸分析优化掉的新建对象操作将无法优化,我们也将统计到原本不会发生的新建对象操作。
总而言之,当使用字节码注入开发 profiler 时,需要辩证地看待所收集的数据。它仅能表示在被注入的情况下程序的执行状态,而非没有注入情况下的程序执行状态。
面向方面编程
说到字节码注入,就不得不提面向方面编程(Aspect-Oriented Programming,AOP)。面向方面编程的核心理念是定义切入点(pointcut)以及通知(advice)。程序控制流中所有匹配该切入点的连接点(joinpoint)都将执行这段通知代码。
举个例子,我们定义一个指代所有方法入口的切入点,并指定在该切入点执行的“打印该方法的名字”这一通知。那么每个具体的方法入口便是一个连接点。
面向方面编程的其中一种实现方式便是字节码注入,比如AspectJ。
在前面的例子中,我们也相当于使用了面向方面编程,在所有的new
字节码之后执行了下面这样一段通知代码。
`MyProfiler.fireAllocationEvent(<Target>.class)`
复制代码
我曾经参与开发过一个应用了面向方面编程思想的字节码注入框架DiSL。它支持用注解来定义切入点,用普通 Java 方法来定义通知。例如,在方法入口处打印所在的方法名,可以简单表示为如下代码:
@Before(marker = BodyMarker.class)
static void onMethodEntry(MethodStaticContext msc) {
System.out.println(msc.thisMethodFullName());
}
如果有同学对这个工具感兴趣,或者有什么需求或者建议,欢迎你在留言中提出。
总结与实践
今天我介绍了 Java agent 以及字节码注入。
我们可以通过 Java agent 的类加载拦截功能,修改某个类所对应的 byte 数组,并利用这个修改过后的 byte 数组完成接下来的类加载。
基于字节码注入的 profiler,可以统计程序运行过程中某些行为的出现次数。如果需要收集 Java 核心类库的数据,那么我们需要小心避免无限递归调用。另外,我们还需通过自定义类加载器来解决命名空间的问题。
由于字节码注入会产生观察者效应,因此基于该技术的 profiler 所收集到的数据并不能反映程序的真实运行状态。它所反映的是程序在被注入的情况下的执行状态。
4.34 - CH34-GraalVM
GraalVM 是一个高性能的、支持多种编程语言的执行环境。它既可以在传统的 OpenJDK 上运行,也可以通过 AOT(Ahead-Of-Time)编译成可执行文件单独运行,甚至可以集成至数据库中运行。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
今天这一篇,我们就来讲讲 GraalVM 的基石 Graal 编译器。
在之前的篇章中,特别是介绍即时编译技术的第二部分,我们反反复复提到了 Graal 编译器。这是一个用 Java 写就的即时编译器,它从 Java 9u 开始便被集成自 JDK 中,作为实验性质的即时编译器。
Graal 编译器可以通过 Java 虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
启用。当启用时,它将替换掉 HotSpot 中的 C2 编译器,并响应原本由 C2 负责的编译请求。
在今天的文章中,我将详细跟你介绍一下 Graal 与 Java 虚拟机的交互、Graal 和 C2 的区别以及 Graal 的实现细节。
Graal 和 Java 虚拟机的交互
我们知道,即时编译器是 Java 虚拟机中相对独立的模块,它主要负责接收 Java 字节码,并生成可以直接运行的二进制码。
具体来说,即时编译器与 Java 虚拟机的交互可以分为如下三个方面。
- 响应编译请求;
- 获取编译所需的元数据(如类、方法、字段)和反映程序执行状态的 profile;
- 将生成的二进制码部署至代码缓存(code cache)里。
即时编译器通过这三个功能组成了一个响应编译请求、获取编译所需的数据,完成编译并部署的完整编译周期。
传统情况下,即时编译器是与 Java 虚拟机紧耦合的。也就是说,对即时编译器的更改需要重新编译整个 Java 虚拟机。这对于开发相对活跃的 Graal 来说显然是不可接受的。
为了让 Java 虚拟机与 Graal 解耦合,我们引入了Java 虚拟机编译器接口(JVM Compiler Interface,JVMCI),将上述三个功能抽象成一个 Java 层面的接口。这样一来,在 Graal 所依赖的 JVMCI 版本不变的情况下,我们仅需要替换 Graal 编译器相关的 jar 包(Java 9 以后的 jmod 文件),便可完成对 Graal 的升级。
JVMCI 的作用并不局限于完成由 Java 虚拟机发出的编译请求。实际上,Java 程序可以直接调用 Graal,编译并部署指定方法。
Graal 的单元测试便是基于这项技术。为了测试某项优化是否起作用,原本我们需要反复运行某一测试方法,直至 Graal 收到由 Java 虚拟机发出针对该方法的编译请求,而现在我们可以直接指定编译该方法,并进行测试。我们下一篇将介绍的 Truffle 语言实现框架,同样也是基于这项技术的。
Graal 和 C2 的区别
Graal 和 C2 最为明显的一个区别是:Graal 是用 Java 写的,而 C2 是用 C++ 写的。相对来说,Graal 更加模块化,也更容易开发与维护,毕竟,连 C2 的作者 Cliff Click 大神都不想重蹈用 C++ 开发 Java 虚拟机的覆辙。
许多开发者会觉得用 C++ 写的 C2 肯定要比 Graal 快。实际上,在充分预热的情况下,Java 程序中的热点代码早已经通过即时编译转换为二进制码,在执行速度上并不亚于静态编译的 C++ 程序。
再者,即便是解释执行 Graal,也仅是会减慢编译效率,而并不影响编译结果的性能。
换句话说,如果 C2 和 Graal 采用相同的优化手段,那么它们的编译结果是一样的。所以,程序达到稳定状态(即不再触发新的即时编译)的性能,也就是峰值性能,将也是一样的。
由于 Java 语言容易开发维护的优势,我们可以很方便地将 C2 的新优化移植到 Graal 中。反之则不然,比如,在 Graal 中被证实有效的部分逃逸分析(partial escape analysis)至今未被移植到 C2 中。
Graal 和 C2 另一个优化上的分歧则是方法内联算法。相对来说,Graal 的内联算法对新语法、新语言更加友好,例如 Java 8 的 lambda 表达式以及 Scala 语言。
我们曾统计过数十个 Java 或 Scala 程序的峰值性能。总体而言,Graal 编译结果的性能要优于 C2。对于 Java 程序来说,Graal 的优势并不明显;对于 Scala 程序来说,Graal 的性能优势达到了 10%。
大规模使用 Scala 的 Twitter 便在他们的生产环境中部署了 Graal 编译器,并取得了 11% 的性能提升。(Slides, Video,该数据基于 GraalVM 社区版。)
Graal 的实现
Graal 编译器将编译过程分为前端和后端两大部分。前端用于实现平台无关的优化(如方法内联),以及小部分平台相关的优化;而后端则负责大部分的平台相关优化(如寄存器分配),以及机器码的生成。
在介绍即时编译技术时,我曾提到过,Graal 和 C2 都采用了 Sea-of-Nodes IR。严格来说,这里指的是 Graal 的前端,而后端采用的是另一种非 Sea-of-Nodes 的 IR。通常,我们将前端的 IR 称之为 High-level IR,或者 HIR;后端的 IR 则称之为 Low-level IR,或者 LIR。
Graal 的前端是由一个个单独的优化阶段(optimization phase)构成的。我们可以将每个优化阶段想象成一个图算法:它会接收一个规则的图,遍历图上的节点并做出优化,并且返回另一个规则的图。前端中的编译阶段除了少数几个关键的之外,其余均可以通过配置选项来开启或关闭。
Graal 编译器前端的优化阶段(局部)
感兴趣的同学可以阅读 Graal repo 里配置这些编译优化阶段的源文件 HighTier.java,MidTier.java,以及LowTier.java。
我们知道,Graal 和 C2 都采用了激进的投机性优化手段(speculative optimization)。
通常,这些优化都基于某种假设(assumption)。当假设出错的情况下,Java 虚拟机会借助去优化(deoptimization)这项机制,从执行即时编译器生成的机器码切换回解释执行,在必要情况下,它甚至会废弃这份机器码,并在重新收集程序 profile 之后,再进行编译。
举个以前讲过的例子,类层次分析。在进行虚方法内联时(或者其他与类层次相关的优化),我们可能会发现某个接口仅有一个实现。
在即时编译过程中,我们可以假设在之后的执行过程中仍旧只有这一个实现,并根据这个假设进行编译优化。当之后加载了接口的另一实现时,我们便会废弃这份机器码。
Graal 与 C2 相比会更加激进。它从设计上便十分青睐这种基于假设的优化手段。在编译过程中,Graal 支持自定义假设,并且直接与去优化节点相关联。
当对应的去优化被触发时,Java 虚拟机将负责记录对应的自定义假设。而 Graal 在第二次编译同一方法时,便会知道该自定义假设有误,从而不再对该方法使用相同的激进优化。
Java 虚拟机的另一个能够大幅度提升性能的特性是 intrinsic 方法,我在之前的篇章中已经详细介绍过了。在 Graal 中,实现高性能的 intrinsic 方法也相对比较简单。Graal 提供了一种替换方法调用的机制,在解析 Java 字节码时会将匹配到的方法调用,替换成对另一个内部方法的调用,或者直接替换为特殊节点。
举例来说,我们可以把比较两个 byte 数组的方法java.util.Arrays.equals(byte[],byte[])
替换成一个特殊节点,用来代表整个数组比较的逻辑。这样一来,当前编译方法所对应的图将被简化,因而其适用于其他优化的可能性也将提升。
总结与实践
Graal 是一个用 Java 写就的、并能够将 Java 字节码转换成二进制码的即时编译器。它通过 JVMCI 与 Java 虚拟机交互,响应由后者发出的编译请求、完成编译并部署编译结果。
对 Java 程序而言,Graal 编译结果的性能略优于 OpenJDK 中的 C2;对 Scala 程序而言,它的性能优势可达到 10%(企业版甚至可以达到 20%!)。这背后离不开 Graal 所采用的激进优化方式。
4.35 - CH35-Truffle
今天我们来聊聊 GraalVM 中的语言实现框架 Truffle。
我们知道,实现一门新编程语言的传统做法是实现一个编译器,也就是把用该语言编写的程序转换成可直接在硬件上运行的机器码。
通常来说,编译器分为前端和后端:前端负责词法分析、语法分析、类型检查和中间代码生成,后端负责编译优化和目标代码生成。
不过,许多编译器教程只涉及了前端中的词法分析和语法分析,并没有真正生成可以运行的目标代码,更谈不上编译优化,因此在生产环境中并不实用。
另一种比较取巧的做法则是将新语言编译成某种已知语言,或者已知的中间形式,例如将 Scala、Kotlin 编译成 Java 字节码。
这样做的好处是可以直接享用 Java 虚拟机自带的各项优化,包括即时编译、自动内存管理等等。因此,这种做法对所生成的 Java 字节码的优化程度要求不高。
不过,不管是附带编译优化的编译器,还是生成中间形式并依赖于其他运行时的即时编译优化的编译器,它们所针对的都是编译型语言,在运行之前都需要这一额外的编译步骤。
与编译型语言相对应的则是解释型语言,例如 JavaScript、Ruby、Python 等。对于这些语言来说,它们无须额外的编译步骤,而是依赖于解释执行器进行解析并执行。
为了让该解释执行器能够高效地运行大型程序,语言实现开发人员通常会将其包装在虚拟机里,并实现诸如即时编译、垃圾回收等其他组件。这些组件对语言设计 本身并无太大贡献,仅仅是为了实用性而不得不进行的工程实现。
在理想情况下,我们希望在不同的语言实现中复用这些组件。也就是说,每当开发一门新语言时,我们只需要实现它的解释执行器,便能够直接复用即时编译、垃圾回收等组件,从而达到高性能的效果。这也是 Truffle 项目的目标。接下来,我们就来讲讲这个项目。
Truffle 项目简介
Truffle 是一个用 Java 写就的语言实现框架。基于 Truffle 的语言实现仅需用 Java 实现词法分析、语法分析以及针对语法分析所生成的抽象语法树(Abstract Syntax Tree,AST)的解释执行器,便可以享用由 Truffle 提供的各项运行时优化。
就一个完整的 Truffle 语言实现而言,由于实现本身以及其所依赖的 Truffle 框架部分都是用 Java 实现的,因此它可以运行在任何 Java 虚拟机之上。
当然,如果 Truffle 运行在附带了 Graal 编译器的 Java 虚拟机之上,那么它将调用 Graal 编译器所提供的 API,主动触发对 Truffle 语言的即时编译,将对 AST 的解释执行转换为执行即时编译后的机器码。
在这种情况下,Graal 编译器相当于一个提供了即时编译功能的库,宿主虚拟机本身仍可使用 C2 作为其唯一的即时编译器,或者分层编译模式下的 4 层编译器。
我们团队实现并且开源了多个 Truffle 语言,例如JavaScript,Ruby,R,Python,以及可用来解释执行 LLVM bitcode 的Sulong。关于 Sulong 项目,任何能够编译为 LLVM bitcode 的编程语言,例如 C/C++,都能够在这上面运行。
下图展示了运行在 GraalVM EE 上的 Java 虚拟机语言,以及除 Python 外 Truffle 语言的峰值性能指标(2017 年数据)。
这里我采用的基线是每个语言较有竞争力的语言实现。
- 对于 Java 虚拟机语言(Java、Scala),我比较的是使用 C2 的 HotSpot 虚拟机和使用 Graal 的 HotSpot 虚拟机。
- 对于 Ruby,我比较的是运行在 HotSpot 虚拟机之上的 JRuby 和 Truffle Ruby。
- 对于 R,我比较的是 GNU R 和基于 Truffle 的 FastR。
- 对于 C/C++,我比较的是利用 LLVM 编译器生成的二进制文件和基于 Truffle 的 Sulong。
- 对于 JavaScript,我比较的是 Google 的 V8 和 Graal.js。
针对每种语言,我们运行了上百个基准测试,求出各个基准测试峰值性能的加速比,并且汇总成图中所示的几何平均值(Geo. mean)。
简单地说明一下,当 GraalVM 的加速比为 1 时,代表使用其他语言实现和使用 GraalVM 的性能相当。当 GraalVM 加速比超过 1 时,则代表 GraalVM 的性能较好;反之,则说明 GraalVM 的性能较差。
我们可以看到,Java 跑在 Graal 上和跑在 C2 上的执行效率类似,而 Scala 跑在 Graal 上的执行效率则是跑在 C2 上的 1.2 倍。
对于 Ruby 或者 R 这类解释型语言,经由 Graal 编译器加速的 Truffle 语言解释器的性能十分优越,分别达到对应基线的 4.1x 和 4.5x。这里便可以看出使用专业即时编译器的 Truffle 框架的优势所在。
不过,对于同样拥有专业即时编译器的 V8 来说,基于 Truffle 的 Graal.js 仍处于追赶者的位置。考虑到我们团队中负责 Graal.js 的工程师仅有个位数,能够达到如此性能已属不易。现在 Graal.js 已经开源出来,我相信借助社区的贡献,它的性能能够得到进一步的提升。
Sulong 与传统的 C/C++ 相比,由于两者最终都将编译为机器码,因此原则上后者定义了前者的性能上限。
不过,Sulong 将 C/C++ 代码放在托管环境中运行,所有代码中的内存访问都会在托管环境的监控之下。无论是会触发 Segfault 的异常访问,还是读取敏感数据的恶意访问,都能够被 Sulong 拦截下来并作出相应处理。
Partial Evaluation
如果要理解 Truffle 的原理,我们需要先了解 Partial Evaluation 这一个概念。
假设有一段程序P
,它将一系列输入I
转换成输出O
(即P: I -> O
)。而这些输入又可以进一步划分为编译时已知的常量IS
,和编译时未知的ID
。
那么,我们可以将程序P: I -> O
转换为等价的另一段程序P': ID -> O
。这个新程序P'
便是P
的特化(Specialization),而从P
转换到P'
的这个过程便是所谓的 Partial Evaluation。
回到 Truffle 这边,我们可以将 Truffle 语言的解释执行器当成P
,将某段用 Truffle 语言写就的程序当作IS
,并通过 Partial Evaluation 特化为P'
。由于 Truffle 语言的解释执行器是用 Java 写的,因此我们可以利用 Graal 编译器将P'
编译为二进制码。
下面我将用一个具体例子来讲解。
假设有一门语言 X,只支持读取整数参数和整数加法。这两种操作分别对应下面这段代码中的 AST 节点Arg
和Add
。
abstract class Node {
abstract int execute(int[] args);
}
class Arg extends Node {
final int index;
Arg(int i) { this.index = i; }
int execute(int[] args) {
return args[index];
}
}
class Add extends Node {
final Node left, right;
Add(Node left, Node right) {
this.left = left;
this.right = right;
}
int execute(int[] args) {
return left.execute(args) +
right.execute(args);
}
}
static int interpret(Node node, int[] args) {
return node.execute(args);
}
所谓 AST 节点的解释执行,便是调用这些 AST 节点的execute
方法;而一段程序的解释执行,则是调用这段程序的 AST 根节点的execute
方法。
我们可以看到,Arg
节点和Add
节点均实现了execute
方法,接收一个用来指代程序输入的 int 数组参数,并返回计算结果。其中,Arg
节点将返回 int 数组的第i
个参数(i
是硬编码在程序之中的常量);而Add
节点将分别调用左右两个节点的execute
方法, 并将所返回的值相加后再返回。
下面我们将利用语言 X 实现一段程序,计算三个输入参数之和arg0 + arg1 + arg2
。这段程序解析生成的 AST 如下述代码所示:
// Sample program: arg0 + arg1 + arg2
sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
这段程序对应的解释执行则是interpret(sample, args)
,其中args
为代表传入参数的 int 数组。由于sample
是编译时常量,因此我们可以将其通过 Partial Evaluation,特化为下面这段代码所示的interpret0
方法:
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.execute(args);
}
Truffle 的 Partial Evaluator 会不断进行方法内联(直至遇到被``@TruffleBoundary注解的方法)。因此,上面这段代码的
interpret0方法,在内联了对
Add.execute`方法的调用之后,会转换成下述代码:
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.left.execute(args) + sample.right.execute(args);
}
同样,我们可以进一步内联对Add.execute
方法的调用以及对Arg.execute
方法的调用,最终将interpret0
转换成下述代码:
static int interpret0(int[] args) {
return args[0] + args[1] + args[2];
}
至此,我们已成功地将一段 Truffle 语言代码的解释执行转换为上述 Java 代码。接下来,我们便可以让 Graal 编译器将这段 Java 代码编译为机器码,从而实现 Truffle 语言的即时编译。
节点重写
Truffle 的另一项关键优化是节点重写(node rewriting)。
在动态语言中,许多变量的类型是在运行过程中方能确定的。以加法符号+
为例,它既可以表示整数加法,还可以表示浮点数加法,甚至可以表示字符串加法。
如果是静态语言,我们可以通过推断加法的两个操作数的具体类型,来确定该加法的类型。但对于动态语言来说,我们需要在运行时动态确定操作数的具体类型,并据此选择对应的加法操作。这种在运行时选择语义的节点,会十分不利于即时编译,从而严重影响到程序的性能。
Truffle 语言解释器会收集每个 AST 节点所代表的操作的类型,并且在即时编译时,作出针对所收集得到的类型 profile 的特化(specialization)。
还是以加法操作为例,如果所收集的类型 profile 显示这是一个整数加法操作,那么在即时编译时我们会将对应的 AST 节点当成整数加法;如果是一个字符串加法操作,那么我们会将对应的 AST 节点当成字符串加法。
当然,如果该加法操作既有可能是整数加法也可能是字符串加法,那么我们只好在运行过程中判断具体的操作类型,并选择相应的加法操作。
这种基于类型 profile 的优化,与我们以前介绍过的 Java 虚拟机中解释执行器以及三层 C1 编译代码十分类似,它们背后的核心都是基于假设的投机性优化,以及在假设失败时的去优化。
在即时编译过后,如果运行过程中发现 AST 节点的实际类型和所假设的类型不同,Truffle 会主动调用 Graal 编译器提供的去优化 API,返回至解释执行 AST 节点的状态,并且重新收集 AST 节点的类型信息。之后,Truffle 会再次利用 Graal 编译器进行新一轮的即时编译。
当然,如果能够在第一次编译时便已达到稳定状态,不再触发去优化以及重新编译,那么,这会极大地减短程序到达峰值性能的时间。为此,我们统计了各个 Truffle 语言的方法在进行过多少次方法调用后,其 AST 节点的类型会固定下来。
据统计,在 JavaScript 方法和 Ruby 方法中,80% 会在 5 次方法调用后稳定下来,90% 会在 7 次调用后稳定下来,99% 会在 19 次方法调用之后稳定下来。
R 语言的方法则比较特殊,即便是不进行任何调用,有 50% 的方法已经稳定下来了。这背后的原因也不难推测,这是因为 R 语言主要用于数值统计,几乎所有的操作都是浮点数类型的。
Polyglot
在开发过程中,我们通常会为工程项目选定一门语言,但问题也会接踵而至:一是这门语言没有实现我们可能需要用到的库,二是这门语言并不适用于某类问题。
Truffle 语言实现框架则支持 Polyglot,允许在同一段代码中混用不同的编程语言,从而使得开发人员能够自由地选择合适的语言来实现子组件。
与其他 Polyglot 框架不同的是,Truffle 语言之间能够共用对象。也就是说,在不对某个语言中的对象进行复制或者序列化反序列化的情况下,Truffle 可以无缝地将该对象传递给另一门语言。因此,Truffle 的 Polyglot 在切换语言时,性能开销非常小,甚至经常能够达到零开销。
Truffle 的 Polyglot 特性是通过 Polyglot API 来实现的。每个实现了 Polyglot API 的 Truffle 语言,其对象都能够被其他 Truffle 语言通过 Polyglot API 解析。实际上,当通过 Polyglot API 解析外来对象时,我们并不需要了解对方语言,便能够识别其数据结构,访问其中的数据,并进行进一步的计算。
总结与实践
今天我介绍了 GraalVM 中的 Truffle 项目。
Truffle 是一个语言实现框架,允许语言开发者在仅实现词法解析、语法解析以及 AST 解释器的情况下,达到极佳的性能。目前 Oracle Labs 已经实现并维护了 JavaScript、Ruby、R、Python 以及可用于解析 LLVM bitcode 的 Sulong。后者将支持在 GraalVM 上运行 C/C++ 代码。
Truffle 背后所依赖的技术是 Partial Evaluation 以及节点重写。Partial Evaluation 指的是将所要编译的目标程序解析生成的抽象语法树当做编译时常量,特化该 Truffle 语言的解释器,从而得到指代这段程序解释执行过程的 Java 代码。然后,我们可以借助 Graal 编译器将这段 Java 代码即时编译为机器码。
节点重写则是收集 AST 节点的类型,根据所收集的类型 profile 进行的特化,并在节点类型不匹配时进行去优化并重新收集、编译的一项技术。
Truffle 的 Polyglot 特性支持在一段代码中混用多种不同的语言。与其他 Polyglot 框架相比,它支持在不同的 Truffle 语言中复用内存中存储的同一个对象。
4.36 - CH36-SubstrateVM
今天我们来聊聊 GraalVM 中的 Ahead-Of-Time(AOT)编译框架 SubstrateVM。
先来介绍一下 AOT 编译,所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。
而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。它的成果可以是需要链接至托管环境中的动态共享库,也可以是独立运行的可执行文件。
狭义的 AOT 编译针对的目标代码需要与即时编译的一致,也就是针对那些原本可以被即时编译的代码。不过,我们也可以简单地将 AOT 编译理解为类似于 GCC 的静态编译器。
AOT 编译的优点显而易见:我们无须在运行过程中耗费 CPU 资源来进行即时编译,而程序也能够在启动伊始就达到理想的性能。
然而,与即时编译相比,AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序 profile 的投机性优化(并非硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序 profile 来绕开这两个限制)。这两者都会影响程序的峰值性能。
Java 9 引入了实验性 AOT 编译工具jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
在启动过程中,Java 虚拟机将加载参数-XX:AOTLibrary
所指定的动态共享库,并部署其中的机器码。这些机器码的作用机理和即时编译生成的机器码作用机理一样,都是在方法调用时切入,并能够去优化至解释执行。
由于 Java 虚拟机可能通过 Java agent 或者 C agent 改动所加载的字节码,或者这份 AOT 编译生成的机器码针对的是旧版本的 Java 类,因此它需要额外的验证机制,来保证即将链接的机器码的语义与对应的 Java 类的语义是一致的。
jaotc 使用的机制便是类指纹(class fingerprinting)。它会在动态共享库中保存被 AOT 编译的 Java 类的摘要信息。在运行过程中,Java 虚拟机负责将该摘要信息与已加载的 Java 类相比较,一旦不匹配,则直接舍弃这份 AOT 编译的机器码。
jaotc 的一大应用便是编译 java.base module,也就是 Java 核心类库中最为基础的类。这些类很有可能会被应用程序所调用,但调用频率未必高到能够触发即时编译。
因此,如果 Java 虚拟机能够使用 AOT 编译技术,将它们提前编译为机器码,那么将避免在执行即时编译生成的机器码时,因为“不小心”调用到这些基础类,而需要切换至解释执行的性能惩罚。
不过,今天要介绍的主角并非 jaotc,而是同样使用了 Graal 编译器的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计与实现
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,并且能够无缝衔接 C 代码的 Java 运行时。它与 jaotc 的区别主要有两处。
第一,SubstrateVM 脱离了 HotSpot 虚拟机,并拥有独立的运行时,包含异常处理,同步,线程管理,内存管理(垃圾回收)和 JNI 等组件。
第二,SubstrateVM 要求目标程序是封闭的,即不能动态加载其他类库等。基于这个假设,SubstrateVM 将探索整个编译空间,并通过静态分析推算出所有虚方法调用的目标方法。最终,SubstrateVM 会将所有可能执行到的方法都纳入编译范围之中,从而免于实现额外的解释执行器。
有关 SubstrateVM 的其他限制,你可以参考这篇文档。
从执行时间上来划分,SubstrateVM 可分为两部分:native image generator 以及 SubstrateVM 运行时。后者 SubstrateVM 运行时便是前面提到的精简运行时,经过 AOT 编译的目标程序将跑在该运行时之上。
native image generator 则包含了真正的 AOT 编译逻辑。它本身是一个 Java 程序,将使用 Graal 编译器将 Java 类文件编译为可执行文件或者动态链接库。
在进行编译之前,native image generator 将采用指针分析(points-to analysis),从用户提供的程序入口出发,探索所有可达的代码。在探索的同时,它还将执行初始化代码,并在最终生成可执行文件时,将已初始化的堆保存至一个堆快照之中。这样一来,SubstrateVM 将直接从目标程序开始运行,而无须重复进行 Java 虚拟机的初始化。
SubstrateVM 主要用于 Java 虚拟机语言的 AOT 编译,例如 Java、Scala 以及 Kotlin。Truffle 语言实现本质上就是 Java 程序,而且它所有用到的类都是编译时已知的,因此也适合在 SubstrateVM 上运行。不过,它并不会 AOT 编译用 Truffle 语言写就的程序。
SubstrateVM 的启动时间与内存开销
SubstrateVM 的启动时间和内存开销非常少。我们曾比较过用 C 和用 Java 两种语言写就的 Hello World 程序。C 程序的执行时间在 10ms 以下,内存开销在 500KB 以下。在 HotSpot 虚拟机上运行的 Java 程序则需要 40ms,内存开销为 24MB。
使用 SubstrateVM 的 Java 程序的执行时间则与 C 程序持平,内存开销在 850KB 左右。这得益于 SubstrateVM 所保存的堆快照,以及无须额外初始化,直接执行目标代码的特性。
同样,我们还比较了用 JavaScript 编写的 Hello World 程序。这里的测试对象是 Google 的 V8 以及基于 Truffle 的 Graal.js。这两个执行引擎都涉及了大量的解析代码以及执行代码,因此可以当作大型应用程序来看待。
V8 的执行效率非常高,能够与 C 程序的 Hello World 相媲美,但是它使用了约 18MB 的内存。运行在 HotSpot 虚拟机上的 Graal.js 则需要 650ms 方能执行完这段 JavaScript 的 Hello World 程序,而且内存开销在 120MB 左右。
运行在 SubstrateVM 上的 Graal.js 无论是执行时间还是内存开销都十分优越,分别为 10ms 以下以及 4.2MB。我们可以看到,它在运行时间与 V8 持平的情况下,内存开销远小于 V8。
由于 SubstrateVM 的轻量特性,它十分适合于嵌入至其他系统之中。Oracle Labs 的另一个团队便是将 Truffle 语言实现嵌入至 Oracle 数据库之中,这样就可以在数据库中运行任意语言的预储程序(stored procedure)。如果你感兴趣的话,可以搜索 Oracle Database Multilingual Engine(MLE),或者参阅这个网址。我们团队也在与 MySQL 合作,开发 MySQL MLE,详情可留意我们在今年 Oracle Code One 的[讲座](https://oracle.rainfocus.com/widget/oracle/oow18/catalogcodeone18?search=MySQL JavaScript)。
Metropolis 项目
去年 OpenJDK 推出了Metropolis 项目,他们希望可以实现“Java-on-Java”的远大目标。
我们知道,目前 HotSpot 虚拟机的绝大部分代码都是用 C++ 写的。这也造就了一个非常有趣的现象,那便是对 Java 语言本身的贡献需要精通 C++。此外,随着 HotSpot 项目日渐庞大,维护难度也逐渐上升。
由于上述种种原因,使用 Java 来开发 Java 虚拟机的呼声越来越高。Oracle 的架构师 John Rose 便提出了使用 Java 开发 Java 虚拟机的四大好处:
- 能够完全控制编译 Java 虚拟机时所使用的优化技术;
- 能够与 C++ 语言的更新解耦合;
- 能够减轻开发人员以及维护人员的负担;
- 能够以更为敏捷的方式实现 Java 的新功能。
当然,Metropolis 项目并非第一个提出 Java-on-Java 概念的项目。实际上,JikesRVM 项目和Maxine VM 项目都已用 Java 完整地实现了一套 Java 虚拟机(后者的即时编译器 C1X 便是 Graal 编译器的前身)。
然而,Java-on-Java 技术通常会干扰应用程序的垃圾回收、即时编译优化,从而严重影响 Java 虚拟机的启动性能。
举例来说,目前使用了 Graal 编译器的 HotSpot 虚拟机会在即时编译过程中生成大量的 Java 对象,这些 Java 对象同样会占据应用程序的堆空间,从而使得垃圾回收更加频繁。
另外,Graal 编译器本身也会触发即时编译,并与应用程序的即时编译竞争编译线程的 CPU 资源。这将造成应用程序从解释执行切换至即时编译生成的机器码的时间大大地增长,从而降低应用程序的启动性能。
Metropolis 项目的第一个子项目便是探索部署已 AOT 编译的 Graal 编译器的可能性。这个子项目将借助 SubstrateVM 技术,把整个 Graal 编译器 AOT 编译为机器码。
这样一来,在运行过程中,Graal 编译器不再需要被即时编译,因此也不会再占据可用于即时编译应用程序的 CPU 资源,使用 Graal 编译器的 HotSpot 虚拟机的启动性能将得到大幅度地提升。
此外,由于 SubstrateVM 编译得到的 Graal 编译器将使用独立的堆空间,因此 Graal 编译器在即时编译过程中生成的 Java 对象将不再干扰应用程序所使用的堆空间。
目前 Metropolis 项目仍处于前期验证阶段,如果你感兴趣的话,可以关注之后的发展情况。
总结与实践
今天我介绍了 GraalVM 中的 AOT 编译框架 SubstrateVM。
SubstrateVM 的设计初衷是提供一个高启动性能、低内存开销,和能够无缝衔接 C 代码的 Java 运行时。它是一个独立的运行时,拥有自己的内存管理等组件。
SubstrateVM 要求所要 AOT 编译的目标程序是封闭的,即不能动态加载其他类库等。在进行 AOT 编译时,它会探索所有可能运行到的方法,并全部纳入编译范围之内。
SubstrateVM 的启动时间和内存开销都非常少,这主要得益于在 AOT 编译时便已保存了已初始化好的堆快照,并支持从程序入口直接开始运行。作为对比,HotSpot 虚拟机在执行 main 方法前需要执行一系列的初始化操作,因此启动时间和内存开销都要远大于运行在 SubstrateVM 上的程序。
Metropolis 项目将运用 SubstrateVM 项目,逐步地将 HotSpot 虚拟机中的 C++ 代码替换成 Java 代码,从而提升 HotSpot 虚拟机的可维护性,也加快新 Java 功能的开发效率。
4.37 - CH37-常用工具
1. javap:查阅 Java 字节码
javap 是一个能够将 class 文件反汇编成人类可读格式的工具。在本专栏中,我们经常借助这个工具来查阅 Java 字节码。
举个例子,在讲解异常处理那一篇中,我曾经展示过这么一段代码。
public class Foo {
private int tryBlock;
private int catchBlock;
private int finallyBlock;
private int methodExit;
public void test() {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}
编译过后,我们便可以使用 javap 来查阅 Foo.test 方法的字节码。
$ javac Foo.java
$ javap -p -v Foo
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#24 // Foo.tryBlock:I
#3 = Fieldref #7.#25 // Foo.finallyBlock:I
#4 = Class #26 // java/lang/Exception
#5 = Fieldref #7.#27 // Foo.catchBlock:I
#6 = Fieldref #7.#28 // Foo.methodExit:I
#7 = Class #29 // Foo
#8 = Class #30 // java/lang/Object
#9 = Utf8 tryBlock
#10 = Utf8 I
#11 = Utf8 catchBlock
#12 = Utf8 finallyBlock
#13 = Utf8 methodExit
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 test
#19 = Utf8 StackMapTable
#20 = Class #31 // java/lang/Throwable
#21 = Utf8 SourceFile
#22 = Utf8 Foo.java
#23 = NameAndType #14:#15 // "<init>":()V
#24 = NameAndType #9:#10 // tryBlock:I
#25 = NameAndType #12:#10 // finallyBlock:I
#26 = Utf8 java/lang/Exception
#27 = NameAndType #11:#10 // catchBlock:I
#28 = NameAndType #13:#10 // methodExit:I
#29 = Utf8 Foo
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/Throwable
{
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int catchBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int finallyBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
private int methodExit;
descriptor: I
flags: (0x0002) ACC_PRIVATE
public Foo();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: iconst_0
2: putfield #2 // Field tryBlock:I
5: aload_0
6: iconst_2
7: putfield #3 // Field finallyBlock:I
10: goto 35
13: astore_1
14: aload_0
15: iconst_1
16: putfield #5 // Field catchBlock:I
19: aload_0
20: iconst_2
21: putfield #3 // Field finallyBlock:I
24: goto 35
27: astore_2
28: aload_0
29: iconst_2
30: putfield #3 // Field finallyBlock:I
33: aload_2
34: athrow
35: aload_0
36: iconst_3
37: putfield #6 // Field methodExit:I
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
line 13: 5
line 14: 10
line 10: 13
line 11: 14
line 13: 19
line 14: 24
line 13: 27
line 14: 33
line 15: 35
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
frame_type = 7 /* same */
}
SourceFile: "Foo.java"
这里面我用到了两个选项。第一个选项是 -p。默认情况下 javap 会打印所有非私有的字段和方法,当加了 -p 选项后,它还将打印私有的字段和方法。第二个选项是 -v。它尽可能地打印所有信息。如果你只需要查阅方法对应的字节码,那么可以用 -c 选项来替换 -v。
javap 的 -v 选项的输出分为几大块。
1. 基本信息,涵盖了原 class 文件的相关信息。
class 文件的版本号(minor version: 0,major version: 54),该类的访问权限(flags: (0x0021) ACC_PUBLIC, ACC_SUPER),该类(this_class: #7)以及父类(super_class: #8)的名字,所实现接口(interfaces: 0)、字段(fields: 4)、方法(methods: 2)以及属性(attributes: 1)的数目。
这里属性指的是 class 文件所携带的辅助信息,比如该 class 文件的源文件的名称。这类信息通常被用于 Java 虚拟机的验证和运行,以及 Java 程序的调试,一般无须深入了解。
Classfile ../Foo.class
Last modified ..; size 541 bytes
MD5 checksum 3828cdfbba56fea1da6c8d94fd13b20d
Compiled from "Foo.java"
public class Foo
minor version: 0
major version: 54
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // Foo
super_class: #8 // java/lang/Object
interfaces: 0, fields: 4, methods: 2, attributes: 1
class 文件的版本号指的是编译生成该 class 文件时所用的 JRE 版本。由较新的 JRE 版本中的 javac 编译而成的 class 文件,不能在旧版本的 JRE 上跑,否则,会出现如下异常信息。(Java 8 对应的版本号为 52,Java 10 对应的版本号为 54。)
Exception in thread "main" java.lang.UnsupportedClassVersionError: Foo has been compiled by a more recent version of the Java Runtime (class file version 54.0), this version of the Java Runtime only recognizes class file versions up to 52.0
类的访问权限通常为 ACC_ 开头的常量。具体每个常量的意义可以查阅 Java 虚拟机规范 4.1 小节 [1]。
2. 常量池,用来存放各种常量以及符号引用。
常量池中的每一项都有一个对应的索引(如 #1),并且可能引用其他的常量池项(#1 = Methodref #8.#23)。
Constant pool:
#1 = Methodref #8.#23 // java/lang/Object."<init>":()V
...
#8 = Class #30 // java/lang/Object
...
#14 = Utf8 <init>
#15 = Utf8 ()V
...
#23 = NameAndType #14:#15 // "<init>":()V
...
#30 = Utf8 java/lang/Object
举例来说,上图中的 1 号常量池项是一个指向 Object 类构造器的符号引用。它是由另外两个常量池项所构成。如果将它看成一个树结构的话,那么它的叶节点会是字符串常量,如下图所示。
3. 字段区域,用来列举该类中的各个字段。
这里最主要的信息便是该字段的类型(descriptor: I)以及访问权限(flags: (0x0002) ACC_PRIVATE)。对于声明为 final 的静态字段而言,如果它是基本类型或者字符串类型,那么字段区域还将包括它的常量值。
private int tryBlock;
descriptor: I
flags: (0x0002) ACC_PRIVATE
另外,Java 虚拟机同样使用了“描述符”(descriptor)来描述字段的类型。具体的对照如下表所示。其中比较特殊的,我已经高亮显示。
4. 方法区域,用来列举该类中的各个方法。
除了方法描述符以及访问权限之外,每个方法还包括最为重要的代码区域(Code:)。
public void test();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
...
10: goto 35
...
34: athrow
35: aload_0
...
40: return
Exception table:
from to target type
0 5 13 Class java/lang/Exception
0 5 27 any
13 19 27 any
LineNumberTable:
line 9: 0
...
line 16: 40
StackMapTable: number_of_entries = 3
frame_type = 77 /* same_locals_1_stack_item */
stack = [ class java/lang/Exception ]
...
代码区域一开始会声明该方法中的操作数栈(stack=2)和局部变量数目(locals=3)的最大值,以及该方法接收参数的个数(args_size=1)。注意这里局部变量指的是字节码中的局部变量,而非 Java 程序中的局部变量。
接下来则是该方法的字节码。每条字节码均标注了对应的偏移量(bytecode index,BCI),这是用来定位字节码的。比如说偏移量为 10 的跳转字节码 10: goto 35,将跳转至偏移量为 35 的字节码 35: aload_0。
紧跟着的异常表(Exception table:)也会使用偏移量来定位每个异常处理器所监控的范围(由 from 到 to 的代码区域),以及异常处理器的起始位置(target)。除此之外,它还会声明所捕获的异常类型(type)。其中,any 指代任意异常类型。
再接下来的行数表(LineNumberTable:)则是 Java 源程序到字节码偏移量的映射。如果你在编译时使用了 -g 参数(javac -g Foo.java),那么这里还将出现局部变量表(LocalVariableTable:),展示 Java 程序中每个局部变量的名字、类型以及作用域。
行数表和局部变量表均属于调试信息。Java 虚拟机并不要求 class 文件必备这些信息。
LocalVariableTable:
Start Length Slot Name Signature
14 5 1 e Ljava/lang/Exception;
0 41 0 this LFoo;
最后则是字节码操作数栈的映射表(StackMapTable: number_of_entries = 3)。该表描述的是字节码跳转后操作数栈的分布情况,一般被 Java 虚拟机用于验证所加载的类,以及即时编译相关的一些操作,正常情况下,你无须深入了解。
2. OpenJDK 项目 Code Tools:实用小工具集
OpenJDK 的 Code Tools 项目 [2] 包含了好几个实用的小工具。
在第一篇的实践环节中,我们使用了其中的字节码汇编器反汇编器 ASMTools[3],当前 6.0 版本的下载地址位于 [4]。ASMTools 的反汇编以及汇编操作所对应的命令分别为:
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jdis.Main Foo.class > Foo.jasm
和
$ java -cp /path/to/asmtools.jar org.openjdk.asmtools.jasm.Main Foo.jasm
该反汇编器的输出格式和 javap 的不尽相同。一般我只使用它来进行一些简单的字节码修改,以此生成无法直接由 Java 编译器生成的类,它在 HotSpot 虚拟机自身的测试中比较常见。
在第一篇的实践环节中,我们需要将整数 2 赋值到一个声明为 boolean 类型的局部变量中。我采取的做法是将编译生成的 class 文件反汇编至一个文本文件中,然后找到 boolean flag = true 对应的字节码序列,也就是下面的两个。
iconst_1;
istore_1;
将这里的 iconst_1 改为 iconst_2[5],保存后再汇编至 class 文件即可完成第一篇实践环节的需求。
除此之外,你还可以利用这一套工具来验证我之前文章中的一些结论。比如我说过 class 文件允许出现参数类型相同、而返回类型不同的方法,并且,在作为库文件时 Java 编译器将使用先定义的那一个,来决定具体的返回类型。
具体的验证方法便是在反汇编之后,利用文本编辑工具复制某一方法,并且更改该方法的描述符,保存后再汇编至 class 文件。
Code Tools 项目还包含另一个实用的小工具 JOL[6],当前 0.9 版本的下载地址位于 [7]。JOL 可用于查阅 Java 虚拟机中对象的内存分布,具体可通过如下两条指令来实现。
$ java -jar /path/to/jol-cli-0.9-full.jar internals java.util.HashMap
$ java -jar /path/to/jol-cli-0.9-full.jar estimates java.util.HashMap
3. ASM:Java 字节码框架
ASM[8] 是一个字节码分析及修改框架。它被广泛应用于许多项目之中,例如 Groovy、Kotlin 的编译器,代码覆盖测试工具 Cobertura、JaCoCo,以及各式各样通过字节码注入实现的程序行为监控工具。甚至是 Java 8 中 Lambda 表达式的适配器类,也是借助 ASM 来动态生成的。
ASM 既可以生成新的 class 文件,也可以修改已有的 class 文件。前者相对比较简单一些。ASM 甚至还提供了一个辅助类 ASMifier,它将接收一个 class 文件并且输出一段生成该 class 文件原始字节数组的代码。如果你想快速上手 ASM 的话,那么你可以借助 ASMifier 生成的代码来探索各个 API 的用法。
下面我将借助 ASMifier,来生成第一篇实践环节所用到的类。(你可以通过该地址 [9] 下载 6.0-beta 版。)
$ echo '
public class Foo {
public static void main(String[] args) {
boolean flag = true;
if (flag) System.out.println("Hello, Java!");
if (flag == true) System.out.println("Hello, JVM!");
}
}' > Foo.java
# 这里的 javac 我使用的是 Java 8 版本的。ASM 6.0 可能暂不支持新版本的 javac 编译出来的 class 文件
$ javac Foo.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar org.objectweb.asm.util.ASMifier Foo.class | tee FooDump.java
...
public class FooDump implements Opcodes {
public static byte[] dump () throws Exception {
ClassWriter cw = new ClassWriter(0);
FieldVisitor fv;
MethodVisitor mv;
AnnotationVisitor av0;
cw.visit(V1_8, ACC_PUBLIC + ACC_SUPER, "Foo", null, "java/lang/Object", null);
...
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
mv.visitVarInsn(ILOAD, 1);
...
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
}
...
可以看到,ASMifier 生成的代码中包含一个名为 FooDump 的类,其中定义了一个名为 dump 的方法。该方法将返回一个 byte 数组,其值为生成类的原始字节。
在 dump 方法中,我们新建了功能类 ClassWriter 的一个实例,并通过它来访问不同的成员,例如方法、字段等等。
每当访问一种成员,我们便会得到另一个访问者。在上面这段代码中,当我们访问方法时(即 visitMethod),便会得到一个 MethodVisitor。在接下来的代码中,我们会用这个 MethodVisitor 来访问(这里等同于生成)具体的指令。
这便是 ASM 所使用的访问者模式。当然,这段代码仅包含 ClassWriter 这一个访问者,因此看不出具体有什么好处。
我们暂且不管这个访问者模式,先来看看如何实现第一篇课后实践的要求。首先,main 方法中的 boolean flag = true; 语句对应的代码是:
mv.visitInsn(ICONST_1);
mv.visitVarInsn(ISTORE, 1);
也就是说,我们只需将这里的 ICONST_1 更改为 ICONST_2,便可以满足要求。下面我用另一个类 Wrapper,来调用修改过后的 FooDump.dump 方法。
$ echo 'import java.nio.file.*;
public class Wrapper {
public static void main(String[] args) throws Exception {
Files.write(Paths.get("Foo.class"), FooDump.dump());
}
}' > Wrapper.java
$ javac -cp /PATH/TO/asm-all-6.0_BETA.jar FooDump.java Wrapper.java
$ java -cp /PATH/TO/asm-all-6.0_BETA.jar:. Wrapper
$ java Foo
这里的输出结果应和通过 ASMTools 修改的结果一致。
通过 ASM 来修改已有 class 文件则相对复杂一些。不过我们可以从下面这段简单的代码来开始学起:
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
cr.accept(cw, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
这段代码的功能便是读取一个 class 文件,将之转换为 ASM 的数据结构,然后再转换为原始字节数组。其中,我使用了两个功能类。除了已经介绍过的 ClassWriter 外,还有一个 ClassReader。
ClassReader 将读取“Foo”类的原始字节,并且翻译成对应的访问请求。也就是说,在上面 ASMifier 生成的代码中的各个访问操作,现在都交给 ClassReader.accept 这一方法来发出了。
那么,如何修改这个 class 文件的字节码呢?原理很简单,就是将 ClassReader 的访问请求发给另外一个访问者,再由这个访问者委派给 ClassWriter。
这样一来,新增操作可以通过在某一需要转发的请求后面附带新的请求来实现;删除操作可以通过不转发请求来实现;修改操作可以通过忽略原请求,新建并发出另外的请求来实现。
import java.nio.file.*;
import org.objectweb.asm.*;
public class ASMHelper implements Opcodes {
static class MyMethodVisitor extends MethodVisitor {
private MethodVisitor mv;
public MyMethodVisitor(int api, MethodVisitor mv) {
super(api, null);
this.mv = mv;
}
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello, World!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
}
static class MyClassVisitor extends ClassVisitor {
public MyClassVisitor(int api, ClassVisitor cv) {
super(api, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
String[] exceptions) {
MethodVisitor visitor = super.visitMethod(access, name, descriptor, signature, exceptions);
if ("main".equals(name)) {
return new MyMethodVisitor(ASM6, visitor);
}
return visitor;
}
}
public static void main(String[] args) throws Exception {
ClassReader cr = new ClassReader("Foo");
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
ClassVisitor cv = new MyClassVisitor(ASM6, cw);
cr.accept(cv, ClassReader.SKIP_FRAMES);
Files.write(Paths.get("Foo.class"), cw.toByteArray());
}
}
这里我贴了一段代码,在 ClassReader 和 ClassWriter 中间插入了一个自定义的访问者 MyClassVisitor。它将截获由 ClassReader 发出的对名字为“main”的方法的访问请求,并且替换为另一个自定义的 MethodVisitor。
这个 MethodVisitor 会忽略由 ClassReader 发出的任何请求,仅在遇到 visitCode 请求时,生成一句“System.out.println(“Hello World!”);”。
5 - JVM 性能
5.1 - JVM 性能
5.2 - JVM 性能
5.3 - JVM 性能
5.4 - JVM 性能
5.5 - JVM 性能
6 - Scala 编程
6.1 - Scala 精要
6.1.1 - Case Class
下面会用到的 case 类实例:
case class Person(lastname: String, firstname: String, birthYear: Int)
基本特性
case 类与普通类的最大区别在于,编译器会为 case 类添加更多的额外特性。
创建一个类和它的伴生对象
创建一个名为
apply
的工厂方法。因此可以在创建实例时省略掉new
关键字:val p = new Person("Lacava", "Alessandro", 1976) val p = Person("Lacava", "Alessandro", 1976)
为参数列表中的所有参数添加
val
前缀,表示这些参数作为类的不可变成员,因此你会得到所有这个成员的访问器方法,而没有修改器方法:val lastname = p.lastname p.lastname = "Brown" // 编译失败
为
hashCode
、equals
、toString
方法添加原生实现。因为==
在 Scala 中代表equals
,因此 case 类实例之间总是以结构的方式进行比较,即比较数据而不是比较引用:val p_1 = Person("Brown", "John", 1969) val p_2 = Person("Lacava", "Alessandro", 1976) p == p_1 // false p == p_2 // true
生成一个
copy
方法,使用现有的实例并接收一些新的字段值来创建一个新的实例:val p_3 = p.copy(firstname = "Michele", birthYear = 1972)
最重要的特性,实现一个
unapply
方法。因此,case 类可以支持模式匹配。这在定义 ADT 时尤为重要。unapply
方法就是一个析构器。当不需要参数列表时,可以定义为一个
case object
常用其他特性
创建一个函数,根据提供的参数创建一个 case 类的实例:
val personCreator: (String, String, Int) => Person = Person.apply _ personCreator("Brown", "John", 1969) // Person(Brown,John,1969)
如果需要将上面的函数柯里化,分多步提供参数来创建一个实例:
val curriedPerson: String => String => Int => Person = Person.curried val lacavaBuilder: String => Int => Person = curriedPerson("Lacava") val me = lacavaBuilder("Alessandro")(1976) val myBrother = lacavaBuilder("Michele")(1972)
通过一个元组来创建实例:
val tupledPerson: ((String, String, Int)) => Person = Person.tupled val meAsTuple: (String, String, Int) = ("Lacava", "Alessandro", 1976) val meAsPersonAgain: Person = tupledPerson(meAsTuple)
将一个实例转换成一个由其参数构造的元组的
Option
:val toOptionOfTuple: Person => Option[(String, String, Int)] = Person.unapply _ val x: Option[(String, String, Int)] = toOptionOfTuple(p) // Some((Lacava,Alessandro,1976))
curried
和tupled
方法通过伴生对象继承自AbstractFunctionN
。N
是参数的数量,如果N = 1
则并不会得到这两个方法。
以 柯里化 的方式定义 case 类
其他内建方法
因为所有的 case 类都会扩展Product
特质,因此他们会得到如下方法:
def productArity:Int
:获得参数的数量def productElement(n:Int):Any
:从 0 开始,获得第 n 个参数的值def productIterator: Iterator[Any]
:获得由所有参数构造的迭代器def productPrefix: String
:获得派生类中用于toString
方法的字符串,这里会返回类名
6.1.2 - 类型系统
什么是类型系统(type-system)?
A type system is a tractable syntactic method for proving the absence of certain program behaviors by classifying phrases according to the kinds of values the compute. — Benjamin Pierce
一个类型系统是一种易于理解的语法方法,根据所计算的值的类别对短语进行分类,以证明某些程序行为的缺失。
什么是类型(type)?
- 一个类型定义了一个变量可以拥有的一组值,以及一组能够应用于这些值的函数;
- 一组值可以被定义为:
- 笛卡尔产品类型(Cartesian product Types),比如
case
类或元组; - 总和类型(Sum Types),比如
Either
。
- 笛卡尔产品类型(Cartesian product Types),比如
- 类型可以是抽象的和(或)多态的。
为什么需要类型?
- Make illegal states unrepresentable. — Yaron Minsky
- 使非法状态无法表示
- Where static typing fits, do it every time because it has just fantastic maintenance benefits. — Simon Peyton
- 在合适的场景总是使用静态类型,因为它具有非常好的维护优势
- Compiler can use Type informations to optimize compiled code.
- 编译器可以使用类型信息来优化被编译的代码
Scala 的类型系统?
- Scala 是一种同时支持面向对象和函数式的多范式语言
- Scala 拥有强大的静态类型系统
- 会在编译期检查类型
- 编译器能够通过推断类型
- 函数也可以作为类型:
A => B
类型的用途?
类型可以用于定义:
- (抽象)类
- 对象
- 特质
设计目的
本部分整理至 Martin Odersky 的访谈,原文地址:The Purpose of Scala’s Type System,中文地址:Scala类型系统的目的。
可伸缩性(scalability)
Scala 可以让你不必混用多种专用语言,无论是小型程序还是大型程序,无论是通用用途还是特定应用领域,Scala 都可以胜任。这样,可以避免你在多种语言环境中传递数据。
比如你想跨越数据边界传递数据,像 JDBC,从 Java 像数据库发起一次 SQL 查询,那么发出的查询最终将会是个字符串。这就意味着如果程序中只要有小小的拼写错误,在最终运行时,就会表现为一次非法查询,从而导致一系列相关错误。整个过程中编译器和类型系统不会告诉你那里写错了。
同时,如果仅使用一种语言,则只需要面对一套环境和工具。
可扩展性(extensibility)
“可伸缩性“的维度是”从小到大“。而可扩展性表示从”从通用用途到特定需求“。你可以强化 Scala 语言,使之涵盖你特别关注的领域。
比如数字类型,不同领域有很多不同的数字类型,密码学中的大数、商务人士用的十进制大数、科学家用的复数等等。但如果在语言中加入所有这些可能的类型,则会变得非常笨重。
因此我们可以留给外部库实现,但是又想像使用内置类型的代码一样干净、优雅。为此,你需要语言提供某些扩展机制,是你能够编写用起来不像库的库。
小规模编程中的类型
小规模编程中类型会显得不那么重要。类型的价值分布在一条长长的光谱上,一端表示超级有用,一端表示极度麻烦。通常说它麻烦是因为类型定义可能会太过冗余,要求你手动指定大量类型。有用则是因为,类型能避免运行时错误,能够提供 API 签名文档,能够为代码重构提供安全底线。
Scala 的类型推断,试图尽可能减少类型的麻烦之处。这意味着你编写脚本时并不需要涉及类型,系统会自动进行推断,同时编译器内部仍然会考虑类型。因此仍然能够享受类型检查带来的便利。
单元测试与随心所欲的表达式
仍然需要单元测试来测试你的程序逻辑。但相比动态类型语言,不需要额外对类型进行琐碎的单元测试,因此 Scala 中的单元测试也会精练很多。
另一条针对类型系统的反对意见是:静态类型对表达式限制太严。比如”我想更自由的表达自己,不想要类型系统的条条框框“。我(Martin)认为这种意见不靠谱,第一,Scala 的类型系统实际上很灵活,所以通常它可以让你用非常灵活的模式排列组合,而像 Java 这种类型系统偏弱的语言则难以实现;第二,通过模式匹配,你可以通过非常灵活的方式提取类型信息,甚至根本感觉不到类型信息的损失。
鸭子类型,Java 中的缺失
只要它具备我需要的功能,那么就可以把它当真。比如我需要某种支持关闭操作的资源,我可以以这种方式声明”它必须有 close 方法“,因此我不用关心它是 File、Channel 等等。
要想在 Java 中实现的话,需要定义一个包含该方法的通用接口,然后大家都需要实现这个接口。首先为了实现这一切就会带来大量接口和样板代码。其次,如果有了即成事实之后,你才想到要提个接口,那么几乎不可能做到。如果事先就写了一个类,鉴于这些类早已存在,那么你不改代码就无法加入该接口,除非修改所有客户端代码。所以,这一切都是类型系统强加给你的限制。
而 Scala 中则能表达鸭子类型。你完全可以用 Scala 把某一类型定义为:”任何拥有 close 方法并且该方法返回 Unit 的类型“。同时还可以把类型定义与其他约束结合起来。则可以这样定义类型:任何继承自某个类型,且拥有某某方法签名的类型。还可以这样定义,任何继承自某某类,且拥有某某类型的内部类的类型。从本质上讲,你可以通过定义类型汇总有哪些东西将为你所用,来描绘类型的结构。
既存类型(existential types)
既存类型已有 20 多年历史,它表示,给定某种类型,比如 List,但其内部元素是未知类型。你只知道它是由某种特定类型元素构成的 List,然而并不知道这个特定类型具体是哪种类型。这个概念在 Scala 中可以用既存类型来表达。比如:List[T] forSome {type T}
。稍微有点笨重,这也算有意为之。因为事实证明,既存类型往往不大好处理。Scala 有更好的选择,因此不那么需要既存类型,因为 Scala 的类型可以包含其他类型作为内部成员。
归根结底,Scala 只有三种情况需要既存类型。
- Scala 需要能表示 Java 通配符的语义。
- Scala 需要能表示 Java 的 raw 类型,因为有许多库使用类非泛型类型,会出现 raw 类型。如果有一个 Java raw 类型
java.util.List
,那么它其实是位置元素类型的 List。 - 既存类型可以把虚拟机中的实现细节反映到上层。类似 Java ,Scala 使用的泛型模型时”擦除类型“。所以在程序运行起来以后,我们就再也找不到类型参数了。之所以要进行擦除,是为了与 Java 的对象模型进行互操作。可是,如果我们需要反射,或者需要表达虚拟机的实现细节时该怎么办呢?我们需要有能力用 Scala 中的某些类型表示虚拟机的行为。
有了既存类型,即使某一类型中的某些方面尚不可知,我们仍然可以操作该类型。
比如,Scala 中的 List,我希望能够描述 head 方法的返回类型。在虚拟机级别,List 类型是 List[T] forSome {type T}
。我们不知道 T 是什么。只知道 head 返回 T。既存类型理论告诉我们,该类型是”某些类型 T 中的 T“。也就相当于根类型 Object。那么我们从 head 方法中取回的就是 Object。因此 Scala 中我们要是知道更多信息,就可以直接指定具体类型而不用既存类型的限定规则。但是如果没有更多信息,我们就留着既存类型,让既存类型理论帮我们推断出返回类型。
如果 Java 采用的是”具现化“的泛型类型系统,不支持 raw 类型或通配符类型,那么 Scala 中的既存类型在意义不大,恐怕不会实现既存类型。
Java 与 Scala 中的型变
Java 的型变是定义在使用通配符的代码之处,而 Scala 则是在类型定义之处,当然 Scala 的既存类型一样支持通配符,支持使用 Java 的写法,但并不推荐这么做。
首先,什么是”类型定义之处的型变“?当你定义某个类的类型参数时,例如 List[T]
,会有一个问题,如果你给一个苹果列表,那这个列表算不算水果列表呢?显然算,只要苹果是水果的子类型,List[Appke]
就是List[Fruit]
的子类型,这称为协变。
但某些情况下种种关系不成立,比如我有一个变量,只能用来保存 Apple,那么这个变量就是对类型 Apple 的引用。这个变量并不能当做 Fruit 类型的引用,因为我不能把任意 Fruit 赋值给这个变量,它只能是 Apple。因此可以发现,上述子类型关系在有些情况下适用,有些则不适用。
Scala 中的解决方案是给类型参数添加一个标志。如果 List 中的 T 支持协变,则写作List[+T]
。这将意味着任意 List 之间的关系都可以随着其 T 的关系而协变。要想支持协变,必须遵守协变的附加条件。举例来说,只有 List 内容不可变式,List 才能支持协变,否则将会遇到刚才引用变量中类似的问题,而导致无法协变。
Scala 中的机制是这样的:程序员可以声明”我觉得 List 应用支持协变“,即,要求 List 必须遵守子类型关系,然后给 List 的类型参数加上加号标志,仅需一次,List 的所有用户都可以使用。然后编译器会去找出 List 内的所有定义实际上是否兼容协变,确保 List 中不存在导致冲突的成员签名。
相比之下,以 Java 的方式使用通配符,这就意味着库的作者对协变爱莫能助,只能草草定义成 List<T>
了事。但接下来如果用户需要协变 List,却不能写作List<Fruit>
,而是List<? extends Fruit>
。通配符就是这样用的。问题在于这是用户代码啊,用户总不能人人都像设计库的人那么专业吧。此外,这些标注之间,只要有一处不匹配,就会导致类型错误。
Scala 中可以在库中处理型变,让用户感觉不到型变存在,不必手动处理型变。
抽象类型成员
抽象类型与泛型参数看似类似,但拥有更多好处。对于抽象,业界有两套不同的机制:参数化和抽象类型成员。Java 也一样支持两套抽象,只不过 Java 的两套抽象取决于对什么进行抽象。Java 支持抽象方法,但不支持把方法作为参数;Java 不支持抽象字段,但支持把值作为参数;Java 不支持抽象类型成员,但支持把类型作为参数。所以在 Java 中三者均可抽象,但是之间的原理有所区别。
在 Scala 中试图把这些抽象支持的更完备、更正交,即对上述三类成员都采用相同的构造原理。因此,你可以使用抽象字段,也可以使用值参数;可以把方法(函数)作为参数,也可以声明抽象方法;即可以指定类型参数也可以声明抽象类型。至少在原则上,我们可以用同一种面向对象抽象成员的形式,表达全部三类参数。
抽象类型能够更好的处理先前谈到的协变问题。比如,一个 Ainmal 类,其中一个 eat 方法。问题是,如果从 Animal 派生一个类,比如 Cow,那么就能吃某一种食物,比如 Grass。Cow 不可以吃 Fish 之类的其他食物。你希望有办法可以声明 Cow 拥有一个 eat 方法,且该方法只能吃 Grass。实际上,这个需求在 Java 中实现不了,因为你一定会构造出有矛盾的情形,类似之前把 Fruit 复制给 Apple 一样。
Scala 中则可以增加一个类型成员,比如声明一个 SuitableFood 类型,它不定义具体是什么,这就是抽象类型,直接让 Animal 的 eat 方法吃下 SuitableFood 即可。然后在 Cow 中指定其为 Grass 即可。
所以,抽象类型提供了一种机制:先在父类中声明未知类型,稍后再在子类中填上某种已知类型。
你可能会说用参数也可以实现同样功能。确实可以。你可以给 Animal 增加参数,表示能吃的食物。但实践中,当你需要支持许多不同的功能是就会导致参数爆炸。而且通常更要命的问题是参数的边界。
相关概念
类型推断
Scala 拥有类型推断,这表示我们可以省略掉类型注解。
trait Thing
def getThing = new Thing { }
// without Type Ascription, the type is infered to be `Thing`
val infered = getThing
// with Type Ascription
val thing: Thing = getThing
在这些情况下都是可以编译通过的。所以都有哪些地方不能省略类型注解呢:
- 参数
- 公共方法返回值
- 递归或重载方法
- 需要包含类型签名来加快编译速度
- 需要类型签名来使代码更易读
统一的类型系统 — Any、AnyRef、AnyVal
我们之所以称 Scala 的类型系统是统一的,是因为它拥有一个顶层类型 Any。这与 Java 不同,它有一些原始类型(int/long/float/double/byte/char/short/boolean)并非扩展自 Java 中看起来像是顶层类型的 Object。
Any 作为顶层类型,下面有 AnyVal 和 AnyRef 两种子类型。
AnyRef 相当于 Java 中 对象的世界,对应于 Object,作为所有对象的超类。而 AnyVal 表示了 Java 中的 值世界,像 int 或其他 JVM 原始类型。
因此我们可以定义一个接收 Any 类型的方法,同时能够处理 Int 或 String。这对类型系统来说是透明的虽然在虚拟机层 Int 实例会被打包成一个对象。通过查看字节码能够发现:
Method scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer;
- Conventional types
- Value type classes
- Nonnullable type
- Monad types
- Trait types
- Singlton object types
- Compound types
- Functional Types
- Case classes
- Path-dependent types
- Anonymous types
- Self types
- Type aliases
- Package object
- Generic types
- 类型擦除问题
- Pattern Match
- Bounded generic types
- Type Variance
- Higher kinded types
- Abstract types
- Existential types
- Implicit types
- View bounded types
- Structural types
- Dotty?
TODO: http://ktoso.github.io/scala-types-of-types/#type-of-an-code-object-code
社区评论
Scala 中提供了相当多的便利,但这与类型系统的强大无关,仅仅是因为与类型有关。而类型系统的强大之处在于其强大的表现力,能够表示在其它语言中无法表示的事情。
事实上,类型推断是类型系统效率的直接影响因素—它并非那么强大,有些语言拥有完整的类型推断,比如 Haskell。
Scala 中采用局部的基于流的推导,而不是全局式的 HM 推导,导致有些情况不如 Haskell 的推导那么强大。
图灵完备:http://www.tuicool.com/articles/rqUjAb
6.1.3 - 函数式-基础
方法
创建函数最简单的方式就是作为对象的成员,即方法。
本地函数
函数式编程风格的一个主要原则就是:程序应该被解构为若干小的函数块,每块实现一个完备的任务,每块都很小。
但是大量的小块函数会污染程序的命名空间,这时可以私有函数,或者另一种方式,本地函数,即嵌套函数:
import scala.io.Source
object LongLines {
def processFile(filename:String, width:Int){
def processLines(line:String){ // 打印超出宽度的行, 引用外层的 width
if (line.length > width) println(filename + ": " + line)
}
val source = Source.fromFile(filename)
for (line <- source.getLines)
processLine(line)
}
}
头等函数
Scala 的函数是头等函数(first-class-function),不仅可以定义或调用函数,还可以把函数写成匿名字面量,当做值来传递。函数字面量被编译进类,并在运行期实例化为函数值。因此函数字面量与值的区别在于:函数字面量存在于源代码,而函数值作为对象存在于运行期。类似于类(源代码)和对象(运行期)之间的关系。
(x: Int) => x + 1
符号=>
指:把左边的东西转换为右边的东西。
函数值是对象,因此可以赋给变量。同时也是函数,可以按照通常的函数调用方式,使用一个括号来调用:
var increase = (x: Int) => x + 1
increase(2) // 3
函数可以由多条语句构成,由大括号包围,组成一个代码块。当函数执行时,所有的语句都会执行,最后一行作为返回值。
函数字面量的短格式
可以省略函数字面量中的参数类型来进行简化:
someNumber.filter((x) => x > 0)
因为使用这个函数来过滤整数列表someNumber
,因此 Scala 可以推断出 x 肯定为整数。这种方式称为目标类型化(target typing)。
某些参数的类型是推断的,可以省略参数的括号:
someNumber.filter(x => x > 0)
占位符语法
只要每个参数在函数字面量中仅出现一次,可以使用占位符_
来代替该参数名:
someNumber.filter(_ > 0)
但是有时候把下划线当做参数的占位符,编译器可能无法推断缺失的参数类型:
val f = _ + _
因为上面的filter
调用是一个整数列表,编译器可以推断,但这里,编译器无从推断,会编译失败。这时,我们可以为参数提供类型:
val f = (_:Int) + (_:Int)
这里需要注意的是,这里的两个占位符表示的是需要两个参数,而不是一个参数使用两次。
_ + _ (x, y) => x + y
_ * 2 x => x * 2
_.head xs => xs.head
_ drop _ (xs, n) => xs.drop(n)
在参数较少时,使用这种方式可以使程序更清晰,但是当参数增多,比如foo(_, g(List(_ + 1), _))
,则会让程序变得难以理解。
偏应用函数
或称为部分应用函数(Partially applied functions)。
可以使用占位符代替一个参数,也可以代替整个参数列表。比如println(_)
,或者println _
。
这中下划线方式的使用,实际上定义的是一个部分应用函数。它是一种表达式,不需要提供函数需要的所有参数,可以只提供部分,或者不提供。
比如一个普通的sum
函数:
def sum(a:Int, b:Int, c:Int) = a + b + c
当调用函数时,传入任何需要的参数,实际是把函数应用到参数上。
如果通过sum
来创建一个部分应用表达式,不需要提供所需要的参数,只需要在sum
之后添加一个下划线,使用空格隔开,然后把得到的函数存入变量:
val a = sum _
a : (Int, Int, Int) => Int = <function3> // function3 类型的实例
a(1,3,4) // 6
可以看到,a
为一个函数。变量a
指向一个函数值对象。这个函数值是由 Scala 编译器依照部分应用函数表达式sum _
自动产生的类的一个实例。编译器产生的类有一个apply
方法,该方法接收 3 个参数。在通过a
进行a(1,2,3)
调用时,会被翻译成对函数值的apply
方法的调用,即apply(1,2,3)
。
可以看到function3
的源码定义:
/** A function of 3 parameters.
*
*/
trait Function3[-T1, -T2, -T3, +R] extends AnyRef { self =>
/** Apply the body of this function to the arguments.
* @return the result of function application.
*/
def apply(v1: T1, v2: T2, v3: T3): R
...
}
偏应用函数实际上是指该函数未被应用到所有的参数,sum
的例子是没有应用到任何参数,也可以只应用到部分参数。
val b = sum(1, _:Int, 3)
b: Int => Int = <function1>
这时会发现b
为一个function1
类型的实例,它接收一个参数。
同时,如果定义的是一个省略所有参数的偏应用函数,比如这里的sum
,二者在代码的某个位置需要一个相同签名的函数,这时可以省略掉占位符进行简化:
someNumbers.foreach(println _)
someNumbers.foreach(println)
需要注意的是,只有在需要一个函数的地方才可以省略占位符。比如这里,编译器知道foreach
需要一个函数。否则会编译错误。比如:val s = sum
,必须是:val s = sum _
。
闭包
重复参数
可以指定一个函数的最后一个参数是重复的,然后就可以传入可变长度的参数列表:
def echo(args:String*) =
for(arg <- args) println(arg)
echo("a")
echo("a", "b")
val seq = Seq("a","b","c")
echo(seq:_*)
尾递归
高阶函数
函数值作为参数的函数称为高阶函数。
减少代码重复
比如我们要实现一个 根据文件名查找文件的程序,首先是文件名以指定字符串结尾的文件名:
object FileMatcher {
private def filesHere = (new java.io.File(".")).listFiles
def filesEnding(query: String) =
for (file <- filesHere; if file.getName.endsWith(query)) yield file
}
filesHere
这里作为一个工具函数来获取所有文件名列表。
现在如果需要的不只是以指定字符串结尾的方式查找,只要是文件名中包含指定字符串,或者以指定的方式能够匹配指定的字符串,因此我们需要查找的方式是这样的:
def filesMatching(query: String, method ) =
for (file <- filesHere; if file.getName.method (query)) yield file
method
表示匹配方式,但是 Scala 中不支持传入函数名的方式,因此我们可以传递一个函数值:
def filesMatching(query: String, matcher: (String, String) => Boolean) = {
for (file <- filesHere; if matcher(file.getName, query)) yield file
}
mathcer
接收两个字符串,一个是文件名,一个是需要匹配的字符串,返回一个布尔值表示该文件名与指定的字符串是否匹配。因此,我们可以实现我们的不同匹配方式,而对filesMatching
函数进行复用:
def filesEnding(query: String) = filesMatching(query, _.endsWith(_))
def filesContaining(query: String) = filesMatching(query, _.contains(_))
def filesRegex(query: String) = filesMatching(query, _.matches(_))
类似_.endsWith(_)
的部分使用了占位符语法,前面已经提到,函数的参数只被使用一次,且参数顺序与使用顺序一致,则可以使用占位符语法简化。其完整的写法实际是:
(fileName: String, query: String) => fileName.endsWith(query)
简化后会发现,函数filesMatching
的参数中,query
已经不再需要了,因为该参数只用于matcher
函数,并且已经通过匹配方法传入,因此再次进行简化:
object FileMatcher{
private def filesHere = (new java.io.File(".")).listFiles
private def filesMatching(matcher: String => Boolean) =
for (file <- filesHere; if matcher(file.getName)) yield file
def filesEnding(query:String) = filesMatching(_.endsWith(query))
def filesContains(query:String) = filesMatching(_.contains(query))
def filesRegex(query:String) = filesMatching(_.matches(query))
}
简化客户端代码
集合 API 提供一些列常用的方法,其中应用了大量的高阶函数,通过将高阶函数作为参数来定义 API,从而使客户端代码更加易于使用。
比如常用的exists
、find
, 在scala.collection.TraversableLike
包中:
def exists(p: A => Boolean): Boolean = {
var result = false
breakable {
for (x <- this)
if (p(x)) { result = true; break }
}
result
}
def find(p: A => Boolean): Option[A] = {
var result: Option[A] = None
breakable {
for (x <- this)
if (p(x)) { result = Some(x); break }
}
result
}
参数p
是一个A => Boolean
类型的函数,它接收一个参数并放回一个布尔值,用于判断集合中的元素是否满足条件,比如在应用时:
List(1,2,3,-1).exists(_ < 0)
这里同样应用了占位符语法,使整个操作更加简便。
柯里化
柯里化是将函数应用到多个参数列表上。
def plainOldSum(x: Int, y: Int) = x + y // 普通函数
plainOldSum(1, 2)
def curriedSum(x: Int)(y: Int) = x + y // 柯里化函数
curriedSum(1)(2)
在调用柯里化函数时,如果没有一次给出所有的参数列表,比如上面的curriedSum
,第一次只提供一个参数列表进行调用curriedSum(1)
,这时会返回一个函数值(y: Int) => x + y
,调用该函数值并提供另一个参数列表,即(y: Int)
,得出最后的求和值。
其过程类似于以下面的方式定义函数:
def first(x: Int) = (y: Int) => x + y
val second = first(1) // Int => Int = <function1>
second(2) // 3
柯里化函数也可以以下面的方式,使用一个占位符语法,来获取中间的函数值,即上面的second
函数值:
val onePlus = curriedSum(1)_ // Int => Int = <function1>
调用时提供的占位符_
代表第二个参数列表。
同样,可以定义更多个参数列表的柯里化函数,比如:
def multiSum(x: Int)(y: Int)(z: Int) = x + y + z
val second = multiSum(1)_ // Int => (Int => Int) = <function1>
val third = second(2) // Int => Int = <function1>
third(3) // 6
自定义控制结构
因为函数可以作为参数值来传递,因此可以使用该特性来定义自己的控制结构,只需要定义接收函数值的方法即可。
比如:
def twice(op: Double => Double, x: Double) = op(op(x))
twice(_ + 1, 5) // 7
一旦发现代码中有重复的控制模式,就可以通过定义一个函数的方式来代替。
比如我们需要一个控制结构,操作一个文件并最终将其关闭,这就是一个控制模式,而操作部分是主要的处理:
def withPrintWriter(file: File, op: PrintWriter => Unit) = {
val writer = new PrintWriter(file)
try {
op(writer)
} finally {
writer.close()
}
}
withPrintWriter(
new File("date.txt"),
writer => writer.println(new java.util.Date)
)
我们可以利用这种模式来实现不同的控制,比如将withPrintWriter
实现为一个日志打印过程或缓存更新过程,而操作部分是一次数据库查询。
这种模式成为借贷模式。这个例子中,控制抽象函数withPrintWriter
打开一个资源,即writer
,借贷给op
函数,当op
函数不再需要时又将其关闭。
但是这种模式的使用方式扛起来并不像是一个控制结构,它就是一个函数调用,然而可以通过使用大括号的方式使其更像是真正的控制结构。
但是在使用大括号进行函数调用时只能接收一个参数,比如:
println { "Hello, world!" }
"Hello, world!".substring { 7, 9 } // error,多个参数必须使用小括号包围参数列表
因此,我们可以将上面的withPrintWriter
函数改写为柯里化的方式,每次只接收一个函数,来满足只有一个参数才能使用大括号的要求:
def withPrintWriter(file: File)(op: PrintWriter => Unit) = {
val writer = new PrintWriter(file)
try {
op(writer)
} finally {
writer.close()
}
}
然后,下面的调用使withPrintWriter
看起来更像一个控制结构:
withPrintWriter(file) { writer =>
writer.println(new java.util.Date)
}
传名参数
但是上面的例子与内建的控制接口,比如if
、while
并不相似,因为在大括号中需要传入一个参数,这可以通过传名参数实现。
比如定义一个断言函数:
var assertionsEnabled = true
def myAssert(predicate: () => Boolean) =
if (assertionsEnabled && !predicate()) throw new AssertionError
然后以下面的方式调用:
myAssert(() => 5 > 3)
或许你更希望使用myAssert(5 > 3)
的方式来调用,在创建传名参数时可以使用=>
来代替完整的() =>
,括号部分实际是函数的参数列表,只不过该函数不接受任何参数,因此省略。
def byNameAssert(predicate: => Boolean) =
if (assertionsEnabled && !predicate) throw new AssertionError
myAssert(5 > 3)
一个传名类型,即其参数列表为空()
,并进行省略,这样的用法仅仅在作为参数时可行。
但是根据上面的定义方式,其实与def byNameAssert(predicate:Boolean)
没有什么差别了。
真正的差别在于,如果是传入的值,这个值必须在调用byNameAssert
之前完成计算,如果是传入的函数,则会在调用之后进行计算。
6.1.4 - 函数式-用例
匿名函数
匿名函数,或函数字面值,可以将它传递给一个方法,或者赋值给一个变量。
比如过滤集合中的偶数,可以将一个匿名函数传递给集合的 filter 方法:
val x = List.range(1, 10)
val evens = x.filter((i: Int) => i % 2 == 0)
这里的匿名函数就是(i: Int) => i % 2 == 0
。符号=>
可以认为是一个转换器,将集合中的每个整数 i ,通过符号右边的表达式i % 2 == 0
转换为一个布尔值,filter 通过这个布尔值判断这个 i 的去留。
因为 Scala 可以推断类型,因此可以省略掉类型:
val evens = x.filter(i => i % 2 == 0)
又因为当一个参数在函数中只出现一次时使用通配符 _ 表示,因此可以省略为:
val evens = x.filter(_ % 2 == 0)
在更为常见的场景中:
x.foreach((i:Int) => println(i))
简略为:
x.foreach((i) => println(i))
进一步简略:
x.foreach(i => println(i))
再次简略:
x.foreach(println(_))
最终,如果由一条语句组成的函数字面量仅接受一个参数,并不需要明确的名字和指定参数,因此最终可以简略为:
x.foreach(println)
像变量一样使用函数
可以向传递一个变量一样传递函数,就像是一个 String、Int 一样。
比如需要将一个数字转换为它的2倍,向上一节一样,将=>
当做一个转换器,这时是将一个数字转换为它的2倍,即:
(i: Int) => { i * 2 }
然后将这个函数字面量赋值给一个变量:
val double = (i: Int) => { i * 2 }
变量 double 是一个实例,就像 String、Int 的实例一样,但是它是一个函数的实例,作为一个函数值,然后可以像方法调用一样使用它:
double(2) // 4
或者将它传递给一个函数或方法:
list.map(double)
如何声明一个函数
已经见过的方式:
val f = (i: Int) => { i % 2 == 0 }
Scala 编译器能够推断出该方法返回一个布尔值,因此这里省略了函数的返回值。一个包含完整类型的声明会是这样:
val f: (Int) => Boolean = i => { i % 2 == 0 }
val f: Int => Boolean = i => { i % 2 == 0 }
val f: Int => Boolean = i => i % 2 == 0
val f: Int => Boolean = _ % 2 == 0
将函数作为方法的参数
这种需求的处理过程:
- 定义方法,定义想要接收并作为参数的函数签名
- 定义一个或多个对应该签名的函数
- 将需要的函数传入方法
def executeFunction(callback:() => Unit) {
callback()
}
签名callback:() => Unit
表示该函数不需要参数并返回一个空值。比如:
val sayHello = () => { println("Hello") }
executeFunction(sayHello)
这里,方法中定义的函数名并没有实际意义,仅作为方法的一个参数名,就像一个 Int 常被命名为字母 i 一样,这里可以通用定义为任何形式:
def executeFunction(f:() => Unit) {
f()
}
同时,传入的函数必须与签名完全一致,签名通用的语法为:
methodParameterName: (functionParameterType_s) => functionReturnType
更为复杂的函数
函数参数的签名为:接收一个整形数字作为参数并返回一个空值:
def exec(callback: Int => Unit) {
callback(1) // 调用传入的函数
}
val plusOne = (i: Int) => { println(i+1) }
exec(plusOne)
或者接收更多参数的函数:
executeFunction(f:(Int, Int) => Boolean)
或者返回一个集合类型:
exec(f:(String, Int, Double) => Seq[String])
或者接收函数参数并同时接受其他类型的参数:
def executeAndPrint(f:(Int, Int) => Int, x: Int, y: Int) {
val result = f(x, y)
println(result)
}
使用闭包
如果需要将函数作为一个参数传递,同时又需要使函数在其声明的作用域引用以存在的变量。比如下面这个例:
class Foo {
def exec(f:String => Unit, name:String){ f(name) }
}
object ClosureExample extends App{
var hello = "hello"
def sayHello(name:String) = { println(s"$hello, $name") } // 引入闭包变量 hello
val foo = new otherscope.Foo
foo.exec(sayHello, "Al")
hello = "Hola" // 修改本地变量 hello
foo.exec(sayHello, "Lorenzo")
}
Hello, Al
Hola, Lorenzo
在这个例子中,函数 sayHello 在定义时除了声明了一个正式参数 name,同时引用了当前作用域的变量 hello。将 函数 sayHello 作为一个参数传入 exec 方法后,再修改本地变量 hello 的值,sayHello中仍然能够引用到改变后的 hello 的值。这里,Scala 创建了一个闭包。
这里为了简单只只是将 sayHello 传递给了 exec 方法,同样可以将其传递到很远的位置,即多层传递。但是变量并不在 Foo 的作用域或方法 exec 的作用域中,比如在 Foo 中或 exec 中再单独打印 hello 都不会编译通过。
闭包的三个要素:
- 一段代码块可以像值一样传递
- 任何拥有这段代码块的人都可以在任何时间根据需要执行它
- 这段代码块可以在创建它的上下文中引用变量
创建闭包
var votingAge = 18
val isOfVotingAge = (age: Int) => age >= votingAge
现在可以把函数 isOfVotingAge 传递给任意作用域中的函数、方法、对象,同时 votingAge 是可变的,改变它的值同时会引起 isOfVotingAge 函数中对他引用的值的改变。
使用其他数据结构闭包
val fruits = mutable.ArrayBuffer("apple")
val addToBasket = (s: String) => {
fruits += s
println(fruits.mkString(", "))
}
这时,将 addToBasket 函数传递到其他作用域并执行时,都能够修改 fruits 的值。
使用偏应用函数
可以定义一个需要多个参数的函数,提供部分参数并返回一个偏函数,它会携带已获得的参数,最终传递给他剩余需要的参数以完成执行:
val sum = (a: Int, b: Int, c: Int) => a + b + c
它本身需要三个参数,当只提供两个参数时,它会返回一个偏函数:
val sum = (a: Int, b: Int, c: Int) => a + b + c
// sum: (Int, Int, Int) => Int = <function3>
val f = sum(1, 2, _: Int)
// f: Int => Int = <function1>
最后给他提供一个参数,完成整个计算:
f(3)
// 6
创建返回函数的函数
可以定义一个返回函数的函数,将它传递给另一个函数,最终提供需要的参数并调用。
这是一个匿名函数:
(s: String) => { prefix + " " + s }
定义一个函数来生成这个函数:
def saySomething(prefix: String) = (s: String) => {
prefix + " " + s
}
可以将它赋值给一个变量并通过这个拥有函数值的变量来调用函数:
val sayHello = saySomething("Hello")
sayHello("Alex")
或者更复杂的,根据输入值的不同从而返回不同的函数:
def greeting(language: String) = (name: String) => {
language match {
case "english" => "Hello, " + name
case "spanish" => "Buenos dias, " + name
}
}
创建偏函数
可以创建一个函数,只对所有可能的输入值的一个子集有效(称为偏函数的原因),或者一组这样的函数,最后通过组合来完成需要的功能。
定义一个偏函数:
val divide = new PartialFunction[Int, Int] {
def apply(x: Int) = 42 / x
def isDefinedAt(x: Int) = x != 0 // 仅对部分不等于 0 的整数有效
}
divide.isDefinedAt(1) // true
if (divide.isDefinedAt(1)) divide(1) // 42
divide.isDefinedAt(0) // false
或者更为常用的模式:
val divide2: PartialFunction[Int, Int] = {
case d: Int if d != 0 => 42 / d
}
意思就是,它只能接受 Int 类型参数的一部分(d 不等于 0)进行处理并返回一个 Int 值。
使用 case 的方式仍然能够更第一种方式一样判断它是否能够接受一个值:
divide2.isDefinedAt(0)
divide2.isDefinedAt(1)
同时可以使用 orElse 将多个偏函数组合,andThen 则是将多个偏函数进行链接。
6.1.5 - Future-基础
介绍:Java 与 Scala 中的并发
Java 通过内存共享和锁来提供并发支持。Scala 中通过不可变状态的转换来实现:Future。虽然 Java 中也提供了 Future,但与 Scala 中的不同。
二者都是通过异步计算来表示结果,但是 Java 中需要使用阻塞的get
方法来访问结果,同时可以在调用get
之前使用isDone
来检查结果是否完成来避免阻塞,但是仍然需要等待结果完成以支持后续使用该结果的计算。
在 Scala 中,无论Future
是否完成,都可以对他指定转换过程。每一个转换过程的结果都是一个新的Future
,这个新的Future
表示通过函数对原始Future
转换后得到的结果。计算执行的线程通过一个隐式的*execution context(执行上下文)*来决定。以不可变状态串行转换的方式来描述异步计算,避免共享内存和锁带来的额外开销。
锁机制的弊端
Java 平台中,每个对象都与一个逻辑监视器关联,以控制多线程对数据的访问。使用这种模式时需要指定哪些数据会被多线程共享并将被访问的、控制访问的和共享数据的代码段都标记为synchronized。Java 运行时使用锁的机制来确保同一时间只有一个线程能够进入被锁保护的代码段。以此协调你能够通过多线程来访问数据。
为了兼容性,Scala 提供了 Java 的并发原语。可以在 Scala 中调用方法wait/notify/notifyAll
,并且意义与 Java 一致。但是并不提供关键字synchronized
,但是预定义了一个方法:
var counter = 0
synchronized {
// 这里同时只能有一个线程
counter = counter + 1
}
但是这种模式难于编写可靠的多线程应用。死锁、竟态…
使用 Try 处理异步中的异常
当你调用一个 Scala 方法时,它会在你等待返回结果时执行一个计算,如果结果是一个Future
,它表示另一个异步化执行的计算,通常会被一个完全不同的线程执行。在Future
上执行的操作都需要一个excution context
来提供异步执行的策略,通常可以使用由 Scala 自身提供的全局执行上下文,在 JVM 上,它使用一个线程池。
引入全局执行上下文:
import scala.concurrent.ExecutionContext.Implicits.global
val future = Future { Thread.sleep(10000); 21 + 21 }
当一个Future
未完成时,可以调用两个方法:
future.isComplated // false
future.value // Option[scala.util.Try[Int]] = None
完成后:
future.isComplated // true
future.value // Option[scala.util.Try[Int]] = Some(Success(42))
value
方法返回的Option
包含一个Try
,成功时包含一个类型为 T 的值,失败时包含一个异常,java.lang.Throwable
的实例。
Try
支持在尝试异步计算前进行同步计算,同时支持一个可能包含异常的计算。
同步计算时可以使用try/catch来确保新城调用方法并捕捉、处理方法抛出的异常。但是异步计算中,发起计算的线程常会移动到其他任务上,然后当计算中抛出异常时,原始的线程不再能通过catch
子句来处理异常。因此使用Future
进行异步操作时使用Try
来处理可能的失败并生成一个值,而不是直接抛出异常。
scala> val fut = Future { Thread.sleep(10000); 21 / 0 }
fut: scala.concurrent.Future[Int] = ...
scala> fut.value
res4: Option[scala.util.Try[Int]] = None
// 10s later
scala> fut.value
res5: Option[scala.util.Try[Int]] = Some(Failure(java.lang.ArithmeticException: / by zero))
Try
的定义:object Try { /** 通过传名参数构造一个 Try。 * 捕获所有 non-fatal 错误并返回一个 `Failure` 对象。 */ def apply[T](r: => T): Try[T] = try Success(r) catch { // 常规的 try、catch 调用 case NonFatal(e) => Failure(e) } } sealed abstract class Try[+T] final case class Failure[+T](exception: Throwable) extends Try[T] final case class Success[+T](value: T) extends Try[T]
Future 操作
map
将传递给map
方法的函数作用到原始Future
的结果并生成一个新的Future
:
val result = fut.map(x => x + 1)
原始Future
和map
转换可能在两个不同的线程上执行。
for
因为Future
声明了一个flatMap
方法,因此可以使用for
表达式来转换。
val fut1 = Future { Thread.sleep(10000); 21 + 21 } // Future[Int]
val fut2 = Future { Thread.sleep(10000); 23 + 23 } // Future[Int]
for { x <- fut1; y <- fut2 } yield x + y // Future[Int]
因为for
表达式是对转换的串行化,如果没有在for
之前创建Future
并不能达到并行的目的。
for {
x <- Future { Thread.sleep(10000); 21 + 21 }
y <- Future { Thread.sleep(10000); 23 + 23 }
} yield x + y // 需要最少 20s 的时间完成计算
for { x <- fut1; y <- fut2 } yield x + y
实际会被转化为fut1.flatMap(x => fut2.map(y => x + y))
。
flatMap
的定义:将一个函数作用到Future
成功时的结果并生成一个新的Future,如果原Future
失败,新的Future
将会包含同样的异常。
创建 Future
上面的例子是通过Future
的apply
方法来创建:
def apply[T](body: =>T)(implicit @deprecatedName('execctx) executor: ExecutionContext): Future[T] = impl.Future(body)
body
是需要执行的异步计算。
创建一个成功的Future
:
Future.successful { 21 + 21 }
// def successful[T](result: T): Future[T] = Promise.successful(result).future
// result 为 Future 的结果
创建一个失败的Future
:
Future.failed(new Exception("bummer!"))
// def failed[T](exception: Throwable): Future[T] = Promise.failed(exception).future
// exception 为指定的异常
通过Try
创建一个已完成的Future
:
import scala.util.{Success,Failure}
Future.fromTry(Success { 21 + 21 })
Future.fromTry(Failure(new Exception("bummer!")))
// def fromTry[T](result: Try[T]): Future[T] = Promise.fromTry(result).future
常用的方式是通过Promise
来创建,得到一个被这个Promise
控制的Future
,当这个Promise
完成时对应的Future
才会完成:
val pro = Promise[Int] // Promise[Int]
val fut = pro.future // Future[Int]
fut.value // None
pro.success(42) // 或者 pro.failure(exception)/pro.complete(result: Try[T])
fut.value // Try[Int]] = Some(Success(42))
或者调用completeWith
方法并传入一个新的Future
,新的Future
一旦完成则用值赋予给这个Priomise
。
filter & collect
filter
用户验证Future
的值,如果满足则保留这个值,如果不满足则会抛出一个NoSuchElementException
异常:
val fut = Future { 42 }
val valid = fut.filter(res => res > 0)
valid.value // Some(Success(42))
val invalid = fut.filter(res => res < 0)
invalid.value // Some(Failure(java.util.NoSuchElementException: Future.filter predicate is not satisfied))
同时提供了一个withFilter
方法,因此可以在for
表达式中执行相同的操作:
val valid = for (res <- fut if res > 0) yield res
val invalid = for (res <- fut if res < 0) yield res
collect
方法对Future
的值进行验证并通过一个操作将其转换。如果传递给collect
的偏函数符合Future
的值,该Future
会返回经过偏函数转换后的值,否则会抛出NoSuchElementException
异常:
val valid = fut collect { case res if res > 0 => res + 46 } // Some(Success(88))
val invalid = fut collect { case res if res < 0 => res + 46 } // NoSuchElementException
错误处理:failed、fallBackTo、recover、recoverWith
failed
failed
方法将一个任何类型的、错误的Future
转换为一个成功的Future[Throwable]
,这个Throwable
即引起错误的异常。
val failure = Future { 42 / 0 }
failure.value // Some(Failure(java.lang.ArithmeticException: / by zero))
val expectedFailure = failure.failed
expectedFailure.value // Some(Success(java.lang.ArithmeticException: / by zero))
如果调用failed
方法的Future
最终是成功的,而调用failed
方法返回的Future
会以一个NoSuchElementException
异常失败。因此,只有当你需要Future
失败时,调用failed
方法才是适当的:
val success = Future { 42 / 1 }
success.value // Some(Success(42)), 原本是一个成功的 Future
val unexpectedSuccess = success.failed
unexpectedSuccess.value // NoSuchElementException, 称为一个失败的 Future
fallBackTo
fallBackTo
方法用于提供一个可替换的Future
,以便调用该方法的Future
失败时作为备用。
val fallback = failure.fallbackTo(success)
fallback.value
如果调用fallBackTo
方法的原始Future
执行失败,传递给fallBackTo
的错误本质上会被忽略。但是如果调用fallBackTo
提供的Future
也失败了,则会返回最初的错误,即原始Future
中的错误:
val failedFallback = failure.fallbackTo(
Future { val res = 42; require(res < 0); res } // 这里实际是一个 require 异常
)
failedFallback.value // Some(Failure(java.lang.ArithmeticException: / by zero)),仍然返回了原始 Future 中的除零异常
recover
recover
允许将一个失败的Future
转换为一个成功的Future
,或者原始Future
成功时则不作处理。
val recovered = failedFallback recover { case ex: ArithmeticException => -1 }
recovered.value // Some(Success(-1)), 捕捉异常并设置成功值,返回新的 Future
如果原始Future
成功,recover
部分会以相同的值完成:
val unrecovered = fallback recover { case ex: ArithmeticException => -1 }
unrecovered.value // Some(Success(42))
同时,如果传递给recover
的偏函数并不包含原始Future
的错误类型,新的Future
仍然会以原始Future
中的失败完成:
val alsoUnrecovered = failedFallback recover { case ex: IllegalArgumentException => -2 }
alsoUnrecovered.value // Some(Failure(java.lang.ArithmeticException: / by zero))
recoverWith
recoverWith
与recover
类似,但是使用的是一个Future
值。
val alsoRecovered = failedFallback recoverWith {
case ex: ArithmeticException => Future { 42 + 46 } // 这是一个 Future
}
其他方面的处理则于recover
一致。
transform:对可能性的映射
transfor
接收两个转换Future
的函数:一个处理原始Future
成功的请求,一个处理失败的情况。
val first = success.transform(
res => res * -1, // 成功
ex => new Exception("see cause", ex) // 失败
)
**注意:**现有的transform
并不能将一个成功的Future
转换为一个失败的Future
,或者反向。只能对成功时的结果进行转换或失败时的异常类型进行转换。
Scala 2.12 版本中提供了一种替代的方式,接收Try => Try
的函数:
val firstCase = success.transform { // 处理成功的 Future
case Success(res) => Success(res * -1)
case Failure(ex) => Failure(new Exception("see cause", ex))
}
val secondCase = failure.transform { // 处理失败的 Future
case Success(res) => Success(res * -1)
case Failure(ex) => Failure(new Exception("see cause", ex))
}
val nonNegative = failure.transform { // 将失败转换为成功
case Success(res) => Success(res.abs + 1)
case Failure(_) => Success(0)
}
组合 Future:zip、fold、reduce、sequence、traverse
zip
zip
方法将两个成功的Future
转换为一个新的Future
,其值两个Future
值的元组。
val zippedSuccess = success zip recovered // scala.concurrent.Future[(Int, Int)]
zippedSuccess.value // Some(Success((42,-1)))
如果其中一个失败,zip
方法的值会以同样的异常失败:
val zippedFailure = success zip failure
zippedFailure.value // Some(Failure(java.lang.ArithmeticException: / by zero))
如果两个都失败,结果值会包含最初的异常,即调用zip
方法的那个Future
的异常。
fold
trait TraversableOnce[+A] extends GenTraversableOnce[A]
可以被贯穿一次或多次的集合的模板特质。它的存在主要用于消除
Iterator
和Traversable
之间的重复代码。包含一系列抽象方法并在Iterator
和Traversable
..中实现,这些方法贯穿集合中的部分或全部元素并返回根据操作生成的值。
fold
方法通过穿过一个TraversableOnce
的Future
集合来累积值,生成一个Future
结果。如果集合中的所有Future
都成功了,结果Future
会以累积值成功。如果集合中任何一个失败,结果Future
就会失败。如果多个Future
失败,结果中会包含第一个失败的错误。
val fortyTwo = Future { 21 + 21 }
val fortySix = Future { 23 + 23 }
val futureNums = List(fortyTwo, fortySix)
val folded = Future.fold(futureNums)(0) { // (0), 提供一个累积值的初始值
(acc, num) => acc + num
}
folded.value // Some(Success(88))
reduce
reduce
方法与fold
类似,但是不需要提供初始的默认值,它使用最初的Future
的结果作为开始值。
val reduced = Future.reduce(futureNums) {
(acc, num) => acc + num
}
reduced.value // Some(Success(88))
如果给reduce
方法传入一个空的集合,则会以NoSuchElementException
异常失败,因为没有初始值。
sequence
sequence
方法将一个TraversableOnce
的Future
集合转换为一个包含TraversableOnce
值的Future。比如List[Future[Int]] => Future[List[Int]]
:
val futureList = Future.sequence(futureNums)
futureList.value // Some(Success(List(42, 46)))
traverse
traverse
方法将一个包含任意元素类型的TraversableOnce
转换为一个TraversableOnce
的Future
,并且这个序列转换为一个TraversableOnce
值的Future
。比如,List[Int] => Future[List[Int]]
:
val traversed =Future.traverse(List(1, 2, 3)) { i => Future(i) } // .Future[List[Int]]
traversed.value // Some(Success(List(1, 2, 3)))
执行副作用:foreach、onComplete、andThen
有时需要在Future
完成时执行一些副作用,而不是通过Future
生成一个、一些值。
foreach
最基本的foreach
方法会在Future
成功完成时执行一些副作用。失败时将不会执行:
failure.foreach(ex => println(ex)) // 不会执行
success.foreach(res => println(res)) // 42
因为不带yield
的for
表达式会被重写为一个foreach
执行,因此也可以使用for
表达式来实现:
for (res <- failure) println(res)
for (res <- success) println(res)
onComplete
这是Future
的一种回调函数,无论Future
最终成功或失败,onComplete
方法都会执行。它需要被传入一个Try
:Success
用于处理成功的情况,Failure
用于处理失败的情况:
success onComplete {
case Success(res) => println(res)
case Failure(ex) => println(ex)
}
andThen
Future
并不会保证通过onComplete
注册的回调函数的执行顺序。如果需要保证回调函数的执行顺序,可以使用andThen
方法代替,它是Future
的两一个回调函数。
andThen
方法返回一个对原始Future
映射(即与原始 Future 同样的方式成功或失败)的新Future
,但是当回调完全执行后才会完成。它的功能是,既不影响原始 Future 的结果,又能在原始 Future 完成时执行一些回调。
val newFuture = success andThen {
case Success(res) => println(res)
case Failure(ex) => println(ex)
}
42 // 在回调中打印 结果
newFuture.value // Some(Success(42)), 同时仍然保持了原始 Future 的值
但是需要注意的是,如果传递给andThen
的函数如果在执行时引发异常,该异常会传递给后续的回调或者通过结果Future
呈现。
2.12 中的新方法
flatten
flatten
方法将一个嵌套的Future
转换为一个单层的Future
,即Future[Future[Int]] =>Future[Int]
:
val nestedFuture = Future { Future { 42 } } // Future[Future[Int]]
val flattened = nestedFuture.flatten // Future[Int]
zipWith
zipWith
方法实质上是对两个Future
执行zip
方法,并将结果元组执行一个map
调用:
val futNum = Future { 21 + 21 }
val futStr = Future { "ans" + "wer" }
val zipped = futNum zip futStr
val mapped = zipped map { case (num, str) => s"$num is the $str" }
使用zipWith
只需要一步:
val fut = futNum.zipWith(futStr) { // Scala 2.12
case (num, str) => s"$num is the $str"
}
transformWith
transformWith
支持通过一个Try => Future
的函数来转换Future
:
val flipped: Future[Int] = success.transformWith { // Scala 2.12
case Success(res) => Future { throw new Exception(res.toString) }
case Failure(ex) => Future { 21 + 21 }
}
该方法实质上是对transform
方法的重写,它支持生成一个Future
而不是生成一个Try
。
测试 Future
Future 的作用在于避免阻塞。在很多 JVM 实现上,创建上千个线程之后,线程间的上下文切换对性能的影响达到不能接受的程度。通过避免阻塞,可以繁忙时维持有限的线程数。不过,Scala 支持在需要的时候阻塞Future
的结果,通过Await
。
val fut = Future { Thread.sleep(10000); 21 + 21 }
val x:Int = Await.result(fut, 15.seconds) // <= blocks
然后就可以对其结果进行测试:
import org.scalatest.Matchers._
x should be (42)
或者直接通过特质ScalaFutures
提供的阻塞结构来测试。比如futureValue
方法,它会阻塞直到Future
完成,如果Future
失败,则会抛出TestFailedException
异常。
import org.scalatest.concurrent.ScalaFutures._
val fut = Future { Thread.sleep(10000); 21 + 21 }
fut.futureValue should be (42) // <= futureValue 阻塞
或者使用 ScalaTest 3.0 提供的异步测试风格:
import org.scalatest.AsyncFunSpec
import scala.concurrent.Future
class AddSpec extends AsyncFunSpec {
def addSoon(addends: Int * ): Future[Int] = Future { addends.sum }
describe("addSoon") {
it("will eventually compute a sum of passed Ints") {
val futureSum: Future[Int] = addSoon(1, 2)
// You can map assertions onto a Future, then return
// the resulting Future[Assertion] to ScalaTest:
futureSum map { sum => assert(sum == 3) }
}
}
}
6.1.6 - Future-用例
在 Akka 中实现事务
因为 Akka 中的ask
模式有超时问题,这种方式不易于多重逻辑处理与 Debug,因此可以使send
模式与Promise
组合的方式来实现与其他 actor 的通信:在当前 actor 中创建Promise
,通过send
发送给其他 actor 引用,在其他 actor 中完成对Promise
的填充,然后在当前 actor 中来处理这个被填充后的Promise
- 即处理一个Future
。
如果需要同时与多个 actor 通信,拿到所有 actor 的结果 - 即多个由Promise
填充后生成的Future
,才能完成后续的逻辑处理 - 即事务部分。只需要通过for
表达式的方式来实现“所有需要的Future
“都已完成。但是多个Future
中任何一部分都会引发异常,包括事务部分,因此在最后的结果失败处理(事务回调)中要对所有的错误情况进行处理,比如通知其他的各个 actor 将刚才的操作分别进行回滚(比如买了一个东西,事务失败,重新将这个东西放回库存中,或者同时,将用户账户的余额扣款取消)。
import scala.concurrent.{ Future, Promise }
import scala.concurrent.ExecutionContext.Implicits.global
val fundsPromise:Promise[Funds] = Promise[Funds]
val sharesPromise:Promise[Shares] = Promise[Shares]
buyerActor ! GetFunds(amount, fundsPromise)
sellerActor ! GetShares(numShares, stock, sharesPromise)
val futureFunds = fundsPromise.future
val futureShares = sharesPromise.future
def transact(funds:Funds, shares:Shares):ResultType = {
// 一些事务操作,比如更新数据库、缓存等
// 这里也可能引发一些异常
}
val purchase = for{
funds <- futureFunds
shares <- futureShares
// if ... 一些条件等等
} yield transact(funds, shares)
// 通过回调来处理事务结果
purchase onComplete{
case Success(transcationResult) =>
buyerActor ! PutShares(numShares)
sellerActor ! PutFunds(amount)
// 通知其他系统事务执行成功
case Failure(err) =>
// 分别检查各个操作是否成功,成功则通知其进行对应的回滚操作
futureFunds.onSuccess{ case _ => buyerActor ! PutFunds(amount) }
futureShares onSuccess { case _ => sellerActor ! PutShares(numShares) }
// 通知其他系统事务执行失败
}
在处理事务结果部分,如果需要得到值而不是以通知(副作用)的方式,并对事务结果进行检查以执行其他操作(回滚等),可以使用andThen
方法,而不是通过onComplete
对调:
val purchaesResult:Future[Result] = purchase andThen{
case Success(res) => ???
case Failure(ex) => ???
}
// 然后再响应给客户端或其他后续的处理,而不需要在 onComplete 中编写大量嵌套很深的逻辑
doSomething(purchaesResult)
另一种解决方案是创建一个临时的 actor 来保存状态。
6.1.7 - Future-集合
**主要解决的问题:**处理Future
的集合,使用分组方式避免一次将过多的Future
压入执行器队列。有效处理,每个单独Future
可能引发的异常,同时不会丢弃Future
的结果,对所有的Future
结果进行累积并返回。最后抽象为一种清晰易复用的模式提供使用。
Scala、Akka 中的
Future
并不是 lazy 的,一旦构造会立即执行。而scalaz
中为 lazy。Lazy 性质的Future
实际是创建一个执行计划,并被最终的调用者执行。这种技术有很多优势,而本文基于标准的Future
。
示例需要的所有引入:
import scala.language.higherKinds
import scala.collection.generic._
import scala.concurrent._
import scala.concurrent.duration._
import ExecutionContext.Implicits.global
多次调用返回 Future 的方法
首先是一个返回Future
的方法:
trait MyService {
def doSomething(i: Int) : Future[Unit]
}
class MyServiceImpl extends MyService {
def doSomething(i: Int) : Future[Unit] = Future { Thread.sleep(500);println(i) }
}
有些场景下需要调用多次:
val svc : MyService = new MyServiceImpl
val someInts : List[Int] = (1 to 20).toList
val result : Unit = someInts.foreach(svc.doSomething _)
然而这会创建一个List[Future[Unit]]
,其中包含 20 个会立即执行的Future
,20 个还好,如果是成百上千个则难以承受。
程序内部,执行器会将Future
存入队列,并使用所有可用的 worker 来执行尽可能多的Future
。一次将过多的Future
压入队列将会使其他需要执行器的代码进入饥饿状态并引起内存溢出错误。
另一方面,所有svc.doSomething
的返回值被丢弃并赋予一个Unit
。这里不但没有正确的等待Future
完成,而且还把Future
分配为Unit
,这会扔掉所有可能的异常。
不能将 Future 指派为 Unit
为了避免将Future
指派为Unit
,可以使用map
来替换foreach
。同时,需要一种方式来等待所有的Future
完成。
val futResult:Future[List[Unit]] =
Future.sequence{ // 2
someInts.map(svc.doSomething _) // 1
}
val result: Unit = Await.result(futResult, Duration.Inf) // 3
- 使用
map
将不会丢弃Future
的结果 - 使用
Future.sequence
将List[Future[Unit]]
转换为Future[List[Unit]]
- 恰当的等待所有
Future
完成,这时可以安全的丢弃List[Unit]
,因为没有抛出任何异常
当svc.doSomething
调用过程中抛出异常时会通过Await.result
体现。
Future.sequence
会等待所有内部的Future
完成,一旦完成,外部的Future
则会完成。
Future.sequence 源码
控制 Future 的执行流程
val result:Unit =
someInts
.grouped(3) // 1
.toList
.map{ group =>
val innerFutResult: Future[List[Unit]] = // 2
Future.sequence {
group.map(svc.doSomething _)
}
Await.result(innerFutResult, Duration.Inf) // 3
}.flatten // 4
- 将
someInts
每 3 个分成一组 - 每一组创建一个
Future[List[Unit]]
- 使用
Await.result
等待每一个内部分组完成 - 因为将
someInts
分割成了多个小组,这时需需要使用flatten
将整个嵌套的分组展开
这样可以很好的解决一次将大量的Future
压入执行器队列,同时使用map
也不会丢弃Future
的结果。但是有个问题就是这种方式不会返回一个Future
结果,因为Await.result
的使用使整个执行变成了部分同步阻塞。在实际的异步编程中,这样是不可取的。
返回 Future
为了使结果为Future
,下面是新的实现:
val futResult: Future[List[Unit]] =
someInts
.grouped(3)
.toList
.foldLeft(Future.successful(List[Unit]())){ (futAccumulator, group) => // 1
futAccumulator.flatMap{ accumulator => // 2
val futInnerResult:Future[List[Unit]] =
Future.sequence {
group.map(svc.doSomething _)
}
futInnerResult.map(innerResult => accumulator ::: innerResult) // 3
}
}
val result: Unit = Await.result(futResult, Duration.Inf)
- 使用
foldLeft
替换map
,这样可以确保一次只处理一个组,然后当每个组完成时,从左到右对每个组进行累积。累计器被初始化为一个已完成的Future.successful(List[Unit]())
。 - 使用
Future.flatMap
替换Future.map
,这里用于展开返回结果的类型为Future[List[Unit]]
。如果使用map
,返回结果将会是Future[Future[List[Unit]]]
。 - 一旦一个分组完成,将结果进行累积。
现在返回结果已经是一个Future
了,但是这种使用模式很常见,但上面的写法难以复用,因此需要简化其复杂性。
简化
这里将会使用 for 表达式,并使用值类(value class)来实现pimp-my-library
模式,pimp-my-library
模式将会创建一个隐式包装器类,将方法添加到已有的类上,本质上以面向对象的方式调用新的方法。
implicit class Future_PimpMyFuture[T](val self: Future[T]) extends AnyVal {
def get : T = Await.result(self, Duration.Inf)
}
implicit class Future_PimpMyTraversableOnceOfFutures[A, M[AA] <: TraversableOnce[AA]](val self: M[Future[A]]) extends AnyVal {
/** @return a Future of M[A] completes once all futures have completed */
def sequence(implicit cbf: CanBuildFrom[M[Future[A]], A, M[A]], ec: ExecutionContext) : Future[M[A]] =
Future.sequence(self)
}
然后以下面的方式使用:
val futResult : Future[List[Unit]] =
someInts
.grouped(3)
.toList
.foldLeft(Future.successful(List[Unit]())) { (futAccumulator,group) =>
for { // 1
accumulator <- futAccumulator
innerResult <- group.map(svc.doSomething _).sequence // 2
} yield accumulator ::: innerResult
}
val result : Unit = futResult.get // 3
- 将
Future.flatMap
和嵌套的Future.map
替换为更清晰易懂的 for 表达式 - 使用上面预定义的“语法糖方法”替换
Future.sequence
- 使用上面预定义的“语法糖方法”替换
Await.result
再次简化
在一个新的值类Future_PimpMyTraversableOnce
创建另一个 pimp-my-library
方法。
implicit class Future_PimpMyTraversableOnce[A, M[AA] <: TraversableOnce[AA]](val self: M[A]) extends AnyVal {
/** @return a Future of M[B] that completes once all futures have completed */
def mapAsync[B](groupSize: Int)(f: A => Future[B])(implicit
cbf: CanBuildFrom[M[Future[A]], A, M[A]],
cbf2: CanBuildFrom[Nothing, B, M[B]],
ec: ExecutionContext) : Future[M[B]] = {
self
.toList // 1
.grouped(groupSize)
.foldLeft(Future.successful(List[B]())) { (futAccumulator,group) =>
for {
accumulator <- futAccumulator
innerResult <- group.map(f).sequence
} yield accumulator ::: innerResult
}
.map(_.to[M]) // 2
}
}
- 转换为
List
以有效的进行结果累积 - 转换为预期的集合
然后使用:
val futResult : Future[List[Unit]] = someInts.mapAsync(3)(svc.doSomething _)
val result : Unit = futResult.get
6.1.8 - Future-Promise
介绍
Promise
是一个可以被一个值或一个异常完成的对象。并且只能完成一次,完成后就不再可变。称为单一赋值变量,如果再次赋值则会引发异常。
Promise
值的类型为Promise[T]
,可以通过其伴生对象中的Promise.apply()
创建一个实例:
def apply[T](): Promise[T]
调用该方法后会立即返回一个Promise
实例,它是非阻塞的。当新的Promise
对象被创建后,不会包含值或异常,通过success
和failure
方法分别赋值为值或异常。
通过complete
可以提供一个Try[T]
实例来填充Promise
,这时Promise
的值是一个值还是一个异常,取决于Try[T]
最终是一个包含值的Sucess
还是一个包含异常的Failure
对象。
每个Promise
对象都明确对应一个Future
对象,通过future
方法获取关联的Future
对象,并且无论调用该方法多少次,都会放回相同的Future
对象。
与success/failure/complete
对应的方法是trySuccess/tryFailure/tryCompletee
,这些方法会尝试对Promise
进行赋值并返回赋值操作是否成功的布尔值。而基本的success/failure/complete
操作返回的是则是对原Promise
的引用。
object Promise
Promise
的伴生对象,一共定义了四种构造器。
apply
def apply[T](): Promise[T] = new impl.Promise.DefaultPromise[T]()
它不接受然和参数,创建一个能够被类型为T
的值完成的Promise
对象。即创建它时只需要设置预期被完成的值的类型:
val promise: Promise[User] = Promise[User]()
fromTry
def fromTry[T](result: Try[T]): Promise[T] = new impl.Promise.KeptPromise[T](result)
提供一个Try[T]
类型的值,并返回一个被完成后的Promise
对象。这个结果Promise
中是一个值还是异常,取决于传入的Try[T]
最终是一个Success
还是一个Failure
。
failed
def failed[T](exception: Throwable): Promise[T] = fromTry(Failure(exception))
通过传入一个异常创建一个被该异常完成的Promise
对象。
successful
def successful[T](result: T): Promise[T] = fromTry(Success(result))
通过传入一个值创建一个被该值完成的Promise
对象。
trait Promise
isCompleted
判断该Promise
是否已被完成,返回一个布尔值。
complete & tryComplete
def complete(result: Try[T]): this.type =
if (tryComplete(result)) this
else throw new IllegalStateException("Promise already completed.")
def tryComplete(result: Try[T]): Boolean
tryComplete
方法尝试通过传入的Try[T]
来完成该Promise
,返回一个该操作成功失败的布尔值。需要注意的是,虽然返回的是一个布尔值,但这个布尔值表示,该 Promise 之前没有被完成并且已经被当前的操作完成,或在调用该方法之间就已经被完成。
因此,在complete
方法中,通过传入一个Try[T]
来完成一个Promise
,实际上会在内部调用tryComplete
,如果``tryComplete返回
true,表示该
Promise还没有被完成并通过这次操作成功完成,同时返回该
Promise`的引用,否则则报错已经被完成过。
completeWith & tryCompleteWith
final def completeWith(other: Future[T]): this.type = tryCompleteWith(other)
final def tryCompleteWith(other: Future[T]): this.type = {
other onComplete { this tryComplete _ }
this
}
与complete
和tryComplete
类似,不过他接收的是一个Future[T]
而不是一个Try[T]
。
success & trySuccess
def success(@deprecatedName('v) value: T): this.type = complete(Success(value))
def trySuccess(value: T): Boolean = tryComplete(Success(value))
success
方法通过一个值来完成Promise
,而trySuccess
则首先将传入的值包装为一个Success[T ]
然后调用前面的tryComplete
方法,尝试完成Promise
并返回一个布尔值。
failure & tryFailure
def failure(@deprecatedName('t) cause: Throwable): this.type = complete(Failure(cause))
def tryFailure(@deprecatedName('t) cause: Throwable): Boolean = tryComplete(Failure(cause))
与success
和trySuccess
处理过程相同,只是通过一个异常而不是一个值来完成Promise
future
def future: Future[T]
获取该Promise
对应的Future
。
总结
- 一个
Promise
有两种构造方式,构造为未完成的、构造为已完成的 - 一个
Promise
只能调用完成方法一次,无论是哪个完成方法,再次调用将抛出已完成异常 - 一个
Promise
只与一个Future
对应,无论调用多少次future
方法都会返回相同的Future
6.1.9 - Implicits-基础
《Programming in Scala 3rd》- Implicit Conversions And Parameters
主要解决的问题是扩展已有的对象。
隐式转换
每个库对实质上相同的概念有其各自不同的编码方式,隐式转换用于减少两种类型之间的显式转换操作。
隐式-规则
这些隐式定义是编译器允许的、被用于插入到程序中以用来解决类型错误问题。
- 标记规则:只有那些被关键字
implicit
标记的定义 - 作用域规则:被插入的隐式转换在作用域中必须是一个单独的标示符,或者是在被转换对象的伴生对象中。必须直接引入该隐式定义,而不是拥有该定义的对象。比如需要
convert
,它属于一个对象object
,只有直接通过import object.convert
才有效。需要的隐式定义在伴生对象中时则不需要显式引入。 - 一次一个规则:只有一个隐式被插入。编译器永远不会将
x + y
重写为convert1(convert2(x)) + y
。 - 显式优先规则:无论何时以代码编写的方式类型检查,都不会尝试隐式。编译器不会再去改变已经运行的代码。因此你可以随时使用显式定义替换隐式定义。
隐式转换到一个指定的类型
如果编译器看到一个 X,但是需要的是一个 Y,这时他会寻找隐式转换函数来将 X 转换为 Y。
隐式转换方法调用者
如果编译器看到如下代码obj.toInt
,但是对象obj
并不拥有toInt
方法,这是会尝试查找拥有该方法的类型并尝试进行隐式转换。
与新类型互操作
6.1.10 - Implicits-进阶
隐式转换的用途:
- DSL
- Type evidence
- 减少代码冗余
- Type class
- 编译期 DI
- 扩展现有库
需要注意的地方:
- 解决规则会变得困难
- 自动转换
- 不要过度使用
隐式转换
一个普通的方法:
def makeString(i:Int):String = s"makeString: ${i.toString}"
以正常方式调用:
makeString(100)
但是如果我们有一个其他非 Int 类型的对象需要作为该方法的参数,则需要提供一个隐式转换,将其他类型转换为需要的 Int 类型:
case class Balance(amount:Int)
val balance = Balance(100)
implicit def balance2Int(balance:Balance):Int = balance.amount
makeString(balance)
JavaConversion & JavaConverters
在与 Java 互操作时,比如我们的方法需要一个 Scala 类型的集合,而原有代码只能提供 Java 类型的集合,这时可以引入 Scala 中预定义的两种类型的隐式转换:
val javaList:java.util.List[Int] = java.util.Array.asList(1,2,3)
def someDefUsingSeq(seq:Seq[Int]) = println(seq)
import scala.collection.JavaConversions._
someDefUsingSeq(javaList)
或者以更好的方式,在转换时显示指定:
import scala.collection.JavaConverters
someDefUsingSeq(javaList.asScala)
隐式视图
如果我们需要包装或扩展一个已有的类型对象:
class StringWrapper(s:String){
def quoted = s"$s"
}
可以以下面的方式调用:
new StringWrapper("string").quoted
但是并不能以下面的方式调用:
"string".quoted
这时,可以定义一个从String
到StringWrapper
的视图作为隐式转换:
implicit def warpString(s:String):StringWrapper = new StringWrapper(s)
"string".quoted // "string" 会通过隐式转换自动转换为一个 StringWrapper 对象
视图绑定:废弃。
隐式参数
声明一个带有隐式参数的方法:
def giveMeAnInt(implicit i: Int) = i
可以以正常的方式调用该方法:
giveMeAnInt(1)
以隐式的方式提供参数:
implicit val someInt = 100
giveMeAnInt // 100
但是如果同时提供多个隐式 Int 类型值,则会报错。
隐式类
假如其他的库已经定义了一个类:
case class Balance(amount:Int)
val balacne = 100
如果这时我们想对它做些操作,比如:
-balance
这时,我们可以创建一个包装类来扩展它:
implicit class RichBalance(val balance:Balance){
def unary-: Balance = balance.copy(amount = -balance.amount)
}
这种用法主要用于扩展其他已有的库或类型。
隐式声明
作用域
查找优先级:
- 通过名字,不使用任何前缀,当前作用域
- 在隐式作用域中:
- 伴生对象
- 包对象
- 源类型的包对象
- 参数和超类、超特质的包对象
Type class
6.1.11 - Trait-基础
原理
定义特质
trait Philosophical{
def philosophical() {
println("I consume memory, therefore I am!")
}
}
除了使用trait
关键字,与类的定义一样,也没有声明超类,因此只有一个默认的AnyRef
作为超类。同时定义了一个具体的方法。
在类中混入特质
然后将特质混入到一个类中:
class Forg extends Philosophical {
override def toString = "green"
}
可以使用extends
或with
混入特质,混入特质的类同时将会隐式的继承特质的超类。
从特质继承的方法可以向使用超类中的方法一样:
val forg = new Forg()
forg.philosophical() // I consume mem...
特质也是类型
val phil: Philosophical = forg
phil.philosophical() // I consume mem...
phil
变量被声明为Philosophical
类型,因此它可以被初始化为任何继承了Philosophical
特质的类的实例。
混入多个特质
如果需要将特质混入到一个继承自超类的类里,可以使用with
来混入特质,或者使用多个with
混入多个特质:
class Animal
trait HasLegs
class Forg extends Animal with Philosophical with HasLegs{
override def toString = "green"
}
重写特质方法
class Forg extends Philosophical {
override def toString = "green"
override def philosophical() {
println("It ain't easy being $toString!")
}
}
这时,类Forg
的实例仍然可以赋值到Philosophical
类型的变量,但是其方法已经被重写。
特质与类的不同
特质类似于带有具体方法的 Java Interface。特质能够实现类的所有功能,但是有两点差别:
- 特质没有构造器
- 无论类的所处位置,
super
调用都是静态绑定的。但是特质中是动态绑定的。因为可以同时混入多个特质,其绑定会基于混入特质的顺序。
胖瘦接口
特质一种主要的应用方式是更具类已有的方法自动为类添加方法。但是在 Java 的接口中,如果需要多个接口的相互继承,必须在子接口中声明所有父接口的抽象方法。而在 Scala 中,可以通过定义一个包含部分抽象方法的特质,和一个包含大量已实现方法的特质,子类在继承多个特质后只需要实现抽象方法部分,并且同时能够获得需要的所有方法。
实例:Ordered 特质
比如一个需要比较的对象,希望使用>
或<
等操作符来进行比较:
class Rational(n:Int, d:Int){
// ...
def < (that.Rational) = this.numer * that.denom > that.numer * this.denom
def > (that.Rational) = that < this
def <= (that:Rational) = (this < that) || (this == that)
def >= (that.Rational) = (this > that) || (this == that)
}
一共定义了 4 中操作符,并且后续的 3 中都是基于第一个方法,这是一个胖接口的典型,如果是瘦接口的话,只会顶一个一个<
操作符,然后其他的功能由客户端自己实现。
这些用法很常见,因此 Scala 专门提供了一个Ordered
特质,只需要定义一个compare
方法,然后Ordered
会自动创建所有的比较操作。比如:
class Rational(n:Int, d:Int) extends Ordered[Rational]{
def compare(that:Rational) =
(this.numer * that.denom) - (that.numer - this.denom)
}
这个compare
方法通过比较两个值的差来判断大小。可以参考Ordered
原码:
trait Ordered[A] extends Any with java.lang.Comparable[A] {
def compare(that: A): Int
def < (that: A): Boolean = (this compare that) < 0
def > (that: A): Boolean = (this compare that) > 0
def <= (that: A): Boolean = (this compare that) <= 0
def >= (that: A): Boolean = (this compare that) >= 0
def compareTo(that: A): Int = compare(that)
}
特质叠加
当类同时混入多个特质时,混入的特质会从右到左依次执行。
如果混入特质中使用了super
,将会调用其左侧特质中的方法。
多重继承
应用场景
- 如果行为不会被复用,使用具体类
- 如果需要在多个不相关的类中使用,使用特质
- 如果需要构造参数,或者在 Java 中使用这部分代码,使用抽象类。因为 Scala 中只有那些仅包含抽象成员的特质会被翻译为 Java 的接口,否则并没有具体的模拟。
- 如果效率非常重要,使用类。大多数 Java 运行时都能让类成员的续方法调用快于接口方法调用。
6.1.12 - Trait-用例
介绍
特质(trait)类似于 Java 中的接口(interface)。类似 Java 类能够实现多个接口,Scala 类能够扩展多个特质。
像 Interface 一样使用 trait
在特质中,仅声明在需要扩展的类中需要的方法。
trait BaseSoundPlayer {
def play
def close
def pause
def stop
def speak(whatToSay: String)
}
然后在需要扩展的类中使用特质,仅有一个特质时使用 extends,多于一个时第一个使用 extends,后续的使用 with:
class Mp3SoundPlayer extends BaseSoundPlayer { ...
class Foo extends BaseClass with Trait1 with Trait2 { ...
class Foo extends Trait1 with Trait2 with Trait3 with Trait4 { ...
除非一个类被声明为抽象类(abstract),否则需要实现特质中的所有抽象方法:
class Mp3SoundPlayer extends BaseSoundPlayer {
def play { // code here ... }
def close { // code here ... }
def pause { // code here ... }
def stop { // code here ... }
def resume { // code here ... }
}
abstract class SimpleSoundPlayer extends BaseSoundPlayer {
def play { ... }
def close { ... }
}
同时,一个特质能够扩展另一个特质:
trait Mp3BaseSoundFilePlayer extends BaseSoundFilePlayer {
def getBasicPlayer: BasicPlayer
def getBasicController: BasicController
def setGain(volume: Double)
}
特质中除了能够声明抽象方法,还能提供方法的实现,类似 Java 中的抽象类:
abstract class Animal {
def speak
}
trait WaggingTail {
def startTail { println("tail started") }
def stopTail { println("tail stopped") }
}
当一个类拥有多个特质时,这些特质被称为**混入(mix)**这个类。混入同时用于使用特质来扩展一个单独的对象:
val f = new Foo with Trait1
在特质中使用抽象或具体的字段
可以在特质中定义抽象或具体字段,以便所有扩展他们的类型都能够使用它们。没有提供初始值的字段将会是一个抽象字段,拥有初始值的字段将会是具体字段:
trait PizzaTrait {
var numToppings: Int
var size = 14
val maxNumToppings = 10
}
类似于抽象方法,被扩展的类需要实现所有抽象字段,否则需要声明为抽象类。
在特质中定义字段时可以使用 var 或 val,如果是 val,在子类或子特质中需要使用 override 来覆写该字段,而 var 则不需要。
像抽象类一样使用特质
在特质中定义的方法跟普通的 Scala 方法类似,可以直接使用或重写它们。
trait Pet {
def speak { println("Yo") } // 具体方法
def comeToMaster // 抽象方法
}
class Dog extends Pet {
// 如果不需要则不用重写特质中的具体方法
def comeToMaster { ("I'm coming!") } // 实现抽象方法
}
class Cat extends Pet {
override def speak { ("meow") } // 重写具体方法
def comeToMaster { ("That's not gonna happen.") }
}
虽然 Scala 中有抽象类,但是一个类只能继承一个抽象类,但是可以同时扩展多个方法,除非如 Classes 章节中介绍的对抽象类有特殊需要,否则,使用特质户更加灵活。
简单的混入
需要将多个特质混入类以提供健康的设计。为了实现简单的还如,只需要在特质中定义需要的方法,然后在需要扩展的类中使用 extends 或 with 来进行混入。
下面的例子中,同时继承自一个抽象类并混入一个特质,同时实现了抽象类中的抽象方法:
trait Tail {
def wagTail { println("tail is wagging") }
def stopTail { println("tail is stopped") }
}
abstract class Pet (var name: String) {
def speak // abstract
def ownerIsHome { println("excited") }
def jumpForJoy { println("jumping for joy") }
}
class Dog (name: String) extends Pet (name) with Tail {
def speak { println("woof") }
override def ownerIsHome { wagTail speak }
}
限定哪些类可以通过继承来使用特质
如果需要对一些特质进行限制,比如,只能添加到继承了一个抽象类或扩展了一个特质的类上。
trait [TraitName] extends [SuperThing]
这种声明语法称为 TraitName,TraitName 只能被混入到哪些扩展了名为 SuperThing 的类型的类中,这里的 SuperThing 可以是 特质、类、抽象类。
简单的说就是,只有继承或扩展了 SuperThing,才能混入 TraitName 这个特质。
class StarfleetComponent
trait StarfleetWarpCore extends StarfleetComponent
class Starship extends StarfleetComponent with StarfleetWarpCore
这个例子中,StarfleetWarpCore 继承了 StarfleetComponent,而 Starship 也继承了 StarfleetComponent,因此,类 Starship 可以混入特质 StarfleetWarpCore。
限定一个特质只混入到一种类型的子类
可以为一个特质添加一个类型,只有该类型的子类才能混入该特质。
trait StarfleetWarpCore {
this: Starship =>
// more code here ...
}
这个特质只能混入到 Starship 的子类中,比如:
class Starship
class Enterprise extends Starship with StarfleetWarpCore
否则将会报错:
class RomulanShip
class Warbird extends RomulanShip with StarfleetWarpCore // 错误
详细的错误信息:
error: illegal inheritance;
self-type Warbird does not conform to StarfleetWarpCore's selftype StarfleetWarpCore with Starship
class Warbird extends RomulanShip with StarfleetWarpCore
错误中提到了 self type,关于 self type 的描述:
“Any concrete class that mixes in the trait must ensure that its type conforms to the trait’s self type.”
任何混入特质的实现类必须确保它的类型与特质的 self type 一致。
特质同时能够限定混入它的类型需要同时扩展其他多个类型,想要混入特质 WarpCore 的类型需要同时混入 this 关键字后面的所有类型:
trait WarpCore {
this: Starship with WarpCoreEjector with FireExtinguisher =>
}
限定特质只能混入到拥有特定方法的类型
可以使用 self type 语法的变型来限制一个特质只能混入到拥有特定方法的类型(类、抽象类、特质):
trait WarpCore {
this: { def ejectWarpCore(password: String): Boolean } =>
}
因此,如果想要混入特质 WarpCore,需要拥有更上述方法签名一致的方法才可以。
或者需要多个方法:
trait WarpCore {
this: {
def ejectWarpCore(password: String): Boolean
def startWarpCore: Unit
} =>
}
这个方式称为结构化类型。
将特质添加到对象实例
不同于将特质混入到实际的类,同样能够在创建对象时,将特质扩展到一个单独的对象。
class DavidBanner
trait Angry {
println("You won't like me ...")
}
object Test extends App {
val hulk = new DavidBanner with Angry
}
或者更为实际的用法:
trait Debugger {
def log(message: String) {
// do something with message
}
}
// no debugger
val child = new Child
// debugger added as the object is created
val problemChild = new ProblemChild with Debugger
像特质一样扩展一个 Java Interface
如果想要在 Scala 中实现一个 Java 接口,可以和使用特质一样,使用 extends 和 with 来扩展。
首先是 Java 接口的定义:
public interface Animal {
public void speak();
}
public interface Wagging {
public void wag();
}
public interface Running {
public void run();
}
然后在 Scala 中像使用特质一样使用他们:
class Dog extends Animal with Wagging with Running {
def speak { println("Woof") }
def wag { println("Tail is wagging!") }
def run { println("I'm running!") }
}
区别在于 Java 中的接口都没有实现行为,因此要实现接口或声明为抽象类。
6.1.13 - Try
介绍
类型Try
表示一个计算,它的结果可能是一个异常,或者成功完成计算的值。它类似于类型Either
但是语义上完全不同。
Try[T]
的实例必须是一个scala.util.Success[T]
或scala.util.Failure[T]
。
一个实例:处理用户的输入并计算,并且在可能会抛出异常的位置并不需要显式的异常处理:
import scala.io.StdIn
import scala.util.{ Try, Sucess, Failure }
def divide: Try[Int] = {
val dividend: Try[Int] = Try(StdIn.readLine("Enter an Int that you'd like to divide:\n").toInt) // 有可能会抛出异常,比如用户输入一个不能转换为 Int 的值
val divisor: Try[Int] = Try(StdIn.readLine("Enter an Int that you'd like to divide by:\n").toInt) // 有可能会抛出异常
val problem: Try[Int] = dividend.flatMap(x => divisor.map(y => x/y)) // TIP
problem match {
case Success(v) =>
println("Result of " + dividend.get + "/"+ divisor.get +" is: " + v)
Success(v)
case Failure(e) =>
println("You must've divided by zero or entered something that's not an Int. Try again!")
println("Info from the exception: " + e.getMessage)
divide // 递归调用自身,重新获取用户输入
}
}
TIP部分展示了Try
的重要属性,它能够进行管道、链式的方式组合操作,同时进行异常的捕获。
**它只能捕获 non-fatal 异常,如果是系统致命异常,将会抛出。**查看scala.util.control.NonFatal
。
sealed abstract class Try[+T]
Try
本身定义为一个封闭抽象类,继承它的有两个子类:
final case class Failure[+T](exception: Throwable) extends Try[T]
final case class Success[+T](value: T) extends Try[T]
因此,Try
的实例有么是一个Failure
,要么是一个Success
,分别表示失败或成功。
两个子类都只有一个类成员,Failure
为一个异常,Success
为一个值。
toOption
def toOption: Option[T] = if (isSuccess) Some(get) else None
如果是一个Success
就返回它的值,是Failure
则返回None。
用例:
def decode(text:String):Option[String] = Try { base64.decode(text) }.toOption
transform
def transform[U](s: T => Try[U], f: Throwable => Try[U]): Try[U] =
try this match {
case Success(v) => s(v)
case Failure(e) => f(e)
} catch {
case NonFatal(e) => Failure(e)
}
接收两个偏函数,一个用于处理成功的情况,一个用于处理失败的情况。根据对应偏函数的处理结果,生成一个新的Try
实例。
object Try
这是Try
的伴生对象,其中定义了Try
的构造器:
def apply[T](r: => T): Try[T] =
try Success(r) catch {
case NonFatal(e) => Failure(e)
}
可见,构造器中只是进行了普通的try/catch
处理,并且对NonFatal
异常进行捕获。成功则返回一个Success
实例,失败则返回一个Failure
实例。
Failure
isFailure & isSuccess
def isFailure: Boolean = true
def isSuccess: Boolean = false
覆写父类抽象方法,分别写死为true
和false
。用于判断是否成功。
recoverWith
def recoverWith[U >: T](f: PartialFunction[Throwable, Try[U]]): Try[U] =
try {
if (f isDefinedAt exception) f(exception) else this
} catch {
case NonFatal(e) => Failure(e)
}
接受一个Throwable => Try[U]
类型的偏函数,如果该偏函数定义了原始Try
抛出的异常,将异常转换为一个新的Try
实例,否则,返回原始Try
的异常。
最后,对偏函数的执行或原始异常进行NonFatal
捕获,生成一个可能的新的Failure
实例。
get
def get: T = throw exception
直接抛出异常,即实例成员。
flatMap
def flatMap[U](f: T => Try[U]): Try[U] = this.asInstanceOf[Try[U]]
接收一个T => Try[U]
类型的偏函数,将本身转换为当前偏函数返回值类型。
flatten
def flatten[U](implicit ev: T <:< Try[U]): Try[U] = this.asInstanceOf[Try[U]]
foreach
def foreach[U](f: T => U): Unit = ()
接收一个T => U
类型的偏函数,因为实例本身的成员是一个异常,因此对Failure
调用只会返回一个Unit
而不会真正对成员执行传入的偏函数。
map
def map[U](f: T => U): Try[U] = this.asInstanceOf[Try[U]]
接收一个T => U
的偏函数。
filter
def filter(p: T => Boolean): Try[T] = this
接收一个T => Boolean
类型的偏函数,对实例成员进行过滤,直接返回实例本身,结果仍然是一个包含异常的Failure
。
recover
def recover[U >: T](rescueException: PartialFunction[Throwable, U]): Try[U] =
try {
if (rescueException isDefinedAt exception) {
Try(rescueException(exception))
} else this
} catch {
case NonFatal(e) => Failure(e)
}
接受一个Throwable => U
类型的偏函数,如果偏函数定义了原始异常,则通过偏函数来处理原始异常并生成一个新的Try
实例,否则返回自身。
failed
def failed: Try[Throwable] = Success(exception)
将自身转换为一个包含自身成员异常的Success
实例。
Success
isFailure & isSuccess
def isFailure: Boolean = false
def isSuccess: Boolean = true
重写父类方法并分别写死为false
和true
。
recoverWith
def recoverWith[U >: T](f: PartialFunction[Throwable, Try[U]]): Try[U] = this
接收一个Throwable => Try[U]
类型的偏函数,因为自身成员并不是异常类型,因此直接返回自身实例,不做异常处理。
get
def get = value
,直接返回自身成员的值。
flatMap
def flatMap[U](f: T => Try[U]): Try[U] =
try f(value)
catch {
case NonFatal(e) => Failure(e)
}
接收一个T => Try[U]
类型的偏函数,将自身成员应用到该偏函数,成功则生成一个新的Try
,否则进行异常捕获。
flatten
def flatten[U](implicit ev: T <:< Try[U]): Try[U] = value
返回成员值。
foreach
def foreach[U](f: T => U): Unit = f(value)
接收一个函数并将成员值作用到该函数。
map
def map[U](f: T => U): Try[U] = Try[U](f(value))
接收一个函数,将成员值作用到该函数并生成一个新的Try
。
filter
def filter(p: T => Boolean): Try[T] = {
try {
if (p(value)) this
else Failure(new NoSuchElementException("Predicate does not hold for " + value))
} catch {
case NonFatal(e) => Failure(e)
}
}
如果成员值满足传入的函数,则返回自身,否则,返回一个包含NoSuchElementException
异常的Failure
实例。
recover
def recover[U >: T](rescueException: PartialFunction[Throwable, U]): Try[U] = this
返回自身,不做处理。
failed
def failed: Try[Throwable] = Failure(new UnsupportedOperationException("Success.failed"))
调用该方法时,生成一个新的包含UnsupportedOperationException
异常的Failure
。
6.1.14 - Type-用例
介绍
Scala 有一个强大的类型系统。然而,除非你是一个库的创建者,你可以不用深入了解类型系统。但是一旦你要为其他用户创建集合类型的 API,你就需要学习这些。
Scala 类型系统使用一组标示符来表示不同的泛型类型概念,包括型变、界限、限定。
界限
界限(bound)用于限制类型参数。界限标示符汇总:
标示符 | 名称 | 描述 |
---|---|---|
A <: B | 上界 | A 必须是 B 的子类 |
A >: B | 下界 | A 必须是 B 的父类 |
A <: Upper >: Lower | 同时使用上下界 | A 同时用于上界和下界 |
A <% B | 视图界限 | |
T : M | 上下文界限 |
类型限定
Scala 允许你指定额外的类型限制:
A =:= B // A 和 B 必须相同
A <:< B // A 必须是 B 的子类型
A <%< B // A 必须是 B 的视图类型
常用类型参数标示符
标示符 | 说明 |
---|---|
A | 用于一个简单的类型时,List[A] |
B,C,D | 用于同时需要多个类型时 |
K | 在 Java 中常用于 Map 的 key,Scala 中多使用 A |
N | 用于一个数字类型 |
V | 跟 V 类似,Scala 中多用 B |
类型参数化
类型参数化用于编写泛型类和特质。
实例:queues 函数式队列
函数式队列是拥有以下三种操作的数据结构:
head 返回队列的第一个元素
tail 返回除第一个元素之外的队列
append 返回尾部添加了指定元素的队列
不同于可变队列,函数式队列在添加元素时不会改变其内容,而是返回包含这个元素的新队列。
支持的工作方式:
val q1 = Queue(1,2,3)
val q2 = q1.append(4)
print(q1) // Queue(1,2,3)
如果将 Queue 实现为可变类型,则 append 操作会改变 q1 的值,这是 q1 和 q2 都拥有新的元素 4。但是对于函数式队列来说,新添加的元素只能出现在 q1 中而不能出现在 q2 中。
纯函数式队列与 List 具有相似性,都被称为是完全持久的数据结构,即使在扩展或改变之后,旧的版本依然可用。但是 List 通常使用::
操作在前端扩展,队列使用append
在后端扩展。
Queue 需要保持三种操作都能在常量时间内完成,低效的实现和最后的实现:
class SlowAppendQueue[T](elems:List[T]){
def head = elems.head
def tail = new SlowAppendQueue(elems.tail)
// append 操作的时间花费与元素的数量成正比
def append(x:T) = new SlowAppendQueue(elems :: List(x))
}
class SlowHeadQueue[T](smele:List[T]){
// 将 elems 元素顺序翻转
def head = smele.last // 与元素个数成正比
def tail = new SlowHeadQueue(smele.init) // 与元素个数成正比
def append(x:T) = new SlowHeadQueue(x :: smele) // 常量
}
class Queue[T](
private val leading: Lit[T]
private val trailing: List[T]
) {
private def mirror = {
if (leading.isEmpty) new Queue(trailing.reverse :: Nil)
else this
}
def head = mirror.leading.head
def tail = {
val q = mirror
new Queue(q.leading.tail, q.trailing)
}
def append(x: T) = new Queue(leading, x :: trailing)
}
最终的方案中使用两个 List,leading 和 trailing 来表达队列。leading 包含前段元素,trailing 包含了反向排序的后段元素,整个队列表示为:leading :: trailing.reverse
。
- append 时,使用
::
将元素添加到 trailing,时间为常量。如果一直通过添加操作构建队列,则 leading 部分会一直为空。 - tail 时,如果 tailing 不为空会直接返回。如果为空,需要将 tailing 翻转并复制给 leading 然后取第一个元素返回,这个操作称为 mirror,时间与元素量成正比。
- head 与 tail 类似。
这种方案基于三种操作的频率接近。
信息隐藏
上面 Queue 的实现中暴露了细节实现。
私有构造器及工厂方法
使用私有构造器和私有成员来隐藏类的初始化代码和表达代码。
Java 中可以把主构造器声明为私有使其不可见,Scala 中无须明确定义。虽然它的定义可以隐含在类参数以及类方法体中,还是可以通过在类参数列表前添加 private 修饰符把主构造器隐藏起来:
class Queue[T] private (
private val leading:List[T],
private val trailing:List[T]
)
这个参数列表之前的 private 修饰符表示 Queue的构造器是私有的:它只能被类本身或伴生对象访问。类名 Queue 仍然是公开的,因此可以继续使用这个类,但不能调用它的构造器。
可以通过添加辅助构造器来创建实例:
def this() = this(Nil, Nil) // 可以构建空队列
def this(elems:T*) = this(elems.toList, Nil) // 可以提供队列初始元素
另一种方式添加一个工厂方法,最简单的方式是定义与类同名的对象及 apply 方法:
object Queue{
def apply[T](xs:T*) = new Queue[T](xs.toList, Nil)
}
// Usage
Queue(1,2,3)
同时将该对象放到 Queue 类同一个源文件,成为 Queue 类的伴生对象。
供选方案:私有类
直接将类隐藏掉,仅提供能够暴露类公共接口的特质。
trait Queue[T]{
def head:T
def tail:Queue
def appand(x:T):Queue[T]
}
object Queue{
def apply[T](xs:T*):Queue[T] = New QueueImpl[T](xs.toList, Nil)
private class QueueImpl[T](
private val leading:List[T],
private val trailing:List[t]
) extends Queue[T]{
def mirror = {
if (leading.isEmpty) new QueueImpl(trailing.reverse, Nil) else this
}
def head:T = mirror.leading.head
def tail:QueueImpl[T] = {
val q = mirror
new QueueImpl(q.leading.tail, q.trailing)
}
def append(x:T) = new QueueImpl(leading, x :: trailing)
}
}
型变注解
上面定义的 Queue 是一个特质而不是类型,因为他带有类型参数。即 Queue 是特质,也可称为类型构造器(给它提供参数来构建新的类型),Queue[Int]
是类型。
带有参数的类和特质是泛型的,但是他们产生的类型已被“参数化”,不再是泛型的。
泛型:指通过一个能够广泛适用的类或特质来定义许多特定的类型。
**型变:泛型话类型(Queue[T]
)产生的类型家族成员(Queue[String]
,Queue[Int]
,…)之间具有的特定的子类型关系。**定义了参数化类型传入方法的规则。
协变:如果 S 类型是 T 类型的子类型,同时 Queue[S]
也是 Queue[T]
的子类型,即认为,Queue 是与他的类型参数 T 保持协变的。
协变意味着,如果S 为 T 的子类型,向能够接受参数类型为
Queue[T]
的函数传入类型为Queue[S]
的参数。比如方法签名def func(arg:Queue[AnyRef])
,可以调用func(Queue[String])
,因为 String 是 AnyRef 的子类型。
非型变:Scala 中默认的泛型类型是非型变的。即不泛型类型产生的类型家族成员之间没有子类型关系。
非型变意味着,即便是类型参数之间有子类型关系,比如 String 是 AnyRef 的子类型,但是泛型类型为非型变,则
Queue[String]
不能当做Queue[AnyRef]
来使用,必须使用定义的Queue[AnyRef]
类型。
逆变:如果 T 是 S 的子类型,表示 Queue[T]
是 Queue[S]
的父类型。
逆变意味着,如果 S 为 T 的子类型,向能够接受参数类型为
Queue[S]
的函数传入类型为Queue[T]
的参数。
这里说的参数传入的例子,即:方法参数预期为父类,传入必须为子类(里氏替换原则:任何使用父类的地方都可以用子类替换掉,因为子类拥有父类所有的属性和方法,即只需要一个父类就完成的工作,传入了一个功能更多的子类当然也能完成需要的工作)。
型变参数:
标示符 | 名称 | 描述 |
---|---|---|
Array[T] | 非型变类型 | 当容器中的元素是可变的即 collections.mutable。比如:预期参数为 Array[String] 的方法只能传入 Array[String] |
Seq[+A] | 协变类型 | 当容器中的元素是不可变的,这使容器更灵活。比如,预期参数为 Seq[Any] 的方法可以传入 Seq[String] |
Foo[-A] | 逆变类型 | 与协变相反 |
Function1[-A, +B] | 组合型变 | 参考 Function1 特质的定义 |
一个型变的实例:
// 一组具体类型
class Grandparent
class Parent extends Grandparent
class Child extends Parent
// 一组容器类型
class InvariantClass[A] // 不变容器类型,容器中只能传入类型 A
class CovariantClass[+A] // 协变容器类型,容器中只能传入类型 A 和 A 的子类型
class ContravariantClass[-A] // 逆变容器类型,容器中只能传入类型 A 和 A 的父类型
class VarianceExamples {
def invarMethod(x: InvariantClass[Parent]) {}
def covarMethod(x: CovariantClass[Parent]) {}
def contraMethod(x: ContravariantClass[Parent]) {}
invarMethod(new InvariantClass[Child]) // 正确
invarMethod(new InvariantClass[Parent]) // 错误
invarMethod(new InvariantClass[Grandparent]) // 错误
covarMethod(new CovariantClass[Child]) // 正确
covarMethod(new CovariantClass[Parent]) // 正确
covarMethod(new CovariantClass[Grandparent]) // 错误
contraMethod(new ContravariantClass[Child]) // 错误
contraMethod(new ContravariantClass[Parent]) // 正确
contraMethod(new ContravariantClass[Grandparent]) // 正确
}
一个逆变的例子:
trait OutputChannel[-T] {
def write(x: T)
}
这里定义 OutputChannel 是逆变的,比如:一个 Channel[AnyRef]
会是 Channel[String]
的子类型。如果用做一个方法参数:def func(arg: Channel[String])
,可以调用为:func(Channel[AnyRef])
。
型变与数组
Scala 认为 数组是非型变的。
检查型变注解
只要泛型的参数类型被当做方法参数的类型,那么包含它的类或特质就有可能不与类型参数一起协变。
比如:
class Queue[+T] {
def append(x: T) ...
}
类型 T 即作为泛型 Queue 的参数类型,又作为方法 append 的参数类型,这是不允许的,编译器会报错。
下界
上面例子中 Queue[T]
不能实现对 T 的协变,因为 T 作为参数类型出现在了 append 方法中。想要解决这个问题,可以把 append 变为多态使其泛型化,并使用它的类型参数的下界:
class Queue[+T](...){
def append[U >: T](x: U) = new Queue[U](leading, x :: trailing) ...
}
这个定义指定了 append 的类型参数 U,并通过语法U >: T
定义了 T 为 U 的下界,即 U 必须是 T 的超类。
比如类 Fruit 及两个子类 Apple、Orange。现在可以吧 Orange 对象加入到
Queue[Apple]
,返回个Queue[Fruit]
类型。
这个定义支持,队列类型元素类型为 T,即Queue[T]
,允许将任意 T 的超类 U 的对象加入到队列中,结果为 Queue[U]
。
对象私有数据
为了避免 leading 一直为空导致的 mirror 不断重复的执行,下面是改进后的 Queue 定义:
class Queue[+T] private (
private[this] var leading: List[T],
private[this] var trialing: List[T]
) {
private def mirror() = {
if(leading.isEmpty) {
while(!trailing.isEmpty) {
leading = trailing.head :: leading
trailing = trailing.tail
}
}
}
def head: T = {
mirror()
leading.head
}
def tail: Queue[T] = {
mirror()
new Queue(leading.tail, trailing)
}
def append[U >: T](x: U) = new Queue[U](leading, x :: trailing)
}
这个版本中的 leading 和 trailing 都是可变变量。而 mirror 从 trailing 反向复制到 leading 的操作通过副作用对两段队列进行修改而不是返回队列。由于二者都是私有变量,因此这些操作对客户端是不可见的。
同时,leading 和 trailing 都被 private[this]
修饰符声明对对象私有了,因此能通过类型检查。Scala 的型变检查包含了关于对象私有定义的特例。当检查到带有+/-
符号的类型参数只出现在具有相同型变分类的位置上时,这种定义将被忽略。
上界
下面是一个为自定义类实现排序的例子。通过把 Ordered 混入类中,并实现 Ordered 唯一的抽象方法 compare,就可以使用 <,>.<=,>=
来做类实例的比较:
class Person(val firstName:String, val lastName:String) extends Ordered[Person] {
def compare(that:Person) = {
val lastNameComparison = lastNmae.compareToIngoreCase(that.lastName)
if (lastName.comparison != 0) lastNameComparison
else firstName.conpareToIngoreCase(that.firstName)
}
override def toString = firstName + " " + lastName
}
为了让传递给你的新排序函数的列表类型混入到 Ordered 中,需要使用到上界。通过指定T <: Ordered[T]
,表示类型参数 T 具有上界 Ordered[T]
,即传递给排序方法 orderedMergeSort 的列表元素类型必须是 Ordered 的子类型。因此,可以传递 List[Person]
给该方法,因为 Person 混入了 Ordered。
def orderedMergeSort[T <: Ordered[T]](xs: List[T]): List[T] = {
def merge(xs: List[T], ys: List[T]): List[T] = {
(xs, ys) match{
case (Nil,_) => ys
case (_, Nil) => xs
case (x :: xsl, y: ysl) =>
if (x < y) x :: merge(xsl, ysl)
else y :: merge(xs, ysl)
}
}
val n = xs.length / 2
if (n == 0) xs
else {
val (ys, zs) = xs splitAt n
merge(orderedMergeSort(ys), orderedMergeSort(zs))
}
}
实例
如何使用泛型类型创建类
创建一个能够接受泛型类型的类或方法,比如创建一个链表类:
class LinkedList[A] {
private class Node[A](elem:A){
var next: Node[A] = _
overrice def toString = elem.toString
}
private var head:Node[A] = _
def add(elem:A){
val n = new Node(elem)
n.next = head
head = n
}
private def printNodes(n:Node[A]) = {
if (n != null){
println(n)
printNoeds(n.next)
}
}
def printAll(){ printNodes(head) }
}
[A]
是该类的参数化类型,要创建一个 Int 类型的链表实例:val ints = new LinkedList[Int]()
,
此时这个链表的整体类型为LinkedList[Int]
,可以向其添加 Int 类型的节点:ints.add(1)
。
或者创建其他类型的链表:val strings = new LinkedList[String]
或val foos = new LinkedList[Foo]
。
当创建一个基本类型的泛型实例时,比如:val anys = new LinkedList[Any]
,这是可以传入基本类型 Any 的子类型比如 Int,anys.add(1)
。但是如果有一个方法:
def printTypes(elems:LinkedList[Any]) = elems.printAll()
这时并不能传入一个ListedList[Int]
到该方法,这需要这个链表直接协变。
如果同时需要多个类型参数,比如:
trait Pair[A, B]{
def getKey:A
def getValue:B
}
如何使用泛型类型创建方法
创建一个带有类型参数的方法能够使其用于更多的适用范围:
def randomElement[A](seq:Seq[A]):A = {
val randomNum = util.Random
seq(randomNum)
}
如何使用鸭子类型(结构化类型)
def callSpeak[A <: { def speak(): Unit }](obj:A){
obj.speak()
}
在这个定义中,方法callSpeak
可以接受任意一种类型 A,只要该类型拥有一个类型参数中定义签名的 speak 方法。
类型参数语法[A <: { def speak(): Unit }]
表示,类型 A 必须是一个拥有方法def speak(): Unit
的类型的子类型,即上界语法。同时需要注意的是,这个父类中的方法的签名必须与类型参数中定义的签名一致。
使可变集合非型变
在定义一个元素可变的集合时,其元素类型必须是非型变的,即[A]
。
使用非型变类型会有一些副作用,比如,容器可以同时接收基本类型或其子类型。同时,如果一个方法被声明为接收一个父类型的容器,比如ArrayBuffer[Any]
,传入ArrayBuffer[Int]
则不会通过编译。因为:
- ArrayBuffer 中的元素是可以改变的
- 定义的方法接收的是
ArrayBuffer[Any]
,但传入的却是ArrayBuffer[Int]
- 如果编译器通过了,集合会使用 Any 代替普通的 Int 类型,这是不允许的
如果想要一个方法技能接收父类型的集合,又能接收其子类型的集合,需要使用一个不可变的集合类型,比如 List、Set 等。
在 Scala 中,可变集合是非型变的,而不可变集合为协变,参考协变与飞行变的区别。
使不可变集合协变
正如协变中的说明一样,不可变集合被定义为协变,则,使用这类集合作为参数的方法,同样能够接受其子类型的集合作为参数。
创建一个不可变容器,并声明其为协变:
class Container[+A] (val emel:A)
def makeDogsSpeak(dogHouse:Container[Dog]){
dogHouse.elem.speak()
}
makeDogsSpeak(new Container(Dog("dog of Dog")))
// SubDog is sub type of Dog
makeDogsSpeak(new Container(SubDog("dog of SubDog")))
限制类型参数的范围
在一个拥有类型参数的类或方法中,如果需要限制该类型参数的范围,可以使用上界或下界来限制类型参数的可选范围。
比如有一些多重继承的类:
class Professor()
class Teacher() extends Professor
class Student()
class Child() extends Student
假设一些场景:
def teach[A](A >: Teacher)
def learn[A](A <: Student)
这里,只有老师或教授能够讲课,即下界,最少为老师。只有学生或小孩能够学习,即上界。
选择性的为封闭模型添加新行为
比如想要给所有的数字类型增加一个求和方法,比如Int、Double、Float
等。因为Numeric
类型类已经存在,这支持你创建一个能够接受一个任意数字类型的求和方法:
def add[A](x:A, y:A)(implicit numeric: Numeric[A]):A = numeric.plus(x,y)
然后,这个方法就可以用于不同的数字类型求和:
add(1, 3)
add(1.0, 1.5)
add(1, 1.5f)
如何创建一个类型类(type class)
创建类型类的过程有点复杂,单仍然可以总结为一个公式:
- 通常你有一个需求,为一个封闭的模型增加新的行为
- 为了增加这个行为,你会创建一个类型类。通常的方式是,创建一个基本的特质,然后使用隐式对象对该特质创建具体的实现
- 然后回到应用中,创建一个使用该类型类的方法将新的行为添加到封闭模型,比如上面创建的 add 方法
比如你有一些封闭模型,包含一个 Dog 和 Cat,你想要 Dog 能够说话而 Cat 不能。
首先是封闭模型:
// 一个已存在的封闭模型
trait Animal
final case class Dog(name:String) extends Animal
final case class Cat(name:String) extends Animal
为了能够给 Dog 添加说话方法,创建一个类型类并为 Dog 实现 speak 方法:
object Humanish{
// 类型类,创建一个 speak 抽象方法
trait HumanLike[A]{
def speak(speaker:A): Unit
}
// 伴生对象
object HumanLike{
// 为需要的类型实现要增加的行为,这里只要为 Dog 实现
implicit object DogIsHumanLike extends HumanLike[Dog]{
def speak(dog:Dog){ println("I'm a dog, my name is ${dog.name}") }
}
}
}
定义完新的行为后,在应用中使用该功能:
object TypeClassDemo extends App{
// 创建一个方法能够使动物说话
def makeHumanLikeThingSpeak[A](animal:A)(implicit humanLike: HumanLike[A]){
humanLike.speak(animal)
}
// 因为 HumanLike 中实现了 Dog 类型的方法,因此可以用于 Dog 类型
makeHumanLikeThingSpeak(Dog("Rover"))
// 但是 HumanLike 中并没有 Cat 类型的实现,因此不能用于 Cat 类型
// makeHumanLikeThingSpeak(Cat("Mimi"))
}
这里需要注意的是:
- 方法 makeHumanLikeThingSpeak 类似于本节开头的 add 方法
- 因为 Numeric 类型类已经由 Scala 定义,因此可以自己用来创建自己的方法。否则,需要创建自己的类型类,这里就是 HumanLike 特质
- 因为 speak 方法定义于 DogsIsHumanLike 中,该隐式对象继承于
HumanLike[Dog]
,因此只能将一个 Dog 对象传入 makeHumanLikeThingSpeak 方法,而不能是一个 Cat 对象
这里的 类(class) 概念并不是来自面向对象的世界,而是函数式编程的世界。正如上面例子中演示的,一个 类型类(type class) 的益处在于能为一个已存在的(不能再进行修改的)封闭模型添加新的行为。另一个益处在于,能够为泛型类型创建方法,并且能够控制这些泛型类型,比如只有 Dog 可以说话。
与类型构建功能
创建一个计时器
在 Unix 系统中,可以使用一下命令来查看一个执行过程花费的时间:
time find . -name "*.scala"
在 Scala 中我们可以创建一个类似的方法来查看对应执行过程消耗的时间:
val (result, time) = timer(someLongRunningAlgorithm)
println(s"result: $result, time: $time")
这个方法中,timer 方法会执行传入的someLongRunningAlgorithm
方法,返回执行结果和其消耗的时间。
下面是 timer 的实现:
def timer[A](blockOfCode: => A) = {
val startTime = System.nanoTime
val result = blockOfCode
val stopTime = System.nanoTime
val delta = stopTime - startTime
(result, delta/10000000d)
}
timer 方法使用**按名调用(call-by-name)**的语法来接收一个代码块作为一个参数。同时声明一个泛型类型最为该代码块的返回值,而不是指定声明为一个具体的类型比如 Int。这支持你传入任意类型的方法,比如:timer(println("nothing"))
。
创建自己的 Try 类
在 Scala 2.10 之前并没有 Try、Succeeded、Failed 这些类,如何自己实现以拥有以下的功能呢:
val x = Attempt("10".toInt) // Succeeded(10)
val y = Attempt("10A".toInt) // Failed(Exception)
首先需要实现一个 Attempt 类,同时为了不使用 new 关键字来创建实例,需要实现一个 apply 方法。还需要定义 Succeeded 和 Failed 类,并继承 Attempt。下面是第一个版本的实现:
sealed class Attempt[A]
object Attempt {
def apply[A](f: => A) = {
try{
val result = f
return Succeeded(result)
} catch {
case e:Excaption => Failed(e)
}
}
}
final case class Failed[A](val exception:Throwable) extends Attempt[A]
final case class Succeeded[A](value:A) extends Attempt[A]
与上面的 timer 实现类似,apply 方法接收一个按名调用的参数,同时返回值是一个泛型类型。但是为了使这个类真正有用,还需要实现一个 getOrElse 方法来获取结果的信息,无论是 Failed 还是 Succeeded。
val x = Attempt(1/0)
val result = x.getOrElse(0)
val y = Attempt("foo".toInt).getOrElse(0)
下面我们实现这个 getOrElse 方法:
sealed abstract class Attempt[A]{
def getOrElse[B >: A](default: => B): B = if (isSuccess) get else default
var isSuccess = false
def get:A
}
object Attempt{
def apply[A](f: => A):Attempt[A] =
try{
val result = f
Succeeded(result)
} catch {
case e:Exception => Failed(e)
}
}
final case class Failed[A](val exception:Thorwable) extends Attempt[A] {
isSuccess = false
def get:A = thorw exception
}
fianl case class Succeeded[A](result:A) extends Attempt[A]{
isSuccess = true
def get = result
}
这里需要注意的是方法 getOrElse 的签名:
def getOrElse[B >: A](default: => B): B = if (isSuccess) get else default
它的类型签名[B >: A]
使用了下界,同时返回值的类型为 B,即该方法的返回值类型必须是 A 或 A 的父类。因为,在预期一个返回值是父类的地方可以返回一个子类,因为对父类的需求其子类都能满足,但是如果预期返回值是一个子类,但是返回一个父类,对子类要比父类的多,父类并不能满足使用需要,比如子类有个新的方法而父类中并没有,这时候返回了一个父类,再去调用该新方法时将会报错。即任何使用父类的地方都可以使用其子类替换,反之则行不通。
6.1.15 - Type-进阶
类型别名
type Foo = String
type IntList = List[Int]
type MyList[T] = List[T]
val MyList = List
为已有类型创建一个类型别名,或者同时为其半生对象创建一个别名。
场景:提高 API 可用性
如果你的 API 需要引入一些外部类型,比如:
import spray.http.ContentType
import org.joda.time.DateTime
final case class RiakValue(
contentType:ContentType,
lastModified: DateTime
)
当客户端在创建RiakVlue
实例时则必须同时引入这些外部类型:
import spray.http.ContentType // <= 需要引入外部类型
import org.joda.time.DateTime
val rv = RiakValue(
ContentType.`application/json`,
new DateTime()
)
为了避免这些多次重复且必要的引入,我们可以为需要的类型创建别名并组织在一起:
package com.scalapenos
package object riak{
type ContentType = spray.http.ContentType
val ContentType = spray.http.ContentType
val MediaTypes = spray.http.MediaTypes
type DateTime = org.joda.time.DateTime
}
然后客户端就可以这样使用:
import com.scalapenos.rika._ // 引入所有使用 RiakValue 需要的外部类型
val rv = RiakVaule(
ContentType.`application/json`,
new DateTime()
)
场景:简化类型签名
有时候类型签名比较难于理解,特别是一些函数作为参数时的类型签名:
def authenticate[T](auth:RequestContext => Future[Either[Rejection, T]]) = ...
我们可以为这种复杂类型创建别名以隐藏复杂性:
package object authentication {
type Authectication[T] = Either[Rejection, T]
type ContextAuthenticator[T] = RequestContext => Future[Authection[T]]
}
最终得到经过简化的类型签名:
def authenticate[T](auth: ContextAutuenticator[T]) = ...
场景:任何地方都可以使用类型别名
在 Scala 标准库中的 Predef 中,为大量的常用类型定义了类型别名,以简化使用:
object Predef extends LowPriorityImplicits{
...
type String = java.lang.String
type Class[T] = java.lang.Class[T]
...
type Function[-A, +B] = Function1[A, B]
...
type Map[A, +B] = immutable.Map[A, B]
type Set[A] = immutable.Set[A]
val Map = immutable.Map
val Set = immutable.Set
...
}
class Tag & type Tag
type class
6.1.16 - Primitive
基本类型
Byte、Short、Int、Lont、Char称为整形类型,与 Double、Float 一起构成整个数字类型。这些都定义在scala
包中。
而 String 是一个由 Char 构成的序列。
包scala
与java.lang
会被自动引入 Scala 源文件。
字面值
所有这些基本类型都可以用字面值表示,通过字面值可以在代码中显式的定义常量。
整形字面值
整形类型,即 Byte、Short、Int、Lont、Char,有两种表示形式:十进制和十六进制,十六进制以0x
或0X
开头。
无论以何种形式初始化整形字面值,Scala 都会以十进制打印该字面值。
# 以 16 进制初始化整形字面值
scala> val hex = 0x5 # hex: Int = 5
scala> val hex2 = 0x00FF # hex2: Int = 255
scala> val magic = 0xcafebabe # magic: Int = -889275714
# 以 10 进制初始化整形字面值
scala> val dec1 = 31 # dec1: Int = 31
scala> val dec2 = 255 # dec2: Int = 255
scala> val dec3 = 20 # dec3: Int = 20
如果一个整形字面值以字母L
或l
开头,则为类型Long
:
scala> val prog = 0XCAFEBABEL # prog: Long = 3405691582
scala> val tower = 35L # tower: Long = 35
scala> val of = 31l # of: Long = 31
如果一个整形字面值被赋值给一个类型为Short
或Byte
的变量,则这个字面值为被当做Short
或Byte
类型,并且该字面值必须在这些类型的有效取值范围内。
浮点数字面值
浮点数字面值由 10 进制数字创建,带有可选的小数点,和一个E
或e
及对应的指数。
scala> val big = 1.2345 # big: Double = 1.2345
scala> val bigger = 1.2345e1 # bigger: Double = 12.345
scala> val biggerStill = 123E45 # biggerStill: Double = 1.23E47
同时,可以用结尾字符D
或d
表示双精度浮点,f
或F
表示单精度浮点。
字符字面值
字符字面量由使用单引号包围的任意 Unicode 字符构成。
scala> val a = 'A' # a: Char = A
同时可以使用以\u
开头的 Unicode 码表示:
scala> val d = '\u0041' # d: Char = A
同时可以在任意位置使用 Unicode 字符:
scala> val B\u0041\u0044 = 1 # BAD: Int = 1
字符串字面值
字符串字面值是一个由双引号包围的字符字面值序列。
同时,可以定义多行字符串:
println("""Welcome to Ultamix 3000.
Type "HELP" for help.""")
println("""|Welcome to Ultamix 3000.
|Type "HELP" for help.""".stripMargin)
符号字面值(Symbol)
符号字面值写作'indent
,indent
部分可以任意字符数字标示符。这样的一个标示符被被映射为scala.Symbol
的一个实例,编译器会将它编译为一个工厂方法调用:Symbol("indent")
。
字符字面量并不能做太多操作,只能获取它的name
属性:
scala> val s = 'aSymbol # s: Symbol = 'aSymbol
scala> val nm = s.name # nm: String = aSymbol
同时需要注意的是字符字面量是interned
,编写一个字符字面量两次,表达式会引用同一个Symbol
对象。
字符串插值
可以使用s
直接在字符串字面量中引用变量进行插值:
val name = "reader"
println(s"Hello, $name!")
s"The answer is ${6 * 7}." // res0: String = The answer is 42.
使用raw
创建的字符串不会对字面值进行转义:
println(raw"No\\\\escape!") // prints: No\\\\escape!
使用f
创建格式化字符串:
scala> f"${math.Pi}%.5f" // res1: String = 3.14159
操作符都是方法
基本类型的操作符实际是普通的方法调用:
// Scala invokes 1.+(2)
scala> val sum = 1 + 2 // sum: Int = 3
Int
同时包含一些重载方法来接收不同类型的参数:
// Scala invokes 1.+(2L)
scala> val longSum = 1 + 2L // longSum: Long = 3
所有的方法都可以作为操作符。中缀操作符(infix)接收两个运算数,一个在左边一个在右边。前缀操作符(prefix)接收一个操作数,位于操作符的右边。而后缀操作符(postfix)则是操作数位于操作符的左边。
7 + 2 // infix
-7 // prefix
7 toLong // postfix
在前缀操作符中,会将表达式转换成对应的方法调用:
-2.0
(2.0).unary_-
可以作为前缀操作符的标示符只有+、-、!、~
。因此只有使用这四种标示符类定义方法,比如unary_!
,才能以!param
这样的语法调用。
对象相等
==
可以用于所有的对象相等性比较。该方法定义在Any
包中,实际的意义为:
final def == (that: Any): Boolean = if (null eq this) {null eq that} else {this equals that}
x == that
if (x eq null) that eq null else x.equals(that)
Java 中的==
可以用于比较基本类型和引用类型。基本类型时会进行值比较,这与 Scala 一致。
但是在比较引用类型时,Java 进行引用相等性比较,即两个变量是否指向 JVM 堆中的同一个对象,Scala 会使用equals
进行引用类型的比较,该方法由用户定义。
而 Scala 中的引用相等性比较则使用eq
方法。而 Java 中的equal
仅作为引用比较。
创建比较方法
在定义equals
方法时,有四种影响判等行为的陷阱:
equals
方法签名错误- 改变
equals
放但是没有改变hashCode
方法 - 依据可变字段定义
equals
方法 - 没有为
equals
定义正确的等价关系
1、方法签名错误
现在有一个简单的类:
class Point(val x: Int, val y: Int) { ... }
现在是第一种equals
方法的实现:
def equals(other: Point): Boolean =
this.x == other.x && this.y == other.y
进行测试:
val p1, p2 = new Point(1, 2)
val q = new Point(2, 3)
p1 equals p2 // Boolean = true
p1 equals q // Boolean = false
看起来一切正常,但是把他们放入集合时:
val coll = mutable.HashSet(p1)
coll contains p2 // Boolean = false
虽然p1
与p2
相等,但是contains
方法却判断失败。
同时,当我们把p2
赋值给一个Any
类型的对象时:
val p2a: Any = p2
p1 equals p2a // Boolean = false
比较结果任然错误。
下面是Any
中的equals
定义:
def equals(other: Any): Boolean
在一开始我们定义的equals
方法中,参数类型设置为Point
而不是Any
,同时没有对Any
中的方法进行重写,即使用override
关键字标识。因此,这只是一个方法重载。当前,Scala 与 Java 中的重载已经通过参数的静态类型解决,但并非运行时类型。因此,当参数的静态类型为Point
时会调用接收Poiont
类型参数的方法,一旦参数的静态类型为Any
,则会调用Any
类型的方法。
因此在调用Set
的contaions
方法时,它会调用object
类型的泛型equals
方法而不是Point
类型的方法。同时也是p1 equals p2a
失败的原因。
下面是正确的equals
定义:
override def equals(other: Any) = other match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
同时以相同的签名重写
==
方法,因为他被定义为final
。
2、未重新定义 hashCode 方法
现在重复测试coll contains p2
是仍然会出现错误,但并不是 100%。因为Set
会以元素的 hash 值来进行比较,但是Point
并未定义新的hashCode
方法,仍然是原始的定义:只是对已分配对象的地址的转换。
在调用equals
结果为true
后会分别调用两个对象的hashCode
方法并对结果进行比较。
同时,hashCode
只能依赖于字段的值。下面是一个正确的定义:
class Point(val x: Int, val y: Int) {
override def hashCode = (x, y).##
override def equals(other: Any) = other match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
}
##
方法是用于计算主要类型、引用类型、null的快捷方式。
3、依据可变字段定义 equals 方法
比如下面的定义,字段被定义为var
而不再是val
:
class Point(var x: Int, var y: Int) { // var
override def hashCode = (x, y).##
override def equals(other: Any) = other match {
case that: Point => this.x == that.x && this.y == that.y
case _ => false
}
}
这是在通过Set
的contains
方法进行判断:
val p = new Point(1, 2)
val coll = collection.mutable.HashSet(p)
coll contains p // true
// 修改 p 的字段值
p.x += 1
coll contains p // false
coll.iterator contains p // true
如果改变了p
的字段值,将会判断失败,但是通过iterator
方法发现p
仍然是Set
的元素。
这是因为,修改字段值后的p
,其 hash 值也跟着改变,contaions
方法通过 hash 值比较的结果必然失败。
4、错误的等价关系
scala.Any
的equals
方法约定中,指定equals
方法必须为non-null
对象实现正确的等价关系。
- 反射性:
non-null
值 x,表达式x.equals(x)
必须返回true
- 对称性:任何
non-null
值 x 和 y,当且仅当x.equals(y)
返回true
时,y.equals(x)
才会返回true
- 传递性:任何
non-null
值 x、y、z,如果x.equals(y)
和y.equals(z)
都返回true
,则x.equals(z)
也会返回true
- 一致性:任何
non-null
值 x 和 y,多次调用x.equals(y)
都会一致的返回true
或false
- 对任何
non-null
值 x,x.equals(null)
应该返回false
上面的Point
类已经能够很好的工作,但是如果它有一个新的子类,并且新增了一个字段:
object Color extends Enumeration {
val Red, Orange, Yellow, Green, Blue, Indigo, Violet = Value
}
class ColoredPoint(x: Int, y: Int, val color: Color.Value)
extends Point(x, y) {
override def equals(other: Any) = other match {
case that: ColoredPoint => this.color == that.color && super.equals(that)
case _ => false
}
}
通常会以上面的方式实现。这个子类继承父类,并重写了equals
方法,该方法类似父类的形式,比较新字段并利用父类的equals
方法比较原有的字段。
注意当前这个例子中,并不需要重写hashCode
方法,因为子类中的equals
实现比父类中的实现更为严谨(它与更小范围内的对象相等),因此hashCode
的契约依然有效。???
这个子类中的实现看起来没有问题,但是当他与父类混合时:
scala> val p = new Point(1, 2)
# p: Point = Point@5428bd62
scala> val cp = new ColoredPoint(1, 2, Color.Red)
# cp: ColoredPoint = ColoredPoint@5428bd62
scala> p equals cp # res9: Boolean = true
scala> cp equals p # res10: Boolean = false
p equals cp
会调用p
的equals
方法,这个方法只会对对象的坐标进行比较,并返回了true
。
cp equals p
会调用cp
的equals
方法,因为p
并不是一个ColoredPoint
对象,因此返回false
。
因此,equals
中定义的相等性并不是对称的。
canEqual
在继承类型的比较中,需要引入一个canEqual
方法。这个想法是,一旦一个类重新定义了equals
(或同时也冲定义了hashCode
),它也必须明确指出,这类对象永远不能等于那些实现了不同判等方法的父类对象。
def canEqual(other: Any): Boolean
这个方法中,如果other
对象是一个(重)定义了canEqual
方法的类的实例,返回true
,否则返回false
。在equals
方法中调用这个方法来确保将要比较的两个对象能够进行双向比较。
class Point(val x: Int, val y: Int) {
override def hashCode = (x, y).##
override def equals(other: Any) = other match {
case that: Point =>
(that canEqual this) && (this.x == that.x) && (this.y == that.y)
case _ => false
}
def canEqual(other: Any) = other.isInstanceOf[Point] // 运行时类型相同
}
然后是子类的定义:
class ColoredPoint(x: Int, y: Int, val color: Color.Value) extends Point(x, y) {
override def hashCode = (super.hashCode, color).## // 重写 hashCode
override def equals(other: Any) = other match {
case that: ColoredPoint => // 重写 equals
(that canEqual this) && super.equals(that) && this.color == that.color
case _ => false
}
override def canEqual(other: Any) = other.isInstanceOf[ColoredPoint]
}
对象相等性的实现依赖于场景。当前场景中,两个不同的
Point
对象拥有相同的坐标即视作相等。但是两个对象拥有相同坐标,但是一个没有颜色,一个为红色,则视作不相等。
6.1.17 - Numbers
介绍
Scala 中所有的数字类型都是对象,包含 Byte, Char, Double, Float, Int, Long, Short。这 7 种数字类型都继承自 AnyVal。同时,另外的 Unit 和 Boolean 作为非数字类型。
如果需要更复杂的数字类型,可以使用 Spire 库或 ScalaLab 库。
如果需要使用时间库,可以使用 Joda 或对 Joda 的封装 nscala-time。
将 String 转换为 Int
"100".toInt
"100".toDouble
"100".toFloat
"1".toLong
"1".toShort
"1".toByte
如果将一个实际上并不能转换为数字的字符串进行转换,会抛出 NumberFormatException 错误。
如果需要在转换时使用一个基数,即转换到对应的进制:
Inter.parseInt("1",2)
或者可以创建一个隐式类和对应的方法来是转换更易于使用:
implicit class StringToInt(s:String){
def toInt(radix:Int) = Inter.parseInt(s, radix)
}
"1".toInt(2)
数字类型之间进行转换
19.45.toInt
19.toFloat
19.toLong
使用isValid
方法可以检查一个数字能否转换到另一种类型:
1000L.isValidByte // false
1000L.isValidShort // true
覆写默认的数字类型
在向一个变量赋值时,Scala 会自动设置数字类型,可以通过几种不同的方式来设置需要的类型:
val a = 1 // Int
val a = 1d // Double
val a = 1f // Float
val 1 = 1L // Long
val a = 0:Byte // Byte
val a:Long = 1 // Long
var b:Short = _ // 设置为默认值,并不推荐的用法
val name = null.asInstanceOf[String]
使用 ++ 和 — 使数字自增或自减
这种++
和—
的用法在 Scala 中并不支持。因为 val 对象是不可变的,而 var 对象能够使用+=
和-=
来实现。并且,这些操作符是以方法的形式实现的。
比较浮点型数字
可以创建一个方法来设置对比浮点数需要的精度:
def ~=(x:Doubele, y:Double, precision:Double) = {
if ((x - y).abs < precision) true else false
}
~=(0.3, 0.33333, 0.0001)
这个功能的应用场景在于:
0.1 + 0.2 = 0.30000000004 // 并不是等于 0.3
处理大数字
可以使用 BigInt 和 BigDecimal 来处理大的整数和浮点数。
生成随机数字
val r = scala.util.Random
r.nextInt
r.nextInt(100) // 0(包含0) 到 100(不包含100) 之间
r.nextFloat
r.nextDouble
r.nextPrintableChar // H
创建集合与 Range
1 to 10 // Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
1 to 10 by 2 // Range(1, 3, 5, 7, 9)
for (i <- 1 to 5) println(i)
for (i <- 1 until 5) println(i)
1 to 10 toArray
(1 to 10).toList
1 to 10 toList
(1 to 10).toArray
格式化输出浮点数
val pi = scala.math.Pi // pi: Double = 3.141592653589793
println(f"$pi%1.5f") // 3.14159
f"$pi%1.5f" // 3.14159
f"$pi%1.2f" // 3.14
f"$pi%06.2f" // 003.14
6.1.18 - String
介绍
Scala 中的 String 其实是 Java 中的 String,因此可以在 Scala 中使用所有 Java 中的相关 API。同时,StringOps 中定义了很多 String 相关的隐式转换,因此可以对 String 使用很多方便的操作,或者将 String 看做是一个有**字符(character)**组成的序列来操作,使其拥有序列的所有方法。
// Predef.scala
type String = java.lang.String
// Usage
"hello".foreach(println)
for(c <- "hello") println(c)
s.getBytes.foreach(println) // 104,101,108...
"hello world".filter(_ != 'l')
StringOps
在 Scala 中,会自动引入 Predef 对象,Predef 中定义了 String 到 StringOps 的隐式转换,根据需要,String 会被 隐式转换为 StringOps 以拥有所有有序序列的方法。
final class StringOps extends AnyVal with StringLike[String] {
override protected[this] def thisCollection: WrappedString = new WrappedString(repr)
override protected[this] def toCollection(repr: String): WrappedString = new WrappedString(repr)
...
def seq = new WrappedString(repr)
}
new StringOps(repr: String)
WrappedString
Predef 中还定义了 String 和 WrappedString:
implicit def wrapString(s: String): WrappedString = if (s ne null) new WrappedString(s) else null
implicit def unwrapString(ws: WrappedString): String = if (ws ne null) ws.self else null
WrappedString 的实现是对 String 的包装容器,将 String 作为一个参数并提供所有有序序列的操作。
class WrappedString extends AbstractSeq[Char] with IndexedSeq[Char] with StringLike[WrappedString]
new WrappedString(self: String)
StringLike
WrappedString 与 StringOps 的不同是,当执行一些类似 filter、map 的转换操作时,该类生成的是一个 WrappedString 对象而不是 String,在 StringOps 中,间接使用了 WrappedString 的封装。二者都混入了 StringLike,区别在于前者的混入方式是 StringLike[WrappedString]
,后者是StringLike[String]
。
trait StringLike[+Repr] extends IndexedSeqOptimized[Char, Repr] with Ordered[String]
在 StringLike 特质中,实现了 String 对象的相关集合操作。
检查相等性
两个 String 的相等,实际上是检查两个字符集合的相等。
"hello" == "world"
"hello" == "hello"
"hello" == null // ok
null == "hello" // ok
当对一个值为 null 的 String 进行相等性检查时并不会出现空指针异常,但是当使用一个 null 调用方法时则会出现空指针异常:
val test = null
test.toUpperCace == "HELLO" // java.lang.NullPointerException
忽略大小写的相等性检查:
a.equalsIgnoreCase(b)
Scala 中检查对象相等使用的是
==
而不是 Java 中的equal
。
x == y
实际上是:if (x eq null) x eq null else x.equals(y)
因此,使用
==
进行相等判断时不需要检查是否为null
。
创建多行 String
val foo = """This is
a multiline
String"""
val speech = """Four score and
|seven years ago""".stripMargin
切分 String
"hello world".split(" ")
// Array(hello, world)
将变量带入 String
val name = ???
val age = ???
val weight = ???
println(s"$name is $age years old, and weighs $weight pounds.")
或者在 String 中使用表达式:
println(s"Age next year: ${age + 1}")
逐个处理 String 中的每个字符
val upper = "hello, world".map(c => c.toUpper)
val upper = "hello, world".map(_.toUpper)
同时可以使用集合的方法与字符串方法相结合:
val upper = "hello, world".filter(_ != 'l').map(_.toUpper)
模式查找
如果需要使用正则表达式来对 String 中需要的部分进行匹配,首先使用.r
方法创建一个Regex
对象,然后使用findFirstIn
或findAllIn
查找第一个或所有匹配的结果。
scala> val numPattern = "[0-9]+".r
numPattern: scala.util.matching.Regex = [0-9]+
scala> val address = "123 Main Street Suite 101"
scala> val match1 = numPattern.findFirstIn(address)
match1: Option[String] = Some(123)
scala> val matches = numPattern.findAllIn(address)
matches: scala.util.matching.Regex.MatchIterator = non-empty iterator
scala> matches.foreach(println)
123
101
处理匹配的结果:
match1 match{
case Some(result) => ???
case None => ???
}
val match1 = numPattern.findFirstIn(address).getOrElse("no match")
或者另一种方式创建Regex
对象:
import scala.util.matching.Regex
val numPattern = new Regex("[0-9]+")
对于正则表达式的使用,可以应用一个名为“JavaVerbalExpressions”的扩展库,以更 DSL 的方式构建
Regex
对象。VerbalExpression.regex()
VerbalExpression.regex().startOfLine().then("http").maybe("s") .then("://") .maybe("www.").anythingBut(" ") .endOfLine() .build();
模式替换
由于 String 是不可变的,不可以在原有的 String 上进行修改,可以创建一个新的 String 包含替换后的结果。
val address = "123 Main Street".replaceAll("[0-9]", "x")
val regex = "[0-9]".r
val newAddress = regex.replaceAllIn("123 Main Street", "x")
val result = "123".replaceFirst("[0-9]", "x")
使用正则解析多个部分
如果需要将 String 的多个匹配部分解析到不同的变量,可以使用正则表达式组:
val pattern = "([0-9]+) ([A-Za-z]+)".r
val pattern(count, fruit) = "100 Bananas"
count: String = 100
fruit: String = Bananas
或者同事创建多种模式以匹配不同的预期结果:
"movies near 80301"
"movies 80301"
"80301 movies"
"movie: 80301"
"movies: 80301"
"movies near boulder, co"
"movies near boulder, colorado"
// match "movies 80301"
val MoviesZipRE = "movies (\\d{5})".r
// match "movies near boulder, co"
val MoviesNearCityStateRE = "movies near ([a-z]+), ([a-z]{2})".r
textUserTyped match {
case MoviesZipRE(zip) => getSearchResults(zip)
case MoviesNearCityStateRE(city, state) => getSearchResults(city, state)
case _ => println("did not match a regex")
}
访问字符串中的字符
可以以位置索引来访问 String 中的字符:
"hello".charAt(0)
"hello"(0)
"hello".apply(1)
为 String 类添加额外的方法
如果通过给现有的 String 添加额外的方法,使 String 拥有需要的方法,而不是将 String 作为一个参数传入一个需要的方法:
"HAL".increment
// 而不是
StringUtilities.increment("HAL")
可以创建一个隐式类,然后在隐式类中添加需要的方法:
implicit class StringImprovements(s: String) {
def increment = s.map(c => (c + 1).toChar)
}
val result = "HAL".increment
但是在真实的应用中,隐式类必须在一个 class、object、package 中定义。
在 object 中定义 隐式类
object StringUtils{
implicit class StringImprovements(val s:String){
def increment = s.map(c => (c + 1).toChar)
}
}
在 package 中定义 隐式类
package object utils{
implicit class StringImporvemrnts(val s:String){
def increment = s.map(c => (c +1).toChar)
}
}
使用隐式转换的方式
首先定义一个类,带有一个需要的方法,然后创建一个隐式转换,将 String 转换为这个带有目的方法的对象,就可以在 String 上调用该方法了:
class StringImprovement(val s: String){
def increment = s.map(c => (c +1).toChar)
}
implicit def stringToStringImpr(s:String) = new StringImprovement(s)
6.1.19 - Control
使用 for 与 foreach 循环
如果需要迭代集合中的元素,或者操作集合中的每个元素,或者是通过已有的集合创建新集合,都可以使用 for 和 foreach 来处理。
for (elem <- collection) operationOn(elem)
或者从循环中生成值:
val newArray = for (e <- a) yield a.toUpperCase
生成的集合类型与输入的集合类型一致,比如一个 Array 会生成一个 Array。
或者通过一个计数器访问集合中的元素;
for (i <- 0 until a.length) println(s"$i is ${a(i)}")
或者使用集合提供的zipWithIndex
方法,然后访问索引与元素:
for ((e, count) <- a.zipWithIndex) (s"$count is $e")
或者使用守卫了限制处理条件:
for (i <- 1 to 10 if i < 4) println(i)
或者处理一个 Map:
val names = Map("fname" -> "Robert", "lname" -> "Goren")
for ((k,v) <- names) println(s"key: $k, value: $v")
在使用 for/yield 组合时,实际上是在创建一个新的集合,如果只是使用 for 循环则不会创建新的集合。for/yield 的处理过程类似于 map 操作。还有一些别的方法,如:foreach、map、flatMap、collect、reduce等都能完成类似的工作,可以根据需求选择合适的方法。
另外,还有一些特殊的使用方式;
// 简单的处理
a.foreach(println)
// 使用匿名函数
a.foreach(e => println(e.toUpperCase))
// 使用多行的代码块
a.foreach{ e =>
val s = e.toUpperCase
println(s)
}
工作原理
for 循环的工作原理:
- 一个简单的 for 循环对整个集合的迭代转换为在该集合上 foreach 方法的调用
- 一个带有守卫的 for 循环对整个集合的迭代转换为集合上 withFilter 方法的调用后跟一个 foreach 方法调用
- 一个 for/yield 组合表达式被转换为该集合上的 map 方法调用
- 一个带有守卫的 for/yield 组合表达式被转换为该集合上的 withFilter 方法调用后跟 map 方法调用
通过命令scalac -Xprint:parse Main.scala
可以查看 Scala 对 for 循环的具体转换过程。
在多个 for 循环中使用多个计数器
可以在 for 循环中同时使用多个计数器:
for {
i <- 1 to 2
j <- 1 to 2
} println(s"i = $i, j = $j")
for 循环中的<-
标示符被引用为一个生成器(generator)。
在 for 循环中使用守卫
有多种风格可以选择:
for (i <- 1 to 10 if i % 2 == 0) println(i)
for {
i <- 1 to 10
if i % 2 == 0
} println(i)
或者使用传统的方式:
for (file <- files){
if (hasSoundFileExtension(file) && !soundFileIsLong(file))
soundFiles += file
}
再或者可读性更强的方式:
for {
file <- files
if passFilter1(file)
if passFilter2(file)
} doSomething(file)
因为 for 循环会被转换为一个 foreach 的方法调用,可以直接使用 withFilter 然后调用 foreach 方法,也能达到同样的效果。
for/yield 组合
for/yield 组合可以通过在已有的集合的没有元素上应用一个新的算法(或转换等)来生成一个新的集合,并且,新的集合类型与输入集合保持一致。
val names = Array("chris", "ed", "maurice")
val capNames = for (e <- names) yield e.capitalize
// Array(Chris, Ed, Maurice)
如果对每个元素的应用部分需要多行,可以使用{}
来组织代码块:
val capNames = for (e <- names) yield {
// multi lines
e.capitalize
}
实现 break 和 continue
在 Scala 中并没有提供 break 和 continue 关键字,但是通过scala.util.control.Breaks
提供了类似的功能:
import util.control.Breaks._
object BreakAndContinueDemo extends App {
breakable{
for (i <- 1 to 10){
println(i)
if (i > 4) break // 跳出循环
}
}
val searchMe = "peter piper picked a peck of pickled peppers"
var numps = 0
for (i <- 0 until seachMe.length){
breakable{
if (searchMe.charAt(i) != 'p'){
break // 跳出 breakable, 而外层的循环 continue
} else {
numps += 1
}
}
}
println("Found " + numPs + " p's in the string.")
}
在 Breaks 源码的定义当中:
def break(): Nothing = { throw breakException }
def breakable(op: => Unit) {
try {
op
} catch {
case ex: BreakControl =>
if (ex ne breakException) throw ex
}
}
break 的调用实际是抛出一个异常,breakable 部分会对捕捉这个异常,因此也就达到了“跳出循环”的效果。
而在上面例子的第二部分,breakable 实际上是控制的 if/else 部分,当满足了 if 的条件,执行 break,否则执行 numps += 1
,即在不能满足使numps +=1
的条件时跳过了当前元素,从而达到 continue 效果。
通用语法
break:
breakable { for (x <- xs) { if (cond) break } }
continue:
for (x <- xs) { breakable { if (cond) break } }
有些场景需要处理嵌套的 break:
object LabledBreakDemo extends App {
import scala.util.control._
val Inner = new Breaks
val Outer = new Breaks
Outer.breakable{
for (i <- 1 to 5){
Inner.breakable{
for (j <- 'a' to 'e') {
if (i == 1 && j == 'c') Inner.break else println(s"i: $i, j: $j")
if (i == 2 && j == 'b') Outer.break
}
}
}
}
}
其他方式
如果不想使用 break 这样的语法,还有其他的方式可是实现。
通过在外部设置一个标记,满足条件是设定该标记,而执行时检查该标记:
var barrelIsFull = false for (monkey <- monkeyCollection if !barrelIsFull){ addMonkeyToBarrel(monkey) barrelIsFull = checkIfBarrelIsFull }
通过 return 来结束循环
def sumToMax(arr: Array[Int], limit: Int): Int = { var sum = 0 for (i <- arr) { sum += i if (sum > limit) return limit } sum } val a = Array.range(0,10) println(sumToMax(a, 10))
使用 if 实现三目运算符
val absValue = if (a < 0) -a else a
println(if (i == 0) "a" else "b")
hash = hash * prime + (if (name == null) 0 else name.hashCode)
if 表达式会返回一个值。
使用 match 语句
val month = i match {
case 1 => "January"
case 2 => "February"
case 3 => "March"
...
case _ => "Invalid month"
}
当把 match 作为一个 switch 功能使用时,推荐的做法是使用@switch
注解。如果当前的用法不能被编译为一个 tableswitch 或 lookupswitch 时将发出警告:
import scala.annotation.switch
class SwitchDemo {
val i = 1
val x = (i: @switch) match {
case 1 => "One"
case 2 => "Two"
case _ => "Other"
}
}
在一个 case 语句中匹配多个条件
如果有些场景中,多个不同条件都属于同一个业务逻辑,这时可以在一个 case 语句中添加多个条件,使用符号|
分割,各种条件的关系为或
:
val i = 5
i match {
case 1 | 3 | 5 | 7 | 9 => println("odd")
case 2 | 4 | 6 | 8 | 10 => println("even")
}
将匹配表达式的结果赋给你个变量
匹配语句的结果可以作为一个值赋值给一个变量:
val evenOrOdd = someNumber match {
case 1 | 3 | 5 | 7 | 9 => println("odd")
case 2 | 4 | 6 | 8 | 10 => println("even")
}
访问匹配语句中默认 case 的值
如果想要访问默认 case 的值,需要使用一个变量名将其绑定,而不能使用通配符_
:
i match {
case 0 => println("1")
case 1 => println("2")
case default => println("You gave me: " + default)
}
在匹配语句中使用模式匹配
匹配语句中可以使用多种模式,比如:常量模式、变量模式、构造器模式、序列模式、元组模式或类型模式。
def echoWhatYouGaveMe(x:Any):String = x match{
// 常量模式
case 0 => "zero"
case true => "true"
case "hello" => "hello"
case Nil => "an empty list"
// 序列模式
case List(0,_,_) => "一个长度为3的列表,且第一个元素为0"
case List(1,_*) => "含有多个元素的列表,且第一个元素为1"
case Vector(1, _*) => "含有多个元素的 Vector,且第一个元素为1"
// 元组模式
case (a, b) => "匹配 2 元组模式"
case (a,b,c) => "匹配 3 元组模式"
// 构造器模式
case Person(first, "Alexander") => "匹配一个 Person,第二个字段为 Alexander,并绑定第一个字段到变量 first 上"
case Dog("Suka") => "匹配一个 Dog,却字段值为 Suka"
case obj @ Some(value) => "匹配一个 Some 并取出构造器中的值,同时将整个对象绑定到变量 obj"
// 类型模式
case s:String => "String"
case i: Int => "Int"
...
case d: Dog => "匹配任何 Dog 类型,并将该对象绑定到变量 d"
// 通配模式
case _ => "匹配所有上面没有匹配到的值"
}
在匹配表达式中使用 case 类
匹配 case 类或 case 对象的多种方式,用法的选择取决于你要在 case 语句右边使用哪部分值:
trait Animal
case class Dog(name: String) extends Animal
case class Cat(name: String) extends Animal
case object Woodpecker extends Animal
object CaseClassTest extends App {
def determiType(x:Animal):String = x match {
case Dog(moniker) => "将 name 字段的值绑定到变量 moniker"
case _:Cat => "仅匹配所有的 Cat 类"
case Woodpecker => "匹配 Woodpecker 对象"
case _ => "通配"
}
}
匹配语句中使用守卫
可以给每个单独的匹配语句添加额外的一个或多个守卫:
i match {
case a if 0 to 9 contains a => println("0-9 range: " + a)
case b if 10 to 19 contains b => println("10-19 range: " + b)
case c if 20 to 29 contains c => println("20-29 range: " + c)
case _ => println("Hmmm...")
}
或者是将一个对象的不同条件分拆到多个 case 语句:
num match {
case x if x == 1 => println("one, a lonely number")
case x if (x == 2 || x == 3) => println(x)
case _ => println("some other value")
}
使用匹配语句代替 isInstanceOf
如果需要匹配一个类型或多种不同的类型,虽然可以使用isInstanceOf
来进行类型的判断,但这样并不遍历同时也不提倡这种用法:
if (x.isInstanceOf[Foo]) { do something ...
更好的方式是使用匹配语句:
trait SentientBeing
trait Animal extends SentientBeing
case class Dog(name: String) extends Animal
case class Person(name: String, age: Int) extends SentientBeing
// later in the code ...
def printInfo(x: SentientBeing) = x match {
case Person(name, age) => // handle the Person
case Dog(name) => // handle the Dog
}
使用匹配语句处理 List
List 结构与其他的集合结构有点不同,它以常量单元开始,以 Nil 元素结束。
val x = List(1,2,3)
val y = 1 :: 2 :: 3 :: Nil
在编写递归算法时,可以利用最后一个元素为 Nil 对象的便利。比如下面的 listToString 方法,如果当前的元素不是 Nil,则继续递归调用列表剩余的部分。一旦当前元素为 Nil,停止递归调用并返回一个空字符串:
def listToString(list: List[String]): String = list match {
case s :: rest => s + " " + listToString(rest)
case Nil => ""
}
可以用同样的方式来递归求所有元素之和:
def sum(list: List[Int]): Int = list match {
case Nil => 1
case n :: rest => n + sum(rest)
}
或者元素之积:
def multiply(list: List[Int]): Int = list match {
case Nil => 1
case n :: rest => n * multiply(rest)
}
注意,这些用法必须记得要处理 Nil 元素。
在 try/catch 中处理多种异常
try {
openAndReadAFile(filename)
} catch {
case e: FileNotFoundException => println("Couldn't find that file.")
case e: IOException => println("Had an IOException trying to read that file")
}
或者并不关心异常的种类,可以使用一个高阶异常类型来捕获可能的异常:
try {
openAndReadAFile("foo")
} catch {
case t: Throwable => t.printStackTrace()
}
或者:
try {
val i = s.toInt
} catch {
case _: Throwable => println("exception ignored")
}
Java 中可以在 catch 部分抛出异常,但是 Scala 中没有受检异常,不必指定一个方法会抛出的异常:
def toInt(s: String): Option[Int] = try {
Some(s.toInt)
} catch {
case e: Exception => throw e
}
如果想要声明抛出的异常类型,或者要与 Java 集成,可以使用@throws
标注方法的异常类型:
@throws(class[NumberFormatException])
def toInt(s: String): Option[Int] = try {
Some(s.toInt)
} catch {
case e: Exception => throw e
}
在 try/catch/finally 语句块之外声明一个变量
如果需要在 Try 语句块内使用一个变量,并且需要在最后的 finally 块中访问,比如一个资源对象需要在 finally 中关闭:
object CopyBytes extends App {
var in = None: Option[FileInputStream]
var out = None: Option[FileOutputStream]
try {
in = Some(new FileInputStream("/tmp/Test.class"))
out = Some(new FileOutputStream("/tmp/Test.class.copy"))
var c = 0
while ({c = in.get.read; c != −1}) {
out.get.write(c)
}
} catch {
case e: IOException => e.printStackTrace
} finally {
println("entered finally ...")
if (in.isDefined) in.get.close
if (out.isDefined) out.get.close
}
}
或者使用更简洁的方式:
try {
in = Some(new FileInputStream("/tmp/Test.class"))
out = Some(new FileOutputStream("/tmp/Test.class.copy"))
in.foreach { inputStream =>
out.foreach { outputStream =>
var c = 0
while ({c = inputStream.read; c != −1}) {
outputStream.write(c)
}
}
}
}
自定义控制结构
6.1.20 - Pattern Match
类比 switch
模式匹配类似于 Java 中的switch
语句,但与它不同之处在于:
- 它是一个表达式,有返回值
- 不会落空
- 没有任何匹配到的项则会抛出
MatchError
基本语法
case pattern => result
:
case
后跟一个pattern
pattern
必须是合法的模式类型之一- 如果与模式匹配上了,结果会被计算并返回
通配符模式
def matchAll(any: Any): String = any match {
case _ => "It’s a match!"
}
通配符_
会对匹配所有对象,会当做一个默认的模式来使用,以避免MatchError
。
常量模式
def isIt8(any: Any): String = any match {
case "8:00" => "Yes"
case 8 => "Yes"
case _ => "No" // 对其他任何情况进行匹配,设置一个默认的 No
}
变量模式
def matchX(any: Any): String = any match {
case x => s"He said $x!"
}
这里的X
作为一个标示符,会对任何对象进行匹配。
变量 & 常量
import math.Pi
val pi = Pi
def m1(x: Double) = x match {
case Pi => "Pi!" // 大写的常量模式,会引用常量 Pi 的值进行匹配
case _ => "not Pi!"
}
def m2(x: Double) = x match {
case pi => "Pi!" // 小写的变量模式,所有的对象都会进行匹配
case _ => "not Pi!"
}
println(m1(Pi)) // Pi!
println(m1(3.14)) // not Pi!
println(m2(Pi)) // Pi!
println(m2(3.14)) // Pi!
这里需要注意的地方:
- 如果标示符为大写,编译器会把它当做一个常量
- 如果标示符为小写,编译器会把它当做一个变量,仅限匹配表达式内部的作用域
如果需要在匹配表达式中引用一个变量的值,可以通过一下方式:
- 使用名称限制,比如
this.pi
- 使用引号包围变量,比如 “`pi`”
import math.Pi
val pi = Pi
def m3(x: Double) = x match {
case this.pi => "Pi!"
case _ => "not Pi!"
}
def m4(x: Double) = x match {
case `pi` => "Pi!"
case _ => "not Pi!"
}
构造器模式 - case 类
case class Time(hours: Int = 0, minutes: Int = 0)
val (noon, morn, eve) = (Time(12), Time(9), Time(20))
def mt(t: Time) = t match {
case Time(12,_) => "twelve something"
case _ => "not twelve"
}
嵌套构造器
case class House(street: String, number: Int)
case class Address(city: String, house: House)
case class Person(name: String, age: Int, address: Address)
val peter = Person("Peter", 33, Address("Hamburg", House("Reeperbahn", 45)))
val paul = Person("Paul", 29, Address("Berlin", House("Oranienstrasse", 64)))
def m45(p: Person) = p match {
case Person(_, _, Address(_, House(_, 45))) => "Must be Peter!"
case Person(_, _, Address(_, House(_, _))) => "Someone else"
}
序列模式
val l1 = List(1,2,3,4)
val l2 = List(5)
val l3 = List(5,8,6,4,9,12)
def ml(l: List[Int]) = l match {
case List(1,_,_,_) => "starts with 1 and has 4 elements"
case List(5, _*) => "starts with 5"
}
另一种用法
import annotation._
@tailrec
def contains5(l: List[Int]): String = l match {
case Nil => "No"
case 5 +: _ => "Yes"
case _ +: tail => contains5(tail)
}
这里的符号+:
实际上是一个序列的解析器,其源码中的定义为:
/** An extractor used to head/tail deconstruct sequences. */
object +: {
def unapply[A](t: Seq[A]): Option[(A, Seq[A])] =
if(t.isEmpty) None
else Some(t.head -> t.tail)
}
因此上面的匹配语句实际上等同于:
@tailrec
def contains5(l: List[Int]): String = l match {
case Nil => "No"
case +:(5, _) => "Yes"
case +:(_, tail) => contains5(tail)
}
解析器
- 一个解析器是一个拥有
unapply
方法的 Scala 对象 - 可以吧
unapply
理解为apply
的反向操作 unapply
会将需要匹配的值当做一个参数(如果这个值与unapply
的参数类型一致)- 返回结果:
- 没有变量时返回:Boolean
- 一个变量时返回:
Option[A]
- 多个变量时返回:
Option[TupleN[...]]
- the returned is matched with your pattern
编写一个解析器
case class Time(hours: Int = 0, minutes: Int = 0)
val (noon, morn, eve) = (Time(12), Time(9), Time(20))
object AM {
def unapply(t: Time): Boolean = t.hours < 12
}
def greet(t:Any) = t match {
case AM() => "Good Morning!" // 这里调用 AM 中的 unapply 方法,t 作为其参数传入
case _ => "Good Afternoon!"
}
变量绑定
object AM {
def unapply(t: Time): Option[(Int,Int)] =
if (t.hours < 12) Some(t.hours -> t.minutes) else None
}
def greet(t:Time) = t match {
case AM(h,m) => f"Good Morning, it's $h%02d:$m%02d!" // 将 t 的字段绑定到变量 h、m
case _ => "Good Afternoon!"
}
未知数量的变量绑定
val s1 = "lightbend.com"
val s2 = "www.scala-lang.org"
object Domain {
def unapplySeq(s: String) = Some(s.split("\\.").reverse) // unapplySeq
}
def md(s: String) = s match {
case Domain("com", _*) => "business" // 将其他变量绑定到 _*
case Domain("org", _*) => "non-profit"
}
正则表达式模式
scala.util.matching.Regex
提供了一个unapplySeq
方法:
val pattern = "a(b*)(c+)".r
val s1 = "abbbcc"
val s2 = "acc"
val s3 = "abb"
def mr(s: String) = s match {
case pattern(a, bs) => s"""two groups "$a" "$bs""""
case pattern(a, bs, cs) => s"""three groups "$a" "$bs" "$cs""""
case _ => "no match"
}
字符串插值器(string interpolator)
implicit class TimeStringContext (val sc : StringContext) {
object t {
def apply (args : Any*) : String = sc.s (args : _*)
def unapplySeq (s : String) : Option[Seq[Int]] = {
val regexp = """(\d{1,2}):(\d{1,2})""".r
regexp.unapplySeq(s).map(_.map(s => s.toInt))
}
}
}
def isTime(s: String) = s match {
case t"$hours:$minutes" => Time(hours, minutes)
case _ => "Not a time!"
}
类型匹配
def print[A](xs: List[A]) = xs match {
case _: List[String] => "list of strings"
case _: List[Int] => "list of ints"
}
import scala.reflect._
def print[A: ClassTag](xs: List[A]) = classTag[A].runtimeClass match {
case c if c == classOf[String] => "List of strings"
case c if c == classOf[Int] => "List of ints"
}
def t(x:Any) = x match {
case _ : Int => "Integer"
case _ : String => "String"
}
多重匹配
def alt(x:Any) = x match {
case 1 | 2 | 3 | 4 | 5 | 6 => "little"
case 100 | 200 => "big"
}
联合类型
def talt(x:Any) = x match {
case stringOrInt @ (_ : Int | _ : String) =>
s"Union String | Int: $stringOrInt"
case _ => "unknown"
}
@switch
用于检查匹配语句能否被编译为一个tableswitch
或lookupswitch
的跳转表,如果被编译成一系列连续的条件语句,将会报错。
import annotation._
def wsw(x: Any): String = (x: @switch) match {
case 8 => "Yes"
case 9 => "No"
case 10 => "No"
}
def wosw(x: Int): String = x match {
case 8 => "Yes"
case 9 => "No"
case 10 => "No"
}
6.1.21 - Class Object: 基础
类、对象、方法
类是对象的蓝图。在类的定义中可以放置字段和方法,称为成员。
字段同时称为实例变量,因为每个类的实例都会有它自己的一组变量。使用val
定义不可变字段,var
来定义可变字段。
不可变是指,一个变量名始终只能引用一个对象,不能再次修改为引用其他的对象。
所有成员默认为public
,使用private
来指定私有成员。
所有方法的参数可以在方法内使用。并且这些参数为val
。方法使用最后一行表达式的值作为返回值,不需要使用return
语句。
分号
在 Scala 中分号通常可以省略,如果一行中有多个语句,仍可以使用它作为分隔。
对于不明显语句,比如:
x
+ y
可以使用小括号将整个语句包围,或者将操作符放在第一行的末尾:
(x
+ y)
x +
y
语句分隔规则:
行尾通常会作为语句的结束,除非它属于以下情况:
- 一行的末尾不是一个合法的单词,如:
.
、中缀操作符等 - 下一行开头的单词不能作为一个合法的语句开始
- 在小括号内或中括号内的行尾,因为这些符号内不能存在多行语句
单例对象
Scala 没有静态成员,但是拥有单例对象。单例对象使用关键字object
定义。并且不能有参数列表,与其类拥有相同的 name,同时二者必须处于同一个文件内。而这个类就称为该半生对象的伴生类。二者可以互相访问对方的私有成员。
一个伴生对象可以作为类似 Java 中放置静态成员的地方。同时他是一个**一类(first-class)**对象。
定义一个单例对象时并不会定义一个类型(type)。比如定义一个TimeUtil
的单例对象并不能创建TimeUtil
类型的变量。名字为TimeUtil
的类型只能通过单例对象的伴生类来定义。
单例对象可以扩展一个超类或超特质,是单例对象成为这些超类、超特质的一个实例。
单例对象会在第一次没访问时进行初始化。
与类不同名的单例对象称为独立对象(standalone object)。可以用来收集公用性质的方法作为 Scala 应用的入口点。
Scala 应用
运行一个 Scala 应用时必须提供一个独立单例对象,包括一个带有单个Array[String]
类型的参数的main
方法,同时返回类型为Unit
。
object Application {
def main(args:Array[String]) = {
// excutable code here
}
}
所有的 Scala 文件都会自动从 scala 包中自动引入
Predef
单例对象,其中包含一些常用的定义,比如常用类型别用、常用隐式转换等。
Scala 中类名不需要与文件名一致。
运行一个 Scala 文件中的应用:
$ scalac ChecksumAccumulator.scala Summer.scala
$ fsc ChecksumAccumulator.scala Summer.scala
App 特质
独立单例对象可以混入一个App
特质,就不需要再编写main
方法,来作为一个程序入口点。
object Application extends App{
// excutable code here
}
不可变对象
使用不可变对象的优势:
- 不可变对象没有会随着时间改变的复杂状态,易于推理。
- 可以将不可变对象自由传递,如果是可变对象则需要对他们进行深拷贝。
- 不会有多个线程并行访问并破会他们最初构造时的状态。
- 不可变对象拥有安全的 Hash 键。
6.1.22 - Class Object: 类
创建主构造器
Scala 的主构造器包括:
- 主构造器参数
- 类体中调用的方法
- 类体中执行的语句和表达式
class Person(var firstName:String, var lastName:String){
println("the constructor begins")
// 字段
private val HOME = System.getProperty("user.home")
var age = 0
// 方法
override def toString = s"$firstName $lastName is $age years old"
def printHome = { println(s"HOME = $HOME") }
def printFullName = { println(this) } // uses toString
// 方法调用
printHome
printFullName
println("still in the constructor")
}
这个例子中,整个类体中的部分都属于构造器,包括字段、表达式执行、方法、方法调用。
在 Java 中你可以清晰的区分是否在主构造器之中,但是 Scala 模糊了这种区分。
- 构造器参数:这里被声明为 var,表示两个字段是可变字段,类体中的 age 字段也是一样,而 HOME 字段没声明为 val,表示为不可变字段
- 方法调用:类体中的方法调用同样属于主构造器的一部分
控制构造器字段可见性
影响字段可见性的几种因素:
- 如果字段声明为 var,会同时生成 setter 和 getter 方法
- 如果字段声明为 val,只会生成 getter 方法
- 如果既没有 val 也没有 var,Scala 会以保守的方式不生产任何 setter 或 getter
- 如果字段声明为 private,无论是 var 还是 val,都不会生成任何 setter 或 getter
而 case 类默认指定字段为 val。
定义辅助构造器
可以同时定义多个辅助构造器,以支持使用不同的方式创建类实例。定义的方式为,使用 this 方法创建辅助构造器,并且所有的辅助构造器需要拥有不同的签名(参数列表),并且,每个辅助构造器都必须调用以定义的上个构造器。
// 主构造器
class Pizza(var crustSize:Int, var crustType: String){
// 一个参数的辅助构造器
def this(crustSize:Int){
this(crustSize, Pizza.DEFAULT_CRUST_TYPE)
}
// 一个参数的辅助构造器
def this(crustType:String) {
this(Pizza.DEFAULT_CRUST_SIZE, crustType)
}
// 无参数辅助构造器
def this() {
this(Pizza.DEFAULT_CRUST_SIZE, Pizza.DEFAULT_CRUST_TYPE)
}
override def toString = s"A $crustSize inch pizza with a $crustType crust"
}
object Pizze {
val DEFAULT_CRUST_SIZE = 12
val DEFAULT_CRUST_TYPE = "THIN"
}
然后就可以使用不同的方式来创建实例:
val p1 = new Pizza(Pizza.DEFAULT_CRUST_SIZE, Pizza.DEFAULT_CRUST_TYPE)
val p2 = new Pizza(Pizza.DEFAULT_CRUST_SIZE)
val p3 = new Pizza(Pizza.DEFAULT_CRUST_TYPE)
val p4 = new Pizza
辅助构造器的使用要点:
- 使用 this 方法定义
- 都必须调用在前面已定义的辅助构造器
- 都必须拥有不同的签名
- 每个构造器使用 this 调用其他的构造器
为 case 类创建辅助构造器
case 类会比一般的类为你生成更多的模板代码,并且,case 类的构造器并不是真正的构造器,而是伴生对象中的 apply 方法,因此辅助构造器的定义方式也有所不同。
case class Pserson(var name:String, var age:Int)
// 伴生对象
object Person{
def apply() = new Person("<no name>", 0)
def apply(name:String) = new Person(name, 0)
}
Person()
Person("Pam")
Persion("Alex", 10)
定义私有构造器
有时候需要定义一个私有构造器,比如在实现单例模式的时候:
class Order private { ... }
class Person private (name:String) { ... }
这时只能在类的内部或半生对象中创建实例:
object Person{
val person = new Person("Alex")
def getInstance = person
}
some where {
val person = Person.getInstance
}
这样就实现了单例模式。大多数时候并不需要使用私有构造器,通常只需要定义一个 object。
为构造器参数提供默认值
class Socket(val timeout:Int = 10000)
这种方式实际上是有两个构造器组成:一个单参数的主构造器,一个无参数的辅助构造器。
class Socket(val timeout: Int) {
def this() = this(10000)
override def toString = s"timeout: $timeout"
}
重写默认的访问器和修改器
比如一个 Person 类:
class Person(private var name: String) {
// 实际上是创建了一个循环引用
def name = name
def name_=(aName: String) { name = aName }
}
这会导致编译错误,因为 Scala 已经自动生成了同名的 getter 和 setter 方法,如果再创建同名的方法,实际上是一个循环引用,并导致编译失败。遍历的方式是修改主构造器中的参数名,而在自定义的 setter 和 getter 方法中使用真正有用的参数名:
class Person(private var _name: String) {
def name = _name // getter
def name_=(aName: String) { _name = aName } // setter
}
注意,参数必须被声明为 private,因此只能通过 setter 和 getter 方法来设置和访问字段值。
避免自动生成 setter 和 getter
Scala 会自动为主构造器参数生成 setter 和 getter 方法,如果不想生成这些方法:
class Stock {
// getter and setter methods are generated
var delayedPrice: Double = _
// keep this field hidden from other classes
private var currentPrice: Double = _
}
私有字段
如果一个字段被声明为 private,则只有类的实例能够访问该字段,或者类的实例访问该类的其他实例的这个字段:
class Stock {
private var price: Double = _
def setPrice(p: Double) { price = p }
def isHigher(that: Stock): Boolean = this.price > that.price
}
object Driver extends App {
val s1 = new Stock
s1.setPrice(20)
val s2 = new Stock
s2.setPrice(100)
println(s2.isHigher(s1)) // s2 的 isHigher 方法访问了 s1 的私有字段 price
}
对象私有字段
如果使用private[this]
来修饰字段会进一步增强该字段的私密性,被修饰的字段只能被当前对象访问,与普通的 private 不同,这种方式使相同类的不同实例也不能访问该字段。
使用代码块或函数为字段赋值
类字段可以通过一段代码块或一个函数来赋值。这些操作都属于构造器的一部分,只有在该类创建新的实例时执行。
class Foo{
val text = { var lines = "" try {
lines = io.Source.fromFile("/etc/passwd").getLines.mkString } catch {
case e: Exception => lines = "Error happened"
}
lines
}
}
如果使用了 lazy 关键字来修饰字段,则只有该字段在第一次被访问时才会进行初始化。
设置未初始化的可变字段类型
通常的方式是使用 Option 并设置为 None,对于一些基本类型,可以设置为常用的默认值。
case class Person(var name:String, var password:String){
var age = 0
var firstName = ""
var lastName = ""
var address = None: Option[Address]
}
但是,推荐的方式是设置字段为 val 类型,并在需要的时候使用 copy 方法根据原有的对象创建一个新对象而不是修改原有对象的字段值,同时,避免使用 null 和初始值,应尽量使用 Option 并设置 None 来作为默认值。
类继承时如何处理构造器参数
在子类继承基类时,由于 Scala 已经为基类的构造器参数自动生成了 setter(var) 和 getter 方法,因此子类在声明构造器参数时,可以省略掉参数前面的 var 或 val,以避免重新自动生成的 setter 和 getter 方法。
class Person(var name:String, var age:Int){
...
}
class Employe(name:String, age:Int, var gender:Int) extends Person(name, age){
...
}
调用父类构造器
可以在子类的主构造器中调用父类构造器或不同的辅助构造器,但是不能在子类辅助构造器中调用父类构造器。
class Animal(var name:String, var age:Int){
def this(name:String){
this(name, 0)
}
}
class Dog(name:String, age:Int) extends Animal(name,age){
...
}
class Dog(name:String) extends Animal(name, 0){
...
}
因为任何类的辅助构造器都要调用类自身中已定义的其他构造器,因此也就不能调用父类的构造器了。
何时使用抽象类
由于 Scala 中拥有特质,比抽象类更加轻量且支持线性扩展(允许混入多个特质,但是不能继承多个抽象类),只有很少的需求来使用抽象类:
- 对构造器参数有需求的时候,因为特质没有构造器
- 这部分代码会被 Java 调用
抽象类语法:
abstract class BaseController(db:Database) {
def save { db.save }
def update { db.update }
def connect
def getStatus:String
def setServerName(serverName:String)
}
继承自抽象类的类要么实现所有的抽象方法,要么也声明为抽象类。
在抽象基类或特质中定义属性
可以在抽象类或特质中使用 var 或 val 来定义属性,以便在所有子类中都能访问:
- 一个抽象的 var 字段会自动生成 setter 和 getter 字段
- 一个抽象的 val 字段会自动生成 getter 字段
- 定义抽象字段时,并不会在编译后的结果代码中创建这些字段,只是自动生成对应的方法,因此在子类中仍然要使用 val 或 var 来定义这些字段,但是如果抽象类中已经提供了字段的默认值,子类中就不需要再使用 var 或 val 来修饰字段,可以根据需要直接修改字段值
同时,抽象类中不应该使用 null,而应该使用 Option。
通过 case 类生成模板代码
使用 case 类会自动创建一系列模板代码:
- 生成一个 apply 方法,因此不需要使用 new 关键字创建实例
- 生成 getter 方法,因为 case 类的参数默认为 val,如果生命为 var,则会自动生成 setter 方法
- 生成一个好用的 toString 方法
- 生成一个 unapply 方法,以便能够很好的用于模式匹配
- 生成 equals 和 hashCode 方法
- 生成 copy 方法
定义一个 equal 方法(对象相等性)
定义一个 equal 方法用于比较实例之间的相等性:
class Person(name:String, age:Int){
def canEqual(a:Any) = a.isInstanceOf[Person]
override def equals(that:Any): Boolean = {
that match{
case that:Person => that.canEqual(this) && this.hashCode == that.hashCode
case _ => false
}
}
override def hashCode:Int = {
val prime = 34
var result = 1
result = prime * result + age;
result = prime * result + (if (name == null) 0 else name.hashCode)
return result
}
}
因为定义了 canEqual 方法,因此可以使用==
来比较实例之间的相等性,与 Java 不同的是,==
在 Java 中是引用的比较。
原理
Scala 文档中对任何类中 equal 方法的要求:任何该方法的实现必须是值相等性关系。必须包含以下三个属性:
- 它是反射的:任何类型的实例 x,
x.equals(x)
必须返回 true - 它是对称的:任何类型的实例 x 和 y,当且仅当
y.equals(x)
返回 true 时,x.equals(y)
返回 true - 它是传递的:任何类型的实例 x、y、z,如果
x.equals(y)
和y.equals(z)
都返回 true,x.equals(z)
也必须返回 true
创建内部类
class PandorasBox {
case class Thing (name: String)
var things = new collection.mutable.ArrayBuffer[Thing]()
things += Thing("Evil Thing #1")
things += Thing("Evil Thing #2")
def addThing(name: String) { things += new Thing(name) }
}
外部对 Thing 一无所知,只能通过 addThing 方法来添加。在 Scala 中,内部类会绑定到外部对象上,而不是一个单独的类。
6.1.23 - Class Object: 方法
控制方法作用域
Scala 方法默认为 public,可见性的控制方法与 Java 类似,但是提供比 Java 更细粒度更有力的控制方式:
- 对象私有(object-private)
- 私有(private)
- 包(package)
- 指定包(package-specific)
- 公共(public)
对象私有域
只有该对象的当前实例能够访问该方法,相同类的其他实例无法访问:
private[this] def isFoo = true
私有域
该类或该类的所有实例都能访问该方法:
private def isFoo = true
保护域
只有子类能够访问该方法:
protected def breathe {}
在 Java 中,该域方法可以被当前包(package)的其他类访问,但是在 Scala 中不可以。
包域
当前包中所有成员都可以访问:
package com.acme.coolapp.model {
class Foo {
private[model] def doX {} // 定义为包域,model 包中所有成员可以访问
private def doY {}
}
}
更多级别的包控制
package com.acme.coolapp.model {
class Foo {
private[model] def doX {} // 指定到 model 级别
private[coolapp] def doY {} // 指定到 coolapp 级别
private[acme] def doZ {} // 指定到 acme 级别
}
}
公共域
如果没有任何作用域的声明,即为公共域。
调用父类方法
可以在子类中调用父类或特质中已存在的方法来复用代码:
class WelcomeActivity extends Activity {
override def onCreate(bundle: Bundle) {
super.onCreate(bundle)
// more code here ...
}
}
指定调用不同特质中的方法
如果同时继承了多个特质,并且这些特质都实现了相同的方法,这时不但能指定调用方法名,还能指定调用的特质名:
trait Human {
def hello = "the Human trait"
}
trait Mother extends Human {
override def hello = "Mother"
}
trait Father extends Human {
override def hello = "Father"
}
class Child extends Human with Mother with Father {
def printSuper = super.hello
def printMother = super[Mother].hello
def printFather = super[Father].hello
def printHuman = super[Human].hello
}
但是并不能跨级别的调用,比如:
trait Animal
class Pets extends Animal
class Dog extends Pets
这时 Dog 只能指定 Pets 中的方法,不能再指定 Animal 中的方法,除非显示继承了 Animal。
指定默认参数值
def makeConnection(timeout: Int = 5000, protocol: = "http") {
println("timeout = %d, protocol = %s".format(timeout, protocol))
// more code here
}
c.makeConnection() // 括号不能省略,除非方法定义中没有参数
c.makeConnection(2000)
c.makeConnection(3000, "https")
如果方法有一个参数为默认,而其他参数并没有提供默认值:
def makeConnection(timeout: Int = 5000, protocol: String)
// error: not enough arguments for method makeConnection:
c.makeConnection("https")
这时任何只提供一个参数值的调用都会报错,可以将定义中带有默认值的参数放在后面,然后就可以通过一个参数来调用:
def makeConnection(protocol: String, timeout: Int = 5000)
makeConnection("https")
调用时提供参数名
methodName(param1=value1, param2=value2, ...)
通过参数名提供参数时,参数顺序没有影响。
方法返回值为元组
def getStockInfo = {
// other code here ...
("NFLX", 100.00, 10) // this is a Tuple3
}
val (symbol, currentPrice, bidPrice) = getStockInfo
val (symbol:String, currentPrice:Double, bidPrice:Int) = getStockInfo
无括号的访问器方法调用
class Pizza {
// no parentheses after crustSize
def crustSize = 12
}
val p = new Pizza
p.crustSize
推荐的策略是在调用没有副作用的方法时使用无括号的方式调用。
在纯的函数式编程中不存在副作用,副作用包括:
- 写入或打印输出
- 读取输入
- 修改作为输入的变量的状态
- 抛出异常,或错误发生时终止程序
- 调用其他有副作用的函数
接收多变量参数
def printAll(strings: String*) {
strings.foreach(println)
}
printAll("a","b","c")
val list = List(1,2,3)
printAll(list:_*)
如果方法拥有多个参数,其中一个是多变量,则这个参数要放在参数列表的末端:
def printAll(i: Int, strings: String*)
声明一个能够抛出异常的方法
如果想要声明一个方法,该方法可能会抛出异常:
@throws(classOf[Exception])
override def play {
// exception throwing code here ...
}
@throws(classOf[IOException])
@throws(classOf[LineUnavailableException]) @throws(classOf[UnsupportedAudioFileException])
def playSoundFileWithJavaAudio {
// exception throwing code here ...
}
作用是用于提醒调用者或者与 Java 集成。
支持流式风格编程
如果想要支持调用者以流式方式调用,即方法链接,如下面的方式:
person.setFirstName("Leonard").setLastName("Nimoy")
.setAge(82)
.setCity("Los Angeles")
.setState("California")
为了支持这种方式,需要遵循以下原则:
- 如果你的类会被继承,指定
this.type
作为方法返回值类型 - 如果确定你的类不会被继承,你可以直接在方法中返回
this
class Person {
protected var fname = ""
protected var lname = ""
def setFirstName(firstName: String): this.type = {
fname = firstName
this
}
def setLastName(lastName: String): this.type = {
lname = lastName
this
}
}
class Employee extends Person {
protected var role = ""
def setRole(role: String): this.type = {
this.role = role
this
}
override def toString = {
"%s, %s, %s".format(fname, lname, role)
}
}
然后我们就可以以流式的风格调用方法:
object Main extends App {
val employee = new Employee
// use the fluent methods
employee.setFirstName("Al")
.setLastName("Alexander")
.setRole("Developer")
println(employee)
}
如上面的原则所述,如果确定这个类不会被继承,并不需要在 set* 类型的方法中指定this.type
作为返回值类型,这种情况可以省略,只需要在方法中返回 this 的引用即可。
6.1.24 - Class Object: 对象
介绍
object 在 Scala 中有多种意义,可以和 Java 一样当做一个类的实例,但是 object 本身在 Scala 就是一个关键字。
对象映射
如果需要将一个类的实例从一种类型映射为另一种类型,比如动态的创建对象。可以使用 asInstanceOf 来实现这种需求:
val recognizer = cm.lookup("recognizer").asInstanceOf[Recognizer]
类似于在 Java 中:
Recognizer recognizer = (Recognizer)cm.lookup("recognizer");
该方法定于与 Any 类因此所有对象可用。需要注意的是,这种转换可能会抛出 ClassCastException 异常。
Scala 中与 Java 中 .class 等效的部分
如果有些 API 需要传入一个 Class,在 Java 中调用了一个 .class,但是在 Scala 中却不能工作。在 Scala 中等效的方法是 classOf 方法,定义于 Predef 对象,因此所有的类可用。
// java
info = new DataLine.Info(TargetDataLine.class, null);
// scala
val info = new DataLine.Info(classOf[TargetDataLine], null)
测定对象的 Class
obj.getClass
使用 Object 启动应用
有两种方式启动一个应用,即作为一个程序的入口点:
- 创建一个 object 并集成 App 特质
- 创建一个 object 并实现 main 方法
object Hello extends App {
println("Hello, world")
}
object Hello2 {
def main(args: Array[String]) {
println("Hello, world")
}
}
两种方式中,Scala 都是以objct
启动应用而不是一个类。
创建单例对象
创建单例对象即确保只有该类一个实例存在。
object CashRegister {
def open { println("opened") }
def close { println("closed") }
}
object Main extends App {
CashRegister.open
CashRegister.close
}
CashRegister 会以单例的形式存在,类似 Java 中的静态方法。常用语创建功能性方法。
或者用于创建复用的消息对象:
case object StartMessage
case object StopMessage
actorRef ! StartMessage
使用伴生对象创建静态成员
如果需要一个类拥有实例方法和静态方法,只需要在类中创建实例(非静态)方法,在伴生对象中创建静态方法。伴生对象即,以 object 关键字定义,与类名相同并与类处于相同源文件。
// 类定义
class Pizza (var crustType: String) {
override def toString = "Crust type is " + crustType
}
// 伴生对象
object Pizza {
val CRUST_TYPE_THIN = "thin"
val CRUST_TYPE_THICK = "thick"
def getFoo = "Foo"
}
实现步骤:
- 在同一源文件中定义类和 object,并且拥有相同的命名
- 静态成员定义在 obejct 中
- 非静态成员定义在类中
类与伴生对象可以互相访问对方的私有成员。
将通用代码放到包(package)对象
如果需要创建包级别的函数、字段或其他代码,而不需要一个类或对象,只需要将代码以 package object 的形式,放到你期望可见的包中。
比如你想要com.alvinalexander.myapp.model
能够访问你的代码,只需要在com/alvinalexander/myapp/model
目录中创建一个package.scala
文件并进行一下定义:
package com.alvinalexander.myapp
package object model {
// code
}
不使用 new 关键字创建对象实例
实现步骤:
- 为类创建一个伴生对象,并以预期的构造器签名创建 apply 方法
- 直接将类创建为 case 类
class Person {
var name: String = _
}
object Person {
def apply(name: String): Person = {
var p = new Person
p.name = name p
}
}
可以为类创建不同的 apply 方法,类似类的辅助构造器。
通过 apply 实现工厂方法
为了让子类声明创建哪种类型的对象,或者把对象的创建集中在同一个位置管理,这时候需要实现一个工厂方法。
比如创建一个 Animal 工厂,根据你提供的需要创建 Dog 或 Cat 的实例:
trait Animal {
def speak
}
object Animal {
private class Dog extends Animal {
override def speak { println("woof") }
}
private class Cat extends Animal {
override def speak { println("meow") }
}
// the factory method
def apply(s: String): Animal = {
if (s == "dog") new Dog
else new Cat
}
}
val cat = Animal("cat")
val dog = Animal("dog")
6.1.25 - 函数式对象
函数式对象,即不拥有任何可变状态的对象的类。
构建一个分数类
比如我们要构建一个分数类,并最终能够实现下面的操作:
scala> val oneHalf = new Rational(1, 2)
# oneHalf: Rational = 1/2 scala>
val twoThirds = new Rational(2, 3)
# twoThirds: Rational = 2/3
scala> (oneHalf / 7) + (1 - twoThirds)
# res0: Rational = 17/42
设计分数类
设计一个分数类时需要考虑客户端会如何使用该类来创建实例。同时,我们把分数类的实例设计成不可变对象,并在创建实例时提供所有需要的参数,这里指 分数和分母。
class Rational(n:Int, d:Int)
重新实现 toString
使用上面的类创建实例时:
scala> new Rational(1, 2)
res0: Rational = Rational@2591e0c9
默认情况下,一个类会继承java.String.Object
中的toString
实现。然而,为了更好的使用toString
方法,比如日志、错误追踪等,我们需要实现一个更加详细的方法,比如包含该类的字段值。通过override
来重写:
class Rational(n:Int, d:Int) {
override def toString = n + "/" + d
}
现在就可以获得更详细的信息了:
scala> val x = new Rational(1, 3)
x: Rational = 1/3
这里需要注意的是,Java 中的类拥有构造器,并使用构造器来接收构造参数。在 Scala 中,类可以直接接收参数,称为类参数,这些类参数能够在类体中直接使用。
如果类参数使用 val 或 var 声明,它们同时成为类的可变或不可变字段,但是如果不适用任何 var 或 val,这些类参数不会成为类的成员,只能在类内部引用。也即本例中的使用方式。
检查前提条件
事实上,分数中分母是不能为 0 的,但是我们的主构造器中没有任何处理。如果使用了 0 作为分母,后续的处理中将会出现错误。
scala> new Rational(5, 0)
res1: Rational = 5/0
面向对象语言的一个优势就是可以讲数据封装到一个对象,因此可以在该对象整个生命周期中确保数据的状态。在一个不可变对象中,比如这里的Rational
,要确保它的状态,就要求在一开始构造的时间对数据做充分的验证,因为一旦创建就不会再进行改变。因此我们可以通过require
在其主构造器中定义一个前提条件:
class Rational(n:Int, d:Int){
require(d != 0)
override def toString = n + "/" + d
}
这时,如果在构造时传入一个 0 作为分母,require
则会抛出一个IllegalArgumentException
异常。
加法操作
现在我们实现Rational
的加法操作,实际也就是其字段的加法操作。因为它是一个不可变类,因此不能在一个Rational
对象本身进行操作,而应该创建一个新的对象。
或许我们可以这样实现:
class Rational(n: Int, d: Int) { // This won't compile
require(d != 0)
override def toString = n + "/" + d
def add(that: Rational): Rational =
new Rational(n * that.d + that.n * d, d * that.d)
}
但是当我们尝试编译时:
<console>:11: error: value d is not a member of Rational
new Rational(n * that.d + that.n * d, d * that.d)
^
尽管类参数 n 和 d 在add
方法的作用域中,但是add
方法只能访问调用对象自身的值。因此,add
方法中,可以访问并使用 n 和 d 的值。但是却不能使用that.n
和that.d
,因为that
并不是add
方法的调用者,只是作为add
方法的参数。如果想要使用that
的类参数,需要将这些参数放在字段中,以支持使用实例来引用:
class Rational(n:Int, d:Int){
require(d != 0)
val numer:Int = n
val denom:Int = d
override def toString = numer + "/" + denom
def add(that:Rational): Rational =
new Rational(
numer * that.denom + that.numer * that.denom,
denom * that.denom
)
}
同时需要注意的时,之前使用类参数的方式来构造对象,但是并不能在外部访问这些类参数,现在可以直接访问类的字段:
scala> r.numer # res3: Int = 1
scala> r.denom # res4: Int = 2
自引用
关键字this
指向当前执行方法被调用的对象实例,或者如果使用在构造器内时,指正在被构建的对象实例。
比如添加一个lessThan
方法,测试当前分数是否小于传入的参数:
def lessThan(that:Rational) =
this.numer * that.denom < that.numer * this.denom
这里的this
指调用lessThan
方法的实例对象,也可以省略不写。
再比如添加一个max
方法,比较当前对象与传入参数那个更大,并返回大的那一个:
def max(that:Rational) =
if (this.lessThan(that)) that else this
这里的this
就不能省略了。
辅助构造器
Scala 中朱构造器之外的构造器称为辅助构造器。比如创建一个分母为 1 的分数,可以实现为只需要提供一个分子,分母默认为 1:
class Rational(n:Int, d:Int){
require(d != 0)
val numer:Int = n
val denom:Int = d
def this(n:Int) = this(n, 1) // 辅助构造器
....
}
辅助构造器的函数体这是对朱构造器的调用。Scala 中的每个辅助构造器都是调用当前类的其他构造器,可以是主构造器,也可以使已定义的其他辅助构造器。因此最终也都是对主构造器的调用,主构造器是类的唯一入口点。
Java 中构造器能够调用同类的其他构造器或超类构造器。Scala 中只有主构造器可以调用超类构造器。
私有字段和方法
分数 66/42 并不是最简化形式,简化过程就是求最大公约数的过程,比如我们定义一个私有字段 g 表示当前分数的最大公约数,定义一个私有方法 gcd 来求最大公约数:
class Rational(n:Int, d:Int){
...
private val g = gcd(n.abs, d.abs)
val numer = n /g
val denum = d /g
private def gcd(a:Int, b:Int):Int = if (b ==0) a else gcd(b, a % b) // 辗转相除
...
}
定义操作符
使用 + 来作为求和的方法名,而不是 add。同时定义乘法操作:
class Rational(n:Int, d:Int){
...
def +(that:Rational): Rational =
new Rational(
number * that.denom + that.numer* denom,
denom * that.denom
)
def *(that:Rational): Rational =
new Rational(numer * that.numer, denom * that.denom)
...
}
以操作符来组合调用时仍然会按照运算操作符的优先级进行。
标识符
字母数字下划线标识符,以字母数字或下划线开始,后跟字母数字下划线。$
同样被当做字符,但是被保留作为编译器生成的标识符,因此不做他用。
遵循驼峰命名,避免使用下划线,特别是结尾使用下划线。
常量使用大写并用下划线分割单词。
方法重载
比如分数和整数不能直接做除法,需要首先将整数转换为分数,r * new Rational(2)
,这样很不美观,因此可以创建新的方法来直接接受整数来进行乘法运算:
def * (that: Rational): Rational =
new Rational(numer * that.numer, denom * that.denom)
def * (i: Int): Rational = new Rational(numer * i, denom)
隐式转换
但是如果先要以2 * r
的方式进行运算,但是整数并没有一个接受Rational
实例作为参数的方法,因此我们可以定义一个隐式转换,将整数在需要的时候自动转换为一个分数实例:
implicit def intToRational(x: Int) = new Rational(x)
6.1.26 - 整体类层级
Scala 中,所有的类都继承自一个共同的超类,Any
。因此所有定义在Any
中的方法称为通用方法,任何对象都可以调用。并且在层级的最底层定义了Null
和Nothing
,作为所有类的子类。
类层级
可以看到,顶层类型为Any
,下面分为两个大类,AnyVal
包含了所有值类,AnyRef
包含了所有引用类。
值类共包含 9 种:Byte, Short, Char, Int, Long, Float, Double, Boolean, 和 Unit。前 8 中与 Java 中的原始类型一致,在运行时都变现为 Java 原始类型。Unit
等价于 Java 中的void
类型,表示一个方法并没有返回值,而它的值只有一个,写作()
。
引用类对应于 Java 中的Object
,AnyRef
只是java.lang.Object
的别名,因此所有 Scala 或 Java 中编写的类都是AnyRef
的子类。
Any
中定义了一下多个方法:
final def ==(that: Any): Boolean
final def !=(that: Any): Boolean
def equals(that: Any): Boolean
def ##: Int
def hashCode: Int
def toString: String
因此所有对象都能够调用这些方法。
原始类的实现方式
Scala 与 Java 以同样的方式存储整数:以 32 位数字存放。这对在 JVM 上的效率以及与 Java 库的互操作性都很重要。标准的操作如加减乘除都被实现为基本操作。
但是,当整数需要被当做 Java 对象看待时,比如在整数上调用toString
或将整数赋值给Any
类型的变量,这时,Scala 会使用“备份”类java.lang.Integer
。需要的时候,Int
类型的整数能被透明转换为java.lang.Integer
类型的装箱整数。
比如一个 Java 程序:
boolean isEqual(int x, int y){
return x == y;
}
System.out.println(isEqual(1,1)) // true
但是如果将参数类型改为Integer
:
boolean isEqual(Integer x, Integer y){
return x == y;
}
System.out.println(isEqual(1,1)) // false
在调用isEqual
时,整数 1 会被自动装箱为Integer
类型,而Integer
为引用类型,==
在比较引用类型时比较的是引用相等性,因此结果为false
。
但是在 Scala 中,==
被设计为对类型表达透明。对于值类来说,就是自然(数学)的相等。对于引用类型,==
被视为继承自Objct
的equals
方法的别名。而这个equals
方法最初始被定义为引用相等,但被许多子类重写实现为自然理念上(数据值)的相等。因此,在 Scala 中使用==
来判断引用类型的相等仍然是有效的,不会落入 Java 中关于字符串比较的陷阱。
而如果真的需要进行引用相等的比较,可以直接使用AnyRef
类的eq
方法,它被实现为引用相等并且不能被重写。其反义比较,即引用不相等的比较,可以使用ne
方法。
底层类型
类层级的底部有两个类型,scala.Null
和scala.Nothing
。他们是用统一的方式来处理 Scala 面向对象类型系统的某些边界情况的特殊类型。
Null
类是null
引用对象的类型,它是每个引用类的子类。Null
不兼容值类型,比如把null
赋值给值类型的变量。
Nothing
类型在 Scala 类层级的最底端,它是任何其他类型的子类型。并且该类型没有任何值,它的一个用处是标明一个不正常的终止,比如scala.sys
中的error
方法:
def error(message: String): Nothing
调用该方法始终会抛出异常。因为error
方法的返回值是Nothing
类型,我们可以简便的利用该方法:
def divide(x:Int, y:Int): Int =
if (y != 0) x / y
else error("can't divide by zero!") // 返回 Nothing,是 Int 的子类型,兼容
另外,空的列表Nil
被定义为List[Nothing]
,因为List[+A]
是协变的,这使得Nil
可以是任何List[T]
实例,T
为任意类型。
6.1.27 - 组合继承
类之间的两种关系:组合、继承。组合即持有另一个类的引用,借助被引用的类完成任务。继承是超类与子类的关系。
实例:二维布局库
目标是建立一个创建和渲染二维元素的库。每个元素都将显示一个由文字填充的矩形,称为 Element。提供一个工厂方法 elem 来通过传入的数据构建新元素:
elem(s:String):Element
可以对元素调用 above 和 beside 方法并传入第二个元素,来获取一个将二者合并后生成的新元素:
val column1 = elem("hello") above elem("***")
val cloumn2 = elem("***") above elem("workd")
获得的结果为:
hello ***
*** world
above 和 beside 可以称为组合操作符,或连接符,它们把某些区域的元素组合成新的元素。
抽象类
Element 代表布局元素类型,因为元素是二维的字符矩形,因此它包含一个 content 成员表示元素内容。内容有字符串数组表示,每个字符串代表一行:
abstract class Element{
def contents: Array[String]
}
定义无参数方法
需要向 Element 添加显示高度和宽度的方法,height 返回 contents 的行数,也就表示高度,width 返回第一行的长度,没有元素则返回 0。
abstract class Element{
def contents: Array[String]
def height:Int = contents.length
def width:Int = if (height == 0) 0 else contents(0).length
}
这三个方法都是无参方法,甚至没有空的参数列表括号。
如果方法中不需要参数,并且,方法只能通过读取所包含的对象的属性去访问可变状态(即方法本身不能改变可变状态),就使用无参方法。
这一惯例支持统一访问原则,即客户端不应由属性是通过方法实现还是通过字段实现而受影响(访问字段与调用无参方法看上去没有差别)。
如果是直接或间接的使用了可变对象,应该使用空的括号,以此来说明该调用触发了计算。
比如,可以直接将 height 方法和 width 方法改成字段实现的形式:
abstract class Element{
def contents: Array[String]
val height:Int = contents.length
val width:Int = if (height == 0) 0 else contents(0).length
}
客户端不会感觉到任何差别。唯一的区别是访问字段比调用方法略快,因为字段值在类初始化的时候被预计算,而方法调用在每次调用的时候都要计算。同时,使用字段需要为每个 Element 对象分配更多的存储空间。
如果需要将字段改写为方法时,方法是由纯函数构成,即没有副作用也没有可变状态,那么客户端代码就不需要做出改变。
扩展类
为了穿件 Element 对象,我们需要实现一个子类扩展抽象类 Element 并实现其抽象方法 contents。
class ArrayElement(conts:Array[String]) extends Element{
def contents: Array[String] = conts
}
关键字 extends 的作用:使 ArrayElement 类继承了 Element 的所有非私有成员,并成为其子类。
重写方法和字段
Scala 中的字段和方法属于相同的命名空间。字段可以重写无参数方法。比如父类中的 contents 是一个无参方法,可以在子类中重写为一个字段而不需要修改父类中的定义:
class ArrayElement(conts:Array[String]) extends Element{
val contents: Array[String] = conts
}
同时,禁止在一个类中使用相同的名称定义方法和字段。这在 Java 中是支持的,因为 Java 提供了四个命名空间:字段、方法、类型、包。但是 Scala 中仅提供两个命名空间:
- 值(字段、方法)
- 类型(类、特质名)
定义参数化字段
上面 ArrayElement 类的构造器参数 conts 的实际作用是将值复制给 contents 字段,这里存在了冗余,因为 conts 实际上就是 contents,只是取了一个与 contents 类似的变量名以作区分,实际上可以使用参数化字段,而不需要再进行多余的传递:
class ArrayElement(val contents: Array[String]) extends Element
构造器中的 val,是同时定义同名的参数和字段的简写方式,同时,这个 contents 被定义为一个不可变字段,并且使用参数初始化。如果使用 var 来定义,则该字段是一个可变字段。
对于这样参数化的字段,同样可以进行重写,同时也能使用可见性修饰符:
class Cat {
val dangerous = false
}
class Tiger(
override val dangerous:Boolean,
private var age:Int
) extends Cat
这个例子中,子类 Tiger 通过参数化字段的方式重写了父类中的字段 dangerous,同时定义了一个私有字段 age。或者以更完整的方式:
class Tiger(param1:Boolean, param2:Int) extends Cat(
override val dangerous:Boolean = pararm1,
private var age = param2
)
这两个 Tiger 的实现是等效的。
调用超类构造器
现在系统中已经有了两个类:Element 和 ArrayElement。如果客户想要创造由单行字符串构成的布局元素,我们可以实现一个子类:
class LineElement(s:String) extends ArrayElement(Array(s)) {
override def width = s.length
override def height = 1
}
因为子类 LineElement 要继承 ArrayElement,但是 ArrayElement 有一个参数,这时 LineElement 需要给超类的构造器传递一个参数。
需要调用超类的构造器,只需要把要传递的参数列表放在超类之后的括号里即可。
使用 override 修饰符
如果子类成员重写父类具体成员,则必须使用 override 修饰符;如果父类中是抽象成员时,可以省略;如果子类未重写或实现基类中的成员,则禁用该修饰符。
常用习惯是,重写或实现父类成员时均使用该修饰符。
多态和动态绑定
前面的例子中:
val elem:Element = new ArrayElement(Array("hello","world"))
这样将一个子类的实例赋值给一个父类的变量应用,称为多态。这种情况下,Element 可以有多种形式,现在已经定义的有 ArrayElement 和 LineElement,可以通过继承 Element 来实现更多的形式。比如,下面定义一个拥有给定长度和高度并通过提供的字符进行填充的实现:
class UniformElement(
ch:Char,
override val width:Int,
override val height:Int
) extends Element{
private val line = ch.toString *width
def contents = Array.make(height, line)
}
现在,Element 类型的变量可以接受多种子类的实现:
val e1:Element = new ArrayElement(Array("hello","world"))
val ae:ArrayElement = new LineElement("hello")
val e2:Element = ae
val e3:Element = new UniformmElement('x',2,3)
另一方面,变量和表达式上的方法调用是动态绑定的。被调用的实际方法取决于运行期对象基于的类,而不是变量或表达式的类型。
定义 final 成员
有时需要确保一个成员不会被子类重写,这时可以使用 final 修饰符限定。
或者有时候需要确保整个类都不会有子类,也可以在类的声明上添加 final 修饰符。
使用组合与继承
组合与继承是使用其他现存类定义新类的两种方法。如果追求的是根本上的代码重用,通常推荐采用组合而不是继承。组合可以避免脆基类的问题,因为在修改基类时会在无意中破换子类。
在使用继承时需要确定,是否建模了一种 “is-a” 的关系,同时,客户端是否想把子类型当做超类来用。
实现 above、beside、toString
在 Element 中实现 above 方法,将一个元素放在另一个上面:
def above(that:Element):Element = {
new ArrayElement(this.contents ++ that.contents)
}
实现 beside 方法,把两个元素靠在一起生成一个新元素,新元素的每一行都来自原始元素的相应行的串联(这里先假设两个元素的长度相同):
def beside(that:Element):Element = {
val contents = new Array[String](this.contents.length)
for(i <- 0 until this.contents.length)
contents(i) = this.contents(i) + that.contents.(i)
new ArrayElement(contents)
}
或者以更简洁的方式实现:
def beside(that:Element):Element = new ArrayElement(
for(
(line1, line2) <- this.contents zip that.contents
) yiied line1 + line2
)
然后实现一个 toString 方法:
override def toString = contents.mkString("\n")
最后的 Element 实现:
abstract class Element{
def contents:Array[String]
def width:Int = if(height == 0) 0 else contents(0).length
def height:Int = contents.length
def above(that:Element):Element = {
new ArrayElement(this.contents ++ that.contents)
}
def beside(that:Element):Element = new ArrayElement(
for(
(line1, line2) <- this.contents zip that.contents
) yiied line1 + line2
)
override def toString = contents.mkString("\n")
}
定义工厂对象
现在已经拥有了布局元素的类层级,可以将这些层级直接暴露给用户使用,或者可以把这些层级隐藏在工厂对象之后,在工厂对象中包含构建其他对象等方法,客户使用这些工厂方法构建对象而不是直接使用 new 关键字和各层级类来构建对象。
比如在伴生对象中提供工厂方法:
object Element{
def elem(contents:Array[String]):Element = new ArrayElement(contents)
def elem(chr:Char,width:Int,height:Int):Element = new UniformElement(chr,wirdh,height)
def elem(line:String):Element = new LineElement(line)
}
为了能够直接使用 elem 方法而不是 Element.elem,可以直接在 Element 定义文件的头部显示引入该方法,然后对 Element 的实现进行简化:
import Element.elem
abstract class Element{
def contents:Array[String]
def width:Int = if(height == 0) 0 else contents(0).length
def height:Int = contents.length
def above(that:Element):Element = {
elem(this.contents ++ that.contents)
}
def beside(that:Element):Element = elem(
for(
(line1, line2) <- this.contents zip that.contents
) yiied line1 + line2
)
override def toString = contents.mkString("\n")
}
既然有了工厂方法,所有的子类都可以为私有类,引文他们不再需要直接被客户端使用。可以在类和单例对象的内部定义其他的类和单例对象,因此,可以将 Element 的子类放在其单例对象中实现这些子类的私有化:
object Element{
private class ArrayElement(val contents:Array[String]) extends Element
private class LineElement(s:String) extends Element{
val contents = Array(s)
override def width = s.length
override def height = 1
}
private class UniformElement(
ch:Char,
override val width:Int,
override val height:Int,
) extends Element{
private val line = ch.toString * width
def contents = Array.make(height, line)
}
def elem(contents:Array[String]):Element = new ArrayElement(contents)
def elem(chr:Char,width:Int,height:Int):Element = new UniformElement(chr,width,height)
def elem(line:String):Element = new LineElement(line)
}
6.1.28 - Package
介绍
Scala 中会自动导入两个包:
- java.lang._
- scala._
这其中,包括 scala.Predef,预定义了一些常用的功能。
以大括号的方式定义包
package com.acme.store {
class Foo { override def toString = "I am com.acme.store.Foo" }
}
// 等同于
package com.acme.store
class Foo { override def toString = "I am com.acme.store.Foo" }
使用这种大括号的方式可以在一个文件内定义多个包,或者嵌套的包。
引入一个或多个成员
import java.io.File
import java.io.{File, IOException, FileNotFoundException}
import java.io._
- 可以在任意位置引入成员,类中、对象中、方法或代码块中
- 可以引入任意成员,类、包、对象
- 可以隐藏或重命名引入的成员
最佳实践时:除非需要引入的对象超过3个,则一般不适用通配符引入,避免不必要的冲突。
重命名引入的成员
有时候引入的成员会和当前作用域中的成员名冲突,或者需要一个更有意义的名字,这时候可以将引入的成员重命名:
import java.util.{ArrayList => JavaList}
import java.util.{Date => JDate, HashMap => JHashMap}
但是重命名之后,就不能再使用原有的成员名了。
引入时隐藏部分成员
import java.util.{Random => _, _}
这个语法会引入除 Random 之外的所有包,仅仅是把 Random 隐藏了。
或者同时隐藏多个成员,只引入剩余的其他成员:
import java.util.{List => _, Map => _, Set => _, _}
使用静态引入
如果想要以 Java 静态引入的方式引入一个成员,以便能够直接引用成员的名字:
import java.lang.Math._
然后就可以使用 Math 中的所有成员,sin(0)、cos(PI)
,而不再需要以Match.sin(0)
的方式使用。
在任何地方引入
唯一需要注意的是,引入语句的位置必须处于使用的位置之前,否则会找不到使用的对象。
6.1.29 - SBT
子项目构建
基本配置文件
首先编辑project
目录下的build.properties
和plugins.sbt
文件:
// project/build.properties
sbt.version = 0.13.11
// project/plugins.sbt
logLevel := Level.Warn
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.2")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.0.0")
addSbtPlugin("com.typesafe.sbt" % "sbt-scalariform" % "1.3.0")
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.1")
项目通用配置
项目配置相关的文件均位于project/
路径,创建新的CommonSettings.scala
,别写整个项目的基本配置,包括代码风格配置、依赖仓库配置、依赖冲突配置等:
import sbt._
import Keys._
import sbtassembly.AssemblyPlugin.autoImport._
import sbtassembly.PathList
import com.typesafe.sbt.SbtScalariform.{ScalariformKeys, scalariformSettings}
import scalariform.formatter.preferences._
object CommonSettings {
// 代码风格配置
val customeScalariformSettings = ScalariformKeys.preferences := ScalariformKeys.preferences.value
.setPreference(AlignSingleLineCaseStatements, true)
.setPreference(AlignSingleLineCaseStatements.MaxArrowIndent, 200)
.setPreference(AlignParameters, true)
.setPreference(DoubleIndentClassDeclaration, true)
.setPreference(PreserveDanglingCloseParenthesis, true)
// 基本配置与仓库
val settings: Seq[Def.Setting[_]] = scalariformSettings ++ customeScalariformSettings ++ Seq(
organization := "com.promisehook.bdp",
scalaVersion := "2.11.8",
scalacOptions := Seq("-feature", "-unchecked", "-deprecation", "-encoding", "utf8"),
updateOptions := updateOptions.value.withCachedResolution(true),
fork in run := true,
test in assembly := {},
resolvers += Opts.resolver.mavenLocalFile,
resolvers ++= Seq(
DefaultMavenRepository,
Resolver.defaultLocal,
Resolver.mavenLocal,
Resolver.jcenterRepo,
Classpaths.sbtPluginReleases,
"scalaz-bintray" at "http://dl.bintray.com/scalaz/releases",
"Atlassian Releases" at "https://maven.atlassian.com/public/",
"Apache Staging" at "https://repository.apache.org/content/repositories/staging/",
"Typesafe repository" at "https://dl.bintray.com/typesafe/maven-releases/",
"Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
"Java.net Maven2 Repository" at "http://download.java.net/maven/2/",
"softprops-maven" at "http://dl.bintray.com/content/softprops/maven",
"OpenIMAJ maven releases repository" at "http://maven.openimaj.org",
"Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots",
"Eclipse repositories" at "https://repo.eclipse.org/service/local/repositories/egit-releases/content/"
)
)
// 依赖冲突合并配置
val commonAssemblyMergeStrategy = assemblyMergeStrategy in assembly := {
case PathList("org", "ansj", xs @ _*) => MergeStrategy.first
case PathList("org", "joda", xs @ _*) => MergeStrategy.first
case PathList("org", "apache", xs @ _*) => MergeStrategy.first
case PathList("org", "nlpcn", xs @ _*) => MergeStrategy.first
case PathList("org", "w3c", xs @ _*) => MergeStrategy.first
case PathList("org", "xml", xs @ _*) => MergeStrategy.first
case PathList("javax", "xml", xs @ _*) => MergeStrategy.first
case PathList("edu", "stanford", xs @ _*) => MergeStrategy.first
case PathList("org", "cyberneko", xs @ _*) => MergeStrategy.first
case PathList("org", "xmlpull", xs @ _*) => MergeStrategy.first
case PathList("org", "objenesis", xs @ _*) => MergeStrategy.first
case PathList("com", "esotericsoftware", xs @ _*) => MergeStrategy.first
case PathList(ps @ _*) if ps.last endsWith ".dic" => MergeStrategy.first
case PathList(ps @ _*) if ps.last endsWith ".data" => MergeStrategy.first
// case "application.conf" => MergeStrategy.concat
// case "unwanted.txt" => MergeStrategy.discard
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}
}
子项目依赖配置
为各个子项目编写不同的依赖配置:
// project/CommonDependencies.scala
object CommonDependencies{
val specsVersion = "3.6.6"
val specs = Seq(
"specs2-core", "specs2-junit", "specs2-mock").
map("org.specs2" %% _ % specsVersion % Test)
val jodaTime = "joda-time" % "joda-time" % "2.8.2"
val PlayJson = "com.typesafe.play" % "play-json_2.11" % "2.5.2"
val commonDependencies: Seq[ModuleID] = specs ++ Seq(jodaTime, PlayJson)
}
编写主配置文件
编写build.sbt
文件:
name := "Root-Project-Name"
version := "1.0"
scalaVersion := "2.11.8"
lazy val common = project.
settings(Commons.settings: _*).
settings(libraryDependencies ++= Dependencies.databaseDependencies).
settings(libraryDependencies ++= Dependencies.commonDependencies)
lazy val webserver = project.
dependsOn(common).
settings(Commons.settings: _*).
settings(libraryDependencies ++= Seq(specs2,filters,evolutions)). // Play插件
settings(libraryDependencies ++= Dependencies.akkaDependencies).
settings(libraryDependencies ++= Dependencies.playDependencies).
enablePlugins(PlayScala)
lazy val proserver = project.
dependsOn(common).settings(CommonSettings.settings: _*).
settings(libraryDependencies ++= Dependencies.akkaDependencies).
settings(libraryDependencies ++= Dependencies.processDependencies).
settings(CommonSettings.commonAssemblyMergeStrategy) // 合并依赖冲突
此时,在主项目路径运行sbt -> compile
即可生成子项目目录,同样,可以在各个子项目的目录中添加需要的配置。
6.1.30 - Predef
Predef 提供了一些定义,可以在所有 Scala 的编译单元中可见且不需要明确的限制。在所有的 Scala 代码中自动引入。
最常用的类型
提供了一些最常用类型的类型别名(alias)。比如一些不可变集合及其构造器。
控制台 I/O
提供了一些用于控制台 I/O 的函数,比如:print
、println
等。这些函数都是scala.Console
中提供的函数的别名。
断言
一组assert
函数用于注释和动态检查代码中的常量。
在命令行中添加参数
-Xdisable-assertions
可以完成编译器的assert
调用。
隐式转换
这里和其父类型scala.LowPriorityImplicits
提供了一组最常用的隐式转换。为一些类型提供了一些扩展功能。
6.1.31 - I/O
原文链接:更好的Scala I/O: better-files
添加依赖
libraryDependencies += "com.github.pathikrit" %% "better-files" % version
实例化
import better.files._
import java.io.{File => JFile}
val f = File("/User/johndoe/Documents") // using constructor
val f1: File = file"/User/johndoe/Documents" // using string interpolator
val f2: File = "/User/johndoe/Documents".toFile // convert a string path to a file
val f3: File = new JFile("/User/johndoe/Documents").toScala // convert a Java file to Scala
val f4: File = root/"User"/"johndoe"/"Documents" // using root helper to start from root
val f5: File = `~` / "Documents" // also equivalent to `home / "Documents"`
val f6: File = "/User"/"johndoe"/"Documents" // using file separator DSL
val f7: File = home/"Documents"/"presentations"/`..` // Use `..` to navigate up to parent
文件读写
val file = root/"tmp"/"test.txt"
file.overwrite("hello")
file.appendLine().append("world")
assert(file.contentAsString == "hello\nworld")
或者类似 Shell 风格:
file < "hello" // same as file.overwrite("hello")
file << "world" // same as file.appendLines("world")
assert(file! == "hello\nworld")
或者:
"hello" `>:` file
"world" >>: file
val bytes: Array[Byte] = file.loadBytes
流式接口风格:
(root/"tmp"/"diary.txt")
.createIfNotExists()
.appendLine()
.appendLines("My name is", "Inigo Montoya")
.moveTo(home/"Documents")
.renameTo("princess_diary.txt")
.changeExtensionTo(".md")
.lines
Stream和编码
生成迭代器:
val bytes : Iterator[Byte] = file.bytes
val chars : Iterator[Char] = file.chars
val lines : Iterator[String] = file.lines
val source : scala.io.BufferedSource = file.newBufferedSource // needs to be closed, unlike the above APIs which auto closes when iterator ends
编解码:
val content: String = file.contentAsString // default codec
// custom codec:
import scala.io.Codec
file.contentAsString(Codec.ISO8859)
//or
import scala.io.Codec.string2codec
file.write("hello world")(codec = "US-ASCII")
与Java交互
转换成Java对象:
val file: File = tmp / "hello.txt"
val javaFile : java.io.File = file.toJava
val uri : java.net.uri = file.uri
val reader : java.io.BufferedReader = file.newBufferedReader
val outputstream : java.io.OutputStream = file.newOutputStream
val writer : java.io.BufferedWriter = file.newBufferedWriter
val inputstream : java.io.InputStream = file.newInputStream
val path : java.nio.file.Path = file.path
val fs : java.nio.file.FileSystem = file.fileSystem
val channel : java.nio.channel.FileChannel = file.newFileChannel
val ram : java.io.RandomAccessFile = file.newRandomAccess
val fr : java.io.FileReader = file.newFileReader
val fw : java.io.FileWriter = file.newFileWriter(append = true)
val printer : java.io.PrintWriter = file.newPrintWriter
以及:
file1.reader > file2.writer // pipes a reader to a writer
System.in > file2.out // pipes an inputstream to an outputstream
src.pipeTo(sink) // if you don't like symbols
val bytes : Iterator[Byte] = inputstream.bytes
val bis : BufferedInputStream = inputstream.buffered
val bos : BufferedOutputStream = outputstream.buffered
val reader : InputStreamReader = inputstream.reader
val writer : OutputStreamWriter = outputstream.writer
val printer : PrintWriter = outputstream.printWriter
val br : BufferedReader = reader.buffered
val bw : BufferedWriter = writer.buffered
val mm : MappedByteBuffer = fileChannel.toMappedByteBuffer
模式匹配
/**
* @return true if file is a directory with no children or a file with no contents
*/
def isEmpty(file: File): Boolean = file match {
case File.Type.SymbolicLink(to) => isEmpty(to) // this must be first case statement if you want to handle symlinks specially; else will follow link
case File.Type.Directory(files) => files.isEmpty
case File.Type.RegularFile(content) => content.isEmpty
case _ => file.notExists // a file may not be one of the above e.g. UNIX pipes, sockets, devices etc
}
// or as extractors on LHS:
val File.Type.Directory(researchDocs) = home/"Downloads"/"research"
通配符
val dir = "src"/"test"
val matches: Iterator[File] = dir.glob("**/*.{java,scala}")
// above code is equivalent to:
dir.listRecursively.filter(f => f.extension == Some(".java") || f.extension == Some(".scala"))
或者使用正则表达式:
val matches = dir.glob("^\\w*$")(syntax = File.PathMatcherSyntax.regex
文件系统操作
file.touch()
file.delete() // unlike the Java API, also works on directories as expected (deletes children recursively)
file.clear() // If directory, deletes all children; if file clears contents
file.renameTo(newName: String)
file.moveTo(destination)
file.copyTo(destination) // unlike the default API, also works on directories (copies recursively)
file.linkTo(destination) // ln file destination
file.symbolicLinkTo(destination) // ln -s file destination
file.{checksum, md5, sha1, sha256, sha512, digest} // also works for directories
file.setOwner(user: String) // chown user file
file.setGroup(group: String) // chgrp group file
Seq(file1, file2) >: file3 // same as cat file1 file2 > file3
Seq(file1, file2) >>: file3 // same as cat file1 file2 >> file3
file.isReadLocked / file.isWriteLocked / file.isLocked
File.newTemporaryDirectory() / File.newTemporaryFile() // create temp dir/file
UNIX DSL
提供了UNIX风格的操作:
import better.files_, Cmds._ // must import Cmds._ to bring in these utils
pwd / cwd // current dir
cp(file1, file2)
mv(file1, file2)
rm(file) /*or*/ del(file)
ls(file) /*or*/ dir(file)
ln(file1, file2) // hard link
ln_s(file1, file2) // soft link
cat(file1)
cat(file1) >>: file
touch(file)
mkdir(file)
mkdirs(file) // mkdir -p
chown(owner, file)
chgrp(owner, file)
chmod_+(permission, files) // add permission
chmod_-(permission, files) // remove permission
md5(file) / sha1(file) / sha256(file) / sha512(file)
unzip(zipFile)(targetDir)
zip(file*)(zipFile)
文件属性
file.name // simpler than java.io.File#getName
file.extension
file.contentType
file.lastModifiedTime // returns JSR-310 time
file.owner / file.group
file.isDirectory / file.isSymbolicLink / file.isRegularFile
file.isHidden
file.hide() / file.unhide()
file.isOwnerExecutable / file.isGroupReadable // etc. see file.permissions
file.size // for a directory, computes the directory size
file.posixAttributes / file.dosAttributes // see file.attributes
file.isEmpty // true if file has no content (or no children if directory) or does not exist
file.isParentOf / file.isChildOf / file.isSiblingOf / file.siblings
chmod
操作:
import java.nio.file.attribute.PosixFilePermission
file.addPermission(PosixFilePermission.OWNER_EXECUTE) // chmod +X file
file.removePermission(PosixFilePermission.OWNER_WRITE) // chmod -w file
assert(file.permissionsAsString == "rw-r--r--")
// The following are all equivalent:
assert(file.permissions contains PosixFilePermission.OWNER_EXECUTE)
assert(file(PosixFilePermission.OWNER_EXECUTE))
assert(file.isOwnerExecutable)
文件比较
file1 == file2 // equivalent to `file1.isSamePathAs(file2)`
file1 === file2 // equivalent to `file1.isSameContentAs(file2)` (works for regular-files and directories)
file1 != file2 // equivalent to `!file1.isSamePathAs(file2)`
file1 =!= file2 // equivalent to `!file1.isSameContentAs(file2)`
排序操作:
val files = myDir.list.toSeq
files.sorted(File.Order.byName)
files.max(File.Order.bySize)
files.min(File.Order.byDepth)
files.max(File.Order.byModificationTime)
files.sorted(File.Order.byDirectoriesFirst)
解压缩
// Unzipping: val zipFile: File = file"path/to/research.zip" val research: File = zipFile.unzipTo(destination = home/“Documents”/“research”) // Zipping: val zipFile: File = directory.zipTo(destination = home/“Desktop”/“toEmail.zip”) // Zipping/Unzipping to temporary files/directories: val someTempZipFile: File = directory.zip() val someTempDir: File = zipFile.unzip() assert(directory === someTempDir) // Gzip handling: File(“countries.gz”).newInputStream.gzipped.lines.take(10).foreach(println)
轻量级的ARM (自动化的资源管理)
Auto-close Java closeables:
for {
in <- file1.newInputStream.autoClosed
out <- file2.newOutputStream.autoClosed
} in.pipeTo(out)
better-files
提供了更加便利的管理,因此下面的代码:
for {
reader <- file.newBufferedReader.autoClosed
} foo(reader)
可以改写为:
for {
reader <- file.bufferedReader // returns ManagedResource[BufferedReader]
} foo(reader)
// or simply:
file.bufferedReader.map(foo)
Scanner
val data = t1 << s"""
| Hello World
| 1 true 2 3
""".stripMargin
val scanner: Scanner = data.newScanner()
assert(scanner.next[String] == "Hello")
assert(scanner.lineNumber == 1)
assert(scanner.next[String] == "World")
assert(scanner.next[(Int, Boolean)] == (1, true))
assert(scanner.tillEndOfLine() == " 2 3")
assert(!scanner.hasNext)
或者可以写定制的Scanner。
文件监控
普通的Java文件监控:
import java.nio.file.{StandardWatchEventKinds => EventType}
val service: java.nio.file.WatchService = myDir.newWatchService
myDir.register(service, events = Seq(EventType.ENTRY_CREATE, EventType.ENTRY_DELETE))
better-files
抽象了一个更加简单的接口:
val watcher = new ThreadBackedFileMonitor(myDir, recursive = true) {
override def onCreate(file: File) = println(s"$file got created")
override def onModify(file: File) = println(s"$file got modified")
override def onDelete(file: File) = println(s"$file got deleted")
}
watcher.start()
或者使用下面的写法:
import java.nio.file.{Path, StandardWatchEventKinds => EventType, WatchEvent}
val watcher = new ThreadBackedFileMonitor(myDir, recursive = true) {
override def dispatch(eventType: WatchEvent.Kind[Path], file: File) = eventType match {
case EventType.ENTRY_CREATE => println(s"$file got created")
case EventType.ENTRY_MODIFY => println(s"$file got modified")
case EventType.ENTRY_DELETE => println(s"$file got deleted")
}
}
使用Akka进行文件监控
import akka.actor.{ActorRef, ActorSystem}
import better.files._, FileWatcher._
implicit val system = ActorSystem("mySystem")
val watcher: ActorRef = (home/"Downloads").newWatcher(recursive = true)
// register partial function for an event
watcher ! on(EventType.ENTRY_DELETE) {
case file if file.isDirectory => println(s"$file got deleted")
}
// watch for multiple events
watcher ! when(events = EventType.ENTRY_CREATE, EventType.ENTRY_MODIFY) {
case (EventType.ENTRY_CREATE, file) => println(s"$file got created")
case (EventType.ENTRY_MODIFY, file) => println(s"$file got modified")
}
6.1.32 - 保留字
关键字和保留字符
// 关键字符
<- // 用于 for 表达式,从生成器(generator)中分离元素
=> // 用于函数类型,函数字面值和引入(import)重命名
# 保留字符
( ) // 划界表达式和参数
[ ] // 划界类型参数
{ } // 划界块(block)
. // 方法调用和路径分割
// /* */ // 注释
# // 用于类型标记
: // 类型归属或上下文界限(context bounds)
<: >: <% // 上界、下界或视图(view)界限
" """ // 字符串
' // 标示符号或字符
@ // 注解和模式匹配中的变量绑定
` // 标示常量或使称为任意标示符
, // 参数分割
; // 语句分割
_* // 可变参数展开
_ // 不同场景有多种意义
下划线
import scala._ // 通配符,引入 Scala 包中所有的资源
import scala.{ Predef => _, _ } // 排除,除了 Predef,引入其他所有
def f[M[_]] // 高阶类型参数
def f(m: M[_]) // 存在的类型
_ + _ // 匿名函数参数占位符
m _ // 将方法转换为方法的值
m(_) // 偏函数应用
_ => 5 // 丢弃的参数
case _ => // 通配符,匹配任何
f(xs: _*) // 序列 xs 作为多个参数传入函数 f(ys: T*)
case Seq(xs @ _*) // 将标识符 xs 绑定到所有匹配到的值
通用方法
一些标识符其实是一些类、特质、对象的方法。
List(1, 2) ++ List(3, 4) // 将右边序列的元素追加到左边序列的末尾
List(1, 2).++(List(3, 4)) // 同上
1 :: List(2, 3) // 将一个元素放到一个序列的首部
List(2, 3).::(1) // 同上
1 +: List(2, 3) :+ 4 // +: 绑定到右边,:+ 绑定到左边
以冒号(:)结尾的方法会绑定到右边,而不是左边,作为右边对象的一个方法。
类型和对象同样也会有象征性的名字,比如:对于有两个类型参数的类型来说,名字可以写在参数之间,Int <:< Any
和<:<[Int, Any]
是相同的。
隐式转换提供的方法
Scala 代码会自动进行三个部分的引入:
// 顺序无关
import java.lang._
import scala._
import scala.Predef._
前两者用于类和单例对象,然而 Predef
中定义了一些象征性的名字:
class <:< // 一个 A <:< B 的实例,表示类型 A 是类型 B的子类型
class =:= // 一个 A =:= B 的实例,表示类型 A 与类型 B 相同
object =:=
object <%< // removed in Scala 2.10
def ??? // 将一个方法为未实现
同时还有::
,没有出现在文档中但是在注释中提到了。Predef
通过隐式转换的方式激活一些方法。
语法糖和语法组合
class Example(arr: Array[Int] = Array.fill(5)(0)) {
def apply(n: Int) = arr(n)
def update(n: Int, v: Int) = arr(n) = v
def a = arr(0); def a_=(v: Int) = arr(0) = v
def b = arr(1); def b_=(v: Int) = arr(1) = v
def c = arr(2); def c_=(v: Int) = arr(2) = v
def d = arr(3); def d_=(v: Int) = arr(3) = v
def e = arr(4); def e_=(v: Int) = arr(4) = v
def +(v: Int) = new Example(arr map (_ + v))
def unapply(n: Int) = if (arr.indices contains n) Some(arr(n)) else None
}
val ex = new Example
println(ex(0)) // means ex.apply(0)
ex(0) = 2 // means ex.update(0, 2)
ex.b = 3 // means ex.b_=(3)
val ex(c) = 2 // calls ex.unapply(2) and assigns result to c, if it's Some; throws MatchError if it's None
ex += 1 // means ex = ex + 1; if Example had a += method, it would be used instead
(_+_) // An expression, or parameter, that is an anonymous function with
// two parameters, used exactly where the underscores appear, and
// which calls the "+" method on the first parameter passing the
// second parameter as argument.
6.1.33 - Exception
Java 中的异常
Java 中异常分为两类:受检异常、非受检异常(RuntimeException, 运行时异常)。
两种异常的处理方式:
- 非受检异常
- 捕获
- 抛出
- 不处理
- 受检异常(除了 RuntimeException 都是受检异常)
- 继续抛出,消极的方式,一致抛出到 JVM 来处理
- 使用
try..catch
块来处理
受检异常必须处理,否则不能编译通过。
异常原理
异常,即程序运行期键发生的不正常事件,它会打断指令的正常流程。异常均出现在程序运行期,编译期的问题成为语法错误。
异常的处理机制:
- 当程序在运行过程中出现异常,JVM 会创建一个该类型的异常对象。同时把这个异常对象交给运行时系统,即抛出异常。
- 运行时系统接收到一个异常时,它会在异常产生的代码上下文附近查找对应的处理方式。
- 异常的处理方式有两种:
- 捕获并处理:在抛出异常的代码附近显式使用
try..catch
进行处理,运行时系统捕获后会查询相应的catch
处理块,在catch
处理块中对异常进行处理。 - 查看异常发生的方法是否向上声明异常,有向上声明,向上级查询处理语句,如果没有向上声明, JVM 中断程序的运行并处理,即使用
throws
向外声明
- 捕获并处理:在抛出异常的代码附近显式使用
异常分类
所有的错误和异常均继承自java.lang.Throwable
。
- Error:错误,JVM 内部的严重问题,无法恢复,程序人员不用处理。
- Exception:异常,普通的问题,通过合理的处理,程序还可以回到正常的处理流程,要求编程人员进行处理。
- RuntimeException:非受检异常,这类异常是编程人员的逻辑问题,即程序编写 BUG。Java 编译器不强制要求处理,因此这类异常在程序中可以处理也可以不处理。比如:算术异常、除零异常等。
- 非 RuntimeException:受检异常,这类异常由外部的偶然因素导致。Java 编译器强制要求处理,程序人员必须对这类异常进行处理。比如:Exception、FileNotFoundException、IOException等。
除了受检异常,都是非受检异常。
异常处理方式
try/catch
try{
// 可能会出现异常的代码块
} catch(异常类型1 变量名1){
// 对该类型异常的处理代码块
} catch(异常类型2 变量名2){
// ...
} finally{
// 无论是否发生异常都会执行的代码块
// 常用来释放资源,比如关闭文件
}
向上声明
即使用thorws
关键字,将异常向上抛出,声明一个方法可能会抛出的异常列表。
... methodName(参数列表) throws 异常类型1, 异常类型2 {
// 方法体
}
这种方式通过声明,告诉本方法的调用者,在使用本方法时,应该对那些异常进行处理。
手动抛出
当程序逻辑不符合预期,要终止后面的代码执行时使用这种方式。
在方法的代码段中,可以使用throw
关键字手动抛出一个异常。
如果手动抛出的是一个受检异常,那么本方法必须处理(应该采用向上抛出这个异常),如果是非受检异常,则处理是可选的。
自定义异常
当需要一些跟特定业务相关的异常信息类时,可以根据实际的需求,继承Exception
来定义受检异常,或者继承RuntimeException
来定义非受检异常。
最佳实践
捕获那些已知如何处理的异常,即使用try/catch
来处理已知类型的异常。
向上抛出那些不知如何处理的异常。
减少异常处理的嵌套。
Scala 中的异常
Scala 中定义所有的异常为非受检异常,即便是SQLException
或IOException
。
最简单的处理方式是定义一个偏函数:
val input = new BufferReader(new FileReader(file))
try{
for(line <- Iteratro.continually(input.readLine()).takeWhile(_ != null))
println(line)
} catch{
case e:IOException => errorHandler(e)
// case SomeOtherException(e) => ???
} finally{
imput.close()
}
或者使用 control.Exception
来组合需要处理的多个异常:
Exception.handling(classOf[RuntimeException], classOf[IOException]) by println apply {
throw new IOException("foo")
}
更上面的最佳实践里提到的一样,不能以通配的方式捕获所有异常,这会捕获到类似内存溢出这样的异常:
try{
...
} catch{
case _ => ... // Don't do this!
}
如果需要捕获大部分可能出现的异常,并且不是严重致命的,可以使用NonFatal
:
try{
...
} catch{
case NonFatal(e) => println(e.getMessage)
}
NonFatal
意为非致命错误,不会捕获类似VirtualMachineError
这样的虚拟机错误。
object NonFatal {
/**
* Returns true if the provided `Throwable` is to be considered non-fatal, or false if it is to be considered fatal
*/
def apply(t: Throwable): Boolean = t match {
// VirtualMachineError includes OutOfMemoryError and other fatal errors
case _: VirtualMachineError | _: ThreadDeath | _: InterruptedException | _: LinkageError | _: ControlThrowable => false
case _ => true
}
/**
* Returns Some(t) if NonFatal(t) == true, otherwise None
*/
def unapply(t: Throwable): Option[Throwable] = if (apply(t)) Some(t) else None
}
在使用NonFatal
捕获异常时定义的偏函数case NonFatal(e) => ???
,这类似于模式匹配的构造器模式,会调用NonFatal
的unapply
方法,在unapply
中会对异常进行判断,即调用apply
方法,如果不属于虚拟机错误则进行捕获,否则将不进行捕获。这是一种捕获异常的快捷方式。
通常还有一些为了逻辑处理而主动抛出的异常需要处理,比如assert
、require
、assume
。
Option
在处理异常时,一般讲结果置为一个Option
,成功时返回Some(t)
,失败时返回None
。
Either
Either
是一个封闭抽象类,表示两种可能的类型,它只有两个终极子类,Left
和Rright
,因此Either
的实例要么是一个Left
要么是一个Right
。
类似于Option
,通常也可以用于异常的处理,Option
只能表示有结果或没有结果,Either
则可以表示有结果时结果是什么,没有结果时“结果”又是什么,比如失败时的结果是一个异常信息等等。
sealed abstract class Either[+A, +B]
final case class Left[+A, +B](a: A) extends Either[A, B]
final case class Right[+A, +B](b: B) extends Either[A, B]
通常,Left
表示失败,Right
表示成功。伴生对象中提供了多种转换方法,比如toOption
:
// 如果是 Left 实例
def toOption = e match {
case Left(a) => Some(a)
case Right(_) => None
}
// 如果是 Right 实例
def toOption = e match {
case Left(_) => None
case Right(b) => Some(b)
}
一个使用的实例:
case class FailResult(reason:String)
def parse(input:String) : Either[FailResult, String] = {
val r = new StringTokenizer(input)
if (r.countTokens() == 1) {
Right(r.nextToken())
} else {
Left(FailResult("Could not parse string: " + input))
}
}
这时如果只想要处理成功结果:
val rightFoo = for (outputFoo <- parse(input).right) yield outputFoo
或者使用fold
:
parse(input).fold(
error => errorHandler(error),
success => { ... }
)
或者模式匹配:
parse(input) match {
case Left(le) => ???
case Riggt(ri) => ???
}
并不限制于用在解析或验证,也可以用在业务场景:
case class UserFault
case class UserCreatedEvent
def createUser(user:User) : Either[UserFault, UserCreatedEvent]
或者二选一的时候:
def whatShape(shape:Shape) : Either[Square, Circle]
或者与Option
进行嵌套,返回一个异常,或者成功时包含有值或无值两种情况:
def lookup() : Either[FooException,Option[Foo]]
这种方式比较冗余,可以直接返回一个异常或结果:
def modify(inputFoo:Foo) : Either[FooException,Foo]
**不要在 Either 中返回异常,而是创建一个 case 类来表示异常的结果。**比如:
Either[FailResult,Foo]
Try
Try
与Either
类似,但它不像Either
将一些结果类包装在Left
或Right
中,它会直接返回一个Failure[Throwable]
或Succese[T]
。它是try/catch
的一种简写方式,内部仍然是对NonFatal
的处理。
它实现了flatMap
方法,因此可以使用下面的方式,任何一个Try
失败都会返回Failure
:
val sumTry = for {
int1 <- Try(Integer.parseInt("1"))
int2 <- Try(Integer.parseInt("2"))
} yield {
int1 + int2
}
或者通过模式匹配的方式对Try
的结果进行处理:
sumTry match {
case Failure(thrown) => Console.println("Failure: " + thrown)
case Success(s) => Console.println(s)
}
或者获取失败时的异常值:
if (sumTry.isFailure) {
val thrown = sumTry.failed.get
}
如果是成功的结果,get
方法会返回对应的值。
可以使用recover
方法处理多个Try
链接中任意位置的异常:
val sum = for {
int1 <- Try(Integer.parseInt("one"))
int2 <- Try(Integer.parseInt("two"))
} yield {
int1 + int2
} recover {
case e => 0
}
// or
val sum = for {
int1 <- Try(Integer.parseInt("one")).recover { case e => 0 }
int2 <- Try(Integer.parseInt("two"))
} yield {
int1 + int2
}
使用toOption
方法将Try[T]
转换为一个Option[T]
。
或者与Either
混合使用:
val either : Either[String, Int] = Try(Integer.parseInt("1")).transform(
{ i => Success(Right(i)) }, { e => Success(Left("FAIL")) }
).get
Console.println("either is " + either.fold(l => l, r => r))
将方法的返回值声明为 Try 可以告诉调用者该方法可能会抛出异常,可以达到受检异常的效果,即调用者必须要处理对应的异常,因此可以使代码更安全。虽然使用常规的 try/catch 也可以做到,但是这样更清晰。
与 Future 组合使用
Try 的存在意义就是为了用于 Future,参考 Future 对应的整理记录。
使用Future
包装阻塞的Try
代码块:
def blockMethod(x: Int): Try[Int] = Try {
// Some long operation to get an Int from network or IO
Thread.sleep(10000)
100
}
def tryToFuture[A](t: => Try[A]): Future[A] = {
future {
t
}.flatMap {
case Success(s) => Future.successful(s)
case Failure(fail) => Future.failed(fail)
}
}
// Initiate long operation
val f = tryToFuture(blockMethod(1))
或者如果经常需要将Future
与Try
进行链接:
object FutureTryHelpers{
implicit def tryToFuture[T](t:Try[T]):Future[T] = {
t match{
case Success(s) => Future.successful(s)
case Failure(ex) => Future.failed(ex)
}
}
}
def someFuture:Future[String] = ???
def processResult(value:String):Try[String] = ???
import FutureTryHelpers._
val result = for{
a <- someFuture
b <- processResult(a)
} yield b
result.map { /* Success Block */ } recover { /* Failure Block */ }
或者使用Promse
的fromTry
方法来构建Future
:
implicit def tryToFuture[T](t:Try[T]):Future[T] = Promise.fromTry(t).future
用法总结
- 在纯函数代码中将异常抛出到单独的非预期错误
- 使用
Option
返回可选的值 - 使用
Either
返回预期的错误 - 返回异常时使用
Try
而不是Either
- 捕获非预期错误时使用
Try
而不是trt/catch
块 - 在处理
Future
时使用Try
- 在公共接口暴露
Try
类似于受检异常,直接使用异常替代
自定义异常
case class PGDBException(message:Option[String] = None, cause:Option[Throwable] = None) extends RuntimeException(PGDBException.defaultMessage(message, cause))
object PGDBException{
def defaultMessage(message:Option[String], cause:Option[Throwable]) = {
(message, cause) match {
case (Some(msg), _) => msg
case (_, Some(thr)) => thr.toString
case _ => null
}
}
def apply(message:String) = new PGDBException(Some(message), None)
def apply(throwable: Throwable) = new PGDBException(None,Some(throwable))
}
// usage
throw PGDBException("Already exist.")
throw PGDBException(new Throwable("this is a throwable"))
6.1.34 - Inject Type
Scala 注解作为元数据或额外信息添加到程序源代码。类似于注释,注解可以添加到变量、方法、表达式或任何其他的程序元素。
注解可以添加到任何类型的定义或声明上,包括:var, val, class, object, trait, def, type
。
可以同时添加多个注解,顺序无关。
给主构造器添加注解时需要将注解放在主构造器之前:
class Credentials @Inject() (var username: String, var password: String)
给表达式添加注解时,需要在表达式之后添加冒号,然后添加注解本身:
(myMap.get(key) : @unchecked) match {...}
可以为类型参数添加注解:
class MyContainer[@specialized T]
只对实际类型的注解应该放在类型名之前:
String @cps[Unit] // @cps带一个类型参数
声明一个注解的语法类似于:
@annot(exp_{1}, exp_{2}, ...) {val name_{1}=const_{1}, ..., val name_{n}=const_{n}}
annot
用于指定注解的类型,所有的注解都要包含这个部分。一些注解不需要提供参数,因此圆括号可以被省略或者提供一个空的圆括号。
传递给注解的精确参数需要依赖于注解类的实际定义。大多数注解执行器支持直接的常量,比如Hi
或678
。关键字this
可以用于在当前作用域引用的其他变量。
类似name=const
这样的参数可以在比较复杂的拥有可选参数的注解中见到。这个参数是可选的,并且可以按任意顺序指定。等到右边的值建议使用一个常量。
Java 注解的参数类型只能是:数值型字面量、字符串、类字面量、枚举、其他注解,或上述类型的数组但不能是嵌套数组。
Scala 注解的参数可以是任何类型。
Scala 中的标准注解
scala.SerialVersionUID
:为一个可序列化的类指定一个SerialVersionUID
字段scala.deprecated
:表示这个定义已经被移除,即废弃的定义scala.volatile
:告诉开发者在并发程序中允许使用可变状态scala.transient
:标记为非持久字段scala.throws
:指定一个方法抛出的异常scala.cloneable
:标明一个类以复制(cloneable)的方式应用(apply)scala.native
:原生方法的标记scala.inline
:这个方法上的注解,请求编译器需要尽力内联这个被注解的方法scala.remote
:标明一个类以远程(remotable)的方式应用(apply)scala.serializable
:标明一个类以序列化(serializable)的方式应用(apply)scala.unchecked
:适用于匹配表达式中的选择器。如果存在,表达式的警告会被禁止scala.reflectBeanProperty
:当附加到一个字段时,根据JavaBean
的管理生成 getter 和 setter 方法
废弃注解:@deprecated
有时需要写一个类或方法,后来又不再需要。可以为类或方法添加一个提醒一面他人使用,但是为了兼容性有不能直接移除。方法或类可以使用@deprecated
标记,然后在使用时会有一个提醒。
不稳定字段:@volatile
有些开发者想要在并发程序中使用可变状态,这种场景中可以使用@volatile
注解,通知编译器这个变量会被多个线程使用。
二进制序列化:@serializable/@SerialVersionUID/@transient
序列化框架将对象转换为流式字节,以节省磁盘占用或网络传输。Scala 并没有自己的序列化框架。@serializable
注解表示一个类是否可以被序列化。默认的,类是不支持序列化的,因此需要添加该注解。
@SerialVersionUID
用于处理可序列化的类并根据时间改变,自增数值可以以@SerialVersionUID(678)
的方式附上当前的版本,678 即为自增 ID。
如果一个字段被标记为@transient
,框架序列化相关的对象是不会保存该字段。当该对象被重新加载时,该字段会被设置为一个默认值。
自动生成 getter/setter 方法
一个带有@scala.reflect.BeanProperty
注解的字段,编译器会自动为其生成 getter 和 setter 方法。
模式匹配忽略部分用例:Unchecked
@unchecked
注解通过编译器在模式匹配时解释。告诉编译器如果匹配语句遗漏了可能的 case 时不用警告。
6.1.35 - Type Class
《Demystifying Implicits and Typeclasses in Scala》一文的整理翻译。
The idea of typeclasses is that you provide evidence that a class satisfies an interface。
类型类思想是你提供了一个类满足于一个接口的证明。
trait CanFoo[A] {
def foos(x: A): String
}
case class Wrapper(wrapped: String)
object WrapperCanFoo extends CanFoo[Wrapper] {
def foos(x: Wrapper) = x.wrapped
}
类型类思想是你提供了一个类(Wrapper)满足于一个接口*(CanFoo)的证明(WrapperCanFoo)。
Wrapper
不是直接的去继承一个接口,类型类让我们把类的定义和接口的实现分开。这表示,我可以为你的类实现一个接口,或者第三方可以为你的类实现我的接口,并且一切基本结束工作。
但是有一个明显的问题,如果你想把一个东西实现为CanFoo
,你需要同时询问你的调用者类的实例和协议。
def foo[A](thing: A, evidence: CanFoo[A]) = evidence.foos(thing)
6.1.36 - Map Flatmap
Map, Map and flatMap in Scala的翻译整理,点击查看原文.
map
map
操作会将集合中的每个元素作用到一个函数上:
scala> val l = List(1,2,3,4,5)
scala> l.map( x => x*2 )
res60: List[Int] = List(2, 4, 6, 8, 10)
或者有些场景,你想让这个函数返回一个序列或列表,或者一个Option
:
scala> def f(x: Int):Option[Int] = if (x > 2) Some(x) else None
scala> l.map(x => f(x))
res63: List[Option[Int]] = List(None, None, Some(3), Some(4), Some(5))
flatMap
flatMap
的作用是,将一个函数作用的到列表中每个序列的各个元素上,注意这里是一个嵌套的序列,然后将这些元素展开到原始的列表中.使用一个实例来解释会比较清晰:
scala> def g(v:Int) = List(v-1, v, v+1)
g: (v: Int)List[Int]
scala> l.map(x => g(x))
res64: List[List[Int]] = List(List(0, 1, 2), List(1, 2, 3), List(2, 3, 4), List(3, 4, 5), List(4, 5, 6))
scala> l.flatMap(x => g(x))
res65: List[Int] = List(0, 1, 2, 1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6)
这种操作对于处理Option
类型的元素非常方便,因为Option
同样是一种序列,只是可能包含一个元素或不包含元素:
scala> l.map(x => f(x))
res66: List[Option[Int]] = List(None, None, Some(3), Some(4), Some(5))
scala> l.flatMap(x => f(x))
res67: List[Int] = List(3, 4, 5)
使用 map 处理 Map
让我们看一下这些概念如何作用到Map
类型上.一个Map
可以通过多种方式实现,事实上他是一个包含二元组键值对的序列,这个二元组的第一个值是键,第二个值是值.
scala> val m = Map(1 -> 2, 2 -> 4, 3 -> 6)
m: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 2 -> 4, 3 -> 6)
scala> m.toList
res69: List[(Int, Int)] = List((1,2), (2,4), (3,6))
然后通过_1
和_2
来方位元组的值:
scala> val t = (1,2)
t: (Int, Int) = (1,2)
scala> t._1
res70: Int = 1
scala> t._2
res71: Int = 2
这时如果我们要使用 map 和 flatMap 来操作Map
,但是 map 操作在这看起来会没有意义,因为我们不会想去将我们的函数作用到一个元组,而是要将其作用到该元组的值.不过 map 提供了一种方式来处理Map
的值,但是不包括对 key 的处理:
scala> m.mapValues(v => v*2)
res73: scala.collection.immutable.Map[Int,Int] = Map(1 -> 4, 2 -> 8, 3 -> 12)
scala> m.mapValues(v => f(v))
res74: scala.collection.immutable.Map[Int,Option[Int]] = Map(1 -> None, 2 -> Some(4), 3 -> Some(6))
使用 flatMap 处理 Map
但是在我的需求中我想要的处理效果更类似于 flatMap. flatMap 与 mapValues 处理Map
的方式不同,它获取传入的元组,如果返回一个单项的List
它会返回一个List
; 如果返回一个元组,它会返回一个Map
:
scala> m.flatMap(e => List(e._2))
res85: scala.collection.immutable.Iterable[Int] = List(2, 4, 6)
scala> m.flatMap(e => List(e))
res86: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 2 -> 4, 3 -> 6)
这样我们可以很漂亮的使用 flatMap 来处理Option
,我需要过滤出所有的None
,如果仅仅使用e => f(e._2)
会得到所有不为None
的值并组成一个List
返回.但是我需要的是一个Option[Tuple2]
,如下所示:
scala> def h(k:Int, v:Int) = if (v > 2) Some(k->v) else None
h: (k: Int, v: Int)Option[(Int, Int)]
然后调用这个函数:
scala> m.flatMap ( e => h(e._1,e._2) )
res109: scala.collection.immutable.Map[Int,Int] = Map(2 -> 4, 3 -> 6)
这已经达到了我的要求,但是这些e._1,e._2
并不是很优雅,如果有一个更好的方式将元组解包为变量就再好不过了.如果按 Python 的方式,或许在 Scala中也能工作:
scala> m.flatMap ( (k,v) => h(k,v) )
:10: error: wrong number of parameters; expected = 1
报错了,这并不符合预期.原因是unapply
只能够在PartialFunction
中执行,在 Scala中就是一个 case 语句,即:
scala> m.flatMap { case (k,v) => h(k,v) }
res108: scala.collection.immutable.Map[Int,Int] = Map(2 -> 4, 3 -> 6)
注意这里使用了大括号而不再是小括号了,表示这里是一个函数块而不是参数,而这个函数块是一个 case 语句.这意味着我们传给 flatMap 的函数块是一个partialFunction
,并且只有与这个 case 语句匹配时才会调用,同时 case 语句中元组的unapply
方法被调用,以将元组的值解析为变量.
其他方式
当然除了使用 flatMap 还有别的方式. 因为我们的目的是移除Map
中所有不满足断言的元素,这里同样可以使用filter
方法:
scala> m.filter( e => f(e._2) != None )
res114: scala.collection.immutable.Map[Int,Int] = Map(2 -> 4, 3 -> 6)
scala> m.filter { case (k,v) => f(v) != None }
res115: scala.collection.immutable.Map[Int,Int] = Map(2 -> 4, 3 -> 6)
scala> m.filter { case (k,v) => f(v).isDefined }
res116: scala.collection.immutable.Map[Int,Int] = Map(2 -> 4, 3 -> 6)
6.1.37 - 泛型型变
里氏替换原则
所有引用父类型的地方必须能透明地使用其子类型的对象。
这里面暗含了一条重要的信息:所有你想由父类完成的操作,子类都能够完成,而且子类能比父类做的更多。
概念
- 协变(covariance)、逆变(contravariance)、不可变(invariant) 统称为型变(variance)。
- 型变表述了:具有父子类型关系的类型经过“类型转换”后,所构造出的更复杂的对应类型之间的关系是如何变化的。
- 协变:具有父子类型关系的类型,经过“类型转换”所构造的复杂类型之间仍然保持着相同的父子类型关系。
- 逆变:具有父子类型关系的类型,经过“类型转换”所构造的复杂类型之间保持着与原类型之间相反的父子关系。
- 不变:不支持协变或逆变。
型变描述的是一组类型转换规则。
公式
假设两种类型 X 和 Y,<:
表示子类型关系,>:
表示父类型关系,即 X <: Y
表示 X 是 Y 的子类。f
表示类型转换,即一个类型构造操作,通过传入一个类型来构造一个新类型。因此 X、Y 对应的构造类型分别为 f(X)
和 f(Y)
。
因此,型变描述的是:基于具有父子关系的 X 和 Y,通过 f
构造而成的 f(X)
和 f(Y)
,f
是如何影响 f(X)
和 f(Y)
之间的父子关系的。
规则如下:
- 如果
X <: Y
, 经过f
构造类型之后之后f(X) <: f(Y)
,f
具有协变性。 - 如果
X <: Y
, 经过f
构造类型之后之后f(X) >: f(Y)
,f
具有逆变性。 - 如果以上两种情况都不符合,则类型构造、或称类型转换
f
具有不可变性。
容器类型
这里的容器类型泛指集合类型,比如 Array
。
协变
假设以下类型:FujiApple <: Apple <: Fruit, Orange <: Fruit
。
根据协变规则的定义,子类型数组可以赋值给一个类型为父类型数组的变量,这里使用的 f
类型构造即为 Java 中的 Array
类型,这是一个协变类型:
Fruit[] fruits = new Apple[0]; // 1
fruits[0] = new Apple(); // 2
fruits[1] = new FujiApple(); // 3
fruits[2] = new Fruit(); // 4
fruits[3] = new Orange(); // 5
首先我们可以确认两点:
- 从
fruits
中取出的元素一定是Fruit
类型; - 在编译期,编译器绝对允许我们将
Fruit
及其子类型的元素填充到fruits
协变数组,因为fruits
的类型为Fruit[]
,可以包含任意Fruit
及其子类型的元素。
从协变数组 fruits
中读取元素是绝对安全的,无论是编译期还是运行时,都不会出现任何问题。上面的操作中,fruits
实际引用的是一个 Apple
数组,即便取出的是 Apple
元素,但仍然是 Fruit
类型。
但是将 Fruit
类型或其 Fruit
类型的子类型元素填充到协变数组 fruits
时,可能会在运行时出现问题。这时 fruits
实际上引用的是一个 Apple[]
类型数组,填充 Apple
或 Apple
子类型之外的任意类型的元素都是不合法的,最终会引起 ArrayStoreException
。
上面的代码中,如果 fruits
是其他接口返回的数组,我们仅知道它是一个 Fruit[]
类型的数组,因此可以按照里氏替换原则向其填充 Fruit
及其子类型元素,编译器也不会抛出错误。但如果像上面的代码中的应用一样实际上引用的是一个子类型数组,则最终会引发运行时异常。
因此,在实际使用或定义协变容器类型时,总是将其当做一个只读容器是绝对安全的。
逆变
Java 实际上是不支持逆变数组的,但这里我们可以假设一下支持逆变的情况:
Fruit[] fruits = new Fruit[10]{ new Apple(), new Orange(), new FujiApple() }
Orange[] oranges = fruits; // 应用逆变特性
oranges[0] = new Orange(); // 1
Orange orange = oranges(2); // 2
这里我们将父类型数组 fruits
赋值给类型为子类型数组的变量 oranges
,如果这样的操作理论上成立,即支持逆变,也同样会出现问题。
这里假如我们从 oranges
数组读取一个元素,因为该数组为 Orange[]
,则其返回的元素一定会是 Orange
类型,但实际上并非如此,它实际上引用的是一个 Fruit[]
数组,元素类型可能是 Fruit
的任何其他子类型。
因此,在实际使用或定义逆变容器类型时,总是将其当做一个只写容器是绝对安全的。
Scala:声明点型变(declaration-site variance)
trait List[+T]
trait A
trait B extends A
这时,List[A]
即为 List[B]
的父类型。
Java:使用点型变(use-site variance)
在 Java 中,实际上参数化类型是不可型变的,比如 List<String>
并非 List<Object>
的子类型,即便 String
实际是 Object
的子类型。但有些时候需要一些比类型型变更多的灵活性。
Java 中的泛型并没有对协变的内建支持,因此 Java 泛型具有不可变型性。但是,为了兼容遗留代码而被保留下来的原生类型确实可以做出这样的行为,但同时我们也非常清楚使用它就像使用数组的协变性一样意味着代码变得不再安全。所以,制定Java标准的那群人想出了个法子使得泛型像数组一样具有这些特性(Java 中的数组具有协变性),同时这种代替原生类型的方案必须是绝对安全的:他们通过给泛型增加通配符特性使得泛型在参数化后具有协变性或逆变性。
因此提供了一种方式来实现型变:
List<? extends Object> list = new ArrayList<String>();
这里,将 List<String>
赋值给 List<Object>
,实际上表示了他们之间的父子类型关系。
Java 中提供的这种特性称为“类型界限通配符”:
? extends T
:T 表示类型通配符的上界。即由?
表示的未知类型参数必须是T
的子类,或T
本身。? super T
:T 表示类型通配符的下界。即由?
表示的未知类型参数必须是T
的父类,或T
本身。
但是何时使用这两种通配符呢,在 Effective Java 中(3rd, Item-31)详细解释了 PECS 原则:
Producer use “Extends” and Consumer uses “Super”.
这里的 Extends 和 Super 对应类型边界声明符 extends
和 super
。为了更大的灵活性,在方法的输入参数中使用通配符类型来表示生产者或消费者。即从一个容器(集合)类型的视角出发:
- 如果仅需要从集合中读取元素,这时集合作为一个生产者,应该在定义集合时使用
extends
来声明类型上界; - 如果仅需要向集合中填充元素,这时集合作为一个消费者,应该在定义集合时使用
super
来声明类型下界。 - 如果同时需要读取和填充操作,则需要制定精确的类型,不能使用上下界来修饰类型参数。
public class Stack<E> {
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
public void pushAll(Iterable<? extends E> src) { // 1
for (E e : src)
push(e);
}
public void popAll(Collection<? super E> dst) { // 2
while (!isEmpty())
dst.add(pop());
}
}
}
src
作为生产者集合,所包含的元素均为 E 的子类或 E,这时可以对一个Stack<Number>
调用pushAll
传入一个Iterable<Int>
。dst
作为消费者集合,所包含的元素均为 E 的父类或 E,这时可以对一个Stack<Number>
调用popAll
传入一个Iterable<Object>
。
返回值与参数中的型变
这种情况的典型应用便是 Scala 中的 Function1
接口:
trait Function1[-T1, +R] extends AnyRef
在 Scala 中,-T
表示逆变,+T
表示协变,T
表示不变。
假定我们拥有以下类型:Garfield <: Cat <: Animal
, Husky <: Dog <: Animal
。对于 Function1[Cat, Dog]
来说,其子类必须保证两个功能:
- 你可以向其传入任意类型的
Cat
,即Cat
的的所有子类; - 你可以使用其返回值调用
Dog
的任意方法。
逆变参数
假如参数是协变的,即 Function1[Garfield, _] <: Function1[Cat, _]
,根据里氏替换原则,任何需要父类的地方都可以使用一个子类来替换,这里作为父类的 Function1[Cat, _]
可以接收任意类型的 Cat
,但作为子类的 Function1[Garfield, _]
仅能接收 Garfield
。因此没有意义。
假如参数是逆变的,即 Function[Animal, _] <: Function[Cat, _]
,根据里氏替换原则,这里作为父类的 Function1[Cat, _]
可以接收任意类型的 Cat
,同样作为子类的 Function[Animal, _]
也可以接收更多类型的 Cat
,而且不仅仅是 Cat
,甚至是 Dog
(子类能做的更多)。
因此对于 A <: B
来说,要求 Function1[B, _] <: Function1[A, _]
。
协变返回值
假如返回值是协变的,即 Function[_, Husky] <: Function[_, Dog]
,根据里氏替换原则,这里作为父类的 Function[_, Dog]
可以在其返回值上调用任意 Dog
的方法,而作为子类的 Function[_, Husky]
同样可以在其返回值上调用任意 Dog
的方法,甚至还能调用 Husky 特有的方法 destoryHouse
(子类能做的更多)。
假如返回值是逆变的,即 Function[_, Animal] <: Function[_, Dog]
,根据里氏替换原则,这里作为父类的 Function[_, Dog]
可以在其返回值上调用任意 Dog
的方法,而作为子类的 Function[_, Animal]
则可能无法执行 Dog
特有的方法,因此没有意义。
反向推导
因此,对于一个 Function1[Animal, Husky]
,它可以完成 Function1[Cat, Dog]
所能完成的所有工作:可以传入任意类型的 Cat
, 甚至是其他 Animal
,可以调用 Dog
的任意方法,甚至是 Husky
特有的方法。总的来说,Function1[Animal, Husky] <: Function1[Cat, Dog]
。因此对于 Function1[Cat, Dog]
本身来说,其参数类型可以替换为父类(逆变),返回值类型可以替换为子类(协变)。
6.1.38 - 常见问题
根据类的不同属性进行排序-1
需要对类的集合按照不同的字段进行排序,排序的规则可以指定:
case class Item(good:String, bad:String, gross:Int, warn:String)
trait BaseSort{
def sort[A, B: Ordering](data: List[A], desc: Boolean)(measure: A => B): List[A] = {
val baseOrdering = Ordering.by(measure)
val ordering = if (desc) baseOrdering.reverse else baseOrdering
data.sorted(ordering)
}
}
trait ItemsSort extends BaseSort{
def sortItems(items:List[Item], by:String, desc:Boolean): List[Item] = by match {
case "good" => sort(items, desc=desc)(_.good)
case "bad" => sort(items, desc=desc)(_.bad)
case "gross" => sort(items, desc=desc)(_.gross)
case "warn" => sort(items, desc=desc)(_.warn)
case _ => items
}
}
val items:List[Item] = List(Item("a","a",1,"a"),Item("b","b",2,"b"),Item("c","c",3,"c"))
sortItems(items, "good",desc = true).foreach(println)
根据类的不同属性进行排序-2
另一种方式是首先预定义需要的排序规则,然后在需要的位置做为隐式参数引入:
case class Item2(id:Int, firstName:String, lastName:String)
object Item2{
// 注意,因为`Ordering[A]`不是逆变的,如果`Item`的子类想要使用该排序方式,则必须声明为参数化类型
implicit def orderByName[A <: Item2]: Ordering[A] =
Ordering.by(e => (e.firstName,e.lastName))
val orderingById: Ordering[Item2] = Ordering.by(_.id)
}
object CustomClassSort2 extends App{
val items:List[Item2] = List(Item2(2,"ccc","ddd"),Item2(1,"aaa","bbb"))
import Item2.orderByName // 直接引入隐式参数
items.sorted.foreach(println)
implicit val ording = Item2.orderingById // 引入排序规则后定义为隐式参数
items.sorted.foreach(println)
}
隐式转换
将类自动转换为元组
有时候需要将一些case class
自动转换为元组以方便处理:
case class Foo(a:String, b:String)
implicit def asTuple(foo:Foo):(String,String) = Foo.unapply(foo).get
val foo1 = Foo("aa","bb")
val (a:String,b:String):(String,String) = foo1
不可过度使用元组
对元组过度的使用会使代码难于理解,特别是元素特别多的元组,比如:
def bestByName(query:String, actors:List[(Int, String, Double)]) =
actors.filter { _._2 contains query}
.sortBy { _._3}
.map { _._1}
.take(10)
而是应该讲使用频繁的元组作为一个case class
,并且将各阶段的中间处理过程进行易于理解的命名:
case class Actor(id:Int, name:String, score:Double)
def bestByName(query:String, actors:List[Actor]) = {
val candidates = actors.filter{ _.name contains query}
val ranked = canditates.sortBy { _.score }
val best = ranked take 10
best map { _.id }
}
可变数据类型的选择
在一个函数或者私有类中时,如果一个可变的数据类型能够有效的缩减代码,这时就可以使用可变数据类型,比如var
或者colleciton.mutable
,因为没有外部的动作可以对他们造成改变。
函数重复参数传入
比如定义一个方法:
def echo(args:String*) = args.forearh(println)
该函数能够接受一个或多个 String 类型的参数,比如:
echo("aa")
echo("bb","cc")
这个String*
实际上是一个Array[String]
,但是当传入一个已存在的序列时,需要先将其展开:
val seq:Seq[String] = Seq("aa","bb","cc")
echo(seq:_*)
这个标注告诉编译器将序列的每个元素当做一个参数,而不是将整个序列做为一个单一的参数传入。直接传入序列将会报错。
并行集合
为并行集合指定线程数
Configuring Parallel Collections
scala> import scala.collection.parallel._
scala> val pc = mutable.ParArray(1, 2, 3)
scala> pc.tasksupport = new ForkJoinTaskSupport(newscala.concurrent.forkjoin.ForkJoinPool(2))
scala> pc map { _ + 1 }
res0: scala.collection.parallel.mutable.ParArray[Int] = ParArray(2, 3, 4)
异常
NoSuchMethodError
此类异常多为使用了错误版本的 JDK 或 Scala。
获取代码运行时间
val t1 = System.nanoTime
/* your code */
val duration = (System.nanoTime - t1) / 1e9d
为Future添加一个基于时间的监控器
import scala.concurrent.duration._
import java.util.concurrent.{Executors, ScheduledThreadPoolExecutor}
import scala.concurrent.{Future, Promise}
val f: Future[Int] = ???
val executor = new ScheduledThreadPoolExecutor(2, Executors.defaultThreadFactory(), AbortPolicy)
def withDelay[T](operation: ⇒ T)(by: FiniteDuration): Future[T] = {
val promise = Promise[T]()
executor.schedule(new Runnable {
override def run() = {
promise.complete(Try(operation))
}
}, by.length, by.unit)
promise.future
}
Future.firstCompletedOf(Seq(f, withDelay(println("still going"))(30 seconds)))
Future.firstCompletedOf(Seq(f, withDelay(println("still still going"))(60 seconds)))
logback 避免日志重复
<logger name="data-logger" level="info" additivity="false">
Loggers are hierarchical, and any message sent to a logger will be sent to all its ancestors by default. You can disable this behavior by setting additivity=false.
OkHttp异步请求
BUG:大量请求之后资源耗尽导致服务不可用,虽然已经关闭了response
。
import java.util.concurrent.TimeUnit
import okhttp3._
trait OkHttpBuilder {
val httpClient: OkHttpClient = new OkHttpClient().newBuilder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(45, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.followSslRedirects(true)
.followRedirects(true)
.build()
def cloneHttpClient() = httpClient.newBuilder().build()
}
object OkHttpBuilder extends OkHttpBuilder
private def asyncDownload(src: String, superior: ActorRef) = {
try {
val client: OkHttpClient = OkHttpBuilder.cloneHttpClient()
val request = new Request.Builder().url(src).get().addHeader("User-Agent", web).build()
client.newCall(request).enqueue(new Callback {
override def onFailure(call: Call, e: IOException): Unit = {
logger.error(s"ImageProcessor.DownloadErr - 1: $src, ${e.getMessage}")
superior ! Redownload(src)
}
override def onResponse(call: Call, response: Response): Unit = {
response.isSuccessful match {
case false =>
logger.error(s"ImageProcessor.DownloadErr - 2: $src, ${response.code()}")
superior ! Discard(src)
response.close()
case true => try {
val stream: InputStream = response.body().byteStream()
val bytes: Array[Byte] = IOUtils.toByteArray(stream)
save(src, bytes) match {
case Some(path) =>
upload(path) match {
case Some(pathU) => superior ! Oss(src, pathU)
case None => superior ! Redownload(src)
}
case None => superior ! Discard(src)
}
} catch {
case NonFatal(e) =>
logger.error(s"ImageProcessor.DownloadErr - 3: $src, ${e.getMessage}")
superior ! Redownload(src)
} finally {
response.close()
}
}
}
})
} catch {
case ex: IOException =>
logger.error(s"ImageProcessor.DownloadErr- IOException: $src, ${ex.getMessage}")
superior ! Redownload(src)
case NonFatal(e) =>
logger.error(s"ImageProcessor.DownloadErr - 0: $src, ${e.getMessage}")
superior ! Redownload(src)
}
}
获取系统包路径
java -XshowSettings:properties -version // java 8
或:
public class PrintLibPath{
public static void main(String[] args){
System.out.println(System.getProperty("java.library.path"));
}
}
javac PrintLibPath.java
java -cp /path PrintLibPath
/usr/java/packages/lib/amd64:/usr/lib64:/lib64:/lib:/usr/lib # centos 6.5
6.1.39 - 占位符
Scala 的匿名函数语法arg => expr
,提供来非常简洁的方式来构造函数字面值,甚至函数中包含多个语句。
同时,匿名函数中可以使用占位符语法:
List(1, 2).map { i => i + 1 }
// equivalent
List(1, 2).map { _ + 1 }
当时,如果在上面的例子中添加一个 debug 信息,比如:
List(1, 2).map { i => println("Hi"); i + 1 }
Hi
Hi
List[Int] = List(2, 3)
List(1, 2).map { println("Hi"); _ + 1 }
Hi
List[Int] = List(2, 3)
可以发现,结果并不符合预期。
因为函数经常被当做参数传递,经常可以看到他们被{...}
包围。通常会认为大括号表示一个匿名函数,但是它只是一个块表达式:一条或多条语句,最后一条决定了这个块的结果。
上面的例子中,两个块被解析的方式不同,决定了他们的不同行为。
第一条语句中,{ i => println("Hi"); i + 1 }
被认为是一个arg => expr
方式的一个函数字面值语句,而expr
在这里就是println("Hi"); i + 1
。因此,println
语句也是函数体的一部分,每当函数被调用时,他都会被执行。
scala> val printAndAddOne = (i: Int) => { println("Hi"); i + 1 }
printAndAddOne: Int => Int = <function1>
scala> List(1, 2).map(printAndAddOne)
Hi
Hi
res29: List[Int] = List(2, 3)
第二条语句中,代码块被识别为两个表达式,println("Hi")
和_ + 1
,println
语句并不是函数体的一部分,它会在整个语句块{ println("Hi"); _ + 1 }
当做参数传递给 map 方法时执行,而不是 map 方法执行的时候。而整个块的计算结果,即最后一行语句的值,_ + 1
,作为一个Int => Int
的匿名函数传递给 map 方法。
scala> val printAndReturnAFunc = { println("Hi"); (_: Int) + 1 }
Hi // println 语句已经被调用
printAndReturnAFunc: Int => Int = <function1> // 整个块已经计算完成,得到匿名函数
scala> List(1, 2).map(printAndReturnAFunc)
res30: List[Int] = List(2, 3)
总结
这个地方的关键是:使用占位符定义的匿名函数的作用域仅延伸到包含占位符(_)的表达式;普通语法的匿名函数,其函数体包含从标示符(=>)开始直到语句块结束。
普通语法的匿名函数:
scala> val regularFunc = { a:Any => println("foo"); println(a); "baz"}
regularFunc: Any => String = <function1>
scala> regularFunc("hello")
foo
hello
res0: String = baz
占位符语法的匿名函数,下面这两个函数是等效的:
scala> val anonymousFunc = { println("foo"); println(_: Any); "baz" }
foo
anonymousFunc: String = baz
scala> val confinedFunc = { println("foo"); { a: Any => println(a) }; "baz" }
foo
confinedFunc: String = baz
6.1.40 - 多变量赋值
如果需要以简便的方式对多变量赋值,比如:
var MONTH = 12; var DAY = 24
var (HOUR, MINUTE, SECOND) = (12, 0, 0)
但是结果并不符合预期:
MONTH: Int = 12
DAY: Int = 24
<console>:11: error: not found: value HOUR
var (HOUR, MINUTE, SECOND) = (12, 0, 0)
^
<console>:11: error: not found: value MINUTE
var (HOUR, MINUTE, SECOND) = (12, 0, 0)
^
<console>:11: error: not found: value SECOND
var (HOUR, MINUTE, SECOND) = (12, 0, 0)
^
Scala 允许使用大写字母作为普通的变量名,就和 MONTH、DAY 一样并没有报错。但是第二条语句中使用的多变量赋值方式却不同。
因为多变量赋值实质上基于模式匹配,但是在模式匹配中,以大写字母开头的变量代表着特殊的意义:它们是稳定标示符。
稳定标识符是给常量预留的,比如:
final val TheAnswer = 42
def checkGuess(guess: Int) = guess match {
case TheAnswer => "Your guess is correct"
case _ => "Try again"
}
scala> checkGuess(21) // res8: String = Try again
scala> checkGuess(42) // res9: String = Your guess is correct
相反,小写的变量名定义为变量模式,表示对变量的赋值:
var (hour, minute, second) = (12, 0, 0)
// hour: Int = 12 minute: Int = 0 second: Int = 0
因此在一开始的例子中,并不是对变量的赋值,而是对常量的匹配。
总结
如果想要使用大写的变量名,在极端的情况下,会对当前作用域中的值进行匹配,这个模式匹配会编译成功,并且最终的结果依赖于值是否真正匹配:
val HOUR = 12; val MINUTE, SECOND = 0;
scala> var (HOUR, MINUTE, SECOND) = (12, 0, 0) // 1 - 匹配成功
val HOUR = 13; val MINUTE, SECOND = 0;
scala> var (HOUR, MINUTE, SECOND) = (12, 0, 0) // 2 - 匹配失败
scala.MatchError: (12,0,0) (of class scala.Tuple3) ...
在上面的第一个语句中,即便是匹配成功也不会进行任何赋值操作:稳定标示符在模式匹配期间不会进行任何赋值。
小写的变量名同样可以使用重音符(`)包围的方式当做稳定标示符,同时它们必须是 val,因此把他们当做常量来处理:
final val theAnswer = 42
def checkGuess(guess: Int) = guess match {
case `theAnswer` => "Your guess is correct"
case _ => "Try again"
}
大写的变量名并声明为 var ,在 Scala 中是不推荐的做法,而且要完全避免。使用大写变量名来声明常量,同时,常量声明为 final。这样避免被子类覆写,同时编译器将他们内联(inline)以提升性能。
6.1.41 - 构造器
继承中的构造器初始化顺序
很多编程语言通过构造器参数类初始化类的成员变量:
class MyClass(param1, param2, ...) {
val member1 = param1
val member2 = param2
...
}
在 Scala 中,构造器参数既是成员变量,避免了重复赋值:
class MyClass(val member1, val member2, ...) {
...
}
但是下面的代码:
trait A {
val audience: String
println("Hello " + audience)
}
// 通过成员实现接口字段
class BMember(a: String = "World") extends A {
val audience = a
println("I repeat: Hello " + audience)
}
// 通过构造器实现接口字段
class BConstructor(val audience: String = "World") extends A {
println("I repeat: Hello " + audience)
}
new BMember("Readers")
new BConstructor("Readers")
其执行结果为:
scala> new BMember("Readers")
Hello null // <======= null
I repeat: Hello Readers
res3: BMember = BMember@1aa6f6eb
scala> new BConstructor("Readers")
Hello Readers
I repeat: Hello Readers
res4: BConstructor = BConstructor@64b6603a
这表示,A 中 audience 的值,随着该成员是在构造器参数列表中声明或在构造器体重声明而不同。
要理解这两种成员声明方式的不同,需要了解类测初始化顺序。B 的两种构造器都是以下面的形式声明:
class c(param1) extends superclass { statements }
new BMember("Readers")
和new BConstructor("Readers")
的初始化会以下面的序列进行:
- 参数值 ”Readers“ 被求值,当然这里他直接是一个字符串,不需要计算,但如果他是一个表达式,比如
"readers".capitalize
,则会首先计算 - 被构造的类以下面的模板进行计算:
superclass { statements }
- 首先,是 A 的构造器,A 的构造体
- 然后,是子类构造体
因此,在 BMember 中,第一步是将 “Readers” 赋值给构造器参数 a,然后是 A 的构造器被调用,但是这时成员 audience 还没有被初始化,所以默认值为 null。接着,子类 BMember 的构造体被执行,变量 a 的值被赋值给成员 audience,最终打印出了 audience 的值。
而 BConstructor 中,”Readers“ 被计算并以直接的方式赋值给 audience,因为这是构造器参数计算的一部分。因此当 A 的构造器被调用时,audience 的值已经被初始化为 ”Readers“。
总结
通常,BConstructor 的模式作为首选的方式。
同样可以使用**字段提前定义(early field definition)**来实现同样的结果。这样可以支持你在构造器参数上执行额外的计算,或者以正确初始化的值来创建匿名类:
class BEarlyDef(a: String = "World")
extends { val audience = a } // 字段提前定义部分
with A { println("I repeat: Hello " + audience) }
scala> new BEarlyDef("Readers")
Hello Readers
I repeat: Hello Readers
res7: BEarlyDef = BEarlyDef@44c93da7
scala> new { val audience = "Readers" } with A {
println("I repeat: Hello " + audience) }
Hello Readers
I repeat: Hello Readers
res0: A = anon1@71e16512
**提前定义(Early definitions)**在超类构造器调用之前定义并赋值成员。
因此,加上之前顺序后的完整顺序:
- 执行子类构造器参数求值
- 执行字段提前定义
- 执行父类、父特质构造器、构造体,被混入的特质按照出现的顺序从左到右执行
- 执行子类、子特质构造体
一个完整的示例:
trait A {
val audience: String
println("Hello " + audience)
}
trait AfterA {
val introduction: String
println(introduction)
}
class BEvery(val audience: String) extends {
val introduction = { println("Evaluating early def"); "Are you there?" } } with A
with AfterA {
println("I repeat: Hello " + audience)
}
scala> new BEvery({ println("Evaluating param"); "Readers" })
Evaluating param // 参数计算
Evaluating early def // 提前定义计算
Hello Readers // 第一个父类构造器、构造体计算
Are you there? // 第二个父类构造器、构造体计算
I repeat: Hello Readers // 第三个匿名父类构造器、构造体计算
res3: BEvery = BEvery@6bcc2569
6.1.42 - 函数式入门
翻译自:A Beginner-Friendly Tour through Functional Programming in Scala
函数式编程的基本核心十分简单:通过组合函数(function)来构建程序。
这里,“function”并非指“计算机科学”中的函数,它指一段机器代码,而是一个“数理函数(mathematical function)”:
- Totality:一个函数必须为所有可能的输入生成一个值;
- Determinism:一个函数必须为相同的输入返回相同的值;
- Purity:函数唯一的副作用必须是计算它的返回值。
所有这些属性,给你一种前所未有的能力来解释你的代码:调用函数并传入任何输入,你总会得到一个有效的值,而且相同的输入总是会得到相同的结果,同时函数不会再做其他任何事,比如发射核导弹….
这种微小的想法对于大型软件工程有着深刻的简化作用,因为这意味着,你的大脑只需要追踪更少的东西就能理解程序的行为。事实上,你可以通过理解程序的个别部分来理解程序的整个行为 - 而无需一次在脑子中掌握一切!
目前,函数式编程并不总是拥有一个“简单”的声誉,但我认为是由于以下几个因素:
- Unfamiliarity(不太常见):函数式编程与大多数专业人员使用的编程类型有着很大的不同。因为它不太常见,看起来很难的样子。最好是将函数式编程与第一次学习编程的经验相比较(而不是学习一种你已经知道的新的编程语言的经验)。
- Jargon(术语太多):函数式编程中有太多术语,比如:“immutability”, “recursion”, “induction”, “hylomorphism”, “transducer”, “functor”等等一大堆。这些概念并不一定很难,但拥有可怕的冠冕堂皇的名字,命令式方式的大量编程经验也不能帮助你理解任何新的术语。
- Motivation(学习动机):从我的经验来看,人们对于学习那些可以清晰的看到如何达成个人目标的东西会感觉更容易。然而,随着越来越多的人开始学习函数式编程(并与他人分享知识),情况也在改善,但是对于函数式编程的核心概念并没有太大积极性。
有时函数式编程通常被关联为“高等静态类型函数式编程(advanced statically-typed functional programming)”,这时,学习“函数式编程”的人其实在一次学习两种东西:函数式编程和一个高级类型系统(多数命令式语言拥有相对简单的类型系统)。
然而,有些人可以用弱类型语言进行函数式编程,或者使用高级类型系统进行命令式编程,因此这二者并无关联。
很多函数式开发者喜欢展示我们为何对函数式编程如此兴奋。然而,我所写的大部分内容并非对函数式的好奇,甚至不是面向新手函数式开发者。相反,我写的都是我感兴趣的东西,这通常是高级函数编程的中间形式,对于那些多年来一直在进行函数式编程的人比较有用。
所以我在做一个实验:我将展示一些函数式编程的思想以帮助我们构建一些实际程序。但是我会以一种有助于前面提到的那些因素的方式进行。
这是一篇为非函数式开发者准备的文章,或者那些了解一点但想知道更多的开发者。
希望你发现它会有用 - 但相比有用,我更希望你发现它鼓舞人心。激发足够的投入和必要的努力,来推动你的函数式编程知识,尽管看起来似乎会有点难。
或者也不一定,看完这篇文章之后一切看起来都会变得很容易!
不切实际的 FP
展示函数式编程能力的一个典型例子就是泛型排序函数:
def sort[A: Orderig](as:List[A]):List[A] = as match{
case Nil => Nil
case a :: as =>
val (before, after) = as partition (_ < a)
sort(before) ++ (a :: sort(after))
}
这个例子很漂亮,因为它展示了函数式编程如何以如此简化的效果来表达一个程序。
看完代码,你自己可能会确认下面这几点:
- 空列表排序仍会得到空列表;
- 一个首元素为 a 后很 as 的列表,经过排序后由 小于 a 的列表、a、大于 a 的列表依次构成。
该函数的每个部分都可以独立推理。如果相信一段是正确的,那么就可以相信整体的正确性。
此外,这个sort
函数,因为它是数学意义上的函数,更容易测试和复用。我们可以传入任何列表并预期得到一个排序后的列表。因为我们知道对于同一个输入,函数总是会返回相同的结果。
因此我们的测试表示起来也会非常简单:
assert(sort[Int](Nil) == Nil)
assert(sort[Int](3 :: 2 :: 1 :: Nil) == 1 :: 2 :: 3 :: Nil)
(事实上,可以以函数式编程的方式来更加有力的表示,不过这就是另外一篇文章的主题了。)
虽然在这个例子中解释的函数式编程的好处看起来很简单,但这是一个很大的延伸,想象如果或如何对这些好处进行扩展以超越我们的玩具样例。
事实上,函数式编程的头号反对者就声称它只适合这些玩具例子,在”现实世界“编程中完全失败。
让我们找一个我们想到的现实世界中最简单的例子:一个函数失败时并不返回一个值。
完整性
我以完整性和正确性定义了一个例子,如果丢掉完整性要求会发生什么呢?
好吧,这个函数不需要返回任何东西。更实际的说,函数会一直运行,或者通过宿主语言支持的其他方式来转义这个返回值的需求 - 通常是抛出一个异常。
一个永不返回的函数是因为没有跳出而一直运行(脱离循环的边缘条件,或类似其他原因),但异常又是什么呢?
在异常出现之前,程序员使用一个返回值来表示函数的成功或失败。在 C 代码中,比如:
int parse_config(config *cfg){
FileHandle handle;
char *bytes;
handle = new Handle();
handle = file_open("config.cfg");
if(handle == NULL) return -1;
char *bytes = file_read(handle);
if(bytes == null) return -2;
.....
return 0;
}
在这种世界里,引入异常处理似乎不可思议。程序员可以避免混乱的错误应用逻辑处理问题,从异常的短路行为中获益。
异常的主要问题是,在一个支持它的语言中,你无法保证他们可能会发生的地方。这意味着他们可能在任何地方触发。这表示,如果时间够长,他们可以无处不在(甚至在 Java 中,未检异常可以随处抛出,其中经常还包含受检异常!)。
同时由于 null 的存在,导致激增了大量防御性、攻击性的异常处理,尽管有些并没有什么意义,但导致了更多错误的发生和怪异的边缘问题。
幸运的是,我们可以轻松的同时实现完整性和整洁代码,但比老旧语言需要更多设施的支持。
我了实现这个想法,我们定义一个函数来返回列表的第一个元素:
def head[A](as: List[A]): A = as.head
这个函数并不完整,取决于你传入的是一个什么列表,可能会返回可能也不会返回列表的首元素。如果你传入一个空列表,函数永远都不会返回,而不是抛出一个异常。
如果想要该函数完整,仅需要引入一个数据结构来建模optionality的概念 - 一个东西可能有也可能没有。
让我们称之为Maybe
:
sealed trait Maybe[A]
final case class There[A](value: A) extends Maybe[A]
final case class NotThere[A]() extends Maybe[A]
通过这个数据结构,我们可以把这个”伪函数“head
转换为真的函数:
def head[A](as:List[A]):Maybe[A] = as match {
case Nil => NotThere[A]()
case a :: _ => There[A](a)
}
现在当我们考虑使用该函数的代码时,不再需要考虑该函数没有返回的可能性。因为该函数总是能够返回。由于不需考虑这种可能性,使用head
函数的代码表述起来也更为简单,包含更少需要分享的场景。
Maybe
数据结构并没有提供跟异常一样的能力。有一条,它不会包含任何对于head
函数来说意味着 OK 的错误信息(因为我们知道错误是什么-空列表),但对于其他函数来说可能并不有效。
为了解决这个问题,我们可以引入一个新的数据结构,称为Resullt
,对exceptionality进行建模:
sealed trait Result[E, A]
final case class Error[E, A](error: E) extends Result[E, A]
final case class Success[E, A](value: A) extends Result[E, A]
这种类型支持我们创建一个file_open
这样的完整性函数:
def file_open(path:String):Result[FileError, FileHandle] = ???
现在我们可以拥有跟异常一行的信息。然而,如果我们需要很多操作需要执行,同时每个操作都返回一个Result
,这看起来我们会拥有相当多的模板代码,不免让人回忆起异常之间的日子:
def parse_config:Result[FileError, Config] = {
file_open("config.cfg") match {
case Error(e) => Error(e)
case Success(handle) =>
file_read(handle) match {
case Error(e) => Error(e)
case Success(bytes) => ???
}
}
}
我们已经创建了file_open
、file_read
函数,等等,简化了我们的表述,同时也引入了不少模板代码,使代码难以阅读。
为了夺回之前异常的优势,我们需要识别上面代码的模式。如果你研究几分钟,则会发现下面的模式:
doX match {
case Error(e) => Error(e)
case Value(x) => doY(x) match {
case Error(e) => Error(e)
case Success(y) => doZ(y) match {
case Error(e) => Error(e)
case Success(z) => doW(w) match {
...
}
}
}
}
你会发现,doY
、doZ
、doW
都会从上一个操作产生的Result[E, A]
那接收一个 A,然后生成一个新的Result[E, A]
。
这暗示我们可以通过一个chain
方法分解重复的代码:
def chain[E, A, B](result:Result[E, A])(f: A => Result[E, B]): Result[E, B] =
result match{
case Error(e) => Error(e)
case Success(a) => f(a)
}
现在,可以使用chain
方法来重新实现原来的parse_config
:
def parse_config: Result[FileError, Config] = {
chain(file_open("config.cfg")) {handle =>
chain(file_read(handle)){ bytes =>
???
}
}
}
这样通过在Result[E,A]
上调用chain
来减少模板代码。通过这种方式,我们既可以拥有异常的短路优势,错误处理逻辑又由chain
方法拆分,应用逻辑就无需再关注这些。
如果你使用Result
这样的结构来建模异常场景,通常你会发现需要一个下面这样的工具方法:
// change the A in a Result[E, A] into a B by using the provided function f
def change[E, A, B](result:Result[E, A])(f: A => B):Result[E, B] = result match{
case Error(e) => Error(e)
case Success(a) => Success(f(a))
}
这是一个类 map 的函数(将列表中的元素映射为其他类型)。如果你喜欢 OO 风格,可以把chain
、change
方法包括在Result
类之中:
sealed trait Result[E, A] {
def change[B](f: A => B):Result[E, B] = this match {
case Error(e) => Error(e)
case Success(a) => Success(f(a))
}
def chain[B](f: A => Result[E, B]):Result[E, B]= this.match{
case Error(e) => Error(e)
case Success(a) => f(a)
}
}
final case class Error[E, A](error:E) extends Result[E, A]
final case c;ass Success[E, A](value:A) extends Result[E, A]
这样一来代码就会更可读:
def parse_config:Result[FileError, Config] = {
file_open("config.cfg").chain{ chanle =>
file_read(handle).chain{bytes =>
???
}
}
}
更进一步,如果你把change
、chain
方法称作map
、flatMap
,Scala 则会提供更加灵巧的方式来进一步简化代码格式:
def parse_config:Result[FileError, Config] = {
for{
handle <- file_open("config.cfg")
bytes <- file_read(handle)
} yield ???
}
最终,我们首先了函数的完整性,同时也拥有异常的短路特性和关注点分离。
我们所需要的也就是chain
函数(即 flatMap)和Result
数据结构。其余的则自然引入。
注意,chain
函数接收一个函数作为参数。这个参数作为一个匿名函数(在其上下文中捕获任意引用)提供,这论证了为什么这种技术永远不会出现在 C 代码中。如果 C 拥有一类函数、垃圾回收或者引用计数,很可能这种模式会自行出现而无需函数式编程社区的任何输入。
函数的其他要求是确定性和纯洁性,下面的章节会讲到。
确定性 & 纯洁性
一个函数,如果拥有不确定性,或者所做的并非仅仅是计算一个返回值,那它就会变得非常难以推理。
比如我用过的一个库,会在构造器中执行 IO:
class Logging{
private OutputStream ostream;
public logging(File file){
ostream = new FileOutputStream(file);
}
}
该构造器可能会抛出异常,并且难以预料!
另一个例子,当你把 Java 中的URL
类放入一个数据结构时,它会执行一个网络连接。原因是 equals 和 hash 编码方法会触发地址的识别。
除了不纯函数的意味性质外(谁知道他们什么时候会干些什么!),非确定性(non-determinism)导致函数的测试和表述变得尤其困难。你可能会被迫对影响函数行为的那些状态的表述代码进行 mock。
纯函数,确定性函数,在现场之外不会做任何不正当的事。你可以期望他们每次都会根据相同的输入返回相同的结果,这意味着代码容易测试且易于理解。
但如果你尝试使所有的函数都是完整的、确定的,并且是纯的,当你执行一些输入输出或副作用操作时则会很快撞到墙上 - 一堵说明了太多函数式编程与真实软件不切实际的墙。
事实上,这墙早就被粉碎了,而不是沿着这条路走下去,让我们看一下一个 IO 的例子,看能不能推荐一种方案。
Console IO
假如我们正开发一个控制台程序,改程序需要从控制台读取输入,然后再会写数据到控制台上。这样的程序能解决很多有用的问题,如果我们能想出如何以确定性、纯函数的方式来构建一个控制台程序,那就能推广到其他类型的程序了。
如果你针对该问题思考了一会,可能会得出下面的想法:可以定义一些描述控制台副作用的数据结构来构建程序,而不是调用那些不确定或不纯的函数。
假如这个ConsoleIo
是我们这个程序的说明书,首先,我们需要一种方式来描述”写入输入到控制台“这种副作用。
一种方式看起来可能会是这样:
sealed trait ConsoleIO
final case class WriteLine(line:String) extends ConsoleIO
这种方式未免也太简单了,因为这样我们只能将文本的一行写入到控制台:
def helloWorld: ConsoleIO = WriteLine("Hello World!")
这是一个完整的、确定的纯函数 – 但是并没有什么卵用。它顶多能描述一个程序将文本的一行写入到控制台。文本可能会变,当然,甚至是函数的一个参数,但最终,程序只会对控制台有一个副作用。
幸运的是,将该结构扩展为支持多个顺序的副作用也很简单:
sealed trait ConsoleIO
final case class WriteLine(line:String, then:ConsoleIO) extends ConsoleIO
通过这种方式,我们可以描述更加复杂的”副作用化“程序,比如:
def infiniteHelloWorld:ConsoleIO = WriteLine("Hello World", infiniteHelloWorld)
如果我们引入一个”thunk“以避免在构造中压栈(blow stack),该结构就能够描述一个向控制台写入无限个”Hello World“的程序。像这种无限制的程序并没有什么用,不过我们可以给ConsoleIO
添加另一项,让他可以支持终止:
sealed trait ConsoleIO
final case class WriteLine(line:String, then:ConsoleIO) extends ConsoleIO
final case object End extends ConsoleIO
现在我们可以描述一个将文本行打印指定次数到控制台的程序:
def printTextNTimes(text:String, n:Int):ConsoleIO = {
if(n <= 0) End
else WriteLine(text, printTextNTimes(text, n -1))
}
该函数也是一个完整的、确定的纯函数。当然,他实际上不会打印任何东西到控制台,但我们很快就能做到。
目前,我们只能描述一个写入文本到控制台的程序。因此扩展一个从控制台读取输入的程序也相当简单:
sealed trait ConsoleIO
final case class WriteLine(line:String, then:ConsoleIO) extends ConsoleIO
final case class ReadLine(process:String => ConsoleIO) extends ConsoleIO
final case class object End extends ConsoleIO
注意构造一个ReadLine
的值时我们需要提供一个函数,传入从控制台读到的行,返回另一个ConsoleIO
,代表该副作用程序的剩余部分。
我们向ReadLine
传入一个函数,它作为一个保证,将来的某个时间某人会通过控制台给我们一行输入,然后我们再将其返回给程序的”剩余“部分。
该结构有足够的能力来描述我们的交互程序。比如,下面的程序会问你的名字并向你 Say hello:
def socialProgram:ConsoleIO = WriteLine(
"hello, what is your name?",
ReadLine(name =>
WriteLine("Hello, " + name + "!", End)
)
)
记得这是一个完整的、确定的纯函数。人们想象使用这个结构来描述非常复杂的副作用程序。事实上,任何仅需要控制台 IO 的程序都可以使用该结构来描述。
注意任何使用ConsoleIO
描述的程序都会在某个点终止。这些程序不能返回一个值。
如果我们需要这样的”控制台式程序“:该程序生成的东西在其他程序又能够使用。这样我们需要泛型化End
来接收一些类型为A
的值,这迫使我们需要给ConsoleIO
添加一个新的类型参数A
,并贯穿于其他项。
最终的结果看起来稍微有点复杂:
sealed trait ConsoleIO[A]
final case class WriteLine[A](line:String, then:ConsoleIO[A]) extends ConsoleIO[A]
final case class ReadLine[A](process: String => ConsoleIO[A]) extends ConsoleIO[A]
final case class EndWith[A](value: A) extends ConsoleIO[A]
现在,ConsoleIO[A]
能够描述一个读写控制台并被一个类型为A
的值终止的程序。这支持我们构建生成值的程序,然后这些值再被其他程序消费。
我们能够使用该结构创建之前的”Hello, !“程序,但这次,我们能从程序中返回用户的名字:
val userNameProgram:ConsoleIO[String] = WriteLine(
"Hello, what is your name?",
ReadLine(name =>
WriteLine("Hello, " + name + "!", EndWith(name))
)
)
ConsoleIO
中我们唯一丧失的是修改返回值类型的能力。比如,你想构建另一个程序并不是返回用户名,而是名字的长度,如何复用userNameProgram
呢?
当前这种方式是不可能的。我们需要一些更强大的东西来实现这个打算。如果我们拥有一个 List 则可以使用 map 来改变结果类型。而这正是ConsoleIO
所需要的。
我们可以直接给他添加一个 map 函数:
sealed trait ConsoleIO[A]{
def map[B](f: A=>B):ConsoleIO[B] = Map(this, f)
}
final case class WriteLine[A](line: String, then: ConsoleIO[A]) extends ConsoleIO[A]
final case class ReadLine[A](process: String => ConsoleIO[A]) extends ConsoleIO[A]
final case class EndWith[A](value: A) extends ConsoleIO[A]
final case class Map[A0, A](v: ConsoleIO[A0], f: A0 => A) extends ConsoleIO[A]
现在给出任何一个ConsoleIO[A]
,我们都可以通过函数A => B
将其转换为ConsoleIO[B]
。因此现在我们可以编写一个新的userNameLenProgram
,计算用户名字的字符长度:
def userNameLenProgram:ConsoleIO[Int] = userNameProgram.map(_.length)
随着map
的加入,EndWith
的作用发生了改变:我们不再需要它从程序中返回一个值,因为我们可以把拥有的任何值转换为返回值。比如你有一个ConsoleIO[String]
,我们可以通过一个String => Int
函数转换为Console[Int]
。
然后,EndWith
仍然可以用于构造一个不执行任何副作用的“纯“程序(但能够与其他程序组合)。因此它仍然是有用的,虽然与其最初的目的不同。因此,我们可以将其重新命名为Pure
:
sealed trait ConsoleIO[A] {
def map[B](f: A => B): ConsoleIO[B] = Map(this, f)
}
final case class WriteLine[A](line:String, then:ConsoleIO[A]) extends ConsoleIO[A]
final case class ReadLine[A](process: String => ConsoleIO[A]) extends ConsoleIO[A]
final case class Pure[A](value: A) extends ConsoleIO[A]
final case class Map[A0, A](v: ConsoleIO[A0], f: A0 => A) extends ConsoleIO[A]
通过这些封装,我们可以没有任何限制和约束的构建并复用这个控制台 IO 程序。所有这些描述都是拥有确定性、完整性的纯函数,因此也获得了函数式代码的强大优势:易于理解、易于测试、易于安全的调整,且易于组合。
最终 ,我们需要把一个程序的描述转换为实际的程序-一个能真正执行副作用的程序(这意味这没有确定性、也不纯)。通常这个程序会叫做interpretation。
可以写一个简单的,如果没有确定性、也不纯洁,解释器可以基于ConsoleIO[A]
使用一下类似的代码:
def interpret[A](program:ConsoleIO[A]):A = program match {
case WriteLine(line, next) => println(line); interpret(next)
case ReadLine(process) => interpret(process(readLine()))
case Pure(value) => value
case Map(v, f) => f(interpret(v))
}
还有更好的方式,不过这里就不再过多演示了。到目前为止,我认为这已经相当有意思了,我们同时能够描述一个充满副作用的,但是又满足完整性、确定性、纯函数的要求,又能很方便的转换为一个真实执行副作用的程序。
注意,这种转换需要、也非常必要在程序的最后进行(将副作用尽可能推迟到最后)!在 Haskell 中,这会发生在 Haskell 运行时的主函数之外。然而在其他的语言中,背后并没有这些函数式级别的机制支持,你总是可以在程序的入口点以副作用的方式来解释你的程序。
这么做是为了确保你在程序中拥有最大程度的完整性、确定性以及纯洁性。在你边缘的地方,你可能有个小层很难去表示,但正是在这里来基于底层将程序转换为可执行副作用的描述。
可扩展性
这种方式在一个固定副作用集的世界里会运行的很多好。在我们目前的用例中,控制台 IO—从一个控制台读写文本行。但是在实际的程序中,多种原因之下的副作用要复杂的多,我们受益于使用不同的副作用组合进所有副作用来构建程序的能力。
第一步是识别附加结构。实质上,我们可以把对控制台程序的描述拆分成生成值(WriteLine)和接收值(ReadLine)。程序剩余的部分则是由纯模板组成:要么是通过映射(map)返回值将一个程序转换为另外一个,要么是,一个程序依赖另外一个程序的结果,将这两个程序进行链接(chain/flatMap)。
如果这没有任何意义,可以研究一下下面的例子:
sealed trait ConsoleIO[A]{
def map[B](f: A=>B):ConsoleIO[B] = Map(this, f)
def flatMap[B](f: A=>ConsoleIP[B]):ConsoleIO[B] = Chain(chis, f)
}
final case class WriteLine(line:String) extends ConsoleIO[Unit]
final case class ReadLine() extends ConsoleIO[String]
final case class Pure[A](value: A) extends ConsoleIO[A]
final case class Chain[A0,A](v:ConsoleIO[A0], f: A0=>ConsoleIO[A]) extends ConsoleIO[A]
final case class Map[A0, A](v: ConsoleIO[A0], f: A0 => A) extends ConsoleIO[A]
这里只有一点不同,增加了更多令人迷惑的方式来表示同一个东西。使用这个结构,我们的交互程序会表示的更复杂一点:
def userNameProgram:ConsoleIO[String] =
Chain[Unit, String](
WriteLine("What is your name?"),
_ => Chain[String, String](
ReadLine(),
name => Chain[Unit, String](
WriteLine("Hello, " + name + "!"),
_ => Pure(name)
)
)
)
在这个模型中,ConsoleIO
拥有一组看上去泛型的项,chain、Map、Pure,他们不会跟我们控制台程序的副作用打交道,另外又有两个额外的项用来描述这些副作用:ReadLine、WriteLine,他们在这个模型中则被简化了。
这种模型构建的解释器也会有一点复杂:
def interpret[A](program: ConsoleIO[A]):A = program match{
case WriteLine(line) => println(line); ();
case ReadLine() => readLine()
case Pure(value) => value
case Map(v, f) => f(interpret(v))
case Chain(v, f) => interpret(f(interpret(b)))
}
这种简明的描述看起来不会完全不同,但关键点在于只有两项用来描述副作用,剩余则都是纯函数装置。这些装置可以被抽象到另一个类然后复用于其他所有的副作用类型。比如:
sealed trait Sequential[F[_], A] {
def map[B](f: A=> B):Sequential[F, B] = Map[F, A, B](this, f)
def flatMap[B](f:A => Sequential[F, B]):Sequential[F, B] = Chain[F, A, B](this, f)
}
final case class Effect[F[_], A](fa: F[A]) extends Sequential[F, A]
final case class Pure[F[_]](value:A) extends Sequential[F, A]
final case class Chain[F[_], A0, A](v: Sequential[F, A0], f: A0=>Sequential[F,A]) extends Sequential[F,A]
final case class Map[F[_], A0, A](v:Sequential[F, A0], f:A0=>A) extends Sequential[F,A]
sealed trait ConsoleF[A]
final case class WriteLine(line: String) extends ConsoleF[Unit]
final case class ReadLine() extends ConsoleF[String]
type ConsoleIO[A] = Sequential[ConsoleF, A]
Sequential
来并不直接引用ConsoleIO
,因此可以复用与其他不同的副作用类型。
这允许我们清晰地将副作用从计算拆分开。因此新版本的 hello world 程序看起来会是这样:
def userNameProgram:ConsoleIO[String] = {
Chain[ConsoleF, Unit, String](
Effect[ConsoleF, Unit](WriteLine("What is your name?")),
_ => Chain[ConsoleF, String, String](
Effect[ConsoleF, String](ReadLine()),
name => Chain[ConsoleF, Unit, String](
Effect[ConsoleF, Unit](WriteLine("Hello, " + name + "!")),
_ => Pure(name)
)
)
)
}
如果我们利用 Scala 的 for 符号,然后添加一个隐式类来更方便的将副作用包含到Effect
的构造器中:
def userNameProgram:ConsoleIO[String] = {
for{
_ <- WriteLine("What is your name?").effect
name <- ReadLine().effect
_ <- WriteLine("Hello, " + name + "!").effect
} yield name
}
这种方式可以进一步简化,比如,为所有项添加帮助函数。这些函数使程序更加简洁:
def userNameProgram:ConsoleIO[String] = {
for{
_ <- writeLine("What is your name?")
name <- readLine()
_ <- writeLine("Hello "+ name + "!")
} yield name
}
这与上一种实现没有什么不同。结构也大致相同,仅有一点语法不同。在我们的例子中,我们构建了一个程序的description,完整、确定的纯函数,他本身对外部世界没有任何副作用(除了利用 CPU 和内存来计算结构)。
此外,使用这种清晰的分离,实际的副作用集也可以进行扩展。这意味着我们可以在两个程序中使用采用不同的副作用,然后组合成一个程序来同时拥有两种副作用。
为了达到这种效果,你仅需要一些”EitherOr“来表达这是一种副作用(控制台 IO)或是另一种副作用(文件 IO):
sealed trait EitherOr[F[_], G[_], A]
final case class IsLeft[F[_], G[_], A](left:F[A]) extends EitherOr[F, G, A]
final case class IsRight[F[_], G[_], A](right:G[A]) extends EitherOr[G, G, A]
type CompositeProgram[A] = Sequential[EitherOr[ConsoleIO, FileIO, ?], A]
可以基于这个结构来时间简洁的解释器,并可以根据指定的副作用类型(控制台或文件)复用到其他的解释器中。
现在我们已经从第一个原则开发到这,而且没有任何术语,你看到的这种抽象实际上称为著名的”Free monad“,它让不知情的 Scala 程序员无处不在害怕!
这看起来不是太糟,对吧?
在我们结束整个教程之前,看一下这种抽象的其他好处。
纯函数的能力
通常对这种副作用的编码方式的反应是,”有什么意义?“
使用这种风格来描述副作用化程序确实有一些非常实际的好处,
- 你的程序的类型精确描述了它到底能做什么。因为没有无处不在的大块机器代码嵌入,代码推理能做的事就变得非常简单。相反,你以声明的方式准确描述了你的程序是什么(或更进一步,你描述是了如何将一个抽象层的副作用转换为一个较低抽象层的副作用)。
- 你可以将程序的描述与其实际所做的事分开。例如,一个 Web 应用程序可以创建 HTML,但这样的描述可以解释为 HTML DOM 节点,Canvas 节点,或服务器上的 PDF。
- 你可以模拟依赖。如果你的外部依赖(文件系统、网络、API 等)都由数据结构描述,那么你可以轻松遍历这些数据结构,以确保你提供了正确的值,他们也返回了正确的响应。与 mock 库不同,这种方式是完全类型安全的,而却不需要任何运行时支持。
- 你可以在程序运行时执行检查。比如,你可以添加日志行,以打印出每个指令要做什么,以及是否成功。这些日志可以是相当详细的,可以取代手工日志的需要。或者跟多,他们可以在组合解释器时”编织(wave in)“进去,日志在被禁用时可以没有任何开销。没有模板代码也没有开销—对我来说听起来真的不错。
- 除了日志,你可以在运行时将整个切面添加到程序中。比如添加认证、授权、审计,仅通过组合解释器而无需修改代码库。就像面向切面编程,但更加类型安全且灵活。
除了所有的这些好处之外,你还可以得到非常明显的好处,能够很好的推理,完整、确定、纯函数,即使是存在副作用的情况下。这些好处可以是新的开发者收益,维护已有的代码、解决 bug,引进新的团队成员等等。
总结
我希望至少文章中涵盖的部分创造了一些意义。更重要的是,我希望你们看到我们是如何使用完整的、确定的纯函数编写完整程序,以及这种方式带来的好处,其中一些也是我们刚刚发现的。
如果没有别的,这是一个学习更多函数式编程的呼唤!尽管拥有奇怪的名字、不完整的文档、混乱的类型也要坚持下去。
函数式编程非常有力,拥有巨大的力量。根据我的经验,那些花时间学习函数式编程的人最终会变得对他充满热情,永远不会回到过去的老路上。函数式编程给了开发者强大的力量—以简化的方式编写软件并输出简洁代码的能力,维护成本更低,更易组合、更易推理,等等等等。
如果你有兴趣,我期待你坚持下去,如果你卡主了,你要知道我还有社区的其他很多成员都站在你背后。我们会帮助你达到下一个层次,从值到值,从类型到类型,从 lambda 到 lambda。
6.1.43 - Monoid
整理自《Functional Programming In Scala》第十章。
Monoid(幺半群) 是一个代数定义,是*纯代数(purely algebraic)*的一种,它简单、普遍存在且很实用。除了满足同样的代数法则外不同 Monoid 实例之间很少有关联,但这种代数结构定义了用于实现实用的多态函数所必须的所有法则。
操作列表、连接字符串或在循环中累加都可以被拆解成 Monoid。下面介绍它在两个方面的使用方式:将问题拆分成小部分然后并行计算;将简单的部分组装成复杂的计算。
1. 什么是 Monoid
比如在字符串拼接的代数表达中,“foo” + “bar” 得到“foobar”,空串是这个操作的单位元(identity)(或称“幺元”)元素,即 “”+s 与 s + “” 的值都是 s。进一步,如果将三个串相加,r + s + t,由于这个操作是可结合的(associative),因此 (r +s) +t 与 r + (s+t) 的结果是一样的。
该规则同样适用于整数相加,它也是可结合的。(x+y)+z 与 x +(y+z) 的结果相同,而且由一个单位元素 0,它去其他整数相加时不会影响结果。同样乘法也是一样,它的单位元元素是 1。
布尔操作符 || 和 && 同样是可结合的,它们的单位元元素是 true 和 false。
像这样的代数便成为 Monoid,结合律(associativity)和同一律(identity)则一起被称为monoid法则。一个 Monoid 由如下几部分构成:
- 一个类型 A;
- 一个可结合的二元操作 OP,接收两个参数后返回相同类型的值,对于任何
x:A, y:A, z:A
来说,OP(OP(x,y), z)
和OP(x, OP(y,z))
是等价的; - 一个值
zero:A
,它是一个单位元,对于任何x:A
来说,zero
与它的操作都等于 x 自身:OP(x, zero) == x
或OP(zero, x) == x
。
可以使用 Scala 表示:
trait Monoid[A] {
def op(a1:A, a2:A): A
def zero: A
}
然后是 String 实例:
val stringMonoid = new Monoid[String] {
def op(a1:String, a2:String) = a1 + a2
def zero:String = ""
}
或者是 List 的连接:
// 注意这里是 def,一个返回 Monoid 实例的函数,否则将丢失类型参数 A
def listMonoid[A] = new Monoid[List[A]] {
def op(a1:List[A], a2:List[A]) = a1 ++ a2
def zero: Nil
}
“有”一个 Monoid,还是“是”一个 Monoid:
当程序员和数学家讨论:一个类型是 Monoid,或,有一个 Monoid实例,有两种不一致的表述方式。程序员易于认为一个
Monoid[A]
的实例是 Monoid,但这并不准确。Monoid 实际上是类型和定义法则的实例。更准确的说是类型 A 和Monoid[A]
实例定义的操作构成了一个 Monoid。
一个类型、一个此类型的二元操作(满足结合律)、一个单位元元素,这三者构成一个 Monoid。
2. 使用 Monoid 折叠列表
Monoid 和列表联系紧密,从 List 的foldLeft
/foldRight
签名中可以发现参数的类型很特别:
def foldRight[B](z: B)(f: (A, B) => B): B
def foldLeft[B](z: B)(f: (B, A) => B): B
当 A 和 B 类型一样时:
def foldRight(z: A)(f: (A, A) => A): A
def foldLeft(z: A)(f: (A, A) => A): A
如果一个字符串的列表,可以传递 StringMonoid 中的 OP 和 zero,用于将字符串进行拼接:
val words = List("Hic", "Est", "Index")
val s = words.foldRight(stringMonoid.zero)(stringMonoid.op) // "HicEstIndex"
val t = words.foldLeft(stringMonoid.zero)(stringMonoid.op) // "HicEstIndex"
会发现两个操作的结果一样,这正是因为结合律与同一律法则,无论左右结合效果都一样。
words.foldRight("")(_ + _) == (("" + "Hic") + "Est") + "Index"
words.foldLeft("")(_ + _) == "Hic" + ("Est" + ("Index" + ""))
可以编写一个通用的 concatenate 函数,使用 Monoid 去折叠列表:
def concatenate[A](as:List[A], m:Monoid[A]) :A = as.foldLeft(m.zero)(m.op)
但是假如列表中的元素类型不是 Monoid 实例该如何处理呢,总是可以将列表 map 成另外的类型:
def foldMap[A, B](as:List[A], m:Monoid[B])(f: A => B): B
3. 结合律与并行化
Monoid 操作的结合律意味着可以自由选择如何进行数据结构的折叠操作。前面展示了使用列表的 foldLeft 和 foldRight 去调用满足结合律的函数,对列表按照顺序向左或向右的 reduce。如果有个 Monoid 可以使用*平衡折叠法(balance fold)*对列表进行 reduce,这样一些操作可能更加高效或支持并行化。
假设一个有序集 a, b, c, d,三种不同的折叠方式:
op(a, op(b, op(c, d))) // foldRight
op(op(op(a, b), c), d) // foldLeft
op(op(a, b), op(c, d)) // balance fold
在平衡折叠中,因为两个 op 是独立的,因此支持同时运行。当每个 op 的时间花费与参数的长度成正比时平衡树的结构可以变得更加高效,比如下面的表达式:
List("lorem", "ipsum", "dolor", "sit").foldLeft("")(_ + _)
其求值轨迹为:
List("lorem", "ipsum", "dolor", "sit").foldLeft("")(_ + _)
List("ipsum", "dolor", "sit").foldLeft("lorem")(_ + _)
List("dolor", "sit").foldLeft("loremipsum")(_ + _)
List("sit").foldLeft("loremipsumdolor")(_ + _)
List().foldLeft("loremipsumdolorsit")(_ + _)
"loremipsumdolorsit"
每次折叠,分配一个临时的字符串(foldLeft 的第一个参数)然后丢弃,下次又要分配一个更大的字符串。字符串的值是不变的,当 a + b 时,需要分配一个字符数组然后将 a 和 b 的值复制到这个新数组。这个时间花费与 a、b 的总长度是成正比的。相比更高效的方式是对半组合顺序集,先构建“loremipsum”和“dolorsit”,然后将他们加在一起。
4. 例子:并行解析
如果需要统计字符串中的单词数,可以按顺序扫描字符串,寻找空格然后对连续的非空格字符计数。这样按顺序解析,解析器的状态可以表达成最后一个字符是否是空格。
但是如果要处理一个巨大的文件,达到单机内存装不下,需要对文件进行切分才能处理。策略是将文件拆分成多个可以管理的块(chunk),并行处理这些块,最后将结果合并起来。这时,解析器的状态可能会复杂一些,需要可以合并中间结果,无论这个部分是文件的开头、中间或结尾。这意味这合并操作需要时可结合的。
把下面的字符串当做一个大文件:
"lorem ipsum dolor sit amet"
假如对半拆分字符串,可能会将一个单词拆分。当累加这些字符串的计算结果时需要避免重复计入同一个单词。所以这里仅仅将单词作为整体来计数是不严谨的。需要一个数据结构能处理部分结果,并能记录完整的单词。单词计数的结果则可以表示成一个代数数据结构:
sealed trait WC
case class Stub(chars: String) extends WC
case class Part(lStub:String, words:Int, rStum:String) extends WC
Stub
表示没有看到任何完整的单词,Part
保存看到的完整的单词的个数,以及左边的部分单词和右边的部分单词。
比如上面的字符串,拆分成“lorem ipsum do”和“lor sit amet”,对前者计数的结果为Part("lorem", 1, do)
,对后者的计数结果为Part("lor", 2, "")
。
Monoid 同态
可能你会发现 Monoid 的函数之间有个法则。比如字符串的连接 Monoid 和整数累加 Monoid。假如取两个字符串的长度相加,等于连接两个字符串然后取其长度:
"foo".length + "bar".length == ("foo" + "bar").length
length
是一个函数,它将 String 转化为 Int 并保存 Monoid 结构。这样的结构称为Monoid 同态(homomorphism),一个 Monoid 同态 f 定义为在 Monoid M 和 N 之间对所有的值及 x、y 都遵守以下规则:M.op(f(x), f(y)) = f(N.op(x, y))
当设计自己的库时这个特性很有用,加入两个类型是 Monoid 并且他们之前存在函数,好的做法是考虑这个函数是否可以保持 Monoid 结构,并且测试其是否为 Monoid 同态。
某些时候两个 Monoid 之间是双向同态的,**同质(isomorphic)**是在 M 和 N 之间存在的两个同态的函数 f 和 g,而且
f andThen g
和g andThen f
是等同的函数。比如, String 和
List[Char]
monoid 的连接操作是同质的。两个 Boolean monoid (false, ||) 和 (true, &&)通过取反(!)同样也是同质的。
5. 可折叠数据结构
现在需要为 IndexedSeq 实现一个折叠函数。一般处理这类数据结构中的额数据时,通常不在意具体结构是什么,也不在意是否延时或者提供有效的随机读写,等等。
比如有个结构中是整形,需要计算他们的总和,可以使用 foldRight:
ints.foldRight(0)(_ + _)
不需要关心 ints 的具体结构类型,他可以是 Vector、Stream 或其他列表,或者任何一个包含 foldRight 方法的类型。把这种通用性表达成下面的 trait:
trait Foldable[F[_]] {
def foldRight[A,B](as:F[A])(z:B)(f: (A,B) => B): B
def foldLeft[A,B](as:F[A])(z:B)(f: (B,A) => B): B
def foldMap[A,B](as:F[A])(f: A => B)(mb:Monoid[B]): B
def concatenate[A](as:F[A])(m:Monoid[A]): A = foldLeft(as)(m.zero)(m.op)
}
这里抽象出一个类型构造器 F,就像在之前章节中构建的 Parser 类型。表示为F[_]
,这里的下划线表示 F 不是一个类型而是一个类型构造器,它接收一个类型参数。就像接收别的函数作为参数的函数被称为高阶函数,Foldable 是高阶类型构造函数或高阶类型。
6. Monoid 组合
Monoid 的真正强大之处在于组合。比如 类型 A、B 是 Monoid,那么 Tuple 类型 (A, B) 也是 Monoid (称 product)。
6.1. 组装更加复杂的 Monoid
只需要包含的元素是 monoid,某些数据结构就能构建成 Monoid。比如当 value 类型是 Monoid 时,合并 key-value 映射的操作就能够构建 monoid:
def mapMergeMonoid[K, V](V: Monoid[V]): Monoid[Map[K, V]] = {
new Monoid[Map[K, V]] {
def zero = Map[K, V]()
def op(a:Map[K,V], b:Map[K,V]) =
(a.keySet ++ b.keySet).foldLeft(zero) { (acc, k) =>
acc.updated(k, v.op(a.getOrElse(k, V.zero), b.getOrElse(k, V.zero)))
}
}
}
使用这个简单的组合子(combinator)既可以组装出复杂的 Monoid:
val m:Monoid[Map[String, Map[String, Int]]] =
mapMergeMonoid(mapMergeMonoid(intAddition))
6.2.使用组合的 Monoid 融合多个遍历
多个 Monoid 可以被组合在一起,则折叠数据时可以同时执行多个计算。比如同时获得一个列表的总和与长度,来计算平均值:
val m = prodocutMonoid(intAddition, intAddition)
val p = listFoldable.foldMap(List(1,2,3,4,5))(a => (1,a))(m)
val mean = p.1 / p.2.toDouble //=> 2.5
7. 总结
Monoid 是第一个纯抽象代数,它定义为抽象的操作和对应的法则。可以在不知道参数是什么,仅知道其类型可以构建 monoid的情况下编写可用的函数。
6.1.44 - 函数式与类型类
翻译自原文:On Scala, Functional Programming and Type-Classes
我曾经在 Coursera 上追随一个名为“ Functional Programming Principles in Scala”的精彩课程,该课程由 Martin Odersky(Scala 作者) 执教。这并不是我第一次遇到 Scala,因为我已经把它用在了日常工作当中。与此同时,我感觉需要找一个 Javascript 语言的替代者,因为优秀的 ClojureScript,我也开始了对 Clojure 的学习。
我对这两种语言都非常喜欢,真的说不上来更喜欢哪一个。这篇文档代表了我使用 Scala 的(菜鸟)经验,完全的瞎扯,或者你可以称它为“一个傻瓜的精神自慰”。
1. 函数式编程的双赢
它并非银弹,但整体来说非常棒。你真的有必要经历一次,同时撇开那些通过多年的必要技能而建立起的成见和偏见。学生学习起函数式编程会相对容易,他们并无任何经验,否则学习的过程将会很痛苦。
但在过去的 20 万年里我们进化的并不多,所以我们的大脑总是能在那些吸引我们内在兽性(inner-animal)的地方找到乐趣,对繁衍、吃饭、睡觉和躲避野兽感兴趣。学习是一种乐趣,但对于陌生的领域并非如此,因此如果你已经开始,那就要坚持下去。
首先我们需要一些对于函数式编程的定义:
- 通过“引用透明”对函数求值来处理计算;(引用透明:函数的行为类似数学函数,相同的输入总会得到相同的结果)
- 一个计算的最终输出是对输入的多次转换结果的组合,而非通过那些构建可变状态的方式;
一个函数式编程语言:
- 将函数当做“一类(first-class)对象”,这表示处理高阶函数不但是可能的,而且是很以很舒服的方式;
- 为你提供用于组合函数与类型的工具。
根据定义,像 Ruby、Javascript 这些也可以被认为是像样的函数式语言。然而我还要加几条:
- 拥有丰富的不可变、持久化数据结构;
- 提供有效的处理“expression problem”的类型系统。Rich Hickey 称之为“polymorphism a la carte”
你也可以指定所有的副作用(side-effect)必须通过一元(monadic)类型来建模,不过这有点太清规戒律的意思(IMHO),因为只有一种符合主流的语言 - Haskell。
2. Scala 是一个函数式语言吗
当然是。你只需要追随上面我提到的 Coursera 上的精彩课程、完成作业,你就会意识到 Scala 真正是一个非常函数式的语言。该课程虽短,不过有后续计划。因此现在就行动吧….
3. Polymorphism À la Carte
这是我从 Rich Hickey 那听来的名词,当他谈论到开放式类型系统(open type-system),主要引用了 Clojure 的 Protocol 和 Haskell 的 Type-Class。
这些多态机制能够很好的解决表达式问题,这与我们已知的 Java、C++ 这些面向对象语言形成鲜明对比。
OOP 通常是一个封闭的类型系统(closed type-system),特别是在静态语言中使用时。将一个新类添加到层级结构、添加新函数来操作整个层级结构、给接口添加新的抽象成员、使内置类型以某种方式运转,所有这些都难以处理。
Haskell 通过 Type Classes 来处理。Clojure 通过 Multi-Methods 和 Protocol 来处理,Protocol 是动态的,相当于 动态类型系统(dynamic type-system)中的 type-class。
4. Yes Virginia,Scala 拥有 Type-Class
那什么又是 type-class?类似于 Java 中的接口,除了你可以使任何现有类型遵循它而不用修改该类型的实现。
比如,我们想要一个泛型函数能够将事物加起来….比如一个foldLeft()
或sum()
,但是相较于如何 fold,你想要环境知道如何处理每个特殊的类型。
在 Java 或 C# 中这样做有很多问题:
- 对于那些支持相加操作的类型,并没有为
+
定义接口,比如:Integer/BigInteger/BigDecimal/Float/String… - 我们需要从一些"0"开始(你想要折叠的列表可能为空)
或许你可以定义一个这样的类型类:
trait CanFold[-T, R]{
def sum(acc:R, elem:T): R
def zero: R
}
但是等等,这不就是一个类 Java 的接口吗?对,他就是。这就是 Scala 最棒的地方,Scala 中任何实例都是对象,任何类型(type)都是一个类(class)。
那又是什么让这个接口成为了一个 type-class?当然是因为“伴生对象中带有隐式参数的对象”(Objects in combination with implicit parameters)。我们看一下如何使用这些来实现sum
函数:
def sum[A, B](list: Traversable[A])(implicit adder: CanFold[A, B]): B =
list.foldLeft(addr.zero)((acc,e) => adder.sum(acc,e))
因此,如果 Scala 编译器能够在作用域中找到一个为 A 定义的 隐式CanFold
,就会使用它生产一个 B。它的出色表现在多个级别:
- 类型 A 的隐式定义建立在返回类型 B 之上
- 可以为任何你需要的类型定义一个
CanFold
,整数、字符串、列表等等等
隐式定义是有范围的,因此需要导入。如果你需要一些类型的默认隐式定义(全局可见),可以在CanFold
特质的伴生对象中定义:
object CanFold{
// default implementation for integers
implicit object CanFoldInts extends CanFold[Int, Long] {
def sum(acc:Long, e:Int) = acc + e
def zero = 0
}
}
使用时则和预期一样:
// notice how the result of summing Integers is a Long
sum(1 :: 2 :: 3 :: Nil)
//=> Long = 6
我不会骗你这些方式有多难学或者如何学,你最终会拉起头发,期盼这些都不再是问题的动态类型。然而你要分清 hard 和 complex 的区别,前者是相对的、主观上的,后者是绝对的、可观上的。
我们实现中的一个难题是如何为一个基本类型提供默认实现。这也是为什么在CanFold[-T,R]
的定义中我们将类型参数 T 设为逆变(contravariant)。逆变性代表的意思是:
if B inherits from A (B <: A), then
CanFold[A, _] inherits from CanFold[B, _] (CanFold[A,_] <: CanFold[B,_])
这允许我们为任何 Traversable 定义一个 CanFold,该 CanFold 可以支持任何 Seq/Vector/List 等等。
implicit object CanFoldSeqs extends CanFold[Traversable[_], Traversable[_]] {
def sum(x:Travrsable[_], y:Travsesable[_]) = x ++ y
def zero = Traversable()
}
这可以将任何类型的Traversable
相加。问题是会在过程中丢失类型参数:
sum(List(1,3,4) :: List(4,5) :: Nil)
//=> Traversable[Any] = List(1,2,3,4,6)
为什么我会说它难的原因是在我把头发拉出来之后,不得不去StackOverFlow请教怎么才能够返回一个Traversable[Int]
。因此,你可以使用一个隐式的def
替换之前的具体隐式对象,来帮助编译器识别容器中嵌入的类型:
implicit def CanFoldSeqs[A] = new CanFold[Traversable[A], Traversable[A]] {
def sum(x: Traversable[A], y:Traversable[A]) = x ++ y
def zero = Traversable()
}
sum(List(1,2,3) :: List(4,5) :: Nil)
//=> Traversable[Int] = List(1,2,3,4,5)
Implicit 比眼见的要灵活。显然编译器同样能够使用返回你需要的实例的函数,而不是具体的实例。作为一个旁注,我上面做的是很难的,甚至在 Haskell 中,因为子类化(sub-typing)是复杂的,但是 Clojure 中也很简单,因为你无需关注返回类型。
NOTE:上面的实现并不严谨,可能会发生冲突。
未完…
6.1.45 - 异步编程
翻译自:Asynchronous Programming and Scala
现在随处可见异步性的身影,同时它也被包括在并发性之内。这篇文章解释了什么是异步处理和它面临的挑战。
1. 介绍
它作为一个比多线程更加综合的概念,但是人们往往将二者混淆。如果需要一种关系来表示,可以是这样:
Multithreadiing <: Asynchrony
我们可以将异步计算表示成一个type
:
type Async[A] = (Try[A] => Unit) => Unit
如果这些Unit
返回类型看起来很丑陋,那是因为异步本身就是丑陋的。一个异步计算可以是网络中拥有如下特征的任何任务(Task)、线程、进程或节点:
- 在你程序的主流程之外执行,或者从调用者的角度来看,它并不在当前调用栈(call-stack)执行;
- 接收一个回调,并在结果处理完成之后调用;
- 它不能对结果在何时发送做出任何保证,甚至一点也不能保证一个结果会不被发送。
知道异步属于并发的范畴是很重要的,但多线程则没必要。要记得在 Javascript 中,大部分 I/O 操作都是异步的,甚至繁重的业务逻辑也被异步化处理(使用基于调度的 setTimeout)以保证接口是可响应的。但是并不涉及内核级别的多线程,Javascript 成了一个 N:1 的多线程化平台。
将异步化引入到程序中的同时也意味着你要面对并发问题,因为你无法知道异步计算具体何时会完成。因此,将多个异步计算的结果组合并在同一时间运行意味着你需要进行额外的同步操作,因此你也不能再依赖顺序。不能依赖顺序则会带来更多的不确定性。
维基百科:一个不确定的算法,相对于确定性的算法来说,尽管提供了相同的输入,可能会在不同的运行过程表现出不同的行为。一个并行算法因为竟态条件会在多次运行时以不同的方式执行。
敏锐的读者可以会注意到这些类型随处可见,基于用例和规约做一些调整:
- Observer pattern- Gang of Four
- Scala 中的
Future
,由其抽象方法onComplete
定义 - Java 中的ExecutorService.submit(Callable)
- Javascript 汇总的EventTarget.addEventListener
- 在 Akka 的 actor 中,尽管给出的回调被
sender()
替换 - Monix 中的Task.Async定义
- Monix 中的 Observable、Observer对
- Reactive Streams的详细说明
这些抽象有什么共同点呢?他们都提供了处理异步化的方式,其中一些更为优秀。
2. 巨大的错觉
我们喜欢假装能将函数的异步结果转换为同步:
def await[A](fa: Async[A]): A
问题的实质是我们不能假装这些异步处理与普通函数相同。如果你对此需要一刻,只需要了解一下为什么 CORBA 失败了。
针对异步处理,我们有以下非常常见的分布式计算谬误( fallacies of distributed computing):
- 网络是可靠的
- 延迟为 0
- 带宽是无限的
- 网络是完全的
- 拓扑结构不会发生变化
- 拥有一位管理员
- 传输消耗为 0
- 网络是同质的(homogeneous)
当然这些没有一条是真的。这意味着代码是按这些情形来编写的:极少的网络错误处理,忽略了网络延迟和丢包,忽略了带宽限制和随之而来的诸多不确定性。
人们尝试各种方式来对付这些问题:
- 回调,导出都是对调,甚至忽略了基本问题。像 Javascript 中发生的一样,这导致了众所周知的回调地域,以程序员的汗水和鲜血为代价,甚至怀疑人生;
- 阻塞线程,基于1:1 (kernel-level) multithreading平台
- first-class continuations,比如 Scheme 中实现的call/cc,在任何一点上保存执行状体并在程序的稍后点回到该点的能力;
- 来之 C# 的
async
/await
语言扩展,同样在scala-async、最新的ECMAScript也有实现; - 有运行时管理的Green threads,可能与 M:N multithreading组合,来模拟异步动作的阻塞。比如 Golang 和 Haskell;
- Erlang 和 Akka 中实现了actor model,或者 Clojure’s core.async 或 Golang 中的CSP;
- Monad 用于顺序和组合,比如 Haskell 的 Async类型和 IO类型的组合,或者: F# asynchronous workflows、 Scala’s Futures and Promises、Monix Task 或 Scalaz Task, 等等。
有这么多不同的实现,是因为没有任何一种是适用于通用目的的机制来处理异步。没有银弹的窘境在这里很切题,内存管理和并发成为我们开发者面临的巨大问题。
注意 - 个人观点和一些碎碎念:人们喜欢吹嘘像 Golang 这样的 M:N 平台,然而我更偏向于 1:1 的多线程平台,比如 JVM 或 .NET。
因为你可以在编程语言中基于 1:1 平台搭建 M:N 的多线程来提供足够的表现力(比如:Scala 的 Future、Task、Clojure 的 core.async 等等),但是一旦 M:N 的运行时不再适用于你的场景,你则无法修改或替换平台。是的,大多数 M:N 平台都被一种方式或另一种打破。
真正的学习所有可行方案或做出选择是很痛苦的,但总比做出无知的选择要痛苦的少,TOOWTDI(?) 和 “worse is better”在这种情况下害处则会更大。人们在解释难于学习一门新的或更有表现力的语言时,比如 Scala 或 Haskell,往往没有提到点上,因为如果他们不得不处理并发问题,这是学习一种新的编程语言将会使他们最小的问题。我了解到一些人因为并发问题而离开了软件行业。
3. 回调地狱
让我们创建一个仿造的例子来阐明我们的疑问。比如开启两个异步处理并将他们的结果结合在一起。
首先定义一个异步执行的函数:
import scala.concurrent.ExecutionContext.global
type Async[A] = (A => Unit) => Unit
def timeTwo(n: Int): Async[Int] = {
onFinish => {
global.execute(new Runnable{
def run():Unit = {
val result = n * 2
onFinish(result)
}
})
}
}
// Usage
timesTwo(20) { result => println(s"Result: $result")}
// => Result: 40
3.1. 顺序化(副作用炼狱)
让我们来结合两个异步结果,以平滑的顺序让一个在另一个发生之后执行:
def timesFour(n:Int):Async[Int] = {
onFinish => {
timesTwo(n){ a =>
timesTwo(n){ n =>
// Combining the two results
onFinish(a + b)
}
}
}
}
// Usage
timesFour(20) { result => println(s"Result: $result")}
// => Result: 80
看起来很简单,但是我们仅结合了两个结果,一个跟在另一个之后。
巨大的问题仍然是它触及到的所有异步化副作用。我们假设由于参数的缘故我们以一个纯函数开始:
def timesFour(n:Int):Int
但是这是你的企业架构师听说了这些企业 JavaBean 和 a lap dance(?),决定让你基于这些异步的timsTwo
函数。这时我们的timesFour
实现从一个精确的纯函数编程一个有副作用的函数。同时伴随一个并不成熟的Async
类型,我们需要面对在整个管道(pipeline)处理副作用。同时,阻塞结果也没有任何帮助,你只是隐藏了问题所在(第二节所述)。
但是等等,事情还会变得更糟。
3.2. 并行化(梦境中的不确定性)
第二个调用并不基于第一个调用,因此他们可以并行运行。在 JVM 我们可以并行运行 CPU-bound 的任务,但这并不适用于 Javascript,我们可以发起 Ajax 请求或于其他网页工作者(web worker)交谈。
不幸的是事情会变的有点复杂。首先使用所有自然(navie)方式来做都会非常错误:
// REALLY BAD SAMPLE
def timesFourInParallel(n:Int):Async[Int] = {
onFinish => {
var cacheA = 0
timesTwo(n) { a => cacheA = a}
timesTwo(n) { b =>
// Combing the two results
onFinish(cacheA + b)
}
}
}
timesFourInParallel(20) {result => println(s"Result: $result")}
// => Result: 80
timesFourInParallel(20) {result => println(s"Result: $result")}
// => Result: 40
这里的例子展示了实际中的不确定性。我们得不到顺序保证哪个会先结束,因此如果我们要并行执行,需要建模一个迷你状态机来进行同步。
首先,定义 ADT 来描述状态机:
// Define the state machine
sealed trait State
// Initial state
case object Start extends State
// We got a B, waiting for an A
final case class WaitForA(b:Int) extends State
// We got a A, waiting for a B
final case class WaitForB(a:Int) extends State
然后以异步的方式来演化这个状态机:
// BAD SAMPLE FOR THE JVM(only works for Javascript)
def timesFourInParallel(n:Int):Async[Int] = {
onFinish => {
var state:State = Start
timesTwo(n) { a =>
state match {
case Start => state = WaitForB(a)
case WaitForA(b) => onFinish(a + b)
case WaitForB(_) =>
// Can't be caught b/c async, hopefully it gets reported
throw new IllegalStateException(state.toString)
}
}
timesTwo(n) { b =>
state match {
case Start => state = WaitForA(b)
case WaitForB(a) => onFinish(a + b)
case WaitForA(_) =>
// Can't be caught b/c async, hopefully it gets reported
throw new IllegalStateException(state.toString)
}
}
}
}
为了更好的视觉化我们处理的问题,下图是状态机:
但是等等,我们还没结束,因为 JVM 拥有真实的 1:1 多线程,这表示我们要沉浸于可共享内存的并行化,因此对state
的访问必须是同步的。
一种方案是使用synchronized
块,或称为intrinsic块:
// We need a common reference to act as our monitor
val lockkk = new AnyRef
var state:State = Start
timeTwo(n) { a =>
lock.synchronized{
state match {
case Start =>
state = WaitForB(a)
case WaitForA(b) =>
onFinish(a + b)
case WaitForB(_) =>
// Can't be caught b/c async, hopefully it gets reported
throw new IllegalStateException(state.toString)
}
}
}
// ...
这种高级别的锁保护资源(eg. state)不被多线程并行访问。但我个人更倾向于避免这种高级别的锁,因为内核的调度器可以以任何原因冻结任何线程,包括持有锁的线程。冻结一个持有锁的线程意味着如果你想保证持续前进,而其他线程无法再继续前进,这是无阻塞(non-blocking)的逻辑则会更优先。
因此供替代的方式是使用一个AtomicReference,它会更适用这个场景:
// CORRECT VERSION FOR JVM
import scala.annitation.tailrec
import java.util.concurrent.atomic.AtomicReference
def timeFourInParallel(n:Int):Async[Int] = {
onFinish =>{
val state = new AtomicReference[State](Start)
@tailrec def onValueA(a:Int):Unit = {
state.get match{
case Start =>
if(!state.compareAndSet(Start, WaitForB(a))) onValue(a) // retry
case WaitForA(b) => onFinish(a + b)
case WaitForB(a) => throw new IllegalStateException(state.toString)
}
}
timesTwo(n)(onValueA)
@tailrec def onValueB(b:Int):Unit = {
state.get match {
case Start =>
if (!state.compareAndSet(Start, WaitForA(b)))
onValueB(b) // retry
case WaitForB(a) =>
onFinish(a + b)
case WaitForA(_) =>
// Can't be caught b/c async, hopefully it gets reported
throw new IllegalStateException(state.toString)
}
}
timesTwo(n)(onValueB)
}
}
PRO-TIP:如果你想编写 Javascript / Scala.js 的交叉编译代码,基于性能调整和用于操作原子引用的酷炫工具类,可以尝试Monix中的Atomic。
3.3. 递归(爆栈的愤怒)
如果我告诉你上面的onFinish
调用并非栈安全(stack-unsafe)的,同时当你调用它时也不会强制异步边界(asynchronous boundary),这时你的程序会因为一个StackOverflowError
爆炸,又该怎么办呢?
你不应该相信为的话。首先让我们找些乐子,同时以更通用的方式来定义上面的操作:
import scala.annotation.tailrec
import java.util.concurrent.atomic.AtomicReference
type Async[+A] = (A => Unit) => Unit
def mapBoth[A,B,R](fa:Async[A], fb:Async[b])(f:(A,B) => R): Async[R] = {
// Defines the state machine
sealed trait State[+A,+B]
// Initial state
case object Start extends State[Nothing, Nothing]
// We got a B, waiting for an A
final case class WaitForA[+B](b:B) extends State[Nothing, B]
// We got an A, waiting for a B
final case class WaitForB[+A](a:A) extends State[A, Nothing]
onFinish =>{
val state = new AtomicReference[State[A,B]](Start)
@tailrec def onVlueA(a:A):Uint = {
state.get match {
case Start =>
if(!state.compareAndSet(Start, WaitForB(a))) onValue(a) //retry
case WaitForA(b) => onFinish(f(a,b))
case WaitForB(a) =>
throw new IllegalStateException(state.toString)
}
}
@tailrec def onValueB(b:B):Unit = {
state.get match{
case Start =>
if (!state.compareAndSet(Start, WaitForA(b)))
onValueB(b) // retry
case WaitForB(a) => onFinissh(f(a,b))
case WaitForA(b) =>
throw new IllegalStateException(state.toString)
}
}
fa(onValueA)
fb(onValueB)
}
}
现在可以定义一个类似 Scala 中的Future.sequence
操作,因为我们的意志坚强,勇气不可估量…..
def sequence[A](list:List[Async[A]]):Async[List[A]] = {
@tailrec def loop(list:List[Async[A]], acc:Async[List[A]]): Async[List[A]] = {
list match {
case Nil =>
onFinish => acc(r => onFinish(r.reverse))
case x :: xs =>
val update = mapBoth(x, acc)(_ :: _)
loop(xs, update)
}
}
vall empty:Async[List[A]] = _(Nil)
loop(list, empty)
}
// Invocation
sequence(List(timesTwo(10), timesTwo(20), timesTwo(30))) {r =>
println(s"Result: $r")
}
// => Result: List(20, 40, 60)
你一定认为我们完成了?
val list = 0.until(10000).map(timesTwo).toList
sequence(list)(r => println(s"Sum: ${r.sum}"))
注意看这个壮丽的内存错误,它会让你的程序在生产环境崩溃,被认为是一个致命错误,因此 Scala 的NonFatal
也捕捉不到:
java.lang.StackOverflowError
at java.util.concurrent.ForkJoinPool.externalPush(ForkJoinPool.java:2414)
at java.util.concurrent.ForkJoinPool.execute(ForkJoinPool.java:2630)
at scala.concurrent.impl.ExecutionContextImpl$$anon$3.execute(ExecutionContextImpl.scala:131)
at scala.concurrent.impl.ExecutionContextImpl.execute(ExecutionContextImpl.scala:20)
at .$anonfun$timesTwo$1(<pastie>:27)
at .$anonfun$timesTwo$1$adapted(<pastie>:26)
at .$anonfun$mapBoth$1(<pastie>:66)
at .$anonfun$mapBoth$1$adapted(<pastie>:40)
at .$anonfun$mapBoth$1(<pastie>:67)
at .$anonfun$mapBoth$1$adapted(<pastie>:40)
at .$anonfun$mapBoth$1(<pastie>:67)
at .$anonfun$mapBoth$1$adapted(<pastie>:40)
at .$anonfun$mapBoth$1(<pastie>:67)
如为所说,onFinish
作为一个没有强制异步边界的调用会引起栈溢出错误。在 Javascript 中可以通过调度setTimeout
来解决,而 JVM 则需要一个线程池或 Scala 的ExecutionContext
。
Are you feeling the fire yet? 🔥
4. Future & Promise
scala.concurrent.Future
描述了完整的异步求值计算,和我们上面用的Async
有点类似。
维基百科:Future 和 Promise 是在一些并发编程语言中用于异步程序执行的结构。它描述了一个对象,该对象看做是最初并不可知的结果的代理,通常因为该结果的值尚未计算完成。
作者的碎碎念:
docs.scala-lang.org
中关于 Futures and Promises是这样说的,“Future 提供了一个以并行方式执行多个操作的方法 -更加高效、无阻塞的方式。 ”这种说法容易产生误解,一个混淆的源头。
Future
描述的是异步化而非并行化。当然,可以以并行的方式来使用,但并不意味者仅用作并行(async != Parallelism),或适用于那些寻找充分利用 CPU 容量的人,使用Future
可以证明是昂贵和不明智的,因为在有些场景它会出现性能问题,参考本部分的第四小节。
Future
是一个定义了两种主要操作的接口,同时附带一些基于这些主要操作实现的组合子:
import scala.util.Try
import scala.concurrent.ExecutionContext
trait Future[+T] {
// abstract
def value:Option[Try[T]]
// abstract
def onComplete(f:Try[T] => Unit)(implicit ec:ExecutionContext):Unit
// Transforms values
def map[U](f: T => U)(implicit ec:ExecutionContext):Future[U] = ???
// Sequencing
def flatMap[U](f: T => Future[U])(implicit ec:ExecutionContext):Future[U] = ???
}
Future
的特性:
- Eagerly evaluated(立即求值,strict and not lazy),意味着一旦调用者收到一个
Future
引用,无论异步处理要完成的是什么,它都已经开始了; - Memoized(记忆,cached),因为会被立即求值,它的行为更像一个正常值而不是一个函数,同时最终的结果会对所有的监听者(listener)可用。
value
的目的是用于返回记忆结果或尚未完成时返回None
。Goes 并未提到会返回一个不确定的值; - 流经(stream)单个结果时它会显示,因为是记忆化起了作用。因此当监听者注册了完成时,他们最多会被调用一次。
ExecutionContext
的解释性说明:
ExecutionContext
管理异步执行,也可以把它视作一个线程池,但它并非必须是一个线程池(因为异步不等于多线程或并发);onComplete
和我们上面定义的Async
类型一样,然而,它需要一个ExecutionContext
,因为所有的完成时回调需要以异步的方式调用;- 所有的组合子和工具类都基于
onComplete
实现,因此所有的组合子和工具类都要提供一个ExecutionContext
参数。
如果你不理解为什么这些签名都需要一个ExecutionContext
,回到上面的“递归”部分,直到你完全理解了。
4.1. 顺序化
让我们使用Future
重新定义“回调地狱”部分的函数:
import scala.concurrent.{Future, ExecutionContext}
def timesTwo(n:Int)(implicit ec:ExecutionContext):Future[Int] = Future(n * 2)
// Usage
{
import scala.concurrent.ExecutionContext.Implicits.global
timesTwo(20).onComplete{ result => println(s"Result: $result")}
// => Result: Success(40)
}
足够简单,Future.apply
创建器使用提供的ExecutionContext
执行给出的计算。因此在 JVM 上,假设global
执行上下文会运行在不同的线程上。
然后实现顺序化:
def timesFour(n:Int)(implicit ec:ExecutionContext):Future[Int] =
for{
a <- timesTwo(n)
b <- timesTwo(n)
} yield a + b
// Usage
{
import scala.concurrent.ExecutionContext.Implicits.global
timesFour(20).onComplete {result => println(s"Result: $result")}
// => Result: Success(80)
}
足够简单。这里的for 表达式魔法仅仅是会被转换为flatMap
和map
,在字面上等同于:
def timesFour(n:Int)(implicit ec:ExecutionContext):Future[Int]={
timesTwo(n).flatMap{ a=>
timesTwo(n).map{ b=>
a + b
}
}
}
如果你在项目中导入了scala-async,可以像下面这样实现:
import scala.async.Async.{async, await}
def timesFour(n:Int)(implicit ec:ExecutionContext):Future[Int]={
async{
val a = await(timesTwo(n))
val b = await(timesTwo(n))
a + b
}
}
扩展库scala-async
由 macros 驱动,并将你的代码转换为flatMap
和map
调用。因此,await
并不会阻塞线程,尽管它带来了这种错觉。
这些看起来确实不错,不幸的是拥有很多限制。当你的await
处于匿名函数之内时,库将无法“重写”你的代码,不幸的是 Scala 代码中到处都是这种表达式。这将不会工作:
// BAD SAMPLE
def sum(list:List[Future[Int]])(implicit ec:ExecutionContext):Future[Int] = {
async{
var sum = 0
// Nope, not going to work because "for" is translated to "foreach"
for(f <- list){
sum += await(f)
}
}
}
这种方式带来了拥有first-class continuations的幻觉,但是这种扩展并非一等类,仅仅是作为由编译器管理的重写代码。使得,这种约束在 C# 和 ECMAScript 中却应用的很好,因为async
代码并不严重依赖于函数式。
还记得我前面的碎碎念中提到的没有银弹?
4.2. 并行化
像先前的例子中展示的,这两个函数互相独立,因此我们可以并行调用他们。使用Future
则会更加简单,尽管求值语义对于新手来说会有点迷惑:
def timesFourInParallel(n:Int)(implicit ec:ExecutionContext):Future[Int] = {
// Future is eagerly evaluated, so this will trigger the
// execution of both before the composition happens.
val fa = timesTwo(n)
val fb = timesTwo(n)
for{
a <- fa
b <- fb
} yield a + b
// fa.flatMap(a => fb.map(b => a + b))
}
这会有点迷惑,领新手措手不及。因为在这种执行模型中,为了以并行的方式执行,你需要在组合发生之前初始化这些Future
引用。
一种可替代的方式是使用Future.sequence
,可以用于任意集合:
def timesFourInParallel(n:Int)(implicit ec:ExecutionContext):Future[Int] =
Future.sequence(timesTwo(n) :: timesTwo(n) :: Nil).map(_.sum)
这种用法估计也会让新手吃惊,因为这些Future
仅会当传入sequence
的集合是精确的时候才会以并行的方式执行,不像 Scala 的Stream
或Iterator
。显然这个名字是个误称。
4.3. 递归
Future
类型对于递归操作是绝对安全的,因为信心在于执行回调的ExecutionContext
。因此重试前面的例子:
def mapBoth[A,B,R](fa:Future[A], fb:Future[B])(f:(A,B) => R)(implicit ec:...) = {
for{
a <- fa
b <- fb
} yield f(a,b)
}
def sequence[A](list:List[Future[A]])(implicit ec:...):Future[List[A]] = {
val seed = Future.successful(List.empty[A])
list.foldLeft(seed)((acc,f) => for(1 <- accl; a <- f) yield a :: l).map(_.reverse)
}
// Invocation
{
import scala.concurrent.ExecutionContext.Implicits.global
sequence(List(timesTwo(10), timesTwo(20), times(30))).foreach(println)
// => List(20, 40, 60)
}
这次则不会出现StackOverflowError
:
val list = 0.until(10000).map(timesTwo).toList
sequence(list).foreach(r => println(s"Sum: ${r.sum}""))
4.4. 性能代价
Future
的麻烦是每次调用onComplete
都会使用一个ExecutionContext
来执行,通常这意味着一个Runnable
被发送到了线程池,像这样分支(fork)一个逻辑线程。如果你拥有 CPU 绑定的任务,这种实现细节对性能来说是一种灾难,因为跳跃的线程意味着 context switches,同时会带来 CPU 的cache locality被摧毁。当然,该实现拥有确定性的优化,比如flatMap
的实现中使用一个内部的蹦床形式的(trampolined?)执行上线文,为了避免在链接这些内部回调时进行分支,但是这还不够并且基准测试也不会说谎。
同时基于它的记忆化,在完成之上,实现会强制每个生产者执行一个AtomicReference.compareAndSet
,在每个Future
完成之前又会为每个注册的监听者加上一个compareAndSet
。这些调用是十分昂贵的,所有这些都是为了记忆化以便在多个线程之间能够良好运行。
换句话说,如果你想让你的 CPU 绑定任务能够充分利用 CPU,这时使用Future
和Promise
不是一个好注意。
如果你想对比 Scala 的Future
和Task
实现,可以看一下相关benchmark:
[info] Benchmark (size) Mode Cnt Score Error Units
[info] FlatMap.fs2Apply 10000 thrpt 20 291.459 ± 6.321 ops/s
[info] FlatMap.fs2Delay 10000 thrpt 20 2606.864 ± 26.442 ops/s
[info] FlatMap.fs2Now 10000 thrpt 20 3867.300 ± 541.241 ops/s
[info] FlatMap.futureApply 10000 thrpt 20 212.691 ± 9.508 ops/s
[info] FlatMap.futureSuccessful 10000 thrpt 20 418.736 ± 29.121 ops/s
[info] FlatMap.futureTrampolineEc 10000 thrpt 20 423.647 ± 8.543 ops/s
[info] FlatMap.monixApply 10000 thrpt 20 399.916 ± 15.858 ops/s
[info] FlatMap.monixDelay 10000 thrpt 20 4994.156 ± 40.014 ops/s
[info] FlatMap.monixNow 10000 thrpt 20 6253.182 ± 53.388 ops/s
[info] FlatMap.scalazApply 10000 thrpt 20 188.387 ± 2.989 ops/s
[info] FlatMap.scalazDelay 10000 thrpt 20 1794.680 ± 24.173 ops/s
[info] FlatMap.scalazNow 10000 thrpt 20 2041.300 ± 128.729 ops/s
可以看到 Monix Task在 CPU 绑定的任务上击败了Future
。
注意:这些基准测试是有局限的,仍然有一些用例中
Future
会更快(eg. Monix Observer使用Future
用做背压)并且性能通常并不相关,比如执行 I/O,即那些吞吐并非 CPU 绑定的场景。
5. Task,Scala 的 IO Monad
Task
是一种用于控制可惰性、可异步计算的数据类型,可用于控制副作用、避免非确定性和回调地狱。
Monix 库从 Task in Scalaz 获得灵感,提供了一种非常精致的 Task 实现。相同的概念,但实现不同。
Task
类型同样汲取了来自 Haskell’s IO monad 的灵感,而作者认为这是真正的 ScalaIO
类型。该问题存在争论,因为 Scalaz 同样暴漏了一个仅用于处理异步计算的
IO
类型。Scalaz 的IO
并非异步,这表示它的描述并不完整,因为在 JVM 之上你必须以某种方式表示异步计算。另一方面,在 Haskell 中的你拥有转换成IO
类型的Async
类型,或许这是由运行时管理的(green-threads and all)。在 JVM 之上的 Scalaz 实现中,我们无法使用
IO
在求值过程中以不阻塞线程的方式来表达异步计算,这是要避免的,因为阻塞线程则意味着倾向于错误。
总的来说,Task
类型:
- 建模惰性、异步求值
- 建模一个生产者向一个或多个消费者仅发送一个值
- 它是惰性求值,因此对比
Future
它并不会触发执行,或在runAsync
之前都不会有任何效果 - 不会被求值记忆化(memoized),但是 Monix 的
Task
可以 - 无需再另一个逻辑线程执行
而 Monix 中的实现拥有更多特别之处:
- 允许取消一个运行中的计算
- 在其实现中永远不会阻塞任何线程
- 没有暴露任何可以阻塞线程的 API 调用
- 所有异步操作都是栈安全的(stack safe)
Task
在设计上的可视化表示:
Eager | Lazy | |
---|---|---|
Synchronous | A | () => A |
Coeval[A], IO[A] | ||
Asynchronous | (A => Unit) => Unit | (A => Unit) => Unit |
Future[A] | Task[A] |
5.1. 顺序化
使用Task
重新定义第三节中的函数:
import monix.eval.Task
def timesTwo(n:Int):Task[Int] = Task(n * 2)
// Usage
{
// Our ExecutionContext needed on evaluation
import scala.concurrent.Scheduler.Implicits.global
timesTwo(20).foreach{ result => println(s"Result: $result")}
// => Result: 40
}
代码看起来和第四节中Future
的版本一样,唯一的区别是新的timesTwo
函数不再接受ExecutionContext
作为参数。这是因为Task
引用是惰性的,和函数类似,因此在调用强制求值发生的foreach
之前什么都不会打印。我们需要的是一个 Scheduler,这是 Monix 中增强的ExecutionContext
。
现在实现 3.1 节中的顺序化:
def timesFour(n:Int):Task[Int] = {
for{
a <- timesTwo(n)
b <- timesTwo(n)
} yield a + b
}
// Usage
{
import scala.concurrent.Scheduler.Implicits.global
timesFour(20).foreach{ result => println(s"Result: $result")}
// => Result: 80
}
同样是和Future
类型一样,for 表达式魔法仍然是被 Scala 编译器转换成flatMap
和map
调用,字面值等同于:
def timesFour(n:Int):Task[Int] = {
timesTwo(n).flatMap{ a=>
timesTwo(n).map{ b=>
a + b
}
}
}
5.2. 并行化
Task
的并行化比Future
要好的多,因为Task
在分支 task 时支持细粒度控制,当在当前线程和调用栈执行转换(eg. map/flatMap)时,局域性的缓存保留和避免上下文切换则等同于顺序执行。
但是首先,转换成Future
的形式并不能正常工作:
// BAD SAMPLE, 为了达成并行,这实质上会是顺序化
def timesFour(n:Int):Task[Int] = {
// 并不会触发执行,因为 Task 是惰性的
val fa = timesTwo(n)
val fb = timesTwo(n)
// 因为惰性的缘故求值会是顺序化
for{
a <- fa
b <- fb
} yield a + b
}
想要达到并行化,必须显示指定:
def timesFour(n:Int):Task[Int] =
Task.mapBoth(timesTwo(n), timesTwo(n))(_ + _)
是不是mapBoth
看起来很熟悉?如果这两个任务在执行时分支线程,mapBoth
会同时启动两者,从而达到并行化。
5.3. 递归
Task
支持递归,栈安全且十分有效,这是基于其内部的 trampoline。你可以查看这篇 Rúnar Bjarnason 的论文 Stackless Scala with Free Monads 来了解其为何如此有效。
其sequence
实现与Future
非常相似,只不过你会在sequence
的签名中发现其惰性化:
def sequence[A](list:List[Task[A]]): Task[List[A]] = {
val seed = Task.now(List.empty[A])
list.foldLeft(seed)((acc, f) => for{
l <- acc
a <- f
} yield a :: l).map(_.reverse)
}
// Invocation
{
import monix.execution.Scheduler.Implicits.global
sequence(List(timesTwo(10), timesTwo(20), timesTwo(30))).foreach(println)
// => List(20, 40, 60)
}
6. 函数式编程和 Type-class
当你使用这些众所周知的函数时,比如:map
,flatMap
和mapBoth
,我们不再关心这一切的背后是一个(A => Unit) => Unit
,因为这些函数会假设为合法、纯净、透明的。这意味着我们可以脱离其上下文来推导它们的结果。
这是 Haskell 中IO
的伟大成就。Haskell 不会“伪造(fake)”副作用,因为返回IO
函数字面意义上是纯的,副作用会被推迟到其所属程序的边缘。我们可以同样看待Task
。不过,对于Future
急切的本性(立即计算)来说会更加复杂,但是使用Future
也不是一个坏的选择。
那么我们能够基于这些类型,比如Task
、Future
、Coeval
、Eval
、IO
、Id
、Observable
或者一些其他的类型,来创建接口或抽象吗?
当然我们可以,我们已经见过使用flatMap
来描述顺序化,使用mapBoth
来描述并行化。但是我们不能使用经典的 OOP 来描述他们,其中一个原因是Functional
参数的协变和逆变规则,这会导致我们在flatMap
中失去类型信息(除非你使用 F-bounded 泛型类型,这样更适合实现复用或其他 OOP 语言不可用时),同时我们要描述一个数据构造器,他不能是一个方法(比如 OOP 的子类应用到实例而不是整个类)。
幸运的是,Scala 是极少数支持高阶类型且能够编码类型类(type-class)的语言,这意味着我们拥有了从 Haskell 端口概念所需要的一切。
作者的碎碎念:
Monad
,Applicative
,Functor
,这些可怕的单词让那些并不忠实的人心生畏惧,导致他们认为关注的是一些与现实世界脱轨的“学术”概念,书籍作者要避免大量使用这些单词,包括 Scala 的 API 文档及官方教程。但这是给 Scala 和其用户帮倒忙。其他语言中仅有的设计模式主要是难于解释,因为这些不能用类型来表示。你可以用一只手输出拥有这种表达能力的语言。而用户痛苦的是当他们遇到麻烦时不知如何从现有的文献中搜索相关主题,缺失对正确术语的学习。
我也觉得这是一味地反智主义(anti-intellectualism),向往常一样对无知的恐惧。你可以发现这些都来自真正做他们的人,但我们无一幸免。比如 Java 中的
Optional
类型违反了 Functor 的规则(e.g.opt.map(f).map(g) != opt.map(f andThen g)
),Swift 中愚蠢的5 == Some(5)
,可以幸运的向人们解释Some(null)
实际上与null
的意义相同,是AnyRef
的有效值,因为不然的话你不能定义一个Applicative[Option]
。
6.1. Monad(顺序化和递归)
本文并不会解释 Monad。另外有一篇文章来专门解释它。但如果你想建立一个直觉,这里有另外一个:在数据类型的上下文中,比如Future
或Task
,Monads 用于描述操作的顺序,并且是保证顺序的唯一有效方法。
“Observation: programmers doing concurrency with imperative languages are tripped by the unchallenged belief that “;” defines sequencing.” – Aleksey Shipilëv
Scala 中一个简单的编码Monad
的例子:
// we shouldn't need to do this
import scala.language.higherKinds
trait Monad[F[_]]{
/** Constructor (said to lift a value `A` in the `F[A]`
* monadic context). Also part of `Applicative`, see below.
*/
def pure[A](a: A): F[A]
/** FTW */
def flatMap[A,B](fa: F[A])(f: A => F[B]): F[B]
}
同时提供一个Future
实现:
import scala.concurrent._
// Supplying an instance for Future isn't clean, ExecutionContext needed
class FutureMonad(implicit ec: ExecutionContext)
extends Monad[Future] {
def pure[A](a: A): Future[A] =
Future.successful(a)
def flatMap[A,B](fa: Future[A])(f: A => Future[B]): Future[B] =
fa.flatMap(f)
}
object FutureMonad {
implicit def instance(implicit ec: ExecutionContext): FutureMonad =
new FutureMonad
}
这真是一个强力的东西。现在我们可以描述一个用于Task
、Future
、IO
的泛型函数,无论如何,如果flatMap
是栈安全的话这将非常伟大:
/** Calculates the N-th number in a Fibonacci series. */
def fib[F[_]](n: Int)(implicit F: Monad[F]): F[BigInt] = {
def loop(n: Int, a: BigInt, b: BigInt): F[BigInt] =
F.flatMap(F.pure(n)) { n =>
if (n <= 1) F.pure(b)
else loop(n - 1, b, a + b)
}
loop(n, BigInt(0), BigInt(1))
}
// Usage:
{
// Needed in scope
import FutureMonad.instance
import scala.concurrent.ExecutionContext.Implicits.global
// Invocation
fib[Future](40).foreach(r => println(s"Result: $r"))
//=> Result: 102334155
}
注意:这只是一个玩具样例,严肃的实现参考 Typelevel’s Cats。
6.2. Applicative(并行化)
Monad 定义了操作的顺序化,但是有时我们想组合那些互不依赖的计算的结果,他们可以同时求值,或者并行化。还有一个例子可以证明 Applicative 比 Monad 更加可组合。
现在扩展我们的Typeclassopedia:
trait Functor[F[_]] {
/** I hope we are all familiar with this one. */
def map[A,B](fa: F[A])(f: A => B): F[B]
}
trait Applicative[F[_]] extends Functor[F] {
/** Constructor (lifts a value `A` in the `F[A]` applicative context). */
def pure[A](a: A): F[A]
/** Maps over two references at the same time.
*
* In other implementations the applicative operation is `ap`,
* but `map2` is easier to understand.
*/
def map2[A,B,R](fa: F[A], fb: F[B])(f: (A,B) => R): F[R]
}
trait Monad[F[_]] extends Applicative[F] {
def flatMap[A,B](fa: F[A])(f: A => F[B]): F[B]
}
然后扩展我们的Future
实现:
// Supplying an instance for Future isn't clean, ExecutionContext needed
class FutureMonad(implicit ec: ExecutionContext)
extends Monad[Future] {
def pure[A](a: A): Future[A] =
Future.successful(a)
def flatMap[A,B](fa: Future[A])(f: A => Future[B]): Future[B] =
fa.flatMap(f)
def map2[A,B,R](fa: Future[A], fb: Future[B])(f: (A,B) => R): Future[R] =
// For Future there's no point in supplying an implementation that's
// not based on flatMap, but that's not the case for Task ;-)
for (a <- fa; b <- fb) yield f(a,b)
}
object FutureMonad {
implicit def instance(implicit ec: ExecutionContext): FutureMonad =
new FutureMonad
}
现在可以基于Applicative
定义泛型函数并用于Future
:
def sequence[F[_], A](list: List[F[A]])
(implicit F: Applicative[F]): F[List[A]] = {
val seed = F.pure(List.empty[A])
val r = list.foldLeft(seed)((acc,e) => F.map2(acc,e)((l,a) => a :: l))
F.map(r)(_.reverse)
}
注意:同样是一个玩具样例,参考Typelevel’s Cats。
6.3. 为异步求值定义类型类?
上面的部分缺少的是真正触发计算并获得结果值。思考 Scala 的Future
,我们想要一种方式来抽象onComplete
。想象 Monix 中我们想要抽象runAsync
。想象 Haskell 和 Scalaz 的IO
,我们想要抽象unsafePerformIO
。
trait Effect[F[_]] extends Monad[F] {
def unsafeRunAsync[A](fa: F[A])(cb: Try[A] => Unit): Unit
}
这看起来向我们初始的Async
类型,跟Future.onComplete
、Task.runAsync
、IO.unsafePerformIO
很相似。
然而,它并非真正的类型类:
- 它是非法的,然而也不足以取消他的资格(after all, useful lawless type-classes like
Show
exist),但最大的问题是…. - 如 3.3 中所示,为了避免
StackOverflowError
,我们需要某种执行上下文或线程池来执行异步任务且不会导致栈溢出。
但是这样的执行上下文根据实现不同而不同。Java 使用 Executor,Scala 的Future
时候使用ExecutionContext
,Monix 使用增强自ExecutionContext
的Scheduler
。FS2 和 Scalaz 使用 包装自Executor
的Strategy
来 fork 线程,但是调用unsafePerformIO
或runAsync
时并不会注入上下文(这也是为什么很多 Scalaz 组合子实际上并不安全)。
我们可以采取与Future
同样的策略,从作用域中获取一个implicit whatever: Context
来创建实例。但这有点尴尬且效率低下。这也意味这不使用上下文的情况下我们仅仅不能为Effect.unsafePerformIO
定义flatMap
。如果我们不能这样做,这时也不会继承自Monad
,因为它没有必要是一个Monad
。
因此为个人也不是很确定 - 如果你对 Cats 有什么好的建议,我愿洗耳恭听。
我希望你喜欢这个思想试验,设计东西是很有趣的。
7. 选择正确的工具
一些抽象较其他会更为通用,为个人认为"为工作选择正确的工具"这样的口头禅是过度保护可怜人的选择。
为此,Rúnar Bjarnason 有一个非常有意思的表述,名为 Constraints Liberate, ,而 Liberties Constrain 最终真正道出了并发抽象的本质。
如前所述,并没有银弹能够通用的解决并发问题。抽象的层次越高,能解决的问题视野也就越少。但是更少的视野和其强大的能力,模型也会更加简单更加可组合。比如 Scala 社区中很多开发者滥用 Akka Actor,这个库在不被误用时是很伟大的。就像能够使用Future
或Task
时不用 Actor。同样在其他的抽象中,比如 Monix 或 ReactiveX 中的Observable
抽象。
同样用心学习下面两条规则:
- 避免使用回调、线程和锁,它们易错且不可组合;
- 避免像瘟疫一样并发(avoid concurrency like the plague it is)
最后让我告诉你,并发专家首先是避免并发的专家….
6.1.46 - 战略Scala风格
Strategic Scala Style: Practical Type Safety一文的中文翻译,点击查看原文。
这篇文章探索了如何利用Scala的类型安全特性来避免在使用Scala编写程序时出错。
虽然Scala有一个编译器帮助你捕捉错误,或者称它为类型安全,实际上有一个全方位的方式让你能够编写更多或更少安全性的Scala。我们将会讨论多种方式让你把你的代码转变为更加安全的系列。我们将有意识地忽略那些绝对的证明和逻辑事物的理论方面,而更专注于实践的方式来使编译器帮你不做更多隐藏的BUG。
Type Safety
涉及面很广。你需要投入整个生涯来学习Haskell的类型系统或Scala变成语言,并且需要花费另一个周期来学习Haskell运行时的的类型实现或Java虚拟机。这里将会忽略这两个内容。
取而代之,本文将会以实践的方式来介绍如何以“类型安全”的方式来使用Scala,编译器的知识可以让你减轻错误的后果,并且能够把这些错误编程简单的、能够在开发期间完成修改的错误,以此来提高你代码的安全性。一个有经验的Scala开发者会发现本文的“基础“和”显而易见“,但是任何新手将会期望将这些技术添加到工具箱来解决Scala中遇到的问题。
这里的每一种技术描述都会做一些权衡:冗长、复杂、额外的类文件、低劣的运行时性能。本文我们会忽略这些问题而把他们当做是相当完美的。本文只会列举可能的情况,而不会去深入讨论有些取舍是否值得。而且,本文仅作用域纯粹的Scala,不会涉及类似Scalaz、Cats、Shapeless这样的第三方扩展库。如果属性该类技术的人愿意去写的话,这些库应该有他们自己的风格或技术并在他们自己的文章中展示。
原理
在我们讨论具体的技术和围绕类型安全的取舍之前,停下来思考一下问题本质是有意义的。什么是一个类型?”安全“一词有意味这什么?
什么是类型(Type)
在一个程序的运行时,你对一个值的了解就是Type。
基本上所有的编程语言都有一个不同的类型系统。一些有泛型,一些有具体化的泛型。例如Java,有具体化的泛型,一个值的类型总是与一个类护着接口符合并能在运行时检查。其他的,比如C就不能。Python这样的动态语言没有静态类型,因此类型仅存在于运行时。
本文所讲的Scala语言,拥有它自己的相对复杂的特定的类型系统。也有一些尝试将其正式化,比如Dotty项目。本文将会忽略这些。
本文中将会依照上面的释义。在一个程序的运行时,你对一个值的了解就是Type。比如:
- 一个
Int
定义为包含了从-2147483648
到2147483647
的32位整数; - 一个
Option[T]
定义为要么是一个Some[T]
,要么是一个None
; - 一个
CharSequence
定义为包含了一些Char
并支持我们调用.length、.chatAt、.subsequence
等方法,但是我并不知道它是一个String
、String
或别的什么。你并不知道它是否可变(mutable/immutable),它如何保存它的内容,或者性能规格如何; - 一个
String
同样拥有一些字符,但你知道它是不可变的,使用一个内部的字符数组来保存内容,通过索引来查找Char
的时间复杂度我O(1)
。
一个值的类似告诉你某些东西是什么和它不能是什么。Option[String]
可能是Some
或None
,但它绝不会是一个32位的整数!在Scala中,这些是你不需要检查的:这些是你可以依赖的正确事物,编译器会在编译器为你检查。
这个知识点确切的指出了一个值的类型包含了什么。
类型不是什么
A Class
这个定义中,类型不是一个类(Class)。对,在基于JVM之上的Java和Scala,所有的类型被描述为类(class)或接口(interface),这在Scala.js
中并不有效,你可以定义一个假的类型(trait
继承自js.Any
),在编译之后不会留下任何残留,或在其他编程语言中。
虽然类型被类所支持是一个事实,这只是一个实现细节并且与本位无关。
Scala类型系统
这里讨论的类型概念是含糊的、宽泛的,基于所有语言而不仅仅是Scala。Scala自身的类型系统是复杂的,有类,抽象类型,细化类型,特质,类型界限,上下文界限,和一些其他更加晦涩的东西。
纵观本文,这些细节都是为了服务一个目的:在你程序中将你对值的了解描述给编译器,然后让他检查你现在做的,与你说的和想要做的是否一致。
什么是安全
类型安全意味着一点你出错,后果的影响比较小。
相比类型,可能有其他更多对”安全“的定义。上面的定义比类型有宽泛:它作用于安全实践、隔离、分布式系统中的健壮性和恢复性,还有其他一些事情。
人们会犯各种错误:代码排版、可怜的负荷估算、复制粘贴错误的命令。当你出错时发生了什么?
- 你会看到编辑器中红色表示然后5s内修复了它;
- 你想完整的编译,花费了10s,然后修复了它;
- 你运行测试用例,花费了10s,然后修复了它;
- 你部署了这个错误,然后几个小时以后才发现它,然后修复后再部署;
- 你部署了这个错误,这个错误几周内都没有被提醒,但是在提醒后修复它需要花费几周的时间来清理它遗留的错误数据;
- 你部署了错误,然后发现你的公司45分钟后破产了。你的工作、团队、你的组织和计划,都没了。
忽略类型和运行时的概念,很明显不同的环境有不同的安全级别。只要捕捉够早或异步debug,甚至运行时错误都会造成更小的影响,Python中的习惯,当不匹配时在运行时抛出TypeError
,似乎必Php中当不匹配时进行强制执行要安全的多。
什么是类型安全
类型安全是利用我们在运行时对一个值的了解来尽量降低大部分错误的后果。
比如,一个”较小的后果“可以被看做是开发期间就能够发现的易于理解的编译错误,然后花费30s完成修复。
这个定义直接准照上面我们对”类型“和”安全“的定义。这比很多类型安全的定义都要宽泛,特别是:
- 类型安全不是编写Haskell,这个概念要更为宽泛;
- 类型安全不是避免可变状态,除非它有助于我们的目标;
- 类型安全不是一个绝对的目标,是一个尽量和优化的属性;
- 类型安全对每个人都不同;如果不同的人犯不同的错,这些错都有不同的危害级别,他们需要完善不同的事情来尽力优化这些错误的危害;
- 如果错误消息不可理解并难于解决,编译器甚至也会有严重的影响。一个能在10s内修复的优雅的编译器消息和一个需要半小时才能理解的巨大的编译器错误消息是完全不同的。
类型安全的定义多种多样;如果你问一个C++开发者、一个Python的网站开发者或者一个研究编程语言的教授,他们会给出各自截然不同的定义。本文中,我们会使用上面宽泛的定义。
Scalazzi Scala
很多人思考了很多关于若何以类型安全的方式编写Scala。所谓的Scala语言的“Scalazzi Subset”是其中一个哲学:
当然这些指导方针有很多地方需要讨论,我们会花一些时间浏览其中一部分,同时我发现了一些有意思的地方:
- 避免空值
- 避免异常
- 避免副作用
避免空值
使用null
来描述一些空的、未初始化或不可用的值会很吸引人,比如,一个未初始化的值:
class Foo{
val name: String = null // override this with something useful if you want
}
或者是传入到函数的一个”没有值“的参数:
def listFiles(path: String) = ...
listFiles("/usr/local/bin/")
listFiles(null) // no argument, default to current working directory
“Scalazzi Scala”告诉我们要避免这样做,并给了一个很好的理由:
null
会出现在程序的各个角落,任何一个变量或值,没有办法控制那些变量是null
而哪些不是;null
在你的程序中到处传播:可以将null
传入函数,赋给其他变量,或存入集合。
最终,这意味着null
值会在原理他们初始化的地方一起错误,然后就很难被追踪。当有些地方被NullPointerException
终止,你需要首先找到那些可疑的变量(每行代码或许会有很多变量),然后进行追踪,比如函数的传入和传出,集合的存储和检索,直到你找到null
的来源。
在Python这样的动态语言中,这种类型的错误值传播很普通,寻常不会贯穿整个程序来进行追踪,然后到处添加print
语句,尝试去找到初始值的来源。通常有人简单的将参数混入到一个函数,传入一个user_id
而不是user_email
或其他不重要的值,但是会照成很大的后果来追踪和调试。
在一个带有类型检查器的编译型语言,比如Scala,许多这样的错误会在你运行编译器之前就能捕获:在于其为一个String
的地方传入Int
或Seq[Double]
会得到一个类型错误。并不是所有的错误都会被捕捉,但是会捕捉大部分严重的错误。预期为不是null
的地方传入一个null
除外。
这里有一些null
的备选方案:
如果想要表达一个可能存在的值,一个函数参数或者一个需要被覆写的类属性:
class Foo{
val name: String = null // override this with something useful if you want
}
考虑使用Option[T]
替换:
class Foo{
val name: Option[String] = None // override this with something useful if you want
}
"foo"
和null
取而代之为Some("foo")
和None
看起来很相似,但是这样做的话所有人都会知道它可能为None
,而不会像如果将一个Some[String]
放到预期为String
的地方然后跟null
得到一个编译错误。
如果使用null
作为一个未初始化var
值的占位符:
def findHaoyiEmail(items: Seq[(String, String)]) = {
var email: String = null // override this with something useful if you want
for((name, value) <- items){
if (name == "Haoyi") email = value
}
if (email == null) email = "Not Found"
doSomething(email)
}
考虑替换为val
并一次完成声明和初始化:
def findHaoyiEmail(items: Seq[(String, String)]) = {
val email =
items.collect{case (name, value) if name == "Haoyi" => value}
.headOption
.getOrElse("Not Found")
doSomething(email)
如果你不能够在一行代码内初始化email
的值,Scala支持你将片段的代码放到柯里化的{}
中同时将其赋给一个val
, 因此, 大部分你需要稍后初始化为var
的代码都可以放到一个{}
中然后声明并初始化为一个{}
.
def findHaoyiEmail(items: Seq[(String, String)]) = {
val email = {
...
}
doSomething(email)
}
这样做的话,我们就能控制email
永远不会是一个null
.
通过简单的在程序中避免null
,你并没有改变理论状况, 理论上有人可以传入一个null
,你会在同样的地方追踪那些难于调试的问题.但是你改变了实践环境: 不会花费更少的实践来追踪难于调试的NullPointerException
问题.
避免异常
异常基本上是一段代码的额外返回值.任何你写的代码都可以通过return
关键字以正常的方式返回,或者简单的返回最后一个代码块的表达式,或者是抛出一个异常. 这些异常会包含任意的数据.
虽然一些其他语言比如Java,用编译器来检查你可以确定的能够抛出的异常,它的"受检异常"也并不是很成功: 它的不便之处在于必须要声明你抛出的需要检查的异常,以至于人们只是给他们的方法都使用一个throws Exception
,或者捕获受检异常后重新作为未检查的运行时异常抛出.后期的语言比如C#和Scala完全抛弃了这种受检异常的思想.
为什么你不可以使用异常:
- 你没有办法静态的知道一段代码都能抛出哪些种类的异常. 即你不知道是否处理了代码所有可能的返回类型.
- 你抛出的异常的注解是可选的,and trivially fall out of sync with reality as development happens and refactorings occur.
- 他们是传播的,so even if a library you’re using has gone through the discipline of annotating all its methods with the exceptions they throw, the chances are in your own code you’ll get sloppy and won’t.
与其返回一个异常,在只有一种失败模式的函数中,你可以返回一个Option[T]
来表示结果,或者Either[T, V]
,再或者是你自己定义的密闭trait来表示有多重失败模式的返回结果.
sealed trait Result
case class Success(value: String) extends Result
case class InvalidInput(value: String, msg: String) extends Result
case class SubprocessFailed(returncode: Int, stderr: String) extends Result
case object TimedOut extends Result
def doThing(cmd: String): Result = ???
使用密闭trait方式,你可以更易于与用户沟通存在的准确错误,在每种场景可用的数据,同时当用户对doThing
的结果进行match
时,如果少了一个场景,编译器则会给出一个警告.
通常,你并不能去除所有异常:
- 任何非一般的程序都很难去列出它所有可能的失败模式
- 许多都是非常罕见的,你实际上是想捕获他们的大部分然后通过一些通用的方式处理,比如: 写入日志或上报,或重试逻辑,你甚至不知道是什么引起的
- 对这些罕见的错误模式,可以吧错误信息写入日志,然后进行详细的手动检查,这也你能做的最好方式了
然而,尽管有堆栈追踪(stack trace),找出这些预期之外异常的真正原因仍然要比使用Option[T]
在编译器就发现错误要花费的时间更多.
Scala编程中涉及的异常:
- NullPointerExceptions
- MatchError: 来自不健全的模式匹配
- IOExceptions: 来自文件系统的各种问题或网络错误
- ArithmeticException: 除0时的错误
- IndexOutOfBoundsException: 搞砸数组的时候
- IllegalArgumentException: 滥用第三方代码的时候
仍然还有更多,但是并不需要完全去管,尽量在代码中使用Option[T], Either[T, V], sealed trait
来使编译器能有更多的机会帮你进行错误检查.
避免副作用
至少在Scala中,编译器不会提供副作用的追踪.
下面是一个例子:
var result = 0
for (i <- 0 until 10){
result += 1
}
if (result > 10) result = result + 5
println(result) // 50
makeUseOfResult(result)
我们将value
初始化为一个占位值,然后利用副作用来修改result
的值,然后为makeUseOfResult
函数使用.
这里有很多地方会出错,你可能会意外的得到有一个突变:
var result = 0
for (i <- 0 until 10){
results += 1
}
println(result) // 45
makeUseOfResult(result) // getting invalid input!
这些可以看做是很明显的错误,但如果这个片段有1000行而不是10行,在重构中很容易出错.他以为着makeUseOfResult
得到一个无效的输入并处理错误.这里有另一个常见的错误模式:
var result = 0
foo()
for (i <- 0 until 10){
results += 1
}
if (result > 10) result = result + 5
println(result) // 50
makeUseOfResult(result)
...
def foo() = {
...
makeUseOfResult(result)
...
}
这里甚至在result
被初始化之前就开始使用它了.
下面的方式可以避免副作用:
val summed = (0 until 10).sum
val result = if (summed > 10) summed + 5 else summed
println(result) // 50
makeUseOfResult(result)
Scalazzi Scala的局限
下面的代码完全符合上面定义的Scalazzi Scala
,但会让人感到很乱:
def fibonacci(n: Double, count: Double = 0, chain: String = "1 1"): Int = {
if (count >= n - 2) chain.takeWhile(_ != ' ').length
else{
val parts = chain.split(" ", 3)
fibonacci(n, count+1, parts(0) + parts(1) + " " + chain)
}
}
for(i <- 0 until 10) println(fibonacci(i))
1
1
1
2
3
5
8
13
21
34
这个代码是正确的,完全遵守了"Scalazzi Scala"的指导方针:
- 没有Null
- 没有异常
- 没有
isInstanceOf
或asInstanceOf
- 没有副作用并且所有值是不可变的
- 没有
classOf
和getClass
- 没有反射
但是人们会认为他是可怕的不安全的代码,原因在于下面的"Structured Data".
结构化数据
并非所有的数据都有相同的"形状",如果一些数据包含(name, phone-number)
这样的对,有多重方式可以存储他们:
Array[Byte]
: 这是文件系统存储他们的方式,如果你把他们存到磁盘,这就是他们的形式.String
: 在编辑器中打开,会看到这样的形式.Seq[(String, String)]
Set[(String, String)]
Map[String, String]
这些都是有效的方式,如何来选择呢?
避免字符串有利于结构化数据
有时候会将数据存为String
,然后在使用时在使用切片取出其中的不同数据,这样做会带来意外的问题.
Encode Invariants in Types
自描述数据
避免整数枚举
val UNIT_TYPE_UNKNOWN = 0
val UNIT_TYPE_USERSPACEONUSE = 1
val UNIT_TYPE_OBJECTBOUNDINGBOX = 2
这个代码中有一些好处:
Int
类型消耗廉价,需要很少的内存来存储和传递- 避免各种数字这样的魔术代码到处都是,最终难以分辨
但是这种方式并不安全,更安全的方式会是这样:
sealed trait UnitType
object UnitType{
case object Unknown extends UnitType
case object UserSpaceOnUse extends UnitType
case object ObjectBoundingBox extends UnitType
}
或者:
// You can also make it take a `name: String` param to give it a nice toString
case class UnitType private ()
object UnitType{
val Unknown = new UnitType
val UserSpaceOnUse = new UnitType
val ObjectBoundingBox = new UnitType
}
这两种方式都是讲UnitType
标记为一个实际的值,而不会想仅仅一个数字一样能够修改.
避免字符串标记
val UNIT_TYPE_UNKNOWN = "unknown"
val UNIT_TYPE_USERSPACEONUSE = "user-space-on-use"
val UNIT_TYPE_OBJECTBOUNDINGBOX = "object-bounding-box"
这样做仍然不安全,可以对UNIT_TYPE
调用任何字符串的方法,并且能够使用任何字符串替换,更好的方式是这样:
sealed trait UnitType
object UnitType{
case object Unknown extends UnitType
case object UserSpaceOnUse extends UnitType
case object ObjectBoundingBox extends UnitType
}
// Or perhaps
class UnitType private ()
object UnitType{
val Unknown = new UnitType
val UserSpaceOnUse = new UnitType
val ObjectBoundingBox = new UnitType
}
包装整数ID
自增的ID经常是Int
或Long
,UUID可能是String
或java.util.UUID
,与Int
或Long
不同的是,ID都有一个唯一属性:
- 所有的算术运算一般都没有意义
- 不同的ID不能交换:比如一个
userId: Int
和一个函数def deploy(machineId: Int)
,deploy(userId)
这样的调用是不希望出现的
最好的方式是使用不同的类将这些ID进行包装:
case class UserId(id: Int)
case class MachineId(id: Int)
case class EventId(id: Int)
...
或者自定义类型:
type UID = Int
然后使用:
val userId: UID = 2
6.2 - Scala 函数式
6.2.1 - CH00-精要
函数
一个函数是一个从“领域”到“代码域”的映射。函数将领域中的每个元素与代码域中的对应元素进行联结。在 Scala 中,领域和代码域都可以表示为type
(类型)。
val square: Int => Int = x => x * x
square(2) // 4
高阶函数
高阶函数是 接收一个函数作为参数 或 返回一个函数作为结果 的函数。
trait List[A] {
def filter(f: A => Boolean): List[A]
}
这个例子中,函数filter
接收一个类型为A => Boolean
的函数作为参数。
组合子
函数组合子是同时接收、返回函数的高阶函数。
type Conf[A] = ConfigReader => A
def string(name:String):Conf[String] = _.readString(name)
def both(left:Conf[A], right:Conf[B]):Conf[(A, B)] = c => (left(c), right(c))
函数both
即为一个接收函数并返回函数的高阶函数。
多态函数
多态函数通常拥有一个或多个类型参数。Scala 本身对多态函数没有支持,但是可以通过特质的多态方法来实现。构造多态函数的特质通常拥有apply
方法,因此,可以像普通的函数应用语法一样使用:
case object identity {
def apply[T](value:T):T = value
}
identity(3) // 3
indentity("3") // "3"
这样,通过apply
方法的便利,就可以实现多态函数。
类型
一个类型是一组值的运行时描述。比如Int
表示从-2147483648
到2147483647
这样的一组整数集。值都拥有类型,或者说,每个值都表示一组值的一个成员。
2: Int
例子中,2
是Int
集合的一个成员,也即,2
的类型为Int
。
代数数据类型(ADT)
一个 ADT 是由产品和类型自合而成的类型。
乘积类型(product)
乘积类型是由两个或多个类型的笛卡尔积组合构造而成的。
type Point2D = (Int, Int)
例子中,一个二维点是一个数字与另一个数字的积。即每个该类型的值都有一个 x 轴坐标和 y 轴坐标。
样例类
在 Scala 中,样例类是更符合语言习惯的乘积类型表示。
case class Person(name:String, age:Int)
总和类型(sum)
总和类型通过两个或多个类型的不相交形式来定义。
type RequestResponse = Either[Error, HttpResponse]
例子中,定义的类型RequestResponse
是Error
和HttpResponse
的总和构成,一个RequestResponse
类型的值要么是一个 error,要么是一个 HTTP 响应。
密闭特质
在 Scala 中,密闭特质是更符合语言习惯的总和类型表示。
sealed trait AddressType
case object Home extends AddressType
case object Business extends AddressType
例子中,一个AddressType
要么是一个Home
,要么是一个Buiness
,而不能同时是两者。
子类型
如果A
是B
的子集,A
即为B
的子类型。在 Scala 中,A
必须继承自B
。编译器允许在任何需要的地方使用子类型。
sealed trait Shape{
def width:Int
def height:Int
}
case class Rectangle(corner:Point2D, width:Int, height:Int) extends Shape
超类型
如果B
是A
的子集,A
即为B
的超类型,B
必须继承自A
。编译器支持无论在何处定义子类型,均可以使用超类型。
同样还是上面的例子,Shape
即为Rectangle
的超类。
Universals
一个通用(universally)量化类型定义了一个“由一些任意类型参数化的类型”的类别。在 Scala 中,类型构造器(比如特质)和方法必须是通用量化类型,尽管方法并不拥有一个类型,它只是类型的一部分。
类型构造器
一个类型构造器是一个通用量化类型,用于构造类型。
sealed trait List[A]
case class Nil[A]() extends List[A]
case class Cons[A](head: A, List[A]) extends List[A]
例子中,List
是一个类型构造器,定义了一组List-
类似的类型。因此可以认为,List
是针对类型A
的通用类型量化。
高阶类型
类型级别函数(Type-Level)
类型构造器可以看做一个类型级别函数,它接收类型并返回类型。这种解释对于理解高阶类型很有用。
比如,List
是一个接收类型A
并返回类型List[A]
的类型级别函数。
类别(Kind)
类别可以认为是“类型的类型”。
*
:类型的类别,所有类型的集合。* => *
:类型级别函数的类别(接收一个类型,返回一个类型)。[*, *] => *
:接收两个类型并返回一个类型的类型级别函数 的类别。比如类型构造器Either
的类别是[*, *] => *
,Scala 语法表示成_[_, _]
。
可以与函数的类型进行对比:
A => B
,A => B => C
高阶类别(Higher-Order Kinds)
就像函数能够是高阶函数一样,类型构造器也可以是高阶的。Scala 中使用下划线编码(encode)高阶类型构造器。trait CollectionModule[Collection[_]]
表示CollectionModule
的类型构造器需要提供一个* -> *
类别的类型构造器,比如List
。
(* => *) => *
:类型构造器的类别,它接收一个* => *
类别的类型构造器。比如:trait Functor[F[_]] {...}
或trait Monad[F[_]] {...}
。
Existentials
一个“存在量化类型”定义一个基于一些明确但又未知的类型 的类型。“存在判断的类型”用于隐藏一些并非全局相关的类型信息。
trait ListMap[A]{
type B
val list: List[B]
val mapf: B => A
def run: List[A] = list.map(mapf)
}
例子中,类型ListMap[A]#B
是一些明确类型,但是有不能知道其真正类型,它可能是任何类型。
Skolemization
每个“存在判断的类型”都可以被编码为一个通用(universal)类型。这个过程称为Skolemization。
case class ListMap[B, A](list:List[B], mapf: B => A)
trait ListMapInspector[A,Z] {
def apply[B](value: ListMap[B, A]):Z
}
case class AnyListMap[A] {
def apply[Z](value:ListMapInspector[A, Z]): Z
}
例子中,除了我们直接使用ListMap
,还可以使用AnyListMap
,仅当能够为B
处理任何类型参数时允许我们对ListMap
进行检查。
Type Lambdas
函数可以通过使用下划线操作符编程“偏应用型(partially apply)”。比如,zip(a, _)
。一个类型匿名函数是偏应用“高阶类别”类型的一种方式,使用较少的类型参数来生成一个新的类型构造器。
类型匿名函数之于类型构造器,相当于匿名函数之于函数。一个是类型、值的表达式,一个声明:
({type λ[α] = Either[String, α]})#λ
例子中,Either
通过偏应用一个String
作为第一个类型参数。
较多场景中,会使用**类型别名(type aliase)**替代类型匿名函数,比如:type EitherString[A] = Either[String, A]
。
类别投影器(Projector)
类别投影器是一个常用的 Scala 编译器插件,提供了更易用的语法来创建类型匿名函数。比如,({type λ[α] = Either[String, α]})#λ
可以表示为Either[String, ?]
这样的语法。另外有更多语法来创建更复杂的类型匿名函数。
https://github.com/non/kind-projector
类型类
一个类型类是对类型和定义在该类上操作的收集。很多类型类会定义一些规则需要实现来遵守。
trait ShowRead[A] {
def show(v: A): String
def read(v: String): Either[String, A]
}
object ShowRead {
def apply[A](implicit v: ShowRead[A]): ShowRead[A] = v
}
例子中,类型类ShowRead[A]
定义了通过渲染字符串来显示类型A
,并通过读取字符串来读取它,或是生成一个错误消息。
Right Identity
read(show(v)) == Right(v)
Left Partial Identity
read(v).map(show(_)).fold(_ => true, _ == v)
实例
一个类型类的实例就是通过提供一组类型来定义一个该类型类的实现。一般这些事例会被标记为imolicit
,以便编译器能够自动为需要的函数提供这些实现。
implicit val ShowReadString:ShowRead[String] = new ShowRead[String] {
def show(v:String):String = v
def read(v:String): Either[String, String] = Right(v)
}
语法
便利的语法,或称为扩展方法,添加给类型以便这些类型类更加易用:
implicit class ShowOps[A:ShowRead](self:A){
def show:String = ShowRead[A].show(self)
}
implicit class ReadOps(self:String){
def read[A:ShowRead]:Either[String, A] = ShowRead[A].read(self)
}
函数式模式
Option、Either、Validation
这些类型均用来表示可选性和偏应用性:
seald trait Maybe[A]
final case class Just[A](value:A) extends Maybe[A]
final case class Empty[A]() extends Maybe[A]
sealed trait \/[A, B]
final case class -\/[A, B](value:A) extends \/[A, B]
fianl case class \/-[A, B](value:B) extends \/[A, B]
type Either[A, B] = A \/ B
sealed trait Validation[A, B]
final case class Failure[A, B](value:A) extends Validation[A, B]
final case class Success[A, B](value:B) extends Validation[A, B]
半群、幺半群(Semigroup, Monoid)
半群支持将两个相同类型的东西组合成另一个新的相同类型的东西。比如,加法操作构成一个基于整数的半群。幺半群怎加一个额外的拥有“零”元素的属性,比如添加给一个值而不改变该值。
trait Semigroup[A]
https://wiki.scala-lang.org/display/SW/Parser+Combinators–Getting+Started
6.2.2 - CH01-定义
介绍
函数式编程,只是用纯函数来构造程序,即函数是没有副作用的。
函数的副作用大致包括:
- 修改一个变量
- 直接修改数据结构
- 设置一个对象的成员
- 抛出一个异常或以一个错误停止
- 打印到终端或者接收用户的输入
- 读取或写入一个文件
- 在屏幕上绘图
- …
函数式编程的益处
副作用函数的实例
class Cafe{
def buyCoffee(cc:CreditCard):Coffee = {
val cup = new Coffee()
cc.charge(cup.price) // 副作用,对信用卡进行扣费
cup
}
}
扣费过程涉及与整个交易系统的交互,而这个方法的主要用途是为了得到一杯咖啡,因此,扣费动作在获取咖啡的过程中额外完成了,即副作用。
但是如果要测试这段程序,我们并不真的需要与交易系统进行交互,或者将交易信息进行持久。同时信用卡本身也不应该知道交易的细节,我们可以传递一个Payments
对象给buyCoffe
方法,让CreditCard
忽略掉交易的细节,整个交易过程由Payments
对象来完成:
class Cafe {
def buyCoffe(cc:CreditCard, p: Payments) = {
val cup = new Coffe()
p.charge(cc, cup.price) // 副作用,有交易对象来完成扣费
cup
}
}
现在可以使用 mock 来对交易细节进行测试。但是真个程序很难被复用,比如我们要订购 12 杯咖啡。
去除副作用
在上面的例子中,我们可以在购买咖啡的过程中同时返回咖啡和对应的费用,而根据费用进行的交易过程则后续进行处理,这样,这个程序就不再存在副作用:
def buyCoffe(cc:CreditCard):(Coffe, Change) = {
val cup = new Cup()
(cup, Charge(cc, cup.price))
}
这样通过将费用的创建与执行分离,购买咖啡的过程就不再有副作用,而真正的扣费可以在后续的事务中进行。
现在我们对相同信用卡的计费进行合并,以便支持同时购买多杯咖啡:
case class Charge(cc:CreditCard, amount: Double){
def combine(ohter:Charge): Charge = {
if (cc == other.cc) Charge(cc, amount + other.amount)
else throw new Exception("Can't combine charges to different cards.")
}
}
然后实现buyCoffes
来购买多杯咖啡,一次返回对应数量的咖啡和一个合并后的计费值:
def buyCoffes(cc:CreditCard, n:Int): (List[Coffe], Charge) = {
val purchases:List[(Coffe, Charge)] = List.fill(n)(buyCoffe(cc))
val (coffes, charges) = purchases.unzip
(coffes, chargs.reduce((c1,c2) => c1.combine(c2)))
}
如果需要将不同信用卡的不同消费记录合并成一个计费列表,即将相同的信用卡计费合并:
def coalesce(charges:List[Charge]):List[Charge] =
charges.groupBy(_.cc).values.map(_.reduce(_ combine _)).toList
如何消除副作用?
通过把副作用推到程序的最外层,来转换带有副作用的函数。对于很多必须的副作用都存在对应的函数式实现,如果没有对应的实现,也可以通过找到一种方式来构造代码,让副作用发生但不可见。
什么是纯函数
纯函数是没有副作用的,更易推理。
一个函数在执行过程中,除了根据给定的参数给出运算结果之外没有任何其他影响,即为无副作用。
引用透明:对于一个函数,无论何时调用,相同的参数都会返回一致的结果。同样也适用于表达式。
任何程序中符合引用透明的表达式都可以由它的结果替代,而不会改变程序的含义。
纯函数:当调用一个函数时,传入的参数是引用透明的,并且函数调用也是引用透明的,那么他就是一个纯函数。
引用透明与纯粹度
对于程序 p,如果它包含的表达式 e 满足引用透明,所有的 e 都能替换为它的计算结果而不会改变程序 p 的含义。假设存在一个函数 f,若表达式 f(x) 对所有引用透明的表达式 x 也是引用透明的,那么 f 是一个纯函数。
引用透明、纯粹度及替代模型
引用透明要求函数不论进行了何种操作都可以用它的返回值来代替。
比如一开始的咖啡例子:
def buyCoffee(cc:CreditCard):Coffee = {
val cup = new Coffee()
cc.charge(cup.price) // 副作用,对信用卡进行扣费
cup
}
它的返回值为cup
,实际上就是new Coffe()
。对于任何一个函数p(buyCoffee(cc:CreditCard))
与p(new Coffe())
显然不能进行替换,因为buyCoffee
除了返回一杯咖啡,还进行了交易过程。
引用透明的限制使得推导一个程序的求值变得简单,称为替代模型。如果表达式是引用透明的,可以想象计算过程就像是在解代数方程。展开表达式的每一部分,使用指示对象代替变量,然后归约到最简单的形式。在这一过程中,每一项都可以被等价的值替代,计算的过程就像是被一个又一个等价的值替代的过程。引用透明使得程序具备了等式推理的能力。
6.2.3 - CH02-函数
高阶函数-把函数传递给函数
函数也是值,也可以赋值给一个变量、存储在一个数据结构里、像参数一样传递给另一个函数。
循环调用
使用循环方式实现阶乘:
def factirial(n:Int): Int = {
def go(n:Int, acc:Int):Int = { // 内部函数,跟一个函数内的变量含义相同
if (n <= 0) acc
else go(n-1, n * acc)
}
go(n, 1)
}
想不通过修改一个循环变量而实现循环功能,可以借助递归函数。这样的递归函数一般没命名为 go 或 loop。
上面的例子中,内部函数go
实现循环,接收下一个n
和和累积值acc
。要退出循环时,返回一个不继续递归的值,即n <= 0
。
编译器会检测到这种自递归,只要递归调用发生在尾部,即递归调用后面没有其他的调用,编译器会优化成类似while
循环的字节码。
尾调用
指调用者在一个递归调用之后不做其他事,只是返回这个调用结果。
比如
else go(n-1, n * acc)
部分,是直接返回了go
的递归调用,没有再做其他计算。如果是1 + go(n-1, n * acc)
,则不再是尾调用,因为又进行了计算。如果递归调用在尾部位置,则会自动编译为循环迭代,即不会每次进行栈的操作。可以通过
@annotation.tailrec
告诉编译器当没有成功编译成循环迭代时发出警告。
高阶函数
def formatResult(name:String, n:Int, f: Int => Int) = {
val msg = "The %s of %d is %d."
msg.format(name, n, f(n))
}
这里的参数f
,其类型为Int => Int
,表示接受一个整数并返回一个整数的函数。
因为高阶函数一般不能通过函数名来准确表示函数的功能,因此使用较短的函数命名,比如 g、h、f 等。
多态函数-基于类型的抽象
这里的多态跟继承中所说的“父类-子类继承”不同,这里是指类型的多态。
之前见到的函数都是单态的,因为函数只操作一种数据类型。适用于多种数据类型的函数,称为多态函数,或泛型函数。
多态函数的构建,一般是发现多个单态函数有相似的结构,这时,可以封装为一个多态函数。
实例
比如一个函数,返回数组中第一个匹配到 key 的索引,否则返回 -1:
def findFirst(ss:Array[String], key:String):Int = {
@annotatin.tailrec
def loop(n:Int):Int = {
if (n >= ss.length) -1 // 到达数组尾部仍未匹配到 key,返回 -1
else if (ss(n) == key) n // 匹配到 key,返回索引
else loop(n + 1) // 递归调用
}
}
这是从 String 数组中匹配,如果是从 Int 数组查找匹配,也是类似的结构,因此我们就可以将它改写为一个从 A 类型数组中查找对应索引的函数:
def findFirst[A](as:Array[A], p:A => Boolean):Int = {
@annotatin.tailrec
def loop(n:Int):Int = {
if (n >= as.length) -1
else if p(as(n)) n // 使用传入的 测试函数 p 对当前元素进行判断
else loop(n + 1)
}
}
函数名后跟的是类型参数,由中括号包围,多个参数使用逗号分隔,一般使用单个大写字母表示一个类型参数。这些类型参数作为类型变量,可以在其他类型签名中引用,比如上面的as
参数类型。
向高阶函数传入匿名函数
在调用高阶函数时,并没有必要提前定义一个有名函数然后再传入,可以在调用时直接定义一个函数值作为高阶函数的参数,这被称为匿名函数或函数字面量。比如:
findFirst(Array(1,2,3), (x:Int) => x == 9)
(x:Int) => x == 9
即为一个匿名函数,或称为函数字面量。
匿名函数中,=>
左边为该函数的参数列表,右边则会函数体。如果 Scala 可以推断参数的类型,则可以省略掉。
函数也是值
当定义一个函数字面量时,实际上是定义了一个包含一个
apply
方法的 Scala 对象。而当一个对象拥有apply
方法,则可以把该对象当做方法一样调用。比如我们定义一个函数字面量
(a, b) => a < b
,它事实上是创建函数对象的语法糖:val lessThan = new Function2[Int, Int, Boolean] { def apply(a:Int, b:Int) = a < b }
lessThan
的类型为Function2[Int, Int, Boolean]
,通常会写成(a, b) => Boolean
。Function2
特质拥有一个apply
方法,在调用lessThan(10, 20)
时实际上是对apply
方法调用的语法糖:lessThan.apply(10, 20) // true
这些类似
Function2
的特质,实际是由 Scala 标准库提供的普通特质,比如Function1
、Funciton3
等等。其中的数字是指接收的参数个数。因为这些函数在 Scala 中是普通对象,因此他们也是一等值。
通过类型实现多态
在实现多态函数时,各种可能的实现方式明显比普通函数减少了。比如针对类型 A 的多态函数,唯一能够对 A 进行操作的方式是传入一个函数作为参数,通过这个传入的函数来操作 A。
比如一个例子,这个函数签名表示它只有一种实现方式。它是一个执行部分应用的高阶函数。函数partial1
接收一个值和一个带有两个参数的函数,并返回一个带有一个参数的函数。
部分应用函数,表示函数被应用的参数并不是它需要的完整参数,即只提供了参数列表中的部分参数。
def partial1[A, B, C](a:A, f: (A, B) => c): B => C
我们该如何实现这个高阶函数呢。根据它的返回值类型,是接收一个类型 B 的参数并返回一个类型 C 的值的函数:
def partial1[A, B, C](a:A, f: (A, B) => c): B => C = {
(b: B) => ??? // 根据返回值类型 B => C ,定义一个该返回值类型的函数
}
现在如何来实现方法体部分呢,根据声明,这个函数必须返回一个 C 类型的值。而在partial1
的参数列表中,正好有一个函数f
能够返回一个 C 类型的值。除此之外,我们没有其他方式来实现该函数体。因此:
def partial1[A, B, C](a:A, f: (A, B) => c): B => C = {
(b: B) => f(a, b) // 调用参数中的函数,实现符合返回类型的函数体
}
这也就是部分应用函数的实现过程,一个函数,接收两个参数,返回一个值。当我们只提供一个参数值时,在这个例子中,是a
,这时会得到一个 “接收一个参数并返回一个值的” 函数。然后在提供原始函数需要的第二个参数,即b
,就能得到最终的结果, 即c
。
6.2.4 - CH03-数据
定义函数式数据结构
函数式数据结构只能被纯函数操作,纯函数一定不能修改原始数据或产生副作用。因此,函数式数据结构被定义为不可变的。
比如单链表的实现:
sealed trait List[+A]
case object Nil extends List[Nothing]
case class Cons[+A](head:A, tail: List[A]) extends List[A] // 递归引用自身类型
object List{
def sum(ints: List[Int]):Int = ints match {
case Nil => 0
case Cons(x, xs) => x + sum(xs)
}
def product(ds:List[Double]): Double = ds match {
case Nil => 1.0
case Cons(0.0, _) => 0.0
case Cons(x, xs) => x * product(xs)
}
def apply(as: A*): List[A] =
if (as.isEmpty) Nil
else Cons(as.head, apply(as.tail:_*))
}
List[+A]
表示它是一个泛型数据类型。同时拥有两种实现,或者说是两种构造器,每种都由case
关键字定义,表示它的两种可能的形式。如果List
为空,则用Nil
表示,如果非空,则用构造器Cons
表示。一个非空列表由初始元素head
和后续紧跟的也是List
结构的tail
构成,并且,这个tail
可能为空,即他可能是一个Nil
。
这就是一个典型的数据构造器声明,为我们提供了一个函数来构造该类型数据的不同形式:
val ex1:List[Double] = Nil
val ex2:List[Int] = Cons(1, Nil)
val ex3:List[String] = Cons("a", Cons("b", Nil))
模式匹配
在List
的伴生对象中定义了两个方法,sum
和product
,他们都使用了模式匹配。同时他们都是以递归的方式定义,这在编写操作递归数据类型的函数时很常见。
模式匹配类似于switch
,他可以侵入到表达式的数据结构内部,对这个结构进行检验和提取子表达式。符号=>
左边为模式,右边为结果。
如果将模式中的变量分配给目标子表达式,使得它在结构上与目标一致,模式与目标就是一致的。匹配上的话,结果表达式就可以访问这些模式中定义的局部变量。
函数式数据结构的数据共享
当一个新的元素添加到已有的列表时返回一个新的列表。既然列表是不可变的,并不需要真的去复制一份原有列表,而是直接去复用它,比如向原有的列表 xs 添加一个数字 1,返回一个Cons(1, xs)
。
同样,删除列表的第一个元素,比如myList = Cons(x, xs)
,这时只需要返回尾部的xs
。并没有真的删除任何元素。原始的myList
依然可用,不会受到任何影响。
这被称为数据共享。
“函数式数据结构是持久的”,是指:已存在的引用不会因数据结构的操作而改变。
数据共享的效率
???
高阶函数的类型推导
当向一个高阶函数传递函数类型的参数时,需要标识该函数的类型。比如高阶函数dropWhile
:
def dropWhile[A](l: List[A], f: A => Boolean): List[A]
当调用该函数时,参数函数f
必须制定它的参数类型:
val xs:List[Int] = List(1,2,3,4,5)
val ex1 = dropWhile(xs, (x:Int) => x < 4) // (x:Int) 指定参数类型为 Int
因为dropWhile
的两个参数都使用类型参数A
,前一个参数l
的类型为Int
,因此第二个参数的类型也必须是Int
。
当函数定义包含多个参数组时,参数组里的类型信息从左到右进行传递。
因此我们把dropWhile
的参数列表分开,让他成为一个柯里化函数:
def dropWhile[A](as:List[A])(f: A => Boolean): List[A] =
as match {
case Cons(h, t) if f(h) => dropWhile(t)(f)
case _ => as
}
现在可以这样调用dropWhile
,dropWhile(xs)(x => x < 4)
。当调用dropWhile(xs)
时他会返回一个函数,这时已经确定第一个参数的类型为A
,因此第二个参数时就不再需要进行类型标注了,它的类型只能为A
。
通过将函数参数分组排序成多个参数列表(将函数柯里化),来最大化的利用类型推导。
基于 List 的递归并泛化为高阶函数
回顾前面的sum
和product
函数:
def sum(ints: List[Int]): Int = ints match{
case Nil => 0
case Cons(x, xs) => x + sum(xs)
}
def product(ds:List[Double]): Double = ds match{
case Nil => 1.0
case Cons(x, xs) => x * product(xs)
}
这两个函数的结构类似,不同在于他们操作的数据类型(Int/Double)、Nil
时返回的值(0/1.0)、以及对结果的组合操作(+/*)。
对于这两个函数的抽象,首先将Int/Double
泛化为类型参数A
,而Nil
的返回值,可以作为一个函数参数由客户端提供。
如果一个子表达式引用了任何局部变量,把子表达式放入一个接收这些局部变量作为参数的函数。
比如上面这两个函数中的x + sum(xs)
和x * product(xs)
部分,都是对局部变量的引用,因此可以抽象为函数f: (A, B) => B
。最终,把上面两个函数改写成下面的方式:
def foldRight[A, B](as: List[A], z: B)(f: (A, B) => B): B =
as match {
case Nil => z
case Cons(x, xs) => f(x, foldRight(xs, z)(f))
}
def sum2(ns: List[Int]) = foldRight(ns, 0)((x, y) => x + y)
def product2(ns: List[Double]) = foldRight(ns, 1.0)(_ * _)
这里需要注意的是,与之前遇到的泛化不同,该函数的最终计算结果类型与传入的列表类型并不相同,如函数签名foldRight[A, B](as: List[A], z: B)(f: (A, B) => B): B
所示,它的最终计算结果与传入的参数z
类型一致,即类型B
。
因为整个函数的计算过程就是对列表进行循环,一直遍历到列表末尾,这个过程中不断压栈,直至列表的末尾,即Nil
。而遇到Nil
时返回的值为传入的参数z
,套用传入的函数f
,也就是通过传入列表的最后一个元素(Nil 之前的元素)与z
来调用函数f
,得到栈顶的值。然后依次出栈,完成整个计算过程。
可以函数的调用使用传入的函数f
进行替换:
Cons(1, Cons(2, Nil))
f (1, f (2, z ))
foldRight
函数为 Scala 标准库中List
的内置函数,上面的替换过程也就是它的计算过程。
比如我们调用sum2(List(1,2,3), 0)((x, y) => x + y)
,跟踪其运算过程:
foldRight(Cons(1, Cons(2, Cons(3, Nil))), 0)((x, y) => x + y)
1 + foldRight(Cons(2, Cons(3, Nil)), 0)((x, y) => x + y)
1 + (2 + foldRight(Cons(3, Nil), 0)((x, y) => x + y))
1 + (2 + (3 + foldRight(Nil, 0)((x, y) => x + y)))) // Nil, 终止递归,替换为(0 + 0)
1 + (2 + (3 + (0))
6
树
List 是代数数据类型(ADT)的一种,有些场景或称为抽象数据类型。
ADT 是由一个或多个数据构造器所定义的数据类型,每个构造器可以包含零个或多个参数。数据构造器通过累加(sum)或联合(union)构成数据类型,而每个数据构造器又是其参数的乘积(product)。因此称为代数数据类型。
ADT 用于构造其他数据结构。定义一个二叉树数据结构:
sealed trait Tree[+A]
case class Leaf[A](value: A) extends Tree[A]
case class Branch[A](left: Tree[A], right: Tree[A]) extends Tree[A]
6.2.5 - CH04-异常
抛出异常会产生副作用,在函数式解决方案中,以值的方式返回错误更安全,符合引用透明,并且可以通过高阶函数保存异常的优点 - 统一错误处理逻辑。核心思想就是使用普票值来表现异常。
异常的优点与劣势
一个抛出异常的例子:
def failingFn(i: Int): Int = {
val y:Int = throw new Exception("fail!") // 抛出异常
try{
val x = 42 + 5
x + y
} catch {
case e:Exception => 43
}
}
调用该函数会抛出预期的异常,为了证明异常会破坏引用透明,我们可以将y
进行替换:
def failingFn2(i: Int) = {
try{
val x = 42 + 5
x + (throw new Exception("fail!"): Int) // 抛出异常的表达式可以声明为任何类型
} catch {
case e: Exception => 43
}
}
运行结果会得到 43 而不是像替换前一样排除预期的异常,因此,异常并不是引用透明的。
引用透明的表达式不依赖上下文,可以本地推导,而那些非引用透明的表达式是依赖上下文的,并且需要全局推导。比如把42 + 5
这个表达式嵌入到一个更大的表达式中,他不会受这个更大的表达式的影响,即对更大的表达式产生依赖,它永远等于 47。但是把throw new...
嵌入到一个更大的表达式,比如嵌入到一个try/catch
结构中,它的值取决于catch
部分的处理,因此对更大的表达式产生了依赖。
异常的问题:
- 异常破坏了引用透明并且引入了上下文依赖。
- 异常不是类型安全的。函数
failing
,Int => Int
并不会告诉我们会发生什么样的异常。因为没有受检异常,直到运行时才会发现异常。
我们希望其他选择能够排除异常的这些缺点,但是又不想失去异常最主要的好处:整合集中的错误处理逻辑,而不是在整个代码库发布这个逻辑。
异常的其他选择
比如一个计算列表平均值的函数:
def mean(xs:Seq[Double]): Double =
if (xs.isEmpty) throw new ArithmeticException("mean of empty list!")
else xs.sum / xs.length
这是一个典型的偏函数,他对一些输入没有做定义。如果一个函数对那些非隐式的输入类型做了一些假设,那它就是一个典型的偏函数。
除了选择抛出异常,还有其他的选择。
第一种是返回某个伪造的 Double 类型的值。比如为空时返回Double.NaN
,或者其他报警值,或者null
。但是以下理由使我们放弃这种方案:
- 它允许错误无声的传播,如果忘了对这样的值进行检查也不会得到警告,会使后面的代码出错。
- 使用显式的
if
来检查是否得到了正确的结果会导致大量的模板代码,特别是调用多个函数时。 - 不适用与多态代码。比如一个查找最大值的泛型函数
def max[A](xs:List[A])
,当传入为空时无法发明一个 A 类型的值。也不能是null
,因为null
只对非基础类型有效,但是这里的 A 可能是Int
或Double
。 - 需要一个特定的策略或调用约定 - 告诉调用者如何合理的使用
mean
函数。这导致它不能传递给高阶函数,因为高阶函数对待所有参数都是一致的。
第二种是强迫调用者提供一个参数告诉我们该如何处理。比如:
def mean(xs:Seq[Double], onEmpty: Double): Double =
if (xs.isEmpty) onEmpty
else xs.sum / xs.length
这使mean
函数称为一个完全函数。调用者必须知道如何处理未定义的情况。
Option 数据类型
解决方案是在返回值类型明确表示该函数并不总是有结果。
sealed trait Option[+A]
case class Some[+A](get:A) extends Option[A]
case object None extends Option[A]
Option
是一个最多只包含一个元素的List
。
基础 Option 函数
trait Option[+A] {
def map[B](f: A => B): Option[B] // 如果 Option 不为 None,对其应用 f
def flatMap[B](f: A => Option[B]): Option[B] // 如果 Option 不为 None,对其应用 f,可能会失败
def getOrElse[B >: A](default: B): B // 默认值类型 B 必须是 A 的父类
def orElse[B >: A](ob: => Option[B]): Option[B] // 不对 ob 求值,除非需要
def filter(f: A => Boolean): Option[A] // 如果值不满足,转换 Some 为 None
}
基础函数使用场景
???
Option 组合、提升、面向异常的 API 封装
可能会认为,一旦开始使用Option
,整个库都会受影响,因为一些方法必须改变为接收或返回Option
。但是,我们可以把一个普通函数**提升(lift)**为一个对Option
操作的函数。
比如,map
函数支持我们用一个类型为A => B
的函数来操作一个Option
。从另一个角度看,map
可以把一个A => B
的函数转换为Option[A] => Option[B]
类型的函数:
def lift[A, B](f: A => B): Option[A] => Option[B] = _ map f
现在,可以把普通的abs
函数转换为处理Option
的函数:
val absOpt:Option[Double] => Option[Double] = lift(math.abs)
而Try
函数是一个通用目的的函数,用于将一个基于异常的 API 转换成一个面向Option
的 API。
Either 数据类型
Option
只能用来表示可能不存在的值,并不能表示异常条件下发生了什么错误。如果除了需要获取异常时可能的值,还需要知道异常时的错误信息,可以使用Either
:
sealed trait Either[+E, +A]
case class Left[+E](value: E) extends Either[E, Nothing]
case class Right[+A](value: A) extends Either[Nothing, A]
Either
在两种情况都有值,它的值只能是两种情况中的一种,称他是两个类型的互斥并集。一般使用Left
表示失败,而Right
表示成功。
使用Either
改写前面的mean
函数:
def mean(xs:List[Double]): Either[String, Double] =
if (xs.isEmpty) Left("mean of empty list!")
else Right(xs.sum / xs.length)
或者在Left
中包含处理的异常以获取详细的调用栈信息:
def safeDiv(x:Int, y:Int):Either[Exception, Int] =
try Right(x / y)
catch {case e:Exception => Left(e)}
7 - Rust 编程
9 - 前端技术栈
9.1 - 浏览器
9.2 - HTML5
9.3 - CSS3
9.4 - JavaScript
9.5 - TypeScript
9.6 - Vue 3
9.7 - React
10 - Dart 编程
10.1 - 基本认识
Dart 编程语言之所以重要,有以下几个原因:
- 它兼备了两种语言的优点: 它是一种编译的、类型安全的语言(如 C# 和 Java),同时也是一种脚本语言(如 Python 和 JavaScript)。
- 它转换成 JavaScript 用于 Web 前端。
- 它可以运行在任何平台上,并编译为本地移动应用,所以你几乎可以使用它做任何事情。
- Dart 在语法上类似于 C# 和 Java,所以学起来很快。
我们这些来自大型企业系统的 C# 或 Java 世界的人已经知道为什么类型安全、编译时错误和检查器很重要。我们中的许多人都在犹豫是否采用“脚本”语言,因为担心会失去我们所习惯的所有结构、速度、准确性和可调试性。
但随着 Dart 的发展,我们不需要放弃这些。我们可以用同一种语言编写移动应用程序、Web 客户端和后端——并获得我们仍然热爱 Java 和 C# 的所有东西!
为此,让我们浏览一些对 C# 或 Java 开发人员来说很新的关键 Dart 语言示例,我们将在最后的 Dart 语言 PDF 中进行总结。
注意:本文仅涉及 Dart 2.x。版本 1。x 不是“完全熟”的——特别是,类型系统是咨询型的(像 TypeScript),而不是需要型的(像 C# 或 Java)。
1. 代码组织
首先,我们将讨论最重要的区别之一:如何组织和引用代码文件。
源文件、作用域、命名空间、引入
在 C# 中,类的集合被编译为程序集。每个类都有一个名称空间,名称空间通常反映文件系统中源代码的组织——但是最后,程序集不保留任何关于源代码文件位置的信息。 在 Java 中,源文件是包的一部分,名称空间通常符合文件系统的位置,但最终,包只是类的集合。 因此,两种语言都有一种方法使源代码在一定程度上独立于文件系统。
相比之下,在 Dart 语言中,每个源文件必须导入它引用的所有内容,包括其他源文件和第三方包。没有相同的命名空间,通常通过文件系统位置来引用文件。变量和函数可以是顶层的,而不仅仅是类。在这些方面,Dart 更像脚本。 因此,您需要将思路从“类的集合”转变为“包含的代码文件序列”。 Dart 支持包组织或不使用包的临时组织。让我们从一个没有包的例子开始,来说明包含的文件的顺序:
// file1.dart
int alice = 1; // top level variable
int barry() => 2; // top level function
var student = Charlie(); // top level variable; Charlie is declared below but that's OK
class Charlie { ... } // top level class
// alice = 2; // top level statement not allowed
// file2.dart
import 'file1.dart'; // causes all of file1 to be in scope
main() {
print(alice); // 1
}
源文件中引用的所有内容都必须在该文件中声明或导入,因为没有“项目”级别,也没有其他方法在范围中包含其他源元素。 在 Dart 中,名称空间的唯一用途是为导入提供一个名称,这将影响您如何从该文件引用导入的代码。
// file2.dart
import 'file1.dart' as wonderland;
main() {
print(wonderland.alice); // 1
}
包:package
上面的例子在不使用包的情况下组织代码。为了使用包,代码要以更特定的方式组织起来。下面是一个名为apple的包布局示例:
apples/
pubspec.yaml
—定义包名、依赖,以及其他设置lib/
apples.dart
—imports and exports; 其他人通过引入该文件来消费这个包src/
seeds.dart
—其他代码
bin/
runapples.dart
—包含主函数,作为入口点 (如果这是一个可运行的包或包含可运行的工具)
然后你可以导入整个包而不再是导入单个文件:
import 'package:apples';
重要的应用程序应该始终组织为包。这减少了在每个引用文件中重复文件系统路径的工作量;另外,它们跑得更快。它还可以很容易地在 pub.dev 上共享您的包,其他开发人员可以很容易地获取它供自己使用。应用程序使用的包会导致源代码被复制到文件系统中,所以你可以随心所欲地深入调试这些包。
2. 数据类型
需要注意的是,在 Dart 的类型系统中有一些主要的差异,比如空值、数字类型、集合和动态类型。
到处都是 Null
由于来自 C# 或 Java,我们习惯于将基本类型或值类型与引用或对象类型区分开来。实际上,值类型是在堆栈或寄存器中分配的,值的副本作为函数参数发送。引用类型被分配到堆上,只有指向对象的指针作为函数参数发送。由于值类型总是占用内存,值类型变量不能为空,而且所有值类型成员必须有初始值。 Dart 消除了这种区别,因为所有东西都是物体;所有类型最终都派生自 Object 类型。所以,这是合法的:
int i = null;
事实上,所有原语都隐式初始化为 null。这意味着您不能像在 C# 或 Java 中那样假定整数的默认值为零,并且您可能需要添加 null 检查。 有趣的是,即使是 Null 也是一种类型,单词 Null 指的是 Null 的实例:
print(null.runtimeType); // prints Null
数字类型并不多
与我们熟悉的 8 到 64 位的有符号和无符号整数类型不同,Dart 的主要整数类型只是 int,一个 64 位值。(对于非常大的数字,还有 BigInt。)
由于没有字节数组作为语言语法的一部分,二进制文件内容可以作为整数列表进行处理,即 List<Int>
。
如果你认为这肯定是非常低效的,设计师已经想到了。在实践中,根据运行时使用的实际整数值,有不同的内部表示形式。运行时不会为 int 对象分配堆内存,如果它可以优化它,并在开箱模式下使用 CPU 寄存器。另外,库byte_data 提供了 UInt8List 和其他一些优化的表示。
集合
集合和泛型很像我们习惯使用的东西。需要注意的主要事项是没有固定大小的数组:只要在需要使用数组的地方使用 List 数据类型即可。 此外,还提供了对三种集合类型初始化的语法支持:
final a = [1, 2, 3]; // inferred type is List<int>, an array-like ordered collection
final b = {1, 2, 3}; // inferred type is Set<int>, an unordered collection
final c = {'a': 1, 'b': 2}; // inferred type is Map<string, int>, an unordered collection of name-value pairs
所以,在使用Java 数组、ArrayList 或 Vector 时,使用 Dart List;或 C# 数组或 List。在使用 Java/ C# HashSet 的地方使用 Set。在使用 Java HashMap 或 C# Dictionary 的地方使用 Map。
动态类型、静态类型
在 JavaScript、Ruby 和 Python 等动态语言中,即使成员不存在,也可以引用它们。下面是一个 JavaScript 示例:
var person = {}; // create an empty object
person.name = 'alice'; // add a member to the object
if (person.age < 21) { // refer to a property that is not in the object
// ...
}
如果你执行以上代码, person.age
会是 undefined
,但确实是可以运行。
同样地,你可以在 JavaScript 中改变变量的类型:
var a = 1; // a is a number
a = 'one'; // a is now a string
相比之下,在 Java 中,你不能写像上面这样的代码,因为编译器需要知道类型,它会检查所有的操作是否合法——即使你使用 var 关键字:
var b = 1; // a is an int
// b = "one"; // not allowed in Java
Java 只允许使用静态类型编码。(您可以使用内省来执行一些动态行为,但它不是语法的直接组成部分。)JavaScript 和其他一些纯动态语言只允许使用动态类型编码。 Dart 语言允许以下两种情况:
// dart
dynamic a = 1; // a is an int - dynamic typing
a = 'one'; // a is now a string
a.foo(); // we can call a function on a dynamic object, to be resolved at run time
var b = 1; // b is an int - static typing
// b = 'one'; // not allowed in Dart
Dart 具有伪类型 dynamic
,这将导致在运行时处理所有类型逻辑。调用 a.foo()
的尝试不会干扰静态分析器,代码会运行,但它会在运行时失败,因为没有这样的方法。
C# 最初很像 Java,后来又加入了动态支持,所以 Dart 和 C# 在这方面是差不多的。
4. 函数
函数声明语法
与 C# 或 Java 相比,Dart 中的函数语法更轻松、更有趣。语法如下:
// functions as declarations
return-type name (parameters) {body}
return-type name (parameters) => expression;
// function expressions (assignable to variables, etc.)
(parameters) {body}
(parameters) => expression
比如:
void printFoo() { print('foo'); };
String embellish(String s) => s.toUpperCase() + '!!';
var printFoo = () { print('foo'); };
var embellish = (String s) => s.toUpperCase() + '!!';
参数传递
因为所有东西都是对象,包括基本类型 int 和 String,所以参数传递可能会让人困惑。虽然没有像 C# 那样传递 ref 形参,但所有的参数都是通过引用传递的,函数不能更改调用者的引用。因为对象在传递给函数时不会被克隆,所以函数可能会改变对象的属性。然而,像 int 和 String 这样的基本类型的区别实际上是没有意义的,因为这些类型是不可变的。
var id = 1;
var name = 'alice';
var client = Client();
void foo(int id, String name, Client client) {
id = 2; // local var points to different int instance
name = 'bob'; // local var points to different String instance
client.State = 'AK'; // property of caller's object is changed
}
foo(id, name, client);
// id == 1, name == 'alice', client.State == 'AK'
可选参数
如果你是在 C# 或 Java 的世界里,你可能会诅咒这些令人困惑的重载方法的情况:
// java
void foo(string arg1) {...}
void foo(int arg1, string arg2) {...}
void foo(string arg1, Client arg2) {...}
// call site:
foo(clientId, input3); // confusing! too easy to misread which overload it is calling
对于 C# 可选参数,还有另一种困惑:
// C#
void Foo(string arg1, int arg2 = 0) {...}
void Foo(string arg1, int arg3 = 0, int arg2 = 0) {...}
// call site:
Foo("alice", 7); // legal but confusing! too easy to misread which overload it is calling and which parameter binds to argument 7
Foo("alice", arg2: 9); // better
C# 不需要在调用点命名可选参数,所以用可选参数重构方法可能会很危险。如果某些调用站点在重构后恰好是合法的,编译器将不会捕获它们。 Dart 有一种更安全、更灵活的方式。首先,重载方法不受支持。相反,有两种方法来处理可选参数:
// positional optional parameters
void foo(string arg1, [int arg2 = 0, int arg3 = 0]) {...}
// call site for positional optional parameters
foo('alice'); // legal
foo('alice', 12); // legal
foo('alice', 12, 13); // legal
// named optional parameters
void bar(string arg1, {int arg2 = 0, int arg3 = 0}) {...}
bar('alice'); // legal
bar('alice', arg3: 12); // legal
bar('alice', arg3: 12, arg2: 13); // legal; sequence can vary and names are required
不能在同一个函数声明中使用这两种样式。
async
关键字位置
C# 的 async 关键字有一个令人困惑的位置:
Task<int> Foo() {...}
async Task<int> Foo() {...}
这意味着函数签名是异步的,但实际上只有函数实现是异步的。上面的任何一个签名都是这个接口的有效实现:
interface ICanFoo {
Task<int> Foo();
}
在 Dart 语言中,async 位于更符合逻辑的位置,表示实现是异步的:
Future<int> foo() async {...}
作用域与闭包
像 C# 和 Java 一样,Dart 在词法上是有作用域的。这意味着在块中声明的变量在块的末尾超出了作用域。所以 Dart 处理闭包的方式是一样的。
属性语法
Java 普及了属性 get/set 模式,但语言中并没有针对它的任何特殊语法:
// java
private String clientName;
public String getClientName() { return clientName; }
public void setClientName(String value}{ clientName = value; }
C# 有它的语法:
// C#
private string clientName;
public string ClientName {
get { return clientName; }
set { clientName = value; }
}
Dart 的语法支持属性略有不同:
// dart
string _clientName;
string get ClientName => _clientName;
string set ClientName(string s) { _clientName = s; }
5. 构造器
Dart 构造函数比 C# 或 Java 具有更多的灵活性。一个很好的特性是能够在同一个类中命名不同的构造函数:
class Point {
Point(double x, double y) {...} // default ctor
Point.asPolar(double angle, double r) {...} // named ctor
}
你可以只使用类名来调用默认构造函数: 在调用构造函数体之前初始化实例成员有两种简写方式:
class Client {
String _code;
String _name;
Client(String this._name) // "this" shorthand for assigning parameter to instance member
: _code = _name.toUpper() { // special out-of-body place for initializing
// body
}
}
构造函数可以运行超类构造函数并重定向到同一类中的其他构造函数:
Foo.constructor1(int x) : this(x); // redirect to the default ctor in same class; no body allowed
Foo.constructor2(int x) : super.plain(x) {...} // call base class named ctor, then run this body
Foo.constructor3(int x) : _b = x + 1 : super.plain(x) {...} // initialize _b, then call base class ctor, then run this body
在 Java 和 C# 中,在同一个类中调用其他构造函数的构造函数,当它们都有实现时,可能会令人混淆。在 Dart 中,重定向构造函数不能有主体,这一限制迫使程序员将构造函数层变得更清晰。 还有一个 factory 关键字允许函数像构造函数一样使用,但实现只是一个常规函数。你可以使用它来返回一个缓存实例或一个派生类型的实例:
class Shape {
factory Shape(int nsides) {
if (nsides == 4) return Square();
// etc.
}
}
var s = Shape(4);
6. 修饰符
在 Java 和 C# 中,我们有 private、protected 和 public 等访问修饰符。在 Dart 中,这被大大简化了:如果成员名以下划线开头,它在包内的任何地方都是可见的(包括从其他类),而对外部调用者是隐藏的;否则,从任何地方都可以看到它。没有像 private 这样的关键字来表示可见性。 另一种修饰符控制可变性:关键字 final 和 const 就是为了这个目的,但它们的含义不同:
var a = 1; // a is variable, and can be reassigned later
final b = a + 1; // b is a runtime constant, and can only be assigned once
const c = 3; // c is a compile-time constant
// const d = a + 2; // not allowed because a+2 cannot be resolved at compile time
7. 类继承
Dart 语言支持接口、类和一种多继承。但是,没有界面关键字;相反,所有的类也是接口,所以你可以定义一个抽象类,然后实现它:
abstract class HasDesk {
bool isDeskMessy(); // no implementation here
}
class Employee implements HasDesk {
bool isDeskMessy() { ...} // must be implemented here
}
使用 extends 关键字对主沿袭进行多重继承,其他类使用 with 关键字:
class Employee extends Person with Salaried implements HasDesk {...}
在这个声明中,Employee 类派生自 Person 和 Salaried,但是 Person 是主要的超类而 Salaried 是 mixin(次级超类)。
8. 操作符
有一些有趣和有用的Dart操作符是我们不习惯的。 Cascades 允许你在任何东西上使用链接模式:
emp ..name = 'Alice' ..supervisor = 'Zoltron' ..hire();
spread 操作符允许将集合视为初始化器中元素的列表:
var smallList = [1, 2];
var bigList = [0, ...smallList, 3, 4]; // [0, 1, 2, 3, 4]
9. 线程
Dart 没有线程,这使得它可以转换为 JavaScript。相反,它有“隔离”,从不能共享内存的意义上讲,它们更像是独立的进程。由于多线程编程非常容易出错,因此这种安全性被视为 Dart 的优点之一。要在隔离之间进行通信,您需要在它们之间流数据;接收到的对象被复制到接收隔离的内存空间中。
使用 Dart 编程
如果您是一名 C# 或 Java 开发人员,您已经知道的知识将帮助您快速学习 Dart 语言,因为它被设计为熟悉的语言。为此,我们整理了一份 Dart 小抄 PDF 供你参考,特别强调了它与 C# 和 Java 等价物的重要区别: