线程安全问题
# 线程安全问题
如果有多个线程在同时运行,而这些线程可能会同时运行同一段代码。如果多线程程序每次的运行结果,和单线程运行的结果是一样的,且其他的变量的值也和预期的是一样的,就是线程安全的。如果不是,则是不安全的。
我们通过一个案例,演示线程的安全问题。
# 业务场景与演示
电影院要卖票,我们模拟电影院的卖票过程。假设要播放的电影是 “葫芦娃大战奥特曼”,本次电影的座位共 100 个,本场电影只能卖 100 张票。
我们来模拟电影院的售票窗口,模拟多个窗口同时卖这场电影的 100 张票。
首先,可以明确的是,如果是单线程去卖票,是没有问题的:
即使是多个窗口,如果不涉及共同修改数据,也是没问题的:
如果是同时卖,则可能有问题,例如第一个窗口在卖第 100 号票,而第二个窗口也在卖票,此时就出现了重复的票;还有可能一个窗口卖出最后一张票的同时,另一个窗口也在卖,此时就出现了不存在的票。
先来定义票源,以及卖票操作:
package chapter200Thread;
public class Ticket implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
// 执行卖票操作
@Override
public void run() {
// 使用死循环,重复卖票
while (true){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
然后定义一个类,启动多个线程,模拟多个窗口同时卖票:
package chapter200Thread;
public class Demo08Ticket {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new Ticket();
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
运行结果:存在卖出重复票的行为()
Thread-1正在卖第100张票
Thread-0正在卖第100张票
Thread-2正在卖第100张票
Thread-0正在卖第98张票
Thread-1正在卖第99张票
Thread-0正在卖第96张票
Thread-2正在卖第97张票
Thread-0正在卖第94张票
Thread-1正在卖第95张票
Thread-0正在卖第92张票
.....
2
3
4
5
6
7
8
9
10
11
如果没有出现重复卖票或者卖出不存在的票的行为,可以多试几次,或者加个 Thread.sleep 方法,提高出现线程安全问题的概率:
while (true){
Thread.sleep(10);
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
2
3
4
5
6
7
8
# 线程安全问题产生的原理
假设有 3 个线程,分别是 t0,t1,t2
假设这样的场景:
- 程序处于初始状态,票的数量是 100
- 当 t0 判断完 ticket > 0 后,输出了“正在卖第 100 张票”,等输出完在这句后,该程序就暂停,CPU 转去执行 t1 线程,此时 ticket -- 还未执行
- t1 也判断完 ticket > 0 后,输出了“正在卖第 100 张票”,等输出完在这句后,该程序就暂停,CPU 转去执行 t2 线程,此时 ticket -- 还未执行
- t2 同理,也输出了“正在卖第 100 张票”
- 此时,就出现了卖出重复票的行为
同理,卖出不存在的票的场景也是一样的,这里就不赘述了。
怎么解决这个问题呢?我们可以让一个线程访问共享数据的时候,无论是否失去了 CPU 的执行权,其他线程都要等待;等到这个线程卖完了一张票,其他线程才能进行卖票。
# 解决线程安全问题
当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。
要解决多线程并发访问一个资源的安全性问题,Java 中提供了同步机制(synchronized)。
根据案例简述:
窗口 1 线程进入操作的时候,窗口 2 和窗口 3 线程只能在外等着,窗口 1 操作结束,窗口 1 和窗口 2 和窗口 3 才有机会进入代码去执行。也就是说在某个线程修改共享资源的时候,其他线程不能修改该资源,等待修改完毕同步之后,才能去抢夺 CPU 资源,完成对应的操作,保证了数据的同步性,解决了线程不安全的现象。
为了保证每个线程都能正常执行原子操作,Java 引入了线程同步机制。
那么怎么去使用呢?有三种方式完成同步操作:
- 同步代码块。
- 同步方法。
- 锁机制。
# 同步代码块
同步代码块: synchronized 关键字,可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。格式:
synchronized (同步锁){
需要同步操作的代码
}
2
3
对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁:
- 锁对象可以是任意类型。
- 多个线程对象要使用同一把锁。
- 锁对象的作用:把同步代码块锁住,只让一个线程在同步代码块中执行。
- 在任何时候,最多允许一个线程拥有同步锁,谁拿到锁就进入代码块,其他的线程只能在外等着(BLOCKED)。
实践:新建一个类,然后从创建一个锁对象,并将同步代码块放到 synchronized 块中
package chapter200Thread;
public class TicketSynchronized implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
// 创建一个锁对象,注意要在 run 方法外部创建,否则每个线程都会创建一个锁对象,就无效了。
Object obj = new Object();
// 执行卖票操作
@Override
public void run() {
// 使用死循环,重复卖票
while (true){
synchronized (obj){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package chapter200Thread;
public class Demo08TicketSynchronized {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new TicketSynchronized();
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
运行结果:能正常卖出 100 张票。
# 同步技术的原理
同步技术使用了一个锁对象,这个锁对象叫同步锁,也叫对象锁,对象监视器。
3 个线程一起抢夺 CPU 的执行权,谁抢到了,谁就执行 run 方法进行卖票。
比如,t0 抢到了 CPU 的执行权,执行 run 方法时,会遇到 synchronized 代码块,这时 t0 会检查 synchronized 代码块是否有锁对象,发现有,就会获取到锁对象,进入到同步中执行
而等 t1 抢到了 CPU 的执行权时,执行 run 方法,也遇到 synchronized 代码块,这时 t1 会检查 synchronized 代码块是否有锁对象,发现没有,t1 就会进入到阻塞状态,会一直等待 t0 线程归还锁对象。
一直到 t0 线程执行完同步中的代码,会把锁对象归还给同步代码块,t1 才能获取到锁对象进入到同步中执行。
总结:同步中的线程,没有执行完毕不会释放锁,同步外的线程没有锁进不去同步。同步保证了只能有一个线程在同步中执行共享数据,保证了安全。
但也有一个缺点:线程会频繁地判断,锁获取和释放的情况,程序的效率会降低。
# 同步方法
同步方法:使用 synchronized 修饰的方法,就叫做同步方法,保证 A 线程执行该方法的时候,其他线程只能在方法外等着。格式:
修饰符 synchronized 返回值类型 方法名(参数列表){
可能会产生线程安全问题的代码
}
2
3
演示:
package chapter200Thread;
public class TicketSynchronizedMethod implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
// 执行卖票操作
@Override
public void run() {
// 使用死循环,重复卖票
while (true){
sellTicket();
}
}
public synchronized void sellTicket(){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package chapter200Thread;
public class Demo08TicketSynchronizedMethod {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new TicketSynchronizedMethod();
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
同步方法的原理:和之前一样,也会把方法内部的代码锁住,只让一个线程执行。同步方法的锁对象,就是线程的实现类对象,也就是 this。我们可以验证下,在 run 方法里输出这个 this:
package chapter200Thread;
public class TicketSynchronizedMethod implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
// 执行卖票操作
@Override
public void run() {
System.out.println("this: " + this);
// 使用死循环,重复卖票
while (true){
sellTicket();
}
}
public synchronized void sellTicket(){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
在 main 程序中也输出下 run 对象:
package chapter200Thread;
public class Demo08TicketSynchronizedMethod {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new TicketSynchronizedMethod();
System.out.println("run: " + run);
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
运行结果:确实是同一个对象
run: chapter200Thread.TicketSynchronizedMethod@6cd8737
this: chapter200Thread.TicketSynchronizedMethod@6cd8737
this: chapter200Thread.TicketSynchronizedMethod@6cd8737
this: chapter200Thread.TicketSynchronizedMethod@6cd8737
Thread-1正在卖第100张票
...........
2
3
4
5
6
我们可以这么理解:
public void sellTicket(){
synchronized(this){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
2
3
4
5
6
7
8
9
# 静态同步方法
我们可以使用静态的 synchronized 方法:
package chapter200Thread;
public class TicketSynchronizedMethodStatic implements Runnable{
// 定义一个多个线程共享的票源
private static int ticket = 100;
// 执行卖票操作
@Override
public void run() {
System.out.println("this: " + this);
// 使用死循环,重复卖票
while (true){
sellTicket();
}
}
public static synchronized void sellTicket(){
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
}
}
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
package chapter200Thread;
public class Demo08TicketSynchronizedMethodStatic {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new TicketSynchronizedMethodStatic();
System.out.println("run: " + run);
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
静态方法的锁对象:就不能是 this 了(因为 this 是创建对象之后产生的),而是本类的 class 属性,也叫 class 文件对象(反射的时候讲过)
该方法了解即可。
# Lock 锁
java.util.concurrent.locks.Lock
提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作,同步代码块/同步方法具有的功能,Lock 都有,除此之外更强大,更体现面向对象。
Lock 锁也称同步锁,将加锁与释放锁的操作方法化了,如下:
public void lock()
:加同步锁public void unlock()
:释放同步锁
该功能是 JDK 1.5 之后出现的,是个接口,我们主要会用到其实现类:java.util.concurrent.locks.ReentrantLock;
使用步骤:
- 在成员位置创建一个
ReentrantLock
对象 - 在可能出现安全问题的代码前,调用 Lock 接口的方法
lock
获取锁 - 在可能出现安全问题的代码前,调用 Lock 接口的方法
unlock
释放锁
示例:
package chapter200Thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketLock implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
Lock l = new ReentrantLock();
// 执行卖票操作
@Override
public void run() {
// 使用死循环,重复卖票
while (true){
l.lock();
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
l.unlock();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package chapter200Thread;
public class Demo08TicketLock {
public static void main(String[] args) {
// 创建 Runnable 接口的实现类对象
Runnable run = new TicketLock();
// 创建三个窗口对象
Thread t0 = new Thread(run);
Thread t1 = new Thread(run);
Thread t2 = new Thread(run);
//调用start方法开启多线程,执行run方法
t0.start();
t1.start();
t2.start();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
运行结果:也能正常卖出 100 张票。
更好的写法:使用 finally,这样无论是否有异常,都能释放锁。
package chapter200Thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TicketLock implements Runnable{
// 定义一个多个线程共享的票源
private int ticket = 100;
Lock l = new ReentrantLock();
// 执行卖票操作
@Override
public void run() {
// 使用死循环,重复卖票
while (true){
l.lock();
try {
// 提高安全问题出现的概率:让程序睡眠一下
Thread.sleep(10);
// 先判断是否还有票
if ( 0 < ticket){
System.out.println(Thread.currentThread().getName() + "正在卖第" + ticket + "张票");
ticket--;
}
} catch (Exception e){
e.printStackTrace();
} finally {
l.unlock();
}
}
}
}
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