java学习笔记之基础:加密与安全、多线程
加密与安全
计算机加密技术是为了防窃取、防篡改、防伪造。现代计算机密码学理论是建立在严格的数学理论基础上的,密码学已经逐渐发展成一门科学。
编码
ASCII 码是一种编码,字母 A 的编码是十六进制的 0x41,字母 B 是 0x42,以此类推。ASCII 编码最多只能有 128 个字符,要想对更多的文字进行编码就需要用 Unicode。而中文的 中 使用 Unicode 编码就是 0x4e2d,使用 UTF-8 则需要 3 个字节编码:
| 汉字 | Unicode 编码 | UTF-8 编码 |
|---|---|---|
| 中 | 0x4e2d | 0xe4b8ad |
| 文 | 0x6587 | 0xe69687 |
| 编 | 0x7f16 | 0xe7bc96 |
| 码 | 0x7801 | 0xe7a081 |
简单的编码是直接给每个字符指定一个若干字节表示的整数,复杂的编码就需要根据一个已有的编码推算出来。比如 UTF-8 编码,它是一种不定长编码,但可以从给定字符的 Unicode 编码推算出来。
URL 编码
URL 编码是浏览器发送数据给服务器时使用的编码,它通常附加在 URL 的参数部分,例如 https://www.baidu.com/s?wd=%E4%B8%AD%E6%96%87 。之所以需要 URL 编码,是因为出于兼容性考虑,很多服务器只识别 ASCII 字符。但如果 URL 中包含中文等非 ASCII 字符就需要进行编码转换:
- 如果字符是 AZ,az,0~9 以及
-、_、.、*,则保持不变; - 如果是其他字符,先转换为 UTF-8 编码,然后对每个字节以
%XX表示。
例如字符中的 UTF-8 编码是 0xe4b8ad,它的 URL 编码是%E4%B8%AD。URL 编码总是大写。
Java 标准库提供了一个 URLEncoder 类来对任意字符串进行 URL 编码:
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public class Main {
public static void main(String[] args) {
String encoded = URLEncoder.encode("中文!", StandardCharsets.UTF_8);
System.out.println(encoded);
}
}
上述代码的运行结果是 %E4%B8%AD%E6%96%87%21 ,中 的 URL 编码是 %E4%B8%AD,文 的 URL 编码是 %E6%96%87,! 虽然是 ASCII 字符,也要对其编码为 %21。
和标准的 URL 编码稍有不同,URLEncoder 把空格字符编码成 + ,而现在的 URL 编码标准要求空格被编码为 %20 ,不过服务器都可以处理这两种情况。
服务器收到 URL 编码的字符串就可以对其进行解码,还原成原始字符串。Java 标准库的 URLDecoder 就可以解码:
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
public class Main {
public static void main(String[] args) {
String decoded = URLDecoder.decode("%E4%B8%AD%E6%96%87%21", StandardCharsets.UTF_8);
System.out.println(decoded);
}
}
URL 编码是编码算法,不是加密算法。URL 编码的目的是把任意文本数据编码为 % 前缀表示的文本,编码后的文本仅包含 AZ,az,0~9,-,_,.,* 和 %,便于浏览器和服务器处理。
Base64 编码
URL 编码是对字符进行编码,表示成 %xx 的形式,而 Base64 编码是对二进制数据进行编码,表示成文本格式。Base64 编码可以把任意长度的二进制数据变为纯文本,且只包含 AZ、az、0~9、+、/、= 这些字符。它的原理是把 3 字节的二进制数据按 6bit 一组,用 4 个 int 整数表示,然后查表把 int 整数用索引对应到字符,得到编码后的字符串。举个例子:3 个 byte 数据分别是 e4、b8、ad,按 6bit 分组得到 39、0b、22 和 2d。因为 6 位整数的范围总是 0~63,所以能用 64 个字符表示:字符 A~Z 对应索引 0~25,字符 a~z 对应索引 26~51,字符 0~9 对应索引 52~61,最后两个索引 62、63 分别用字符 + 和 / 表示。
在 Java 中,二进制数据就是 byte[] 。Java 标准库提供了 Base64 来对 byte[] 进行编解码:
import java.util.*;
public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad };
String b64encoded = Base64.getEncoder().encodeToString(input);
System.out.println(b64encoded);
}
}
编码后得到 5Lit 4 个字符。要对 Base64 解码,仍然用 Base64 这个类:
import java.util.*;
public class Main {
public static void main(String[] args) {
byte[] output = Base64.getDecoder().decode("5Lit");
System.out.println(Arrays.toString(output)); // [-28, -72, -83]
}
}
如果输入的 byte[] 长度不是 3 的整数,需要对输入的末尾补 1 个 或 2 个 0x00 ,编码后在结尾加 1 个 = 表示补充了 1 个 0x00,加 2 个 = 表示补充了 2 个 0x00,解码的时候,去掉末尾补充的一个或两个 0x00 即可。实际上因为编码后的长度加上 = 总是 4 的倍数,所以即使不加 = 也可以计算出原始输入的 byte[]。Base64 编码的时候可以用 withoutPadding() 去掉 =,解码出来的结果是一样的:
import java.util.*;
public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { (byte) 0xe4, (byte) 0xb8, (byte) 0xad, 0x21 };
String b64encoded = Base64.getEncoder().encodeToString(input);
String b64encoded2 = Base64.getEncoder().withoutPadding().encodeToString(input);
System.out.println(b64encoded);
System.out.println(b64encoded2);
byte[] output = Base64.getDecoder().decode(b64encoded2);
System.out.println(Arrays.toString(output));
}
}
因为标准的 Base64 编码会出现 +、/ 和 =,所以不适合把 Base64 编码后的字符串放到 URL 中。一种可以在 URL 中使用的 Base64 编码,它仅仅是把 + 变成 - ,/ 变成 _ :
import java.util.*;
public class Main {
public static void main(String[] args) {
byte[] input = new byte[] { 0x01, 0x02, 0x7f, 0x00 };
String b64encoded = Base64.getUrlEncoder().encodeToString(input);
System.out.println(b64encoded);
byte[] output = Base64.getUrlDecoder().decode(b64encoded);
System.out.println(Arrays.toString(output));
}
}
Base64 编码的目的是把二进制数据变成文本格式,这样在很多文本中就可以处理二进制数据。例如电子邮件协议就是文本协议,如果要在电子邮件中添加一个二进制文件,就可以用 Base64 编码,然后以文本的形式传送。Base64 编码的缺点是传输效率会降低,因为它把原始数据的长度增加了 1/3。和 URL 编码一样,Base64 编码是一种编码算法,不是加密算法。如果把 Base64 的 64 个字符编码表换成 32 个、48 个或者 58 个,就可以使用 Base32 编码,Base48 编码和 Base58 编码。字符越少,编码的效率就会越低。
哈希算法
哈希算法又称摘要算法,它的作用是对任意一组输入数据进行计算,得到一个固定长度的输出摘要。哈希算法最重要的特点是:相同的输入一定得到相同的输出;不同的输入大概率得到不同的输出。哈希算法的目的是为了验证原始数据是否被篡改。
Java 字符串的 hashCode()就是一个哈希算法,它的输入是任意字符串,输出是固定的 4 字节 int 整数:
"hello".hashCode(); // 0x5e918d2
"hello, java".hashCode(); // 0x7a9d88e8
"hello, bob".hashCode(); // 0xa0dbae2f
两个相同的字符串永远会计算出相同的 hashCode,否则基于 hashCode 定位的 HashMap 就无法正常工作。
哈希碰撞
哈希碰撞是指两个不同的输入得到了相同的输出。哈希碰撞是一定会出现的,因为输出的字节长度是固定的,String 的 hashCode() 输出是 4 字节整数,最多只有 4294967296 种输出,但输入的数据长度是不固定的,有无数种输入。所以哈希算法是把一个无限的输入集合映射到一个有限的输出集合,必然会产生碰撞。碰撞概率的高低关系到哈希算法的安全性。一个安全的哈希算法必须满足:碰撞概率低,不能猜测输出。
常用的哈希算法有:
| 算法 | 输出长度(位) | 输出长度(字节) |
|---|---|---|
| MD5 | 128 bits | 16 bytes |
| SHA-1 | 160 bits | 20 bytes |
| RipeMD-160 | 160 bits | 20 bytes |
| SHA-256 | 256 bits | 32 bytes |
| SHA-512 | 512 bits | 64 bytes |
根据碰撞概率,哈希算法的输出长度越长,就越难产生碰撞,也就越安全。
Java 标准库提供了常用的哈希算法,并且有一套统一的接口。
import java.security.MessageDigest;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
// 创建一个 MessageDigest 实例:
MessageDigest md = MessageDigest.getInstance("MD5");
// 反复调用 update 输入数据:
md.update("Hello".getBytes("UTF-8"));
md.update("World".getBytes("UTF-8"));
byte[] result = md.digest();
System.out.println(HexFormat.of().formatHex(result)); // 16 bytes: 68e109f0f40ca72a15e05cc22786f8e6
}
}
哈希算法的用途
使用哈希算法,相同的输入永远会得到相同的输出,因此如果输入被修改了得到的输出就会不同。哈希算法常用于验证下载文件是否被篡改和存储用户口令。
使用哈希口令要注意防止彩虹表攻击。彩虹表是指常用口令和它们的 MD5 的对照表。如果用户使用了常用口令,黑客从 MD5 就能轻易反查到原始口令。这就是为什么不要使用常用密码以及不要使用生日作为密码的原因。
即使用户使用了常用口令,我们也可以采取措施来抵御彩虹表攻击,方法是对每个口令额外添加随机数,这个方法称之为加盐 salt :digest = md5(salt+inputPassword)。经过加盐处理的数据库表,内容如下:
| username | salt | password |
|---|---|---|
| bob | H1r0a | a5022319ff4c56955e22a74abcc2c210 |
| alice | 7$p2w | e5de688c99e961ed6e560b972dab8b6a |
| tim | z5Sk9 | 1eee304b92dc0d105904e7ab58fd2f64 |
加盐的目的在于使黑客的彩虹表失效,即使用户使用常用口令,也无法从 MD5 反推原始口令。
SHA-1
SHA-1 也是一种哈希算法,它的输出是 160 bits,即 20 字节。SHA-1 是由美国国家安全局开发的,SHA 算法实际上是一个系列,包括 SHA-1、SHA-256、SHA-512 等。在 Java 中使用 SHA-1 和 MD5 完全一样,只需要把算法名称改为 "SHA-1":
import java.security.MessageDigest;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
// 创建一个MessageDigest实例:
MessageDigest md = MessageDigest.getInstance("SHA-1");
// 反复调用update输入数据:
md.update("Hello".getBytes("UTF-8"));
md.update("World".getBytes("UTF-8"));
byte[] result = md.digest();
System.out.println(HexFormat.of().formatHex(result)); // 20 bytes: db8ac1c259eb89d4a131b253bacfca5f319d54f2
}
}
类似的计算 SHA-256,我们需要传入名称 "SHA-256",计算 SHA-512,我们需要传入名称 "SHA-512"。MD5 因为输出长度较短,短时间内破解是可能的,目前已经不推荐使用。
BouncyCastle
BouncyCastle 是一个提供了很多哈希算法和加密算法的第三方库。它提供了 Java 标准库没有的一些算法,例如 RipeMD160 哈希算法。
Java 标准库的 java.security 包提供了一种标准机制,允许第三方提供商无缝接入。我们要使用 BouncyCastle 提供的 RipeMD160 算法,需要先把 BouncyCastle 注册一下。注册只需要在启动时进行一次,后续就可以使用 BouncyCastle 提供的所有哈希算法和加密算法。
public class Main {
public static void main(String[] args) throws Exception {
// 注册 BouncyCastle:
Security.addProvider(new BouncyCastleProvider());
// 按名称正常调用:
MessageDigest md = MessageDigest.getInstance("RipeMD160");
md.update("HelloWorld".getBytes("UTF-8"));
byte[] result = md.digest();
System.out.println(HexFormat.of().formatHex(result));
}
}
Hmac 算法
Hmac 算法是一种基于密钥的消息认证码算法,它的全称是 Hash-based Message Authentication Code,是一种更安全的消息摘要算法。Hmac 算法总是和某种哈希算法配合起来用的。我们使用 MD5 算法,对应的就是 HmacMD5 算法,HmacMD5 可以看作带有一个安全的 key 的 MD5。使用 HmacMD5 而不是用 MD5 加 salt,有如下好处:HmacMD5 使用的 key 长度是 64 字节,更安全;Hmac 是标准算法,同样适用于 SHA-1 等其他哈希算法;Hmac 输出和原有的哈希算法长度一致。
Hmac 本质上就是把 key 混入摘要的算法。验证此哈希时,除了原始的输入数据,还要提供 key。可以通过 Java 标准库的 KeyGenerator 生成安全的随机的 key。
import javax.crypto.*;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5");
SecretKey key = keyGen.generateKey();
// 打印随机生成的 key:
byte[] skey = key.getEncoded();
System.out.println(HexFormat.of().formatHex(skey));
Mac mac = Mac.getInstance("HmacMD5");
mac.init(key);
mac.update("Hello World".getBytes("UTF-8"));
byte[] result = mac.doFinal();
System.out.println(HexFormat.of().formatHex(result));
}
}
我们可以用 Hmac 算法取代原有的自定义的加盐算法,因此存储用户名和口令的数据库结构如下:
| username | secret_key (64 bytes) | password |
|---|---|---|
| bob | a8c06e05f92e...5e16 | 7e0387872a57c85ef6dddbaa12f376de |
| alice | e6a343693985...f4be | c1f929ac2552642b302e739bc0cdbaac |
| tim | f27a973dfdc0...6003 | af57651c3a8a73303515804d4af43790 |
验证:
import javax.crypto.*;
import javax.crypto.spec.*;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
byte[] hkey = HexFormat.of().parseHex(
"b648ee779d658c420420d86291ec70f5" +
"cf97521c740330972697a8fad0b55f5c" +
"5a7924e4afa99d8c5883e07d7c3f9ed0" +
"76aa544d25ed2f5ceea59dcc122babc8");
SecretKey key = new SecretKeySpec(hkey, "HmacMD5");
Mac mac = Mac.getInstance("HmacMD5");
mac.init(key);
mac.update("HelloWorld".getBytes("UTF-8"));
byte[] result = mac.doFinal();
System.out.println(HexFormat.of().formatHex(result)); // 4af40be7864efaae1473a4c601b650ae
}
}
对称加密算法
对称加密算法用一个密码进行加密和解密。我们常用的 WinZIP 和 WinRAR 对压缩包的加密和解密,就是使用对称加密算法。
从程序的角度看,所谓加密就是这样一个函数,它接收密码和明文,然后输出密文:secret = encrypt(key, message); 。而解密则相反,它接收密码和密文,然后输出明文:plain = decrypt(key, secret); 。
常用的对称加密算法:
| 算法 | 密钥长度 | 工作模式 | 填充模式 |
|---|---|---|---|
| DES | 56/64 | ECB/CBC/PCBC/CTR/... N | oPadding/PKCS5Padding/... |
| AES | 128/192/256 | ECB/CBC/PCBC/CTR/... | NoPadding/PKCS5Padding/PKCS7Padding/... |
| IDEA | 128 | ECB | PKCS5Padding/PKCS7Padding/... |
密钥长度直接决定加密强度,而工作模式和填充模式可以看成是对称加密算法的参数和格式选择。Java 标准库提供的算法实现并不包括所有的工作模式和所有填充模式,但是通常我们只需要挑选常用的使用就可以了。DES 算法由于密钥过短可以在短时间内被暴力破解,所以现在已经不安全了。
使用 AES 加密
AES 算法是目前应用最广泛的加密算法。我们先用 ECB 模式加密并解密:
import java.security.*;
import java.util.Base64;
import javax.crypto.*;
import javax.crypto.spec.*;
public class Main {
public static void main(String[] args) throws Exception {
// 原文:
String message = "Hello, world!";
System.out.println("Message: " + message);
// 128 位密钥 = 16 bytes Key:
byte[] key = "1234567890abcdef".getBytes("UTF-8");
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(key, data);
System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(key, encrypted);
System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
}
// 加密:
public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKey keySpec = new SecretKeySpec(key, "AES");
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
return cipher.doFinal(input);
}
// 解密:
public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
SecretKey keySpec = new SecretKeySpec(key, "AES");
cipher.init(Cipher.DECRYPT_MODE, keySpec);
return cipher.doFinal(input);
}
}
ECB 模式是最简单的 AES 加密模式,它只需要一个固定长度的密钥,固定的明文会生成固定的密文,这种一对一的加密方式会导致安全性降低。更好的方式是通过 CBC 模式,它需要一个随机数作为 IV 参数,这样对于同一份明文,每次生成的密文都不同:
import java.security.*;
import java.util.Base64;
import javax.crypto.*;
import javax.crypto.spec.*;
public class Main {
public static void main(String[] args) throws Exception {
// 原文:
String message = "Hello, world!";
System.out.println("Message: " + message);
// 256 位密钥 = 32 bytes Key:
byte[] key = "1234567890abcdef1234567890abcdef".getBytes("UTF-8");
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(key, data);
System.out.println("Encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(key, encrypted);
System.out.println("Decrypted: " + new String(decrypted, "UTF-8"));
}
// 加密:
public static byte[] encrypt(byte[] key, byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
// CBC 模式需要生成一个 16 bytes 的 initialization vector:
SecureRandom sr = SecureRandom.getInstanceStrong();
byte[] iv = sr.generateSeed(16);
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivps);
byte[] data = cipher.doFinal(input);
// IV 不需要保密,把 IV 和密文一起返回:
return join(iv, data);
}
// 解密:
public static byte[] decrypt(byte[] key, byte[] input) throws GeneralSecurityException {
// 把 input 分割成 IV 和密文:
byte[] iv = new byte[16];
byte[] data = new byte[input.length - 16];
System.arraycopy(input, 0, iv, 0, 16);
System.arraycopy(input, 16, data, 0, data.length);
// 解密:
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
IvParameterSpec ivps = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivps);
return cipher.doFinal(data);
}
public static byte[] join(byte[] bs1, byte[] bs2) {
byte[] r = new byte[bs1.length + bs2.length];
System.arraycopy(bs1, 0, r, 0, bs1.length);
System.arraycopy(bs2, 0, r, bs1.length, bs2.length);
return r;
}
}
在 CBC 模式下,需要一个随机生成的 16 字节 IV 参数,必须使用 SecureRandom 生成。因为多了一个 IvParameterSpec 实例,因此初始化方法需要调用 Cipher 的一个重载方法并传入 IvParameterSpec。观察输出可以发现每次生成的 IV 不同,密文也不同。
口令加密算法
用户使用 WinZip/WinRAR 输入的口令一般是随机几个字符,而 AES 加密的密钥长度是固定的 128/192/256 位,即至少 16 个字符。实际上用户输入的口令并不能直接作为 AES 的密钥进行加密,除非长度恰好是 128/192/256 位,并且用户输入的口令一般都有规律,安全性远远不如安全随机数产生的随机口令。因此用户输入的口令通常还需要使用 PBE 算法,采用随机数杂凑计算出真正的密钥,再进行加密。
PBE 就是 Password Based Encryption 的缩写,它的作用:key = generate(userPassword, secureRandomPassword);。PBE 的作用就是把用户输入的口令和一个安全随机的口令采用杂凑后计算出真正的密钥。以 AES 密钥为例,我们让用户输入一个口令,然后生成一个随机数,通过 PBE 算法计算出真正的 AES 口令,再进行加密:
public class Main {
public static void main(String[] args) throws Exception {
// 把 BouncyCastle 作为 Provider 添加到 java.security:
Security.addProvider(new BouncyCastleProvider());
// 原文:
String message = "Hello, world!";
// 加密口令:
String password = "hello12345";
// 16 bytes 安全随机数:
byte[] salt = SecureRandom.getInstanceStrong().generateSeed(16);
System.out.println(HexFormat.of().formatHex(salt));
// 加密:
byte[] data = message.getBytes("UTF-8");
byte[] encrypted = encrypt(password, salt, data);
System.out.println("encrypted: " + Base64.getEncoder().encodeToString(encrypted));
// 解密:
byte[] decrypted = decrypt(password, salt, encrypted);
System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
}
// 加密:
public static byte[] encrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
SecretKey skey = skeyFactory.generateSecret(keySpec);
PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
cipher.init(Cipher.ENCRYPT_MODE, skey, pbeps);
return cipher.doFinal(input);
}
// 解密:
public static byte[] decrypt(String password, byte[] salt, byte[] input) throws GeneralSecurityException {
PBEKeySpec keySpec = new PBEKeySpec(password.toCharArray());
SecretKeyFactory skeyFactory = SecretKeyFactory.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
SecretKey skey = skeyFactory.generateSecret(keySpec);
PBEParameterSpec pbeps = new PBEParameterSpec(salt, 1000);
Cipher cipher = Cipher.getInstance("PBEwithSHA1and128bitAES-CBC-BC");
cipher.init(Cipher.DECRYPT_MODE, skey, pbeps);
return cipher.doFinal(input);
}
}
使用 PBE 时,我们还需要引入 BouncyCastle,并指定算法是 PBEwithSHA1and128bitAES-CBC-BC。真正的 AES 密钥是调用 Cipher 的 init() 方法时同时传入 SecretKey 和 PBEParameterSpec 实现的。在创建 PBEParameterSpec 的时候,我们还指定了循环次数 1000,循环次数越多,暴力破解需要的计算量就越大。
如果我们把 salt 和循环次数固定,就得到了一个通用的“口令”加密软件。如果我们把随机生成的 salt 存储在 U 盘,就得到了一个“口令”加 USB Key 的加密软件,它的好处在于即使用户使用了一个非常弱的口令,没有 USB Key 仍然无法解密,因为 USB Key 存储的随机数密钥安全性非常高。
密钥交换算法
对称加密算法解决了数据加密的问题。密钥交换算法 Diffie-Hellman 算法可以让密钥在双方不直接传递密钥的情况下完成密钥交换,这个神奇的交换原理完全由数学理论支持。更确切地说,DH 算法是一个密钥协商算法,双方最终协商出一个共同的密钥,而这个密钥不会通过网络传输。
使用 Java 实现 DH 算法的代码如下:
import java.security.*;
import java.security.spec.*;
import javax.crypto.KeyAgreement;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) {
// Bob 和 Alice:
Person bob = new Person("Bob");
Person alice = new Person("Alice");
// 各自生成 KeyPair:
bob.generateKeyPair();
alice.generateKeyPair();
// 双方交换各自的 PublicKey:
// Bob 根据 Alice 的 PublicKey 生成自己的本地密钥:
bob.generateSecretKey(alice.publicKey.getEncoded());
// Alice 根据 Bob 的 PublicKey 生成自己的本地密钥:
alice.generateSecretKey(bob.publicKey.getEncoded());
// 检查双方的本地密钥是否相同:
bob.printKeys();
alice.printKeys();
// 双方的 SecretKey 相同,后续通信将使用 SecretKey 作为密钥进行 AES 加解密...
}
}
class Person {
public final String name;
public PublicKey publicKey;
private PrivateKey privateKey;
private byte[] secretKey;
public Person(String name) {
this.name = name;
}
// 生成本地 KeyPair:
public void generateKeyPair() {
try {
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("DH");
kpGen.initialize(512);
KeyPair kp = kpGen.generateKeyPair();
this.privateKey = kp.getPrivate();
this.publicKey = kp.getPublic();
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}
public void generateSecretKey(byte[] receivedPubKeyBytes) {
try {
// 从 byte[] 恢复 PublicKey:
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes);
KeyFactory kf = KeyFactory.getInstance("DH");
PublicKey receivedPublicKey = kf.generatePublic(keySpec);
// 生成本地密钥:
KeyAgreement keyAgreement = KeyAgreement.getInstance("DH");
keyAgreement.init(this.privateKey); // 自己的 PrivateKey
keyAgreement.doPhase(receivedPublicKey, true); // 对方的 PublicKey
// 生成 SecretKey 密钥:
this.secretKey = keyAgreement.generateSecret();
} catch (GeneralSecurityException e) {
throw new RuntimeException(e);
}
}
public void printKeys() {
System.out.println("Name: " + this.name);
System.out.println("Private key: " + HexFormat.of().formatHex(this.privateKey.getEncoded()));
System.out.println("Public key: " + HexFormat.of().formatHex(this.publicKey.getEncoded()));
System.out.println("Secret key: " + HexFormat.of().formatHex(this.secretKey));
}
}
但是 DH 算法并未解决中间人攻击,即甲乙双方并不能确保与自己通信的是否真的是对方。消除中间人攻击需要其他方法。
非对称加密算法
从 DH 算法我们可以看到,公钥-私钥组成的密钥对是非常有用的加密方式,因为公钥是可以公开的,而私钥是完全保密的,由此奠定了非对称加密的基础。非对称加密就是加密和解密使用的不同的密钥:只有同一个公钥-私钥对才能正常加解密,通常是用公钥加密私钥解密。
非对称加密的典型算法是 RSA 算法。非对称加密相比对称加密的显著优点在于,对称加密需要协商密钥,而非对称加密可以安全地公开各自的公钥,在 N 个人之间通信的时候:使用非对称加密只需要 N 组密钥对,每个人只管理自己的密钥对。而使用对称加密需要则需要 N*(N-1)/2 个密钥,因此每个人需要管理 N-1 个密钥,密钥管理难度大,而且非常容易泄漏。
非对称加密的缺点就是运算速度非常慢,比对称加密要慢很多。所以在实际应用的时候,非对称加密总是和对称加密一起使用。假设小明需要给小红需要传输加密文件,他俩首先交换了各自的公钥,然后小明生成一个随机的 AES 口令,然后用小红的公钥通过 RSA 加密这个口令,并发给小红;小红用自己的 RSA 私钥解密得到 AES 口令;双方使用这个共享的 AES 口令用 AES 加密通信。非对称加密应用在加密“AES 口令”。这也是 HTTPS 协议的做法,即浏览器和服务器先通过 RSA 交换 AES 口令,接下来双方通信实际上采用的是速度较快的 AES 对称加密,而不是缓慢的 RSA 非对称加密。
import java.security.*;
import javax.crypto.Cipher;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
// 明文:
byte[] plain = "Hello, encrypt use RSA".getBytes("UTF-8");
// 创建公钥/私钥对:
Person alice = new Person("Alice");
// 用 Alice 的公钥加密:
byte[] pk = alice.getPublicKey();
System.out.println("public key: " + HexFormat.of().formatHex(pk));
byte[] encrypted = alice.encrypt(plain);
System.out.println("encrypted: " + HexFormat.of().formatHex(encrypted));
// 用 Alice 的私钥解密:
byte[] sk = alice.getPrivateKey();
System.out.println("private key: " + HexFormat.of().formatHex(sk));
byte[] decrypted = alice.decrypt(encrypted);
System.out.println(new String(decrypted, "UTF-8"));
}
}
class Person {
String name;
// 私钥:
PrivateKey sk;
// 公钥:
PublicKey pk;
public Person(String name) throws GeneralSecurityException {
this.name = name;
// 生成公钥/私钥对:
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
kpGen.initialize(1024);
KeyPair kp = kpGen.generateKeyPair();
this.sk = kp.getPrivate();
this.pk = kp.getPublic();
}
// 把私钥导出为字节
public byte[] getPrivateKey() {
return this.sk.getEncoded();
}
// 把公钥导出为字节
public byte[] getPublicKey() {
return this.pk.getEncoded();
}
// 用公钥加密:
public byte[] encrypt(byte[] message) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, this.pk);
return cipher.doFinal(message);
}
// 用私钥解密:
public byte[] decrypt(byte[] input) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, this.sk);
return cipher.doFinal(input);
}
}
RSA 的公钥和私钥都可以通过 getEncoded() 方法获得以 byte[] 表示的二进制数据,并根据需要保存到文件中。要从 byte[] 数组恢复公钥或私钥,可以这么写:
byte[] pkData = ...
byte[] skData = ...
KeyFactory kf = KeyFactory.getInstance("RSA");
// 恢复公钥:
X509EncodedKeySpec pkSpec = new X509EncodedKeySpec(pkData);
PublicKey pk = kf.generatePublic(pkSpec);
// 恢复私钥:
PKCS8EncodedKeySpec skSpec = new PKCS8EncodedKeySpec(skData);
PrivateKey sk = kf.generatePrivate(skSpec);
以 RSA 算法为例,它的密钥有 256/512/1024/2048/4096 等不同的长度。长度越长密码强度越大,当然计算速度也越慢。 RSA 总是配合 AES 一起使用,即用 AES 加密任意长度的明文,用 RSA 加密 AES 口令。
此外,只使用非对称加密算法不能防止中间人攻击。
签名算法
我们使用非对称加密算法的时候,对于一个公钥-私钥对,通常是用公钥加密,私钥解密。如果使用私钥加密,那相当于所有人都可以用公钥解密。私钥加密得到的密文实际上就是数字签名,要验证这个签名是否正确,只能用私钥持有者的公钥进行解密验证。使用数字签名的目的是为了确认某个信息确实是由某个发送方发送的,任何人都不可能伪造消息,并且发送方也不能抵赖。
在实际应用的时候,签名实际上并不是针对原始消息,而是针对原始消息的哈希进行签名: signature = encrypt(privateKey, sha256(message))。对签名进行验证实际上就是用公钥解密:hash = decrypt(publicKey, signature),然后把解密后的哈希与原始消息的哈希进行对比。
因为用户总是使用自己的私钥进行签名,所以私钥就相当于用户身份。而公钥用来给外部验证用户身份。常用数字签名算法有:MD5withRSA、SHA1withRSA、SHA256withRSA。它们实际上就是指定某种哈希算法进行 RSA 签名的方式。
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws GeneralSecurityException {
// 生成 RSA 公钥/私钥:
KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA");
kpGen.initialize(1024);
KeyPair kp = kpGen.generateKeyPair();
PrivateKey sk = kp.getPrivate();
PublicKey pk = kp.getPublic();
// 待签名的消息:
byte[] message = "Hello, I am Bob!".getBytes(StandardCharsets.UTF_8);
// 用私钥签名:
Signature s = Signature.getInstance("SHA1withRSA");
s.initSign(sk);
s.update(message);
byte[] signed = s.sign();
System.out.println("signature: " + HexFormat.of().formatHex(signed));
// 用公钥验证:
Signature v = Signature.getInstance("SHA1withRSA");
v.initVerify(pk);
v.update(message);
boolean valid = v.verify(signed);
System.out.println("valid? " + valid);
}
}
使用其他公钥或者验证签名的时候修改原始信息,都无法验证成功。
除了 RSA 可以签名外,还可以使用 DSA 算法进行签名。DSA 是 Digital Signature Algorithm 的缩写,它使用 ElGamal 数字签名算法。DSA 只能配合 SHA 使用,常用的算法有:SHA1withDSA、SHA256withDSA、SHA512withDSA。和 RSA 数字签名相比,DSA 的优点是更快。
椭圆曲线签名算法 ECDSA:Elliptic Curve Digital Signature Algorithm 也是一种常用的签名算法,它的特点是可以从私钥推出公钥。比特币的签名算法就采用了 ECDSA 算法,使用标准椭圆曲线 secp256k1。BouncyCastle 提供了 ECDSA 的完整实现。
数字证书
摘要算法用来确保数据没有被篡改,非对称加密算法可以对数据进行加解密,签名算法可以确保数据完整性和抗否认性,把这些算法集合到一起并搞一套完善的标准,这就是数字证书。数字证书就是集合了多种密码学算法,用于实现数据加解密、身份认证、签名等多种功能的一种安全标准。
数字证书可以防止中间人攻击,因为它采用链式签名认证,即通过根证书 Root CA 去签名下一级证书,这样层层签名,直到最终的用户证书。而 Root CA 证书内置于操作系统中,所以任何经过 CA 认证的数字证书都可以对其本身进行校验,确保证书本身不是伪造的。HTTPS 协议就是数字证书的应用。浏览器会自动验证证书的有效性。
要使用数字证书,首先需要创建证书。正常情况下,一个合法的数字证书需要经过 CA 签名,这需要认证域名并支付一定的费用。开发的时候,我们可以使用自签名的证书,这种证书可以正常开发调试,但不能对外作为服务使用,因为其他客户端并不认可未经 CA 签名的证书。在 Java 程序中,数字证书存储在一种 Java 专用的 key store 文件中,JDK 提供了一系列命令来创建和管理 key store。我们用下面的命令创建一个 key store,并设定口令 123456:keytool -storepass 123456 -genkeypair -keyalg RSA -keysize 1024 -sigalg SHA1withRSA -validity 3650 -alias mycert -keystore my.keystore -dname "CN=www.sample.com, OU=sample, O=sample, L=BJ, ST=BJ, C=CN" 。几个主要的参数是:keyalg 指定 RSA 加密算法;sigalg:指定 SHA1withRSA 签名算法;validity:指定证书有效期 3650 天;alias:指定证书在程序中引用的名称;dname:最重要的 CN=www.sample.com 指定了 Common Name,如果证书用在 HTTPS 中,这个名称必须与域名完全一致。执行上述命令,JDK 会在当前目录创建一个 my.keystore 文件,并存储创建成功的一个私钥和一个证书,它的别名是 mycert。有了 key store 存储的证书,我们就可以通过数字证书进行加解密和签名:
import java.io.InputStream;
import java.security.*;
import java.security.cert.*;
import javax.crypto.Cipher;
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws Exception {
byte[] message = "Hello, use X.509 cert!".getBytes("UTF-8");
// 读取 KeyStore:
KeyStore ks = loadKeyStore("/my.keystore", "123456");
// 读取私钥:
PrivateKey privateKey = (PrivateKey) ks.getKey("mycert", "123456".toCharArray());
// 读取证书:
X509Certificate certificate = (X509Certificate) ks.getCertificate("mycert");
// 加密:
byte[] encrypted = encrypt(certificate, message);
System.out.println("encrypted: " + HexFormat.of().formatHex(encrypted));
// 解密:
byte[] decrypted = decrypt(privateKey, encrypted);
System.out.println("decrypted: " + new String(decrypted, "UTF-8"));
// 签名:
byte[] sign = sign(privateKey, certificate, message);
System.out.println("signature: " + HexFormat.of().formatHex(sign));
// 验证签名:
boolean verified = verify(certificate, message, sign);
System.out.println("verify: " + verified);
}
static KeyStore loadKeyStore(String keyStoreFile, String password) {
try (InputStream input = Main.class.getResourceAsStream(keyStoreFile)) {
if (input == null) {
throw new RuntimeException("file not found in classpath: " + keyStoreFile);
}
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
ks.load(input, password.toCharArray());
return ks;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
static byte[] encrypt(X509Certificate certificate, byte[] message) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(certificate.getPublicKey().getAlgorithm());
cipher.init(Cipher.ENCRYPT_MODE, certificate.getPublicKey());
return cipher.doFinal(message);
}
static byte[] decrypt(PrivateKey privateKey, byte[] data) throws GeneralSecurityException {
Cipher cipher = Cipher.getInstance(privateKey.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, privateKey);
return cipher.doFinal(data);
}
static byte[] sign(PrivateKey privateKey, X509Certificate certificate, byte[] message)
throws GeneralSecurityException {
Signature signature = Signature.getInstance(certificate.getSigAlgName());
signature.initSign(privateKey);
signature.update(message);
return signature.sign();
}
static boolean verify(X509Certificate certificate, byte[] message, byte[] sig) throws GeneralSecurityException {
Signature signature = Signature.getInstance(certificate.getSigAlgName());
signature.initVerify(certificate);
signature.update(message);
return signature.verify(sig);
}
}
在上述代码中,我们从 key store 直接读取了私钥-公钥对,私钥以 PrivateKey 实例表示,公钥以 X509Certificate 表示,实际上数字证书只包含公钥,因此读取证书并不需要口令,只有读取私钥才需要。如果部署到 Web 服务器上,需要把私钥导出为 Private Key 格式,把证书导出为 X509Certificate 格式。
以 HTTPS 协议为例,浏览器和服务器建立安全连接的步骤如下:浏览器向服务器发起请求,服务器向浏览器发送自己的数字证书;浏览器用操作系统内置的 Root CA 来验证服务器的证书是否有效,如果有效就使用该证书加密一个随机的 AES 口令并发送给服务器;服务器用自己的私钥解密获得 AES 口令,并在后续通讯中使用 AES 加密。上述流程只是一种最常见的单向验证。如果服务器还要验证客户端,那么客户端也需要把自己的证书发送给服务器验证,这种场景常见于网银等。
注意数字证书存储的是公钥以及相关的证书链和算法信息。私钥必须严格保密,如果数字证书对应的私钥泄漏,就会造成严重的安全威胁。如果 CA 证书的私钥泄漏,那么该 CA 证书签发的所有证书将不可信。
多线程
现代操作系统都可以执行多任务。CPU 执行代码是一条一条顺序执行的,但是即使是单核 cpu 也可以同时运行多个任务。因为操作系统执行多任务实际上就是让 CPU 对多个任务轮流交替执行。
进程 vs 线程
进程和线程的关系:一个进程可以包含一个或多个线程。操作系统任务调度的最小单位是线程。一个应用程序,既可以有多个进程,也可以有多个线程,因此实现多任务的方法,有以下几种:多进程模式(每个进程只有一个线程);多线程模式(一个进程有多个线程);多进程+多线程模式。
和多线程相比,多进程的缺点在于:创建进程比创建线程开销大,尤其是在 Windows 系统上;进程间通信比线程间通信要慢。而多进程的优点在于:多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
Java 语言内置了多线程支持:一个 Java 程序实际上是一个 JVM 进程,JVM 进程用一个主线程来执行 main() 方法,在 main() 方法内部,我们又可以启动多个线程。此外 JVM 还有负责垃圾回收的其他工作线程等。对于大多数 Java 程序来说多任务实际上是用多线程实现的。和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。因此多线程编程的复杂度高,调试更困难。
创建新线程
从 Thread 派生一个自定义类,然后覆写 run() 方法,调用 start() 方法。start() 方法被调用时会在内部自动调用线程实例的 run() 方法。
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
创建 Thread 实例时,传入一个 Runnable 实例:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}
用 Java 8 引入的 lambda 语法进一步简写为:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}
t 线程开始运行以后,主线程和 t 线程就开始同时运行,并且由操作系统调度,程序本身无法确定线程的调度顺序。
线程的优先级
可以对线程设定优先级,设定优先级的方法是:Thread.setPriority(int n) ,优先级取值范围 1~10,默认值 5。JVM 自动把 1(低)~10(高)的优先级映射到操作系统实际优先级上。操作系统对高优先级线程可能调度更频繁,但不能通过设置优先级来确保高优先级的线程一定会先执行。
线程的状态
在 Java 程序中,线程对象只能调用一次 start() 方法启动新线程,并在新线程中执行 run() 方法。一旦 run() 方法执行完毕,线程就结束了。Java 线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行 run() 方法的 Java 代码;
- Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行 sleep() 方法正在计时等待;
- Terminated:线程已终止,因为 run() 方法执行完毕。
当线程启动后,它可以在 Runnable、Blocked、Waiting 和 Timed Waiting 这几个状态之间切换,直到最后变成 Terminated 状态,线程终止。
一个线程可以等待另一个线程直到其运行结束。主线程在启动 t 线程后,可以通过 t.join() 等待 t 线程结束后再继续运行:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start(); // 启动t线程
t.join(); // 此处 main 线程会等待 t 结束
System.out.println("end");
}
}
当 main 线程对线程对象 t 调用 join() 方法时,主线程将等待 t 线程运行结束,然后才继续往下执行自身线程。如果 t 线程已经结束,对实例 t 调用 join() 会立刻返回。join(long) 重载方法可以指定等待时间,超过等待时间后就不再继续等待。
中断线程
如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是给该线程发一个信号,该线程收到信号后结束执行 run() 方法,使得自身线程能立刻结束运行。中断一个线程只需要对线程调用 interrupt() 方法,需要在自身 run() 方法中反复检测自身状态是否是 interrupted 状态,如果是就立刻结束运行。
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停 1 毫秒
t.interrupt(); // 中断 t 线程
t.join(); // 等待 t 线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}
main 线程通过调用 t.interrupt() 方法中断 t 线程。 interrupt() 方法仅仅向 t 线程发出了 “中断请求”,而 t 线程 run() 方法中的 while 循环会检测 isInterrupted(),所以上述代码能正确响应 interrupt() 请求,使得自身立刻结束运行 run() 方法。
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断 t 线程
t.join(); // 等待 t 线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动 hello 线程
try {
hello.join(); // 等待 hello 线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main 线程通过调用 t.interrupt() 通知 t 线程中断,而此时 t 线程正位于 hello.join() 的等待中,此方法会立刻结束等待并抛出 InterruptedException。在 t 线程中捕获了 InterruptedException,准备结束该线程。在 t 线程结束前对 hello 线程也进行了 interrupt() 调用通知其中断。如果未对 hello 线程调用 interrupt() ,hello 线程仍然会继续运行,且 JVM 不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个 running 标志位来标识线程是否应该继续运行,在外部线程中通过把 HelloThread.running 置为 false,就可以让线程结束:
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(10);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
HelloThread 的标志位 running 使用 volatile 关键字声明,保证了多线程环境下变量的修改对所有线程立即可见,确保每个线程都能读取到更新后的变量值。
守护线程
守护线程是指为其他线程服务的线程。在 JVM 中,所有非守护线程都执行完毕后,JVM 会自动退出,守护线程随主线程结束而立即终止。守护线程生命周期随主线程结束而立即终止,可能无法完成收尾工作,因此守护线程不能持有任何需要关闭的资源,例如打开文件等,因为 JVM 退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。守护线程常用于日志记录、内存监控等后台任务。
创建守护线程的方法和普通线程一样,只是在调用 start() 方法前,调用 setDaemon(true) 把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
线程同步
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。如果多个线程同时读写共享变量,就会出现数据不一致的问题。
public class Main {
private static int tickets = 100;
public static void main(String[] args) {
Runnable sell = () -> {
while (true) {
if (tickets <= 0) break;
System.out.println(Thread.currentThread().getName() + " 售出票#" + tickets);
tickets--;
try {
Thread.sleep(10); // 模拟业务处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
new Thread(sell, "窗口1").start();
new Thread(sell, "窗口2").start(); // 可能出现同一票号被多次售出
}
}
多线程模型下要保证逻辑正确,对共享变量进行读写必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待。通过加锁和解锁的操作,就能保证一组指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区,任何时候临界区最多只有一个线程能执行。
Java 程序使用 synchronized 关键字对一个对象进行加锁,synchronized 保证了代码块在任意时刻最多只有一个线程能执行。
public class Main {
private static int tickets = 100;
private static final Object lock = new Object();
public static void main(String[] args) {
Runnable sell = () -> {
while (true) {
synchronized (lock) {
if (tickets <= 0) break;
System.out.println(Thread.currentThread().getName() + " 售出票#" + tickets);
tickets--;
}
try {
Thread.sleep(10); // 模拟业务处理时间
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
new Thread(sell, "窗口1").start();
new Thread(sell, "窗口2").start();
}
}
两个线程在执行各自的 synchronized(Counter.lock) { ... } 代码块时,必须先获得锁才能进入代码块进行。执行结束后,在 synchronized 语句块结束会自动释放锁。这样一来,对 Counter.count 变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是 0。
使用 synchronized 解决了多线程同步访问共享变量的正确性问题,但是会带来性能下降,因为 synchronized 代码块无法并发执行。此外加锁和解锁需要消耗一定的时间,所以 synchronized 会降低程序的执行效率。
在使用 synchronized 的时候,不必担心抛出异常。无论是否有异常,都会在 synchronized 结束处正确释放锁。
##### 不需要 synchronized 的操作
JVM 规范定义了几种原子操作:基本类型(long 和 double 除外)赋值;引用类型赋值;java.util.concurrent.atomic 包中的原子类的复合操作;不可变对象的访问;volatile 修饰的变量的单次读写。单条原子操作的语句不需要同步。如果是多行赋值语句,就必须保证是同步操作,例如:
```java
class Point {
int x;
int y;
public void set(int x, int y) {
synchronized(this) {
this.x = x;
this.y = y;
}
}
}
有些时候通过转换,可以把非原子操作变为原子操作。上述代码如果改造成:
class Point {
int[] ps;
public void set(int x, int y) {
int[] ps = new int[] { x, y };
this.ps = ps;
}
}
就不再需要写同步,因为 this.ps = ps 是引用赋值的原子操作。而语句 int[] ps = new int[] { x, y };,这里的 ps 是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响并且互不可见,并不需要同步。
如果多线程读写的是一个不可变对象,那么无需同步,因为不会修改对象的状态:
class Data {
List<String> names;
void set(String[] names) {
this.names = List.of(names);
}
List<String> get() {
return this.names;
}
}
set() 方法内部创建了一个不可变 List,整个 List<String> 对象都是不可变的,因此读写均无需同步。
同步方法
用 synchronized 修饰的方法就是同步方法,它表示整个方法都必须用 this 实例加锁。
public class Counter {
private int count = 0;
public synchronized void add(int n) { // 锁住 this
count += n;
} // 解锁
public synchronized void dec(int n) { // 锁住 this
count -= n;
} // 解锁
public int get() {
return count;
}
}
如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的,上面的 Counter 类就是线程安全的。Counter 的 get() 方法没有同步,因为读一个 int 变量不需要同步。
其它线程安全的类:java.util.concurrent并发包中的类;String、Integer等不可变对象; java.lang.StringBuffer。除此之外大部分类如 ArrayList 都是非线程安全的类。但是如果所有线程都只读取不写入,那么 ArrayList 是可以安全地在线程间共享的。没有特殊说明时,一个类默认是非线程安全的。
如果对一个静态方法添加 synchronized 修饰符,锁住的是该类的 Class 实例。
Java 的线程锁是可重入锁。JVM 允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
由于 Java 的线程锁是可重入锁,所以获取锁的时候,JVM 不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录 +1,每退出 synchronized 块,记录 -1,减到 0 的时候,才会真正释放锁。
死锁
一个线程可以获取一个锁后,再继续获取另一个锁。例如:
public class Main {
static final Object lockA = new Object();
static final Object lockB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lockA) {
System.out.println("线程1持有lockA");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockB) {
System.out.println("线程1获取lockB");
}
}
}).start();
new Thread(() -> {
synchronized (lockB) {
System.out.println("线程2持有lockB");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockA) {
System.out.println("线程2获取lockA");
}
}
}).start();
}
}
两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。死锁发生后,没有任何机制能解除死锁,只能强制结束 JVM 进程。因此在编写多线程应用时,要特别注意防止死锁。
避免死锁的解决方案是:线程获取锁的顺序要一致,按固定顺序获取锁,如总是先获取lockA再lockB。
wait 和 notify
synchronized 解决了多线程竞争的问题,但是 synchronized 并没有解决多线程协调的问题。多线程协调运行的原则是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒继续执行任务。在当前获取的锁对象上调用 wait() 方法后,会释放线程获得的锁,线程进入等待状态,wait() 方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait() 方法才会返回,线程又会重新试图获得锁,然后继续执行下一条语句。在当前获取的锁对象上调用 notify() 方法,会唤醒一个正在锁等待的线程,从而使得等待线程从 wait() 方法返回。使用 notifyAll() 将唤醒所有当前正在锁等待的线程。
public class Main {
private static final Object lock = new Object();
private static int current = 1;
public static void main(String[] args) {
new Thread(() -> printNumber(1)).start();
new Thread(() -> printNumber(2)).start();
new Thread(() -> printNumber(3)).start();
}
private static void printNumber(int target) {
synchronized (lock) {
while (current != target) {
try {
lock.wait(); // 非目标序号时等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(target);
current++;
lock.notifyAll(); // 唤醒所有线程重新竞争
}
}
}
通过 wait() 和 notifyAll() 确保线程按 1→2→3 顺序打印。notifyAll() 唤醒所有等待线程,避免单一 notify() 可能导致的线程不能被唤醒风险。通常来说,notifyAll()更安全。wait() 方法返回时需要重新获得锁。我们在 while() 循环中调用 wait(),而不是 if 语句中调用 wait(),因为多个线程被唤醒后,获取锁的线程需要再次判断条件是否符合。
正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。
java.util.concurrent 包提供的锁、集合类、原子类
Java 提供了 synchronized 关键字用于加锁,但这种锁很重且获取时必须一直等待,没有额外的尝试机制。从 Java 5 开始,引入了一个高级的处理并发的 java.util.concurrent 包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。
ReentrantLock 和 Condition
java.util.concurrent.locks 包提供的 ReentrantLock 用于替代 synchronized 加锁。ReentrantLock 是可重入锁,它和 synchronized 一样,一个线程可以多次获取同一个锁。
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
和 synchronized 不同的是,ReentrantLock 可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待 1 秒。如果 1 秒后仍未获取到锁,tryLock() 返回 false,程序就可以做一些额外处理,而不是无限等待下去。使用 ReentrantLock 比直接使用 synchronized 更安全,线程在 tryLock() 失败的时候不会导致死锁。
使用 ReentrantLock 比直接使用 synchronized 更安全,可以替代 synchronized 进行线程同步。synchronized 可以配合 wait 和 notify 实现线程在条件不满足时等待,条件满足时唤醒。用 ReentrantLock 时,使用 Condition 对象来实现 wait 和 notify 的功能。
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
使用 Condition 时,引用的 Condition 对象必须从 Lock 实例的 newCondition() 返回,这样才能获得一个绑定了 Lock 实例的 Condition 实例。
Condition 提供的 await()、signal()、signalAll() 和 synchronized 锁对象的 wait()、notify()、notifyAll() 是一致的,并且其行为也是一样的:await() 会释放当前锁,进入等待状态;signal() 会唤醒某个等待线程;signalAll() 会唤醒所有等待线程;唤醒线程从await() 返回后需要重新获得锁。
和 tryLock() 类似,await() 可以在等待指定时间后,如果还没有被其他线程通过 signal() 或 signalAll() 唤醒,可以自己醒来:
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
使用 Condition 配合 Lock,我们可以实现更灵活的线程同步。
ReadWriteLock
ReentrantLock 保证了只有一个线程可以执行临界区代码,但是有些时候我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。使用 ReadWriteLock 可以解决这个问题,它保证:只允许一个线程写入(其他线程既不能写入也不能读取);没有写入时,多个线程允许同时读(提高性能)。ReadWriteLock 适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
// 注意: 一对读锁和写锁必须从同一个 rwlock 获取:
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
StampedLock
ReadWriteLock 实现了多线程同时读但只有一个线程能写。但如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。要进一步提升并发执行效率,Java 8 引入了新的读写锁:StampedLock。StampedLock 和 ReadWriteLock 相比,改进之处在于:读的过程中允许写进程获取写锁后写入,读进程判断读的过程中是否有写入。这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 下面两行代码不是原子操作
double currentX = x;
double currentY = y;
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
和 ReadWriteLock 相比,写入的加锁是完全一样的,不同的是读取。首先我们通过 tryOptimisticRead() 获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过 validate() 去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
StampedLock 把读锁细分为乐观读和悲观读,能进一步提升并发效率。但也是有代价的:代码更加复杂,而且 StampedLock 是不可重入锁,不能在一个线程中反复获取同一个锁。StampedLock 还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在以下场景:即先读,如果读的数据满足条就返回,如果读的数据不满足条件,再尝试写。
Semaphore
本质上锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)。Semaphore 可以保证同一时刻最多有 N 个线程能访问受限资源。使用 Semaphore 先调用 acquire() 获取,然后通过 try ... finally 保证在 finally 中释放。
public class AccessLimitControl {
// 任意时刻仅允许最多 3 个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
调用 acquire() 可能会进入等待,直到满足条件为止。也可以使用 tryAcquire() 指定等待时间:
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
// 指定等待时间3秒内获取到许可:
try {
// TODO:
} finally {
semaphore.release();
}
}
Semaphore 本质上就是一个信号计数器,用于限制同一时间的最大访问数量。
Concurrent 集合
阻塞队列:当一个线程调用队列的 getTask() 方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()方法才会返回。Java 标准库的 java.util.concurrent 包提供的线程安全的阻塞队列集合:ArrayBlockingQueue。
java.util.concurrent 包提供了线程安全的并发集合类:
| interface | non-thread-safe | thread-safe |
|---|---|---|
| List | ArrayList | CopyOnWriteArrayList |
| Map | HashMap | ConcurrentHashMap |
| Set | HashSet TreeSet | CopyOnWriteArraySet |
| Queue | ArrayDeque LinkedList | ArrayBlockingQueue LinkedBlockingQueue |
| Deque | ArrayDeque LinkedList | LinkedBlockingDeque |
使用这些并发集合与使用非线程安全的集合类完全相同,所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。
Atomic
java.util.concurrent 包除了提供底层锁、并发集合外,还提供了原子操作的封装类,它们位于 java.util.concurrent.atomic 包。Atomic 类是通过无锁的方式实现的线程安全访问。它的主要原理是利用了 CAS 。CAS 是指,在这个操作中如果当前值是 prev,那么就更新为 next,返回 true。如果当前值不是 prev,就什么也不干,返回 false。通过 CAS 操作并配合 do ... while 循环,即使其他线程修改了 AtomicInteger 的值,最终的结果也是正确的。
我们利用 AtomicLong 可以编写一个多线程安全的全局唯一 ID 生成器:
class IdGenerator {
AtomicLong var = new AtomicLong(0);
public long getNextId() {
return var.incrementAndGet();
}
}
在高度竞争的情况下,还可以使用 Java 8 提供的 LongAdder 和 LongAccumulator。
线程池
复用一组线程,把很多小任务让一组线程来执行,而不是一个任务对应一个新线程。这种能接收大量小任务并进行分发处理的就是线程池。线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。如果有新任务,就分配一个空闲线程执行。如果所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。
Java 标准库提供了 ExecutorService 接口表示线程池,几个常用实现类:FixedThreadPool 线程数固定的线程池;CachedThreadPool 线程数根据任务动态调整的线程池;SingleThreadExecutor 仅单线程执行的线程池。ExecutorService 提供了任务提交、线程池生命周期管理等功能。Executors 是一个工具类,提供静态工厂方法用于创建不同类型的ExecutorService 。
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
// 创建一个固定大小的线程池:
ExecutorService es = Executors.newFixedThreadPool(4);
for (int i = 0; i < 6; i++) {
es.submit(new Task("" + i));
}
// 关闭线程池:
es.shutdown();
}
}
class Task implements Runnable {
private final String name;
public Task(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println("start task " + name);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
System.out.println("end task " + name);
}
}
一次性放入 6 个任务,由于线程池只有固定的 4 个线程,因此前 4 个任务会同时执行,等到有线程空闲后,才会执行后面的两个任务。线程池在程序结束的时候要关闭,使用 shutdown() 方法关闭线程池的时候,它会等待正在执行的任务完成后再关闭。shutdownNow() 会立刻停止正在执行的任务,awaitTermination() 则会等待指定的时间让线程池关闭。
如果我们把线程池改为 CachedThreadPool,由于这个线程池的实现会根据任务数量动态调整线程池的大小,所以 6 个任务可一次性全部同时执行。
ScheduledThreadPool
需要定期反复执行的任务,可以使用 ScheduledThreadPool。创建一个 ScheduledThreadPool 仍然是通过 Executors 类:ScheduledExecutorService ses = Executors.newScheduledThreadPool(4);。
一次性任务:ses.schedule(new Task("one-time"), 1, TimeUnit.SECONDS);。
间隔固定时间执行的任务,不管任务执行多长时间:ses.scheduleAtFixedRate(new Task("fixed-rate"), 2, 3, TimeUnit.SECONDS);,2 秒后开始执行定时任务,每3秒执行一次。
任务执行完毕后等待固定的时间间隔再执行下一次任务:ses.scheduleWithFixedDelay(new Task("fixed-delay"), 2, 3, TimeUnit.SECONDS);,2 秒后开始执行定时任务,以 3 秒为间隔执行。
Future
在执行多个任务的时候,提交给线程池的任务只需要实现 Runnable 接口,就可以让线程池去执行。然而 Runnable 接口的方法没有返回值。Java 标准库另外提供了一个 Callable 接口,和 Runnable 接口比,它多了一个返回值,并且 Callable 接口是一个泛型接口,可以返回指定类型的结果。
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
ExecutorService.submit() 方法返回的 Future 类型实例代表一个未来能获取结果的对象,调用 Future 对象的 get() 方法,就可以获得异步执行的结果。如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么 get() 方法会阻塞,直到任务完成后才返回结果。
Future 类型定义的方法有:
get():获取结果(可能会等待)get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;cancel(boolean mayInterruptIfRunning):取消当前任务;isDone():判断任务是否已完成。
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得 Future:
Future<String> future = executor.submit(task);
// 从 Future 获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
CompletableFuture
使用 Future 获得异步执行结果时,要么调用阻塞方法 get(),要么轮询检查 isDone() 是否为 true,这两种方法都会让主线程被迫等待。从 Java 8 开始引入了 CompletableFuture,它针对 Future 做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法,设置好回调后不用再关心异步任务的执行。
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(()->{
try { Thread.sleep(1000); } catch(InterruptedException e){ }
return "done";
});
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("status: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
return null;
});
}
}
多个 CompletableFuture 可以串行执行:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 创建第一个异步执行任务:
CompletableFuture<String> cf = CompletableFuture.supplyAsync(()->{
try { Thread.sleep(1000); } catch(InterruptedException e){ }
return "done";
});
// 第一个任务成功后继续执行第二个任务:
CompletableFuture<Integer> cf2 = cf.thenApplyAsync((status) -> {
try { Thread.sleep(500); } catch(InterruptedException e){ }
return 1;
});
// 第二个任务如果执行成功:
cf2.thenAccept((result) -> {
System.out.println("status: " + result);
});
// 第二个任务如果执行异常:
cf2.exceptionally((e) -> {
e.printStackTrace();
return null;
});
}
}
使用 anyOf 获取多个并行执行的 CompletableFuture 中最先完成的任务结果:
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 模拟两个耗时任务
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(1000); } catch (InterruptedException e) {}
return "Task 1";
});
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {}
return "Task 2";
});
// 使用 anyOf 获取最先完成的任务结果
CompletableFuture<Object> anyResult = CompletableFuture.anyOf(task1, task2);
anyResult.thenAccept(result ->
System.out.println("First completed: " + result); // First completed: Task 2
);
}
通过 allOf 等待所有任务完成
import java.util.concurrent.CompletableFuture;
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
CompletableFuture<String> task1 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(300); } catch (InterruptedException e) {}
return "Task 1";
});
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {}
return "Task 2";
});
// 使用 allOf 合并所有任务
CompletableFuture<Void> allFutures = CompletableFuture.allOf(task1 , task2);
// 异步处理最终结果(不阻塞主线程)
CompletableFuture<List<String>> combinedFuture = allFutures.thenApply(v -> {
return Arrays.asList(task1.join(), task2.join());
});
// 注册回调处理聚合结果
combinedFuture.thenAccept(results -> {
System.out.println("All tasks completed! Results:");
results.forEach(System.out::println);
});
}
}
CompletableFuture 的异步任务默认使用 ForkJoinPool.commonPool(),这是 Java 8 引入的全局公共线程池。公共线程池可以简化线程管理,但可能导致资源竞争,适合轻量级任务,不适合高负载场景。可通过显式指定 Executor 参数使用独立线程池:
ExecutorService customPool = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> "task", customPool);
ForkJoinPool
Java 7 开始引入了一种新的 ForkJoinPool 线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveTask;
public class Main {
// 定义任务类(需继承RecursiveTask)
static class SumTask extends RecursiveTask<Long> {
private final long[] array;
private final int start, end;
private static final int THRESHOLD = 1000; // 拆分阈值
public SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
// 若任务足够小则直接计算
if (end - start <= THRESHOLD) {
long sum = 0;
for (int i = start; i < end; i++) {
sum += array[i];
}
return sum;
}
// 否则拆分子任务
int mid = (start + end) / 2;
SumTask leftTask = new SumTask(array, start, mid);
SumTask rightTask = new SumTask(array, mid, end);
leftTask.fork(); // 异步执行左子任务
long rightResult = rightTask.compute(); // 同步执行右子任务
long leftResult = leftTask.join(); // 获取左子任务结果
return leftResult + rightResult;
}
}
public static void main(String[] args) {
// 创建测试数组(1百万个元素)
long[] array = new long[1_000_000];
for (int i = 0; i < array.length; i++) {
array[i] = i + 1;
}
// 使用ForkJoinPool执行任务
ForkJoinPool pool = new ForkJoinPool();
long result = pool.invoke(new SumTask(array, 0, array.length));
System.out.println("Final sum: " + result);
}
}
左子任务调用 fork() 而右子任务直接调用 compute() 的设计差异主要基于以下原理:
- 异步与同步执行策略:fork() 方法将左子任务异步提交到线程池的工作队列,允许当前线程继续执行其他任务(如右子任务),右子任务直接调用 compute() 实现同步执行,避免不必要的线程切换开销,此时当前线程可立即处理该任务
- 工作窃取优化:异步提交左子任务后,其他空闲线程可能通过工作窃取机制执行该任务,提高并行度。同步执行右子任务确保至少有一个任务由当前线程处理,避免所有任务被窃取导致线程闲置。
- 递归任务处理模式:该模式形成"分治-合并"的执行树:左子树通过 fork() 分散到其他线程,右子树通过 compute() 形成本地调用链。最终通过 join() 等待左子任务结果时,右子任务可能已提前完成,减少总体等待时间。
- 性能平衡考量:完全异步提交(双 fork())会增加任务调度开销,完全同步执行(双 compute())无法利用多核并行优势,该混合策略在并行度和开销间取得平衡。
这种设计使得 CPU 密集型任务能最大化利用多核资源,同时避免过度任务拆分带来的调度损耗。
使用 ThreadLocal
Java 标准库提供的 ThreadLocal 可以在一个线程中传递同一个对象。ThreadLocal 是 Java 中用于实现线程级数据隔离的核心工具,通过为每个线程创建变量的独立副本,解决多线程共享资源的并发安全问题。
设置一个对象关联到 ThreadLocal 中,在移除之前,线程中所有方法都可以随时获取到该对象:
import java.util.concurrent.*;
import java.util.Arrays;
public class Main {
// 定义静态 ThreadLocal 变量
private static final ThreadLocal<Integer> tl = ThreadLocal.withInitial(() -> 0);
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
for (final int i : Arrays.asList(0,1,2)){
executor.execute(() -> {
try {
// 操作线程本地变量
final int count = tl.get()+i;
tl.set(count);
func();
} finally {
tl.remove(); // 必须清理
}
});
}
executor.shutdown();
}
static void func(){
System.out.println(Thread.currentThread().getName() +" : "+ tl.get());
}
}
可以把 ThreadLocal 看成一个全局 Map<Thread, Object>:每个线程获取 ThreadLocal 变量时,总是使用 Thread 自身作为 key:Object threadLocalValue = threadLocalMap.get(Thread.currentThread());。因此 ThreadLocal 相当于给每个线程都开辟了一个独立的存储空间,各个线程的 ThreadLocal 关联的实例互不干扰。
ThreadLocal 一定要在 finally 中清除。这是因为当前线程执行完相关代码后,很可能会被重新放入线程池中。如果 ThreadLocal 没有被清除,该线程执行其他代码时,会把上一次的状态带进去。
使用虚拟线程
对于需要处理大量 IO 请求的任务来说,使用线程是低效的,因为一旦读写 IO,线程就必须进入等待状态,直到 IO 数据返回。常见的 IO 操作包括:读写文件;读写网络,例如 HTTP 请求;读写数据库。真正由 CPU 执行的代码消耗的时间非常少,线程的大部分时间都在等待 IO。我们把这类任务称为 IO 密集型任务。
为了能高效执行 IO 密集型任务,从 Java 19 开始引入了虚拟线程。虚拟线程是一种轻量级线程,它在很多其他语言中被称为协程、用户线程等。虚拟线程的接口和普通线程是一样的,但是执行方式不一样。虚拟线程不是由操作系统调度,而是由普通线程调度。虚拟线程执行一个 IO 操作进入等待时,它会被立刻“挂起”,然后执行下一个虚拟线程。IO 数据返回时挂起的虚拟线程才会被再次调度。因此若干个虚拟线程可以在一个普通线程中交替运行。
计算密集型任务不应使用虚拟线程,只能通过增加 CPU 核心解决,或者利用分布式计算资源。
直接创建虚拟线程并运行:
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(10);
System.out.println("End virtual thread.");
});
创建虚拟线程但不自动运行,而是调用 start() 开始运行:
Thread.ofVirtual().unstarted(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
vt.start();
通过虚拟线程的 ThreadFactory 创建虚拟线程,然后调用 start() 开始运行:
ThreadFactory tf = Thread.ofVirtual().factory();
Thread vt = tf.newThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
vt.start();
直接调用 start() 实际上是由 ForkJoinPool 的线程来调度的。我们也可以自己创建调度线程,然后运行虚拟线程:
// 创建调度器:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 创建大量虚拟线程并调度:
ThreadFactory tf = Thread.ofVirtual().factory();
for (int i=0; i<100000; i++) {
Thread vt = tf.newThread(() -> { ... });
executor.submit(vt);
// 也可以直接传入 Runnable 或 Callable:
executor.submit(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
return true;
});
}
虚拟线程属于非常轻量级的资源,因此用时创建用完就扔,不要池化虚拟线程。虚拟线程在 Java 21 正式发布,在 Java 19/20 是预览功能,默认关闭。
只有以虚拟线程方式运行的代码才会在执行 IO 操作时自动被挂起并切换到其他虚拟线程。普通线程的 IO 操作仍然会等待。可以自动引发虚拟线程调度切换的操作包括:文件 IO;网络 IO;使用 Concurrent 库引发等待;Thread.sleep()操作。
浙公网安备 33010602011771号