JAVA SE第三部分笔记
单元测试、正则表达式、加密与安全、多线程
单元测试 测试驱动开发(TDD,Test-Driven Development)流程
1 2 3 4 5 6 7 8 9 10 11 12 13 编写接口 │ ▼ 编写测试 │ ▼ ┌─> 编写实现 │ │ │ N ▼ └── 运行测试 │ Y ▼ 任务完成
JUnit JUnit是开源的,是单元测试的标准框架。
JUnit会给出测试报告:包括成功率,代码覆盖率。
测试覆盖率应该在80%以上。
测试类的名称通常为<原名称>Test.java
,测试方法加上@Test
注解。
测试文件实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package com.itranswarp.learnjava;import static org.junit.jupiter.api.Assertions.*;import org.junit.jupiter.api.Test;public class FactorialTest { @Test void testFact () { assertEquals(1 , Factorial.fact(1 )); assertEquals(2 , Factorial.fact(2 )); assertEquals(6 , Factorial.fact(3 )); assertEquals(3628800 , Factorial.fact(10 )); assertEquals(2432902008176640000L , Factorial.fact(20 )); } }
assertEquals(expected, actual)
assertEquals(double expected, double actual, double delta)
由于浮点数无法精确比较,需要指定误差值
assertTrue()
: 期待结果为true
assertFalse()
: 期待结果为false
assertNotNull()
: 期待结果为非null
assertArrayEquals()
: 期待结果为数组并与期望数组每个元素的值均相等
…
单元测试规范:
单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;
每个单元测试应当互相独立,不依赖运行的顺序;
测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0
,null
,空字符串""
等情况。
Fixture Fixture:编写测试前准备、测试后清理的代码部分。
对于实例变量,在@BeforeEach
中初始化,在@AfterEach
中清理,它们在各个@Test
方法中互不影响,因为是不同的实例;
对于静态变量,在@BeforeAll
中初始化,在@AfterAll
中清理,它们在各个@Test
方法中均是唯一实例,会影响各个@Test
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class CalculatorTest { Calculator calculator; @BeforeEach public void setUp () { this .calculator = new Calculator(); } @AfterEach public void tearDown () { this .calculator = null ; } @Test void testAdd () { assertEquals(100 , this .calculator.add(100 )); assertEquals(150 , this .calculator.add(50 )); assertEquals(130 , this .calculator.add(-20 )); } @Test void testSub () { assertEquals(-100 , this .calculator.sub(100 )); assertEquals(-150 , this .calculator.sub(50 )); assertEquals(-130 , this .calculator.sub(-20 )); } }
异常测试 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Test void testNegative () { assertThrows(IllegalArgumentException.class, new Executable() { @Override public void execute () throws Throwable { Factorial.fact(-1 ); } }); } @Test void testNegative () { assertThrows(IllegalArgumentException.class, () -> { Factorial.fact(-1 ); }); }
条件测试 对于需要跳过的方法使用@Disabled
注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Disabled @Test void testBug101 () { } @EnabledOnOs(OS.WINDOWS) @EnabledOnOs({ OS.LINUX, OS.MAC }) @DisabledOnOs(OS.WINDOWS) @DisabledOnJre(JRE.JAVA_8) @EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*") @EnabledIfEnvironmentVariable(named = "DEBUG", matches = "true")
参数化测试 @ParameterizedTest
1 2 3 4 5 @ParameterizedTest @ValueSource(ints = { 0, 1, 5, 100 }) void testAbs (int x) { assertEquals(x, Math.abs(x)); }
参数传入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 @ParameterizedTest @MethodSource void testCapitalize (String input, String result) { assertEquals(result, StringUtils.capitalize(input)); } static List<Arguments> testCapitalize () { return List.of( Arguments.arguments("abc" , "Abc" ), Arguments.arguments("APPLE" , "Apple" ), Arguments.arguments("gooD" , "Good" )); } @ParameterizedTest @CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" }) void testCapitalize (String input, String result) { assertEquals(result, StringUtils.capitalize(input)); } @ParameterizedTest @CsvFileSource(resources = { "/test-capitalize.csv" }) void testCapitalizeUsingCsvFile (String input, String result) { assertEquals(result, StringUtils.capitalize(input)); }
正则表达式 1 2 3 4 5 6 7 8 public class Main { public static void main (String[] args) { String re = "java|php" ; System.out.println("java" .matches(re)); System.out.println("php" .matches(re)); System.out.println("go" .matches(re)); } }
匹配规则 特殊字符 \\
、\&
中文字符 匹配非ASCII字符,例如中文,那就用\u####
的十六进制表示,例如:a\u548cc
匹配字符串"a和c"
,中文字符和
的Unicode编码是548c
。
\\d{3,5}
可以匹配3~5个数字
单个字符的匹配规则如下:
正则表达式
规则
可以匹配
A
指定字符
A
\u548c
指定Unicode字符
和
.
任意字符
a
,b
,&
,0
\d
数字0~9
0
~`9`
\w
大小写字母,数字和下划线
a
z
,A
Z
,0
~`9,
_`
\s
空格、Tab键
空格,Tab
\D
非数字
a
,A
,&
,_
,……
\W
非\w
&
,@
,中
,……
\S
非\s
a
,A
,&
,_
,……
多个字符的匹配规则如下:
正则表达式
规则
可以匹配
A*
任意个数字符
空,A
,AA
,AAA
,……
A+
至少1个字符
A
,AA
,AAA
,……
A?
0个或1个字符
空,A
A{3}
指定个数字符
AAA
A{2,3}
指定范围个数字符
AA
,AAA
A{2,}
至少n个字符
AA
,AAA
,AAAA
,……
A{0,3}
最多n个字符
空,A
,AA
,AAA
复杂匹配规则主要有:
正则表达式
规则
可以匹配
^
开头
字符串开头
$
结尾
字符串结束
[ABC]
[…]内任意字符
A,B,C
[A-F0-9xy]
指定范围的字符
A
,……,F
,0
,……,9
,x
,y
[^A-F]
指定范围外的任意字符
非A
~`F`
AB|CD|EF
AB或CD或EF
AB
,CD
,EF
分组匹配 用(...)
把要提取的规则分组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class Main { public static void main (String[] args) { Pattern p = Pattern.compile("(\\d{3,4})\\-(\\d{7,8})" ); Matcher m = p.matcher("010-12345678" ); if (m.matches()) { String g1 = m.group(1 ); String g2 = m.group(2 ); System.out.println(g1); System.out.println(g2); } else { System.out.println("匹配失败!" ); } } }
使用Matcher
时,必须首先调用matches()
判断是否匹配成功,匹配成功后,才能调用group()
提取子串。
非贪婪匹配 给定一个匹配规则,加上?
后就变成了非贪婪匹配。
1 2 3 4 5 6 7 8 9 10 11 public class Main { public static void main (String[] args) { Pattern pattern = Pattern.compile("(\\d+?)(0*)" ); Matcher matcher = pattern.matcher("1230000" ); if (matcher.matches()) { System.out.println("group1=" + matcher.group(1 )); System.out.println("group2=" + matcher.group(2 )); } } }
\d??
第一个?
表示0个或1个,第二个?
表示非贪婪匹配
分割、搜索和替换 分割字符串
1 2 3 "a b c" .split("\\s" ); "a b c" .split("\\s" ); "a, b ;; c" .split("[\\,\\;\\s]+" );
搜索字符串
1 2 3 4 5 6 7 8 9 10 11 public class Main { public static void main (String[] args) { String s = "the quick brown fox jumps over the lazy dog." ; Pattern p = Pattern.compile("\\wo\\w" ); Matcher m = p.matcher(s); while (m.find()) { String sub = s.substring(m.start(), m.end()); System.out.println(sub); } } }
替换字符串
1 2 3 4 5 6 7 public class Main { public static void main (String[] args) { String s = "The quick\t\t brown fox jumps over the lazy dog." ; String r = s.replaceAll("\\s+" , " " ); System.out.println(r); } }
反向引用
1 2 3 4 5 6 7 8 public class Main { public static void main (String[] args) { String s = "the quick brown fox jumps over the lazy dog." ; String r = s.replaceAll("\\s([a-z]{4})\\s" , " <b>$1</b> " ); System.out.println(r); } }
加密与安全 三防:
编码算法 URL编码
出于兼容性考虑,很多服务器只识别ASCII字符。但如果URL中包含中文、日文这些非ASCII字符怎么办?不要紧,URL编码有一套规则:
如果字符是A
Z
,a
z
,0
~`9以及
-、
_、
.、
*`,则保持不变;
如果是其他字符,先转换为UTF-8编码,然后对每个字节以%XX
表示。
Java标准库提供了一个URLEncoder
类来对任意字符串进行URL编码
1 2 3 4 5 6 7 8 9 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); } }
和标准的URL编码稍有不同,URLEncoder把空格字符编码成+
,而现在的URL编码标准要求空格被编码为%20
,不过,服务器都可以处理这两种情况。
1 2 3 4 5 6 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); } }
Base64编码
Base64对二进制数据进行编码。可以把任意长度的二进制数据变为纯文本,且只包含A
Z
、a
z
、0
~`9、
+、
/、
=`这些字符。
它的原理是把3字节的二进制数据按6bit一组,用4个int整数表示,然后查表,把int整数用索引对应到字符,得到编码后的字符串。(长度不足时补充0)
因为6位整数的范围总是0
63
,所以,能用64个字符表示:字符A
Z
对应索引0
25
,字符a
z
对应索引26
51
,字符0
9
对应索引52
~`61,最后两个索引
62、
63分别用字符
+和
/`表示。
1 2 3 4 5 6 7 8 9 ┌───────────────┬───────────────┬───────────────┐ │ e4 │ b8 │ ad │ └───────────────┴───────────────┴───────────────┘ ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ │1│1│1│0│0│1│0│0│1│0│1│1│1│0│0│0│1│0│1│0│1│1│0│1│ └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ ┌───────────┬───────────┬───────────┬───────────┐ │ 39 │ 0b │ 22 │ 2d │ └───────────┴───────────┴───────────┴───────────┘
1 2 3 4 5 6 7 8 9 10 11 12 13 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); String b64encoded2 = Base64.getEncoder().withoutPadding().encodeToString(input); System.out.println(b64encoded); byte [] output = Base64.getDecoder().decode("5Lit" ); System.out.println(Arrays.toString(output)); } }
电子邮件协议就是文本协议,如果要在电子邮件中添加一个二进制文件,就可以用Base64编码,然后以文本的形式传送。
Base64编码的缺点是传输效率会降低,因为它把原始数据的长度增加了1/3。
哈希算法 java自带的哈希算法:https://docs.oracle.com/en/java/javase/14/docs/specs/security/standard-names.html#messagedigest-algorithms
哈希算法(Hash)又称摘要算法(Digest):对输入计算,得到固定长度的输出。
特点: 输入相同则输出相同;输入不同大概率输出不同。
常用的哈希算法有:
算法
输出长度(位)
输出长度(字节)
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
1 2 3 4 5 6 7 8 9 10 11 public class Main { public static void main (String[] args) throws Exception { MessageDigest md = MessageDigest.getInstance("MD5" ); md.update("Hello" .getBytes("UTF-8" )); md.update("World" .getBytes("UTF-8" )); byte [] result = md.digest(); System.out.println(new BigInteger(1 , result).toString(16 )); } }
为了抵御彩虹攻击(黑客持有大量常见字符串的MD5加密结果),可以使用加盐加密(Salt):digest = md5(salt+inputPassword)
GetInstance与new区别:
new的使用 如Object object = new Object(),这时候,就必须要知道有第二个Object的存在,而第二个Object也常常是在当前的应用程序域中的,可以被直接调用的
GetInstance的使用 在主函数开始时调用,返回一个实例化对象,此对象是static的,在内存中保留着它的引用,即内存中有一块区域专门用来存放静态方法和变量,可以直接使用,调用多次返回同一个对象。
BouncyCastle BouncyCastle 是一个提供了很多哈希算法和加密算法的第三方库.
java.security
提供了一种机制,通过将BouncyCastle在启动时注册一下,就可以与原有的加密算法无缝接入。
1 2 3 4 5 6 7 8 9 10 11 public class Main { public static void main (String[] args) throws Exception { Security.addProvider(new BouncyCastleProvider()); MessageDigest md = MessageDigest.getInstance("RipeMD160" ); md.update("HelloWorld" .getBytes("UTF-8" )); byte [] result = md.digest(); System.out.println(new BigInteger(1 , result).toString(16 )); } }
Hmac算法 Hmac算法就是一种基于密钥的消息认证码算法,它的全称是Hash-based Message Authentication Code,是一种更安全的消息摘要算法。
Hmac算法总是和某种哈希算法配合起来用的。例如,我们使用MD5算法,对应的就是HmacMD5算法,它相当于“加盐”的MD5:
1 HmacMD5 ≈ md5(secure_random_key, input)
使用HmacMD5而不是用MD5加salt,有如下好处:
HmacMD5使用的key长度是64字节,更安全;
Hmac是标准算法,同样适用于SHA-1等其他哈希算法;
Hmac输出和原有的哈希算法长度一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class Main { public static void main (String[] args) throws Exception { KeyGenerator keyGen = KeyGenerator.getInstance("HmacMD5" ); SecretKey key = keyGen.generateKey(); byte [] skey = key.getEncoded(); System.out.println(new BigInteger(1 , skey).toString(16 )); Mac mac = Mac.getInstance("HmacMD5" ); mac.init(key); mac.update("HelloWorld" .getBytes("UTF-8" )); byte [] result = mac.doFinal(); System.out.println(new BigInteger(1 , result).toString(16 )); } }
使用HmacMD5的步骤是:
通过名称HmacMD5
获取KeyGenerator
实例;
通过KeyGenerator
创建一个SecretKey
实例;
通过名称HmacMD5
获取Mac
实例;
用SecretKey
初始化Mac
实例;
对Mac
实例反复调用update(byte[])
输入数据;
调用Mac
实例的doFinal()
获取最终的哈希值。
存储用户名和口令的数据库结构如下:
username
secret_key (64 bytes)
password
bob
a8c06e05f92e…5e16
7e0387872a57c85ef6dddbaa12f376de
alice
e6a343693985…f4be
c1f929ac2552642b302e739bc0cdbaac
tim
f27a973dfdc0…6003
af57651c3a8a73303515804d4af43790
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class Main { public static void main (String[] args) throws Exception { byte [] hkey = new byte [] { 106 , 70 , -110 , 125 , 39 , -20 , 52 , 56 , 85 , 9 , -19 , -72 , 52 , -53 , 52 , -45 , -6 , 119 , -63 , 30 , 20 , -83 , -28 , 77 , 98 , 109 , -32 , -76 , 121 , -106 , 0 , -74 , -107 , -114 , -45 , 104 , -104 , -8 , 2 , 121 , 6 , 97 , -18 , -13 , -63 , -30 , -125 , -103 , -80 , -46 , 113 , -14 , 68 , 32 , -46 , 101 , -116 , -104 , -81 , -108 , 122 , 89 , -106 , -109 }; 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(Arrays.toString(result)); } }
对称加密算法 常用的对称加密算法有:
算法
密钥长度
工作模式
填充模式
DES
56/64
ECB/CBC/PCBC/CTR/…
NoPadding/PKCS5Padding/…
AES
128/192/256
ECB/CBC/PCBC/CTR/…
NoPadding/PKCS5Padding/PKCS7Padding/…
IDEA
128
ECB
PKCS5Padding/PKCS7Padding/…
AES加密 ECB模式(简单)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 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); 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); } }
CBC模式(安全性高)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 public class Main { public static void main (String[] args) throws Exception { String message = "Hello, world!" ; System.out.println("Message: " + message); 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" ); 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); return join(iv, data); } public static byte [] decrypt(byte [] key, byte [] input) throws GeneralSecurityException { 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; } }
口令加密算法 对称加密算法的密钥长度固定。用户输入的口令一般要需要使用PBE算法,利用随机数杂凑计算出真正的密钥再进行加密。
PBE(PBE算法内部使用的仍然是标准对称加密算法(例如AES))就是Password Based Encryption的缩写,它的作用如下:
1 key = generate(userPassword, secureRandomPassword);
我们让用户输入一个口令,然后生成一个随机数,通过PBE算法计算出真正的AES口令,再进行加密,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 public class Main { public static void main (String[] args) throws Exception { Security.addProvider(new BouncyCastleProvider()); String message = "Hello, world!" ; String password = "hello12345" ; byte [] salt = SecureRandom.getInstanceStrong().generateSeed(16 ); System.out.printf("salt: %032x\n" , new BigInteger(1 , 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存储的随机数密钥安全性非常高。
密钥交换算法 密钥交换算法即DH算法:Diffie-Hellman算法
DH算法解决了密钥在双方不直接传递密钥的情况下完成密钥交换。
DH算法交换密钥的步骤。假设甲乙双方需要传递密钥,他们之间可以这么做:
甲首选选择一个素数p
,例如509,底数g
,任选,例如5,随机数a
,例如123,然后计算A=g^a mod p
,结果是215,然后,甲发送p=509
,g=5
,A=215
给乙;
乙方收到后,也选择一个随机数b
,例如,456,然后计算B=g^b mod p
,结果是181,乙再同时计算s=A^b mod p
,结果是121;
乙把计算的B=181
发给甲,甲计算s=B^a mod p
的余数,计算结果与乙算出的结果一样,都是121。
DH算法是一个密钥协商算法,双方最终协商出一个共同的密钥,而这个密钥不会通过网络传输。
使用Java实现DH算法的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 public class Main { public static void main (String[] args) { Person bob = new Person("Bob" ); Person alice = new Person("Alice" ); bob.generateKeyPair(); alice.generateKeyPair(); bob.generateSecretKey(alice.publicKey.getEncoded()); alice.generateSecretKey(bob.publicKey.getEncoded()); bob.printKeys(); alice.printKeys(); } } class Person { public final String name; public PublicKey publicKey; private PrivateKey privateKey; private byte [] secretKey; public Person (String name) { this .name = name; } 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 { X509EncodedKeySpec keySpec = new X509EncodedKeySpec(receivedPubKeyBytes); KeyFactory kf = KeyFactory.getInstance("DH" ); PublicKey receivedPublicKey = kf.generatePublic(keySpec); KeyAgreement keyAgreement = KeyAgreement.getInstance("DH" ); keyAgreement.init(this .privateKey); keyAgreement.doPhase(receivedPublicKey, true ); this .secretKey = keyAgreement.generateSecret(); } catch (GeneralSecurityException e) { throw new RuntimeException(e); } } public void printKeys () { System.out.printf("Name: %s\n" , this .name); System.out.printf("Private key: %x\n" , new BigInteger(1 , this .privateKey.getEncoded())); System.out.printf("Public key: %x\n" , new BigInteger(1 , this .publicKey.getEncoded())); System.out.printf("Secret key: %x\n" , new BigInteger(1 , this .secretKey)); } }
非对称加密算法 非对称加密的典型算法就是RSA算法,它是由Ron Rivest,Adi Shamir,Leonard Adleman这三个哥们一起发明的,所以用他们仨的姓的首字母缩写表示。
非对称加密相比对称加密的显著优点在于,对称加密需要协商密钥,而非对称加密可以安全地公开各自的公钥,在N个人之间通信的时候:使用非对称加密只需要N个密钥对,每个人只管理自己的密钥对。而使用对称加密需要则需要N*(N-1)/2
个密钥,因此每个人需要管理N-1
个密钥,密钥管理难度大,而且非常容易泄漏。
非对称加密的缺点是运算速度非常慢,比对称加密要慢很多。
在实际应用的时候,非对称加密总是和对称加密一起使用。假设小明需要给小红需要传输加密文件,他俩首先交换了各自的公钥,然后:
小明生成一个随机的AES口令,然后用小红的公钥通过RSA加密这个口令,并发给小红;
小红用自己的RSA私钥解密得到AES口令;
双方使用这个共享的AES口令用AES加密通信。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 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" ); byte [] pk = alice.getPublicKey(); System.out.println(String.format("public key: %x" , new BigInteger(1 , pk))); byte [] encrypted = alice.encrypt(plain); System.out.println(String.format("encrypted: %x" , new BigInteger(1 , encrypted))); byte [] sk = alice.getPrivateKey(); System.out.println(String.format("private key: %x" , new BigInteger(1 , 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); } } 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);
签名算法 在实际应用的时候,签名实际上并不是针对原始消息,而是针对原始消息的哈希进行签名,即:
1 signature = encrypt(privateKey, sha256(message))
对签名进行验证实际上就是用公钥解密:
1 hash = decrypt(publicKey, signature)
常用数字签名算法有:
MD5withRSA
SHA1withRSA
SHA256withRSA
它们实际上就是指定某种哈希算法进行RSA签名的方式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class Main { public static void main (String[] args) throws GeneralSecurityException { 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(String.format("signature: %x" , new BigInteger(1 , signed))); Signature v = Signature.getInstance("SHA1withRSA" ); v.initVerify(pk); v.update(message); boolean valid = v.verify(signed); System.out.println("valid? " + valid); } }
DSA签名 除了RSA可以签名外,还可以使用DSA算法进行签名。DSA是Digital Signature Algorithm的缩写,它使用ElGamal数字签名算法。
DSA只能配合SHA使用,常用的算法有:
SHA1withDSA
SHA256withDSA
SHA512withDSA
和RSA数字签名相比,DSA的优点是更快。
ECDSA签名 椭圆曲线签名算法ECDSA:Elliptic Curve Digital Signature Algorithm也是一种常用的签名算法,它的特点是可以从私钥推出公钥。比特币的签名算法就采用了ECDSA算法,使用标准椭圆曲线secp256k1。BouncyCastle提供了ECDSA的完整实现。
数字证书 摘要算法用来确保数据没有被篡改,非对称加密算法可以对数据进行加解密,签名算法可以确保数据完整性和抗否认性,把这些算法集合到一起,并搞一套完善的标准,这就是数字证书。
数字证书可以防止中间人攻击,因为它采用链式签名认证,即通过根证书(Root CA)去签名下一级证书,这样层层签名,直到最终的用户证书。而Root CA证书内置于操作系统中,所以,任何经过CA认证的数字证书都可以对其本身进行校验,确保证书本身不是伪造的。HTTPS协议就是数字证书的应用
在Java程序中,数字证书存储在一种Java专用的key store文件中,JDK提供了一系列命令来创建和管理key store。我们用下面的命令创建一个key store,并设定口令123456:
1 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存储的证书,我们就可以通过数字证书进行加解密和签名:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 import java.io.InputStream;import java.math.BigInteger;import java.security.*;import java.security.cert.*;import javax.crypto.Cipher;public class Main { public static void main (String[] args) throws Exception { byte [] message = "Hello, use X.509 cert!" .getBytes("UTF-8" ); 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(String.format("encrypted: %x" , new BigInteger(1 , encrypted))); byte [] decrypted = decrypt(privateKey, encrypted); System.out.println("decrypted: " + new String(decrypted, "UTF-8" )); byte [] sign = sign(privateKey, certificate, message); System.out.println(String.format("signature: %x" , new BigInteger(1 , 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服务器上,例如Nginx,需要把私钥导出为Private Key格式,把证书导出为X509Certificate格式。
以HTTPS协议为例,浏览器和服务器建立安全连接的步骤如下:
浏览器向服务器发起请求,服务器向浏览器发送自己的数字证书;
浏览器用操作系统内置的Root CA来验证服务器的证书是否有效,如果有效,就使用该证书加密一个随机的AES口令并发送给服务器;
服务器用自己的私钥解密获得AES口令,并在后续通讯中使用AES加密。
上述流程只是一种最常见的单向验证。如果服务器还要验证客户端,那么客户端也需要把自己的证书发送给服务器验证,这种场景常见于网银等。
注意:数字证书存储的是公钥,以及相关的证书链和算法信息。私钥必须严格保密,如果数字证书对应的私钥泄漏,就会造成严重的安全威胁。如果CA证书的私钥泄漏,那么该CA证书签发的所有证书将不可信。数字证书服务商DigiNotar 就发生过私钥泄漏导致公司破产的事故。
多线程 创建新线程 方法1:
1 2 3 4 5 6 7 8 9 10 11 12 13 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!" ); } }
直接调用run()
方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。
方法2:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 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!" ); } } public class Main { public static void main (String[] args) { Thread t = new Thread(() -> { System.out.println("start new thread!" ); }); t.start(); } }
线程暂停 Thread.sleep(10)
当前线程暂停10ms。
线程的优先级 Thread.setPriority(int n) // 1~10, 默认值5
优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
线程的状态 Java线程的状态有以下几种:
New:新创建的线程,尚未执行;
Runnable:运行中的线程,正在执行run()
方法的Java代码;
Blocked:运行中的线程,因为某些操作被阻塞而挂起;
Waiting:运行中的线程,因为某些操作在等待中;
Timed Waiting:运行中的线程,因为执行sleep()
方法正在计时等待;
Terminated:线程已终止,因为run()
方法执行完毕。
线程终止的原因有:
线程正常终止:run()
方法执行到return
语句返回;
线程意外终止:run()
方法因为未捕获的异常导致线程终止;
对某个线程的Thread
实例调用stop()
方法强制终止(强烈不推荐使用)。
一个线程还可以等待另一个线程直到其运行结束。例如,main
线程在启动t
线程后,可以通过t.join()
等待t
线程结束后再继续运行。
中断线程 中断一个线程非常简单,只需要在其他线程 中对目标线程调用interrupt()
方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
interrupt()
方法仅仅向t
线程发出了“中断请求”,至于t
线程是否能立刻响应,要看具体代码。
目标线程只要捕获到join()
方法抛出的InterruptedException
,就说明有其他线程对其调用了interrupt()
方法,通常情况下该线程应该立刻结束运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class Main { public static void main (String[] args) throws InterruptedException { Thread t = new MyThread(); t.start(); Thread.sleep(1000 ); t.interrupt(); t.join(); System.out.println("end" ); } } class MyThread extends Thread { public void run () { Thread hello = new HelloThread(); hello.start(); try { hello.join(); } 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
线程仍然会继续运行,且JVM不会退出。
InterruptedException:
Thrown when a thread is waiting, sleeping, or otherwise occupied, and the thread is interrupted
另一个常用的中断线程的方法是设置标志位。我们通常会用一个running
标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running
置为false
,就可以让线程结束:
线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class Main { public static void main (String[] args) throws InterruptedException { HelloThread t = new HelloThread(); t.start(); Thread.sleep(1 ); t.running = 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!" ); } }
守护线程 守护线程(Daemon Thread)是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
创建守护线程 :
1 2 3 Thread t = new MyThread(); t.setDaemon(true ); t.start();
守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
线程同步 原子操作 :某一个线程执行时,其他线程必须等待
临界区: 加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
Java程序使用synchronized
关键字对一个对象进行加锁:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class Main { public static void main (String[] args) throws Exception { var add = new AddThread(); var dec = new DecThread(); add.start(); dec.start(); add.join(); dec.join(); System.out.println(Counter.count); } } class Counter { public static final Object lock = new Object(); public static int count = 0 ; } class AddThread extends Thread { public void run () { for (int i=0 ; i<10000 ; i++) { synchronized (Counter.lock) { Counter.count += 1 ; } } } } class DecThread extends Thread { public void run () { for (int i=0 ; i<10000 ; i++) { synchronized (Counter.lock) { Counter.count -= 1 ; } } } }
不需要synchronized的操作
JVM规范定义了几种原子操作:
基本类型(long
和double
除外)赋值,例如:int n = m
;
引用类型赋值,例如:List<String> list = anotherList
。
long
和double
是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long
和double
的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:
1 2 3 4 5 public void set(int m) { synchronized(lock) { this.value = m; } }
就不需要同步。
volatile只保证:
读主内存到本地副本;
操作本地副本;
回写主内存。
这3步多个线程可以同时进行。
volatile保证了时效性不是原子性
同步方法 用synchronized
修饰方法可以把整个方法变为同步代码块,synchronized
方法加锁对象是this
;
通过合理的设计和数据封装可以让一个类变为“线程安全”;
一个类没有特殊说明,默认不是thread-safe;
多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析。
下面两种写法是等价的:
1 2 3 4 5 6 7 8 public void add (int n) { synchronized (this ) { count += n; } } public synchronized void add (int n) { count += n; }
死锁 一个线程可以获取一个锁后,再继续获取另一个锁。例如:
a 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void add(int m) { synchronized(lockA) { // 获得lockA的锁 this.value += m; synchronized(lockB) { // 获得lockB的锁 this.another += m; } // 释放lockB的锁 } // 释放lockA的锁 } public void dec(int m) { synchronized(lockB) { // 获得lockB的锁 this.another -= m; synchronized(lockA) { // 获得lockA的锁 this.value -= m; } // 释放lockA的锁 } // 释放lockB的锁 }
Wait和notify 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class Main { public static void main (String[] args) throws InterruptedException { var q = new TaskQueue(); var ts = new ArrayList<Thread>(); for (int i=0 ; i<5 ; i++) { var t = new Thread() { public void run () { while (true ) { try { String s = q.getTask(); System.out.println("execute task: " + s); } catch (InterruptedException e) { return ; } } } }; t.start(); ts.add(t); } var add = new Thread(() -> { for (int i=0 ; i<10 ; i++) { String s = "t-" + Math.random(); System.out.println("add task: " + s); q.addTask(s); try { Thread.sleep(100 ); } catch (InterruptedException e) {} } }); add.start(); add.join(); Thread.sleep(100 ); for (var t : ts) { t.interrupt(); } } } class TaskQueue { Queue<String> queue = new LinkedList<>(); public synchronized void addTask (String s) { this .queue.add(s); this .notifyAll(); } public synchronized String getTask () throws InterruptedException { while (queue.isEmpty()) { this .wait(); } return queue.remove(); } }
使用notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()
方法内部的wait()
中等待,使用notifyAll()
将一次性全部唤醒。通常来说,notifyAll()
更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
使用ReentrantLock java.util.concurrent.locks
包提供的ReentrantLock
用于替代synchronized
加锁.
1 2 3 4 5 6 7 8 9 10 11 12 13 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
是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock
是Java代码实现的锁,我们就必须先获取锁,然后在finally
中正确释放锁。
和synchronized
不同的是,ReentrantLock
可以尝试获取锁:
1 2 3 4 5 6 7 if (lock.tryLock(1 , TimeUnit.SECONDS)) { try { ... } finally { lock.unlock(); } }
使用condition
condition类似wait和notify:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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
提供的await()
、signal()
、signalAll()
原理和synchronized
锁对象的wait()
、notify()
、notifyAll()
是一致的:
await()
会释放当前锁,进入等待状态;
signal()
会唤醒某个等待线程;
signalAll()
会唤醒所有等待线程;
唤醒线程从await()
返回后需要重新获得锁。
和tryLock()
类似,await()
可以在等待指定时间后,如果还没有被其他线程通过signal()
或signalAll()
唤醒,可以自己醒来:
1 2 3 4 5 if (condition.await(1, TimeUnit.SECOND)) { // 被其他线程唤醒 } else { // 指定时间内没有被其他线程唤醒 }
ReadWriteLock
ReadWriteLock保证:
只允许一个线程写入(其他线程既不能写入也不能读取);
没有写入时,多个线程允许同时读(提高性能)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class Counter { private final ReadWriteLock rwlock = new ReentrantReadWriteLock(); 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
StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
和ReadWriteLock
相比,写入的加锁是完全一样的,不同的是读取。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 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); } }
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
因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,把:
1 Map<String, String> map = new HashMap<>();
改为:
1 Map<String, String> map = new ConcurrentHashMap<>();
java.util.Collections
工具类还提供了一个旧的线程安全集合转换器,可以这么用(不推荐,会降低性能):
1 2 Map unsafeMap = new HashMap(); Map threadSafeMap = Collections.synchronizedMap(unsafeMap);
Atomic java.util.concurrent
包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic
包。
我们以AtomicInteger
为例,它提供的主要操作有:
增加值并返回新值:int addAndGet(int delta)
加1后返回新值:int incrementAndGet()
获取当前值:int get()
用CAS方式设置:int compareAndSet(int expect, int update)
Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。
使用线程池 把很多小任务让一组线程来执行,而不是一个任务对应一个新线程。这种能接收大量小任务并进行分发处理的就是线程池。
Java标准库提供了ExecutorService
接口表示线程池,它的典型用法如下:
1 2 3 4 5 6 7 8 ExecutorService executor = Executors.newFixedThreadPool(3 ); executor.submit(task1); executor.submit(task2); executor.submit(task3); executor.submit(task4); executor.submit(task5);
因为ExecutorService
只是接口,Java标准库提供的几个常用实现类有:
FixedThreadPool:线程数固定的线程池;
CachedThreadPool:线程数根据任务动态调整的线程池;
SingleThreadExecutor:仅单线程执行的线程池。
1 2 3 4 5 6 7 8 9 10 11 12 13 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(); } }
线程池在程序结束的时候要关闭。使用shutdown()
方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow()
会立刻停止正在执行的任务,awaitTermination()
则会等待指定的时间让线程池关闭。
ScheduledThreadPool
定期反复执行的任务。
1 2 3 4 5 6 7 ScheduledExecutorService ses = Executors.newScheduledThreadPool(4 ); ses.schedule(new Task("one-time" ), 1 , TimeUnit.SECONDS); ses.scheduleAtFixedRate(new Task("fixed-rate" ), 2 , 3 , TimeUnit.SECONDS); ses.scheduleWithFixedDelay(new Task("fixed-delay" ), 2 , 3 , TimeUnit.SECONDS);
如果任务的任何执行遇到异常,则将禁止后续任务的执行。
使用Future Runnable
接口没有返回值。Callable
接口相比之下多了返回值。
1 2 3 4 5 class Task implements Callable <String > { public String call () throws Exception { return longTimeCalculation(); } }
获取执行结果:
1 2 3 4 5 6 7 ExecutorService executor = Executors.newFixedThreadPool(4 ); Callable<String> task = new Task(); Future<String> future = executor.submit(task); String result = future.get();
一个Future<V>
接口表示一个未来可能会返回的结果,它定义的方法有:
get()
:获取结果(可能会等待)
get(long timeout, TimeUnit unit)
:获取结果,但只等待指定的时间;
cancel(boolean mayInterruptIfRunning)
:取消当前任务;
isDone()
:判断任务是否已完成。
CompletableFuture CompletableFuture
相比Future
做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class Main { public static void main (String[] args) throws Exception { CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice); cf.thenAccept((result) -> { System.out.println("price: " + result); }); cf.exceptionally((e) -> { e.printStackTrace(); return null ; }); Thread.sleep(200 ); } static Double fetchPrice () { try { Thread.sleep(100 ); } catch (InterruptedException e) { } if (Math.random() < 0.3 ) { throw new RuntimeException("fetch price failed!" ); } return 5 + Math.random() * 20 ; } }
紧接着,CompletableFuture
已经被提交给默认的线程池执行了,我们需要定义的是CompletableFuture
完成时和异常时需要回调的实例。完成时,CompletableFuture
会调用Consumer
对象:
1 2 3 public interface Consumer <T > { void accept (T t) ; }
异常时,CompletableFuture
会调用Function
对象:java
1 2 3 public interface Function <T , R > { R apply (T t) ; }
CompletableFuture
的优点是:
异步任务结束时,会自动回调某个对象的方法;
异步任务出错时,会自动回调某个对象的方法;
主线程设置好回调后,不再关心异步任务的执行。
多个CompletableFuture
可以串行执行.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public class Main { public static void main (String[] args) throws Exception { CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> { return queryCode("中国石油" ); }); CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> { return fetchPrice(code); }); cfFetch.thenAccept((result) -> { System.out.println("price: " + result); }); Thread.sleep(2000 ); } static String queryCode (String name) { try { Thread.sleep(100 ); } catch (InterruptedException e) { } return "601857" ; } static Double fetchPrice (String code) { try { Thread.sleep(100 ); } catch (InterruptedException e) { } return 5 + Math.random() * 20 ; } }
CompletableFuture
还可以并行执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 public class Main { public static void main (String[] args) throws Exception { CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> { return queryCode("中国石油" , "https://finance.sina.com.cn/code/" ); }); CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> { return queryCode("中国石油" , "https://money.163.com/code/" ); }); CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163); CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> { return fetchPrice((String) code, "https://finance.sina.com.cn/price/" ); }); CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> { return fetchPrice((String) code, "https://money.163.com/price/" ); }); CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163); cfFetch.thenAccept((result) -> { System.out.println("price: " + result); }); Thread.sleep(200 ); } static String queryCode (String name, String url) { System.out.println("query code from " + url + "..." ); try { Thread.sleep((long ) (Math.random() * 100 )); } catch (InterruptedException e) { } return "601857" ; } static Double fetchPrice (String code, String url) { System.out.println("query price from " + url + "..." ); try { Thread.sleep((long ) (Math.random() * 100 )); } catch (InterruptedException e) { } return 5 + Math.random() * 20 ; } }
使用Fork/Join Fork/Join:将大任务拆分为小任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 public class Main { public static void main (String[] args) throws Exception { long [] array = new long [2000 ]; long expectedSum = 0 ; for (int i = 0 ; i < array.length; i++) { array[i] = random(); expectedSum += array[i]; } System.out.println("Expected sum: " + expectedSum); ForkJoinTask<Long> task = new SumTask(array, 0 , array.length); long startTime = System.currentTimeMillis(); Long result = ForkJoinPool.commonPool().invoke(task); long endTime = System.currentTimeMillis(); System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms." ); } static Random random = new Random(0 ); static long random () { return random.nextInt(10000 ); } } class SumTask extends RecursiveTask <Long > { static final int THRESHOLD = 500 ; long [] array; int start; int end; 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 += this .array[i]; try { Thread.sleep(1 ); } catch (InterruptedException e) { } } return sum; } int middle = (end + start) / 2 ; System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d" , start, end, start, middle, middle, end)); SumTask subtask1 = new SumTask(this .array, start, middle); SumTask subtask2 = new SumTask(this .array, middle, end); invokeAll(subtask1, subtask2); Long subresult1 = subtask1.join(); Long subresult2 = subtask2.join(); Long result = subresult1 + subresult2; System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result); return result; } }
使用ThreadLocal Thread.currentThread()
获取当前线程
在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。
Java标准库提供了一个特殊的ThreadLocal
,它可以在一个线程中传递同一个对象。
ThreadLocal
实例通常总是以静态字段初始化如下:
1 static ThreadLocal<User> threadLocalUser = new ThreadLocal<>();
它的典型使用方式如下:
1 2 3 4 5 6 7 8 9 10 void processUser (user) { try { threadLocalUser.set(user); step1(); step2(); } finally { threadLocalUser.remove(); } }
ThreadLocal
相当于给每个线程都开辟了一个独立的存储空间,各个线程的ThreadLocal
关联的实例互不干扰。
最后,特别注意ThreadLocal
一定要在finally
中清除。
为了保证能释放ThreadLocal
关联的实例,我们可以通过AutoCloseable
接口配合try (resource) {...}
结构,让编译器自动为我们关闭。例如,一个保存了当前用户名的ThreadLocal
可以封装为一个UserContext
对象:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class UserContext implements AutoCloseable { static final ThreadLocal<String> ctx = new ThreadLocal<>(); public UserContext (String user) { ctx.set(user); } public static String currentUser () { return ctx.get(); } @Override public void close () { ctx.remove(); } }
使用的时候,我们借助try (resource) {...}
结构,可以这么写:
1 2 3 4 try (var ctx = new UserContext("Bob" )) { String currentUser = UserContext.currentUser(); }
这样就在UserContext
中完全封装了ThreadLocal
,外部代码在try (resource) {...}
内部可以随时调用UserContext.currentUser()
获取当前线程绑定的用户名。