JAVA多线程(二)竞态条件、死锁及同步机制

时间:2022-02-16 15:09:20

4 多线程的安全问题及解决方案

这一篇博客中,我会列出JAVA多线程编程过程中,容易出现的安全问题(竞态条件、死锁等),以及相应的解决方案,例如同步机制等。

究竟什么是线程安全?简单的说,如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。

4.1 竞态条件(racing condition)与多线程同步机制

4.1.1 竞态条件的概念

我们前面已经说过,线程之间共享堆空间,在编程的时候就要格外注意避免竞态条件。危险在于多个线程同时访问相同的资源并进行读写操作。当其中一个线程需要根据某个变量的状态来相应执行某个操作的之前,该变量很可能已经被其它线程修改。这里看个简单的例子:

class MyThread extends Thread{
public static int index;
public void run(){
for(int i=0;i<10;i++){
System.out.println(getName()+":"+index++);
}
}
}

public class Test {
public static void main(String[] args){
new MyThread().start();
new MyThread().start();
}
}

运行结果是:
Thread-0:0
Thread-0:2
Thread-1:1
Thread-0:3
Thread-0:5
Thread-0:6
Thread-0:7
Thread-0:8
Thread-0:9
Thread-0:10
Thread-0:11
Thread-1:4
Thread-1:12
Thread-1:13
Thread-1:14
Thread-1:15
Thread-1:16
Thread-1:17
Thread-1:18
Thread-1:19
在这个例子中,2个线程都会去访问静态变量index,他们获取系统时间片的时刻是不确定的,因此它们对index的访问和修改总是穿插进行的。

4.1.2多线程同步

我们需要想办法解决上面的竞态条件,当多个线程需要访问同一资源的时候,它们需要以某种顺序来确保该资源在某一时刻只能被一个线程使用,否则程序的运行结果将不可预料。也就是说,当线程A需要使用某个资源,如果该资源正被线程B使用,同步机制就会让线程A一直等待下去,直到线程B结束对该资源的使用,线程A才能使用。

要想实现同步操作,必须获得每一个线程对象的锁(lock)。获得锁可以保证同一时刻只有一个线程进入临界区(访问互斥资源的代码块),并且在这个锁被释放之前,其它线程都不能再进入这个临界区。如果还有其他线程想要获得该对象的锁,只能先进入等待队列。当拥有该对象锁的线程退出临界区,锁才会被释放,等待队列中优先级最高的线程才能获得该锁,从而进入共享代码区。

实现同步的方式有以下三种:

同步实现方法 描述
synchronized关键字:以很大的系统开销为代价,慎用 当一个线程调用对象的一段synchronized代码,需要先获取这个锁,然后执行相应的代码,执行结束后释放锁。synchronized关键字有两种用法——1. synchronized方法.示例:public synchronized void multiThreadAccess()只要把多个线程对类需要被同步的资源的操作放到multiThreadAccess()中,就能保证多线程访问的安全性;2.如果方法体规模很大,就应该用synchronized块。它既可以把任意代码段声明为synchronized,也可以指定上锁的对象,很灵活。
wait() notify() 在synchronized代码被执行期间,线程可以调用对象的wait()方法,释放对象锁,进入等待状态,并且可以调用notify()或者notifyAll()方法通知正在等待的其它线程。
Lock(JDK5中新加入,实现类是ReentrantLock) 它提供了四种方法——1. lock()以阻塞的方式获取锁,如果获取到则立即返回,如果别的线程持有锁,当前线程等待,直到获取锁后返回;2.tryLock()以非阻塞的方式获取锁。只是尝试性地获取一下锁,如果获取成功,返回true,否则返回false;3.tryLock(long timeout,TimeUnit unit)如果获取锁,返回true,否则等待给定的时间单元,等待的过程中如果获得锁,返回true,超时则返回false;4.LockInterruptibly()如果获取锁,立即返回;如果没有获取锁,当前线程休眠,直到获得锁,或者当前线程被别的线程中断。

4.1.3 synchronized

其中synchronized块的结构是:

synchronized(syncObject){
//访问syncObject的代码
}

接下来我们看看在使用同步代码块之后,上面提到的竞态条件有没有得到解决。

class MyThread extends Thread{
public static int index;
public static Object obj=new Object();
public void run(){
synchronized(obj){
for(int i=0;i<10;i++){
System.out.println(getName()+":"+index++);
}
}
}
}

public class Test {
public static void main(String[] args){
new MyThread().start();
new MyThread().start();
}
}

这次的运行结果是:
Thread-1:0
Thread-1:1
Thread-1:2
Thread-1:3
Thread-1:4
Thread-1:5
Thread-1:6
Thread-1:7
Thread-1:8
Thread-1:9
Thread-0:10
Thread-0:11
Thread-0:12
Thread-0:13
Thread-0:14
Thread-0:15
Thread-0:16
Thread-0:17
Thread-0:18
Thread-0:19
可以看到,在使用同步之后,线程会按照顺序访问静态变量,也就是说,同步机制通过“锁”解决了竞态条件。

了解同步机制之后,我们需要考虑的就是,到底把哪部分代码放到同步当中?粒度必须足够大,才能将必须视为原子的操作封装在此区域中;然而粒度如果过大,就会导致并发性能降低。因此,应该根据实际业务需求确定锁的粒度大小。

4.1.4 Lock vs synchronized

二者都是常用的同步方法,那么,它们有什么区别呢?

  1. 用法不同。synchronized既可以加在方法上,也可以加在特定代码块中,括号表示需要锁的对象。Lock需要显式地指定起始位置和终止位置。synchronized托管给JVM执行,Lock的锁定是通过代码实现的,有比synchronized更精准的线程语义;
  2. 性能不同。JDK5中新加入了一个Lock接口的实现类ReetrantLock. 它不仅拥有和synchronized相同的并发性和内存语义,还多了锁投票、定时锁、等候和中断锁等。竞争不是很激烈的时候,synchronized性能优于ReetrantLock;但是资源竞争激烈的时候,synchronized性能下降很快,ReetrantLock性能基本不变;
  3. synchronized自动解锁;Lock需要手动解锁,而且必须在finally块中释放,否则会引起死锁(4.2部分会讲到)

下面举一个使用Lock的例子:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReetrantLock;
public class Test{
public static void main(String[] args) throws InterruptedException{
final Lock lock=new ReetrantLock();
lock.lock();
Thread t1=new Thread(new Runnable){
public void run(){
try{
lock.lockInterruptibly();
} catch(InterruptedException e){
System.out.println(" interrupted.");
}
}
});
t1.start();
t1.interrupt();
Thread.sleep(!);
}
}

程序的运行结果是:
interrupted.

4.2 死锁(deadlock)

死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们将一直互相等待而无法推进下去。也就是说,死锁会让你的程序挂起无法完成任务。

另一种与之相近的概念被称为“活锁”。活锁与死锁的主要区别是,活锁进程的状态可以改变(死锁不能改变),但是和死锁一样无法继续执行。活锁可以理解为在狭小的山道,两辆车相向而行,为了避让而同时往一个方向转头,结果谁都过不去。

究竟什么情况下会发生死锁呢? 死锁必须同时满足以下条件

死锁条件 描述
不剥夺 进程已经获得的资源,未使用完之前不能强行剥夺
请求与保持 一个进程一个进程因请求资源而阻塞时,对已获得的资源保持不放
互斥 一个资源每次只能被一个进程使用
循环等待 若干进程之间形成一种头尾相接的循环等待资源关系

既然死锁的发生必须同时满足这几个条件,我们只需要破坏其中之一就可以避免死锁。通常我们选择阻止循环等待条件,将系统中所有的资源设置标志位、排序,规定所有的进程申请资源必须以一定的顺序(升序或降序)做操作来避免死锁。

这里我们举个例子说明死锁和避免死锁(程序引用自How to avoid deadlock)。下面的程序会造成死锁,因为如果线程1在执行method1()的时候,获取String对象的锁,而线程2在执行method2()的时候获取Integer对象的锁,双方就会进入无休止的互相等待状态,因为双方都想获取对方已获取的对象锁。
JAVA多线程(二)竞态条件、死锁及同步机制

按照上面我们说的,对程序做如下更改,就可以避免死锁的发生。当线程1获取Integer对象的锁的时候,线程2就会等待线程1释放锁之后才会执行,反之亦然。
JAVA多线程(二)竞态条件、死锁及同步机制

说明
本人水平有限,不当之处希望各位高手指正。如有转载请注明出处,谢谢。