第 2 章 多线程编程常用功能类
本章我们将开始学习 Java 多线程编程的进阶内容,通过学习常用的多线程编程常用的同步功能、线程锁、同步屏障等功能,然后进行多种线程安全的功能类知识的学习,初步掌握在性能测试中遇到的各种线程安全问题解决思路,为接下来的性能测试实战打好基础。
2.1 线程安全
只要谈起 Java 多线程,就绕不开一个最重要的核心问题 “线程安全”。什么是线程安全呢?那么咱们再来一个小故事来说明问题。
故事主角叫 “小八”,他有一个非常要好的小伙伴,名字叫小七,俩人都很喜欢爬山。
今年立秋之后的某个周末,小七想约小八去爬山,就给小八发个消息:“小八,周末有空嘛?” 小八回复:“有空啊。” 然后俩人就周末去爬山了。
这就是正常的单线程沟通的场景,小八和小七进行一对一的沟通。
加入小八还有一个关系很好的朋友,名字叫小九,俩人都很喜欢钓鱼。于是立秋之后的同一天想约小八钓鱼。于是便发生以下的对话:
频道 1,小九 to 小八:小八,周末有空嘛?
频道 2,小七 to 小八:小八,周末有空嘛?
频道 1,小八 to 小九:有啊
频道 2,小八 to 小七:有啊
频道 1,小九 to 小八:咱们去钓鱼呀
频道 1,小八 to 小九:好啊,走起
频道 2,小七 to 小八:咱们去爬山呀
频道 2,小八 to 小七:啊!我周末跟小九去钓鱼。
频道 2,小七 to 小八:你刚才不是还说有空嘛?
频道 2,小八 to 小七:……
实际生活中经常会发生类似的情况,原因在于小八周末的状态在整个对话过程中是变化的,但是这个变化只告诉了第一个约他钓鱼的小九,小七并没有及时得知最新情况。
在 Java 多线程编程中也是相同的情况,当一个线程改变了一个对象的状态,其他线程并没有及时得知最新情况,持有的还是对象的旧状态,就会导致实际结果与预期不符的现象发生。
那么聪明的你一定想到了解决办法,例如:
- (1)让小七、小八和小九在一个房间和聊天群,这样相互之间可以看到聊天内容,就不会产生尴尬的情况。
- 让两个朋友打电话约钓鱼或者爬山,这样小八在跟小九打电话约钓鱼的时候,就不会接小七的电话约爬山了。
这两个办法其实隐含了解决 Java 线程安全问题的两个思路:一是使用线程安全的类同步状态,二是将多线程转成单线程。
在实际性能测试当中,我们遇到的线程安全问题会比较复杂,解决问题的办法也多种多样,下面分享 Java 解决线程安全常见的几种思路。
2.2 synchronized 关键字
在解决 Java 线程安全问题的答案中,关键字 synchronized 无疑是最直接、最简单的。
synchronized 是 Java 语言非常重要的一个关键字,主要作用是解决线程安全的问题。synchronized 主要用法分成两大类:
2.2.1 synchronized 基础语法
synchronized 关键字用于控制多个线程对共享资源的访问,以避免不一致性的问题。使用 synchronized 关键字可以使一段代码变成同步代码块,这段代码执行就会变成线程安全的代码。
基本的语法展示如下:
Object object = new Object();
synchronized (object) {
doSomething();//同步的代码
}
如果你留意过这个,应该还会经常看到这样的代码:
synchronized (SynchronizedDemo.class) {
doSomething();//同步的代码
}
那么这两者的区别在哪里呢?且听我娓娓道来。
当你使用 synchronized 同步一个对象,那么程序会在多个线程之间建立一个互斥区,确保只有一个线程可以执行同步代码块。这种用法通常在多线程修改某个对象属性场景中,用来保障线程安全。
如果你使用 synchronized 同步一个类,他会同步类的 class 对象,保证只有一个线程可以执行同步代码块。这种用法通常用在多线程修改多个实例共享的类级别的资源,例如:计数器、缓存等等。
这么说或许你还会有疑惑,下面通过演示代码来说明两者的区别。
2.2.2 synchronized 同步对象
首先我们先看一下演示代码:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFirst {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFirst demoFirst = new SynchronizedDemoFirst();
new Thread(() -> {
demoFirst.test();
}).start();
}
}
public void test() {
synchronized (this) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
这个例子中,我定义了个实例方法,该方法同步对象是当前的类对象,休眠 100 毫秒之后打印一些信息。在 main 方法中,我写了一个次数为 3 的 for 循环,每次循环创建 1 个新的类对象,然后在异步线程中执行改对象的 test() 方法。
控制台输出内容如下:
1698459271749 Hello FunTester! Thread-2
1698459271749 Hello FunTester! Thread-1
1698459271749 Hello FunTester! Thread-0
可以看到 3 个线程同一时间执行了 test()
方法,打印了信息。这说明当synchronized
关键字同步对象为实例对象时,是无法保障多个实例对象执行改实例方法的线程安全的。
那么这类场景我们应该如何正确设计代码,实现线程安全呢?请看下面这个例子。
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoSecond {
public static void main(String[] args) {
SynchronizedDemoSecond first = new SynchronizedDemoSecond();
for (int i = 0; i < 3; i++) {
new Thread(() -> {
first.test();
}).start();
}
}
public void test() {
synchronized (this) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
在这个例子中,我们使用只创建 1 个实例对象,多线程均调用改实例的 test() 方法。其中内在含义就是synchronized
关键字同步的对象 “this” 本质都是同一个对象。
控制台输出:
1698459581053 Hello FunTester! Thread-0
1698459581154 Hello FunTester! Thread-2
1698459581258 Hello FunTester! Thread-1
3 个线程时间戳相差约 100 毫秒,说明这个场景下多线程是安全的。
相信聪明的你一定能看出来其中的差别,当 synchronized 同步的对象是同一个,那么线程就是安全的,反之则不安全。那么问题来了,使用 synchronized 关键字可不可以在不同对象访问某段代码块的时候也保障线程安全呢?
当然是可以的。在性能测试工作实战中,有两种方式可以实现这个需求。下面我们先来看第一种方式:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoThird {
static Object object = new Object();
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoThird demoSecond = new SynchronizedDemoThird();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public void test() {
synchronized (object) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
这里将 synchronized 同步的对象手动设置为同一个对象,这样就不用再考虑多个实例对象在多线程场景下的线程安全问题了。
控制台输出内容如下:
1698460561473 Hello FunTester! Thread-0
1698460561574 Hello FunTester! Thread-2
1698460561679 Hello FunTester! Thread-1
第二种方式就用到了上一节提到的 snchronize 第二种用法,将 synchronized 对象设置为类 class 对象。演示代码如下:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFoutth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFoutth demoSecond = new SynchronizedDemoFoutth();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public void test() {
synchronized (SynchronizedDemoFirst.class) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
这里只将第二个演示代码的 test() 方法中 synchronize 对象做了修改。
控制台输出内容:
1698460826400 Hello FunTester! Thread-0
1698460826500 Hello FunTester! Thread-1
1698460826605 Hello FunTester! Thread-2
同样地,这种方式也可以解决多个实例对象在多线程场景下的线程安全。
2.2.3 synchronized 同步方法
经过上一节 4 个例子,你已经对synchronized
使用有了初步了解。对于 synchronized 同步对象使用方法基本掌握了。
如果你留意过 JDK 里面对于synchronized
使用,还可以把synchronized
关键字写到方法定义的修饰符位置上。那么synchronized
同步方法是如何保障线程安全的呢?下面用两个例子说明。
1.synchronized 同步实例方法
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFifth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFifth demoSecond = new SynchronizedDemoFifth();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public synchronized void test() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
控制台输出:
1698461487751 Hello FunTester! Thread-0
1698461487751 Hello FunTester! Thread-1
1698461487751 Hello FunTester! Thread-2
2.synchronized 同步静态方法
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoSixth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
test();
}).start();
}
}
public static synchronized void test() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
控制台输出:
1698461629191 Hello FunTester! Thread-0
1698461629295 Hello FunTester! Thread-2
1698461629395 Hello FunTester! Thread-1
总结来说,synchronized 同步实例方法,并能保障多实例访问的线程安全;synchronized 同步静态方法是可以保障多线程的线程安全的。这么说比较绕,下面是个简化之后的结论:
- (1)synchronized 同步实例方法等效于 synchronized 同步 this 对象
- (2)synchronized 同步静态方法等效于 synchronized 同步改类的 class 对象
这样是不是很明了,在实际的性能测试工作中,synchronized 同步方法是比较少用到的。原因两点:一是不够灵活,颗粒度是方法级别的,太粗了;二是性能较差,特别在高并发场景下,可能会导致性能大幅下降。
对于新手来说,synchronized 关键字使用起来很容易上手,写出来的代码可读性也虽然 synchronized 也可以用来编写较为复杂的线程安全场景,但是对于测试人员代码能力要求比较高,也存在可读性差、排查困难等问题。综上,建议大家在使用 synchronized 时,尽量编写功能逻辑简单的线程安全代码。若功能逻辑复杂,可以抛开 synchronized,寻求其他简单、可靠、已经验证的解决方案。
2.2.4 synchronized 最佳实战
synchronized 有一个重要的使用场景,就是在双重检查锁。双重检查锁是针对单例对象的一种线程安全实战,使用 synchronized 关键字实现,旨在保证线程安全的前提下,提高应用程序的性能和并发能力。
下面是 synchronized 双重检查锁的演示代码:
package org.funtester.performance.books.chapter02.section2;
package org.funtester.performance.books.chapter02.section2;
public class DoubleCheckedLocking {
private static DoubleCheckedLocking driver;
public static DoubleCheckedLocking getDriver() {
if (driver == null) {
synchronized (DoubleCheckedLocking.class) {
if (driver == null) {
driver = new DoubleCheckedLocking();
}
}
}
return driver;
}
}
这里用到 synchronized 同步类的 class 对象用法,保障所有访问该方法的线程在进行第二次检查的时候是线程安全的,从而保障 driver 对象只被初始化一次。当初始化完成之后,再访问该方法的线程又不会执行 synchronized 同步方法,提升了程序的性能。
书的名字:从 Java 开始做性能测试 。
如果本书内容对你有所帮助,希望各位多多赞赏,让我可以贴补家用。赞赏两位数可以提前阅读未公开章节。我也会尝试制作本书的视频教程,包括必要的答疑。
FunTester 原创精华