线程安全问题
# 60.线程安全问题
如果有多个线程在同时运行,而这些线程可能会同时运行同一段代码。如果多线程程序每次的运行结果,和单线程运行的结果是一样的,且其他的变量的值也和预期的是一样的,就是线程安全的。如果不是,则是不安全的。
我们通过一个案例,演示线程的安全问题。
# 业务场景与演示
电影院要卖票,我们模拟电影院的卖票过程。假设要播放的电影是 “葫芦娃大战奥特曼”,本次电影的座位共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