线程安全问题

什么是线程安全?

线程安全:不论什么情况下,多线程的执行结果都和单线程的执行结果保持一致;或者说一个对象不论怎样调用,都能得到正确的结果,那么这个对象就是线程安全的。

但是当多个线程访问同个一资源的时候,就会出现线程安全问题。比如售票系统,多个线程同时访问库存,如果没有做到线程的同步就很有可能会出现超卖(比如卖了-1这个号的票),或者重复卖(票号为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
25
26
27
28
29
30
31
32
33
34
35
36
37
public class LockTest {
public static void main(String[] args) {
Window window = new Window();

Thread t1 = new Thread(window);
Thread t2 = new Thread(window);
Thread t3 = new Thread(window);

t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");

t1.start();
t2.start();
t3.start();
}
}
class Window implements Runnable{
private int ticket = 10;

@Override
public void run() {
while (true){
if (ticket > 0){
try {
Thread.sleep(100);
} catch (Exception e) {
System.out.println(e.getMessage());
}
System.out.println(Thread.currentThread().getName() + ":售票,票号为:" + ticket);
ticket--;
}else {
break;
}
}
}
}

以上代码创建了三个线程,模拟了三个用户去抢票的过程,为了更好的出现超卖的问题,中间通过Thread.sleep(100);让线程睡眠了一段时间,以下是程序的运行结果,可以很明显的看到,几个客户都抢到了票号为10的票,而且有个客户还抢到了票号为-1的票,也就是上面说的重复卖和超卖的问题:

1
2
3
4
5
6
7
8
9
10
11
12
窗口1:售票,票号为:10
窗口2:售票,票号为:10
窗口3:售票,票号为:8
窗口1:售票,票号为:7
窗口2:售票,票号为:7
窗口3:售票,票号为:5
窗口1:售票,票号为:4
窗口2:售票,票号为:4
窗口3:售票,票号为:2
窗口2:售票,票号为:1
窗口1:售票,票号为:1
窗口3:售票,票号为:-1

在这个例子中,为什么会出现重复卖和超卖的问题呢?
因为当某个线程操作车票的过程中,尚未操作完成时,其他的线程也参与进来,也操作车票。
那怎么解决这些问题呢?
当一个线程a操作ticket的时候,其他线程不能参与进来。直到线程a操作完ticket时,其他线程才可以开始操作ticket。这种情况即使线程a出现了阻塞,也不能被改变。

解决方案大都是通用的,下面我们来讲解一下一般情况下有什么方式去保证线程安全问题。

保证线程安全的几种方式

一般有以下三种方式来保证线程安全:
保证线程安全方式一:同步代码块
保证线程安全方式二:同步方法
保证线程安全方式三:Lock 锁机制
下面我们来讲解一下这几种方式,以及他们在实现上有什么区别。

保证线程安全方式一:同步代码块

同步代码块,即有synchronized关键字修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步,保证线程安全。
同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
同步代码块示例如下:

1
2
3
4
5
synchronized (同步监视器){
//需要被同步的代码(操作共享数据的代码)
//共享数据:多个线程共同操作的变量
//同步监视器:俗称为锁。任何一个类的对象都可以充当锁。多个线程必须共用一把锁。
}

通俗来说,被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
37
38
39
40
41
42
public class LockTest {
public static void main(String[] args) {
Window window = new Window();

Thread t1 = new Thread(window);
Thread t2 = new Thread(window);
Thread t3 = new Thread(window);

t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");

t1.start();
t2.start();
t3.start();
}
}
class Window implements Runnable{
int ticket = 10;
final Object obj = new Object();

@Override
public void run() {
while (true) {
//同步代码块
synchronized (obj) {
if (ticket > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":售票,票号为 :" + ticket);
ticket--;
}
else{
break;
}
}
}
}
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
窗口2:售票,票号为 :10
窗口2:售票,票号为 :9
窗口3:售票,票号为 :8
窗口3:售票,票号为 :7
窗口3:售票,票号为 :6
窗口3:售票,票号为 :5
窗口3:售票,票号为 :4
窗口3:售票,票号为 :3
窗口3:售票,票号为 :2
窗口3:售票,票号为 :1

即使大家多执行几次,也不会看到超卖和重复卖的问题;在上面的代码中我们使用了synchronized同步代码块修饰了卖票的操作,使得每次只有一个线程在执行,保证了票号的线程安全。

保证线程安全方式二:同步方法

同步方法,即有synchronized关键字修饰的方法。
由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
synchronized修饰方法时,跟同步代码块相似,也使用到了锁的机制,只不过synchronized修饰方法时默认的锁是当前对象。

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
37
38
39
40
41
42
43
44
45
public class LockTest {
public static void main(String[] args) {
Window window = new Window();

Thread t1 = new Thread(window);
Thread t2 = new Thread(window);
Thread t3 = new Thread(window);

t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");

t1.start();
t2.start();
t3.start();
}
}
class Window implements Runnable{
int ticket = 10;

@Override
public void run() {
while (true) {
if (saleTicket() == 0) {
break;
}
}
}

public synchronized int saleTicket() {
if (ticket > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":售票,票号为 :" + ticket);
ticket--;
return 1;
}
else{
return 0;
}
}
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
窗口1:售票,票号为 :10
窗口1:售票,票号为 :9
窗口1:售票,票号为 :8
窗口1:售票,票号为 :7
窗口1:售票,票号为 :6
窗口1:售票,票号为 :5
窗口1:售票,票号为 :4
窗口1:售票,票号为 :3
窗口1:售票,票号为 :2
窗口1:售票,票号为 :1

咱们上面说到,synchronized修饰方法时,使用到的同步监视器的当前对象(this),所以说在上面的例子中,咱们要保证三个线程传入的对象是一致的,若不一致的话其实也就是各干各的了,不符合售票要求。

保证线程安全方式三:Lock 锁机制

从JDK 5开始,Java提供了更强大的线程同步机制————通过显示定义同步锁对象来实现同步。同步锁使用Lock对象来充当。锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。

先来介绍下Lock接口:
java.util.concurrent.locks.Lock 接口是控制多个线程对共享资源进行访问的工具。该接口提供有这两个方法:
(1) void lock() 获取锁
(2) void unlock() 释放锁

Lock 锁机制保证线程安全过程中,大体上分为三大步:
(1)实例化ReentrantLock;
(2)调用锁定方法(获取锁):lock();
(3)调用解锁方法(释放锁):unlock();

那ReentrantLock是个啥玩意呢:
ReentrantLock 是Lock接口的一个实现类,它拥有与synchronized相同的并发性和内存语义;在实现线程安全的控制中,常用的实现类就是 ReentrantLock。
它可以显示加锁,释放锁;通过lock()和unlock()配合使用,是一种手动锁,比较灵活;但是注意在使用这个锁时一定要注意释放锁,否则可能会造成死锁。

下面我们通过代码来了解Lock锁机制:

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
public class LockTest {
public static void main(String[] args) {
Window window = new Window();

Thread t1 = new Thread(window);
Thread t2 = new Thread(window);
Thread t3 = new Thread(window);

t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");

t1.start();
t2.start();
t3.start();
}
}

class Window implements Runnable{
private int ticket = 100;
// 1.实例化ReentrantLock
private final ReentrantLock lock = new ReentrantLock();

@Override
public void run() {
while (true){
// 2.调用锁定方法:lock()
// lock()必须紧跟代码块,避免中间代码抛出异常导致不能解锁等情况
lock.lock();
try {
if (ticket > 0){
System.out.println(Thread.currentThread().getName() + ":售票,票号为:" + ticket);
ticket--;
}else {
break;
}
}finally {
lock.unlock();
// 3.调用解锁方法:unlock()
}
}

}
}

输出结果如下:

1
2
3
4
5
6
7
8
9
10
窗口1:售票,票号为:10
窗口1:售票,票号为:9
窗口1:售票,票号为:8
窗口1:售票,票号为:7
窗口1:售票,票号为:6
窗口1:售票,票号为:5
窗口1:售票,票号为:4
窗口1:售票,票号为:3
窗口1:售票,票号为:2
窗口1:售票,票号为:1

synchronized与Lock的对比

1、synchronized与Lock都可以解决线程安全问题;
2、Lock是显示锁(手动开启和关闭锁,不要忘记关闭锁),synchronized是隐式锁,出了作用域自动释放。
3、Lock只有代码块锁,synchronized有代码块锁和方法锁。
4、使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(因为提供了更多的子类)。

实际开发过程中优先使用顺序:
Lock锁——>同步代码块(已经进入了方法体,分配了相应资源)——>同步方法(在方法体之外)

线程的死锁问题

死锁:不同的线程分别占用对方的需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。
出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续处理其他事情。

解决/避免方法:
1、专门的算法、原则;
2、尽量减少同步资源的定义;
3、尽量避免嵌套同步。

线程安全的单例模式

饿汉式原本就是线程安全的;懒汉式需要使用同步代码块的方式去保证线程安全。

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
public class SingleTest {
// 方法一:饿汉式
/*private static Person person = new Person();
public static Person getPersion(){
return person;
}*/

// 方法二:懒汉式
/**
* 实例声明成 volatile,避免指令重排造成导致实例未初始化的问题
* 双重null判断避免了实例已经初始化,但是线程每次还要等待锁产生的性能问题
*/
private volatile static Person instance;
public static Person getSingleton() {
if (instance == null) {
synchronized (Person.class) {
if (instance == null) {
instance = new Person();
}
}
}
return instance;
}

// 方式三
/**
* 通过静态内部类的方式获取,这种方法也是《Effective Java》上所推荐的。
* 这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,
* 除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;
* 同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。
*/
private static class SingletonHolder {
private static final Person INSTANCE = new Person();
}
public static Person getInstance() {
return SingletonHolder.INSTANCE;
}

}

总结

总的来说,保证线程安全有同步代码块、同步方法和Lock锁这几种方法,实际开发中推荐使用Lock锁的,因为使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性。本篇就完结啦。