您的位置:首页 > 编程语言 > Java开发

Java多线程——线程同步与锁

2016-12-14 21:26 453 查看
一、线程同步问题

了解线程基本知识的朋友都应该知道,同一进程中的多个线程共享进程的资源,并且每个线程独立运行。现在有这样一个场景:有一个资源Object,threadA在使用它,threadB也在使用。这就出现对于资源的竞争关系,准确的说是threadA和threadB都不能再信任这份资源,因为两个线程都不知道Object被对方做了什么修改。

这里例举一个场景:

/**测试买票场景,多个乘客一起向12306网站发起买票申请。*/
public class TestBuyTickets {
public static void main(String[] args) {
WebServer12306 server = new WebServer12306();
Thread thread1 = new Thread(new Passenger(server));
Thread thread2 = new Thread(new Passenger(server));
Thread thread3 = new Thread(new Passenger(server));
thread1.start();
thread2.start();
thread3.start();
}
}

/**乘客类*/
class Passenger implements Runnable {
private WebServer12306 server;
public Passenger(WebServer12306 server) {
this.server = server;
}
/**从12306买一张车票,并打印票号*/
public void run() {
System.out.println(Thread.currentThread().getName()+"买到车票:" + server.getTicket());
}
}

/**12306网站,初始10张车票,每次购买返回车票号,并且减少一张余票。*/
class WebServer12306 {
private int ticketNum = 10; // 这里直接将余票的序号作为票号
public int getTicket() {
int ticket = ticketNum; // 给乘客出票
ticketNum--; // 减少余票
return ticket; // 返回售出的票号
}
public void addTicket() {
ticketNum++;
}
}


执行结果(这里是随机产生的):

Thread-2买到车票:10

Thread-1买到车票:9

Thread-0买到车票:10

理想的执行结果应该是依次买到10、9、8三张票,造成这种异常的原因是什么呢,执行过程分析:

// 时间点1:
public int getTicket() {// 乘客Thread-0紧接着乘客Thread-2进入方法
int ticket = ticketNum; //乘客Thread-2买到第10张票
ticketNum--;
return ticket;
}


// 时间点2:
public int getTicket() {
int ticket = ticketNum; //乘客Thread-0买到第10张票(注意:此时Thread-2买到的票还没删除)
ticketNum--; // 删除Thread-2买到的票10(此刻这句还没执行完)
return ticket;
}


// 时间点3:
public int getTicket() {
int ticket = ticketNum;
ticketNum--; // 删除票10完成
return ticket;// 返回已买售出的票,此时两个乘客买到了同一张票10
}


可以看出,造成这个异常的根本原因是:操作同一份资源,两个线程执行时序不同步(两条线程分别在两条时间线上执行,二者没有“顺序”关系,造成了时序逻辑上的“混乱”),这种现象就是Java种说的“线程同步问题”。

二、解决同步问题——synchronized关键字

synchronized是Java种的关键字,它的英语意思就是“同步的、使同时发生”。可以理解为:将多条时间线的某个相对的点“统一”,这个说法比较抽象。先调用的线程把资源锁住,其他线程等待资源被释放后调用。恩,说人话就是:这个资源爷要了,玩够了再给你们。这样把所有线程的节奏统一到了“即将使用资源”这个时间点上。这就是synchronized关键字的作用,下面介绍这个关键字的用法。

1、同步方法

这里对上面12306网站的例子稍作修改:

public synchronized int getTicket() { // 在方法上增加了synchronized关键字,注意加在返回类型前面。
int ticket = ticketNum;
ticketNum--; // 删除票10完成
}


这样,某个线程调用getTicket()期间,其他要调用getTicket()时,就会发现这个方法被锁住了,需要等待它被释放。“取票”、“删除余票”两个操作一定是连续进行,就不会出现上述资源修改不同步的问题。此时的执行流程示意如下:

// 时间点1:
public synchronized  int getTicket() {// 乘客Thread-0紧接着乘客Thread-2调用方法,但发现被锁住,等待释放。
int ticket = ticketNum; //乘客Thread-2买到第10张票
ticketNum--;
return ticket;
}


// 时间点2:
public synchronized  int getTicket() {
int ticket = ticketNum;
ticketNum--; // 删除Thread-2买到的票10
return ticket;
}


// 时间点3:
public synchronized  int getTicket() {
int ticket = ticketNum;
ticketNum--;
return ticket;// 返回已买售出的票,Thread-2调用结束,释放方法。
}


// 时间点4:
public synchronized int getTicket() { // Thread-0获得调用权限,锁住方法,并开始执行
int ticket = ticketNum;
ticketNum--;
return ticket;
}


解决了同步问题,但却引入了另一个问题:执行时间变长了,因为线程需要等待!这就是多线程种最重要的部分之一,协调安全和效率。

2、同步块

同步块是synchronized 的另一种用法。废多看码!

public int getTicket() {
synchronized (this){ // 这里的作用与同步方法一样,都是锁住方法体。
return ticketNum--;
}
}


那么同步块的优点在哪呢?我们看看方法变体:

public int getTicket() {
// ...不需要处理同步问题的代码1
synchronized (this){
return ticketNum--;
}
// ...不需要处理同步问题的代码2
}


synchronized 块只锁住了方法中的部分代码,线程在执行其他代码时不需要等待,相对减少了执行时间,提升了程序效率。synchronized 块锁住的“this”其实就是这个方法所属对象本身。实际上,synchronized 块填入的参数可以是任何对象(注意:必须是对象),这种方式“加锁”利用的使Java对象的内置监视器。

利用synchronized 实现同步是隐式加锁,相对的,Java种也有显式加锁的方式。

三、解决同步问题——显式加锁

1、Lock接口

Lock 提供了比synchronized 更自由的锁定操作。它支持更灵活的结构,可以支持多个相关的 Condition 对象。

Lock接口有三个实现类:ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock 。现在先看看Lock接口的定义:

package java.util.concurrent.locks;
import java.util.concurrent.TimeUnit;

public interface Lock {
// 获取锁
void lock();
// 如果当前线程未被中断,则获取锁
void lockInterruptibly() throws InterruptedException;
// 返回绑定到此 Lock 实例的新 Condition 实例
Condition newCondition();
// 仅在调用时锁为空闲状态才获取锁
boolean tryLock();
// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 释放锁
void unlock();
}


锁提供了独占式访问共享资源的能力。对资源的所有访问都需要首先获得锁,通常资源的锁只能给一个线程。不过,某些锁允许对共享资源并发访问,如 ReadWriteLock 的读取锁定。

synchronized强制所有锁的获取和释放均要出现在一个块结构中:当获取了多个锁时,必须以相反的顺序释放。而Lock 接口允许锁在不同的作用范围内获取和释放,并允许以任何顺序获取和释放多个锁定,更为灵活。

随着灵活性的增加,也带来了更多的责任。不使用块结构锁定就失去了使用 synchronized 方法和语句时会出现的锁定自动释放功能。在大多数情况下,应该使用以下语句:

Lock l = new LockImple();
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}


在不同作用范围中获取锁和释放锁时,必须确保锁定执行的所有代码用try-finally或try-catch加以保护,以确保在必要时释放锁定。

注意: Lock是普通的对象,可以 synchronized(Lock) ,这与 Lock . lock() 方法没有特别的关系。为了避免混淆,决不要以这种方式使用 Lock 实例。

2、ReentrantLock

ReentrantLock是可重入的互斥锁,也叫“可重入锁”。 当锁没有被另一个线程获取时,调用 lock()获取锁并返回。如果当前线程已经拥有该锁定,此方法将立即返回。可以使用 isHeldByCurrentThread() 和 getHoldCount() 方法来检查锁是否已经被获取。

此类有一个带参构造如下:

public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}


参数fair表示是否穿件公平锁,默认是false。“公平”是指等待锁的线程按照已等待的时间,公平得获得锁。即,下一个得到锁的是等待时间最长的线程。

使用公平锁的程序,在多线程访问时总体吞吐量低(即速度慢,常常极慢)。要注意的是,公平锁不能保证CPU调度的公平性。因此,使用同一公平锁的某些成员可能获得多倍的执行成功机会。另外,未定时的 tryLock 方法没有使用公平设置。因为即使其他线程正在等待,只要该锁是可用的,此方法就可以获得锁。

ReentrantLock的序列化与内置锁的行为方式相同:不管它被序列化时的状态是怎样的,反序列化的锁都处于解除状态。

此锁定最多支持同一个线程发起的 2147483648 个递归锁定。

新增方法摘要:

查询当前线程保持此锁的次数。

public int getHoldCount()

返回目前拥有此锁的线程,如果此锁不被任何线程拥有,则返回。

protected Thread getOwner()

返回一个 collection,它包含可能正等待获取此锁的线程。

protected Collection getQueuedThreads()

返回正等待获取此锁的线程估计数。

public final int getQueueLength()

返回一个 collection,它包含可能正在等待与此锁相关给定条件的那些线程。

protected Collection getWaitingThreads(Condition condition)

返回等待与此锁相关的给定条件的线程估计数。

public int getWaitQueueLength(Condition condition)

查询给定线程是否正在等待获取此锁。

public final boolean hasQueuedThread(Thread thread)

查询是否有些线程正在等待获取此锁。

public final boolean hasQueuedThreads()

查询是否有些线程正在等待与此锁有关的给定条件。

public boolean hasWaiters(Condition condition)

如果此锁的公平设置为 true,则返回 true。

public final boolean isFair()

查询当前线程是否保持此锁。

public boolean isHeldByCurrentThread()

查询此锁是否由任意线程保持。

public boolean isLocked()

下面用ReentrantLock改造一下我们的12306系统,实现同步。

class WebServer12306 {
private int ticketNum = 10;
private Lock lock = new ReentrantLock(); // 创建一个非公平锁
public int getTicket() {
lock.lock(); // 获取锁
int ticket = -1;
try{
ticket = ticketNum;
ticketNum--;
}catch(Exception e){
// 示意,这个场景然并卵,可以不写catch块。
}
finally{
lock.unlock(); // 在finally种释放(保证一定能释放)。
}
return ticket;
}
public void addTicket() {
//ticketNum++;
}
}


3、Condition接口

在介绍读写锁之前,我们先来看看一个重要的东东:java.util.concurrent.locks.Condition接口。Condition定义了锁的“条件”。条件(条件队列)为线程提供了一个途径:线程在另一个线程通知自己条件成立之前,一直挂起该线程。将锁与该条件相关联。等待条件的主要方式是:以原子方式 释放锁,并挂起当前线程,就像 Object.wait。

Condition 将 Object 监视器方法(wait、notify 和 notifyAll)分解成不同的对象,以便通过将这些对象与任意 Lock 实现组合使用,为每个对象提供多个等待 set (wait-set)。其中,Lock 替代了 synchronized 方法和语句的使用,Condition 替代了 Object 监视器方法的使用。

Condition 实例被绑定到一个锁上,调用Lock 的 newCondition() 方法可获得 Condition 实例。

示例:有一个绑定的缓冲区,它支持 put 和 take 方法。如果在缓冲区为空时执行 take 操作,或者缓冲区为满时执行 put 操作,线程将阻塞。在单独的等待 set 中保存 put 线程和 take 线程,这样就可以在缓冲区中的项可用时利用最佳规划,一次只通知一个线程。可以使用两个 Condition 实例来做到这一点。

class BoundedBuffer {
final Lock lock = new ReentrantLock();
final Condition notFull  = lock.newCondition();
final Condition notEmpty = lock.newCondition();

final Object[] items = new Object[100];
int putptr, takeptr, count;

public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}

public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}


通过两个“条件”的检查,实现了线程间的合作。这就是典型的生产者-消费者例子。

请注意如下代码:

while (count == items.length)
notFull.await();


这里能不能改成:

if (count == items.length)
notFull.await();


答案是不能!如果发生了假唤醒(线程被唤醒,但条件依然不成立),程序将继续往后执行,可能发生业务逻辑错误。采用while循环,给了线程重新检查条件的机会,避免这种错误。

4、ReentrantReadWriteLock

读写锁ReentrantReadWriteLock实现java.util.concurrent.locks.ReadWriteLock接口。

public interface ReadWriteLock {
Lock readLock();  // 返回用于读的锁
Lock writeLock(); // 返回用于写的锁
}


ReentrantReadWriterLock类本身不实现Lock接口,它通过两个内部类实现Lock接口,分别是ReadLock,WriterLock类。

读写锁源码分析可参考博客:这里写链接内容

ReadWriteLock 维护了一对相关的锁,一个用于只读操作,一个用于写操作。读写锁必须保证 writeLock 操作的内存要保持与 readLock 的同步联系。也就是说,获取读锁的线程会看到写入锁对共享资源所做的全部所有更新。

与互斥锁相比,读写锁的优势在于:写入锁必须线程独占,但读取锁可以由多个 reader 线程同时持有!

使用读-写锁能否提升性能则取决于:在同一时间试图对该数据读或写操作的线程数。例如,某个集合只需初始填充一次,后面只会有大量读操作,此时就非常适合使用读写锁。

实例如下,假设Map的访问量非常大,并且能被同时访问。

class RWDictionary {
private final Map<String, Data> m = new TreeMap<String, Data>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();

public Data get(String key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
public String[] allKeys() {
r.lock();
try { return m.keySet().toArray(); }
finally { r.unlock(); }
}
public Data put(String key, Data value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
public void clear() {
w.lock();
try { m.clear(); }
finally { w.unlock(); }
}
}


但是,如果读操作所用时间太短,则读-写锁实现(它本身就比互斥锁复杂)的开销将成为主要的执行成本。应根据实际业务场景选择使用读写锁。

使用读写锁时,可以参考

在 writer 释放写入锁时,reader 和 writer 都处于等待状态,在这时要确定是授予读取锁还是授予写入锁。Writer 优先比较普遍,因为预期写入所需的时间较短并且不那么频繁。Reader 优先不太普遍,因为如果 reader 正如预期的那样频繁和持久,那么它将导致对于写入操作来说较长的时延。公平或者“按次序”实现也是有可能的。

在 reader 处于活动状态而 writer 处于等待状态时,确定是否向请求读取锁的 reader 授予读取锁。Reader 优先会无限期地延迟 writer,而 writer 优先会减少可能的并发。

确定是否重新进入锁:可以使用带有写入锁的线程重新获取它吗?可以在保持写入锁的同时获取读取锁吗?可以重新进入写入锁本身吗?

可以将写入锁在不允许其他 writer 干涉的情况下降级为读取锁吗?可以优先于其他等待的 reader 或 writer 将读取锁升级为写入锁吗?

当评估给定实现是否适合您的应用程序时,应该考虑所有这些情况。

下面的代码展示了写锁降级(忽略了异常处理):

class CachedData {
Object data;
volatile boolean cacheValid;
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
rwl.readLock().unlock(); // 获取写锁前,必须先释放读锁
rwl.writeLock().lock();
if (!cacheValid) { // 因在本次操作前,别的线程可能获取了写锁,并且修改了状态,所以必须检查状态。
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock(); // 在释放写锁前,通过申请读锁,实现写锁降级。
rwl.writeLock().unlock(); // 释放写锁,并继续持有读锁
}

use(data);
rwl.readLock().unlock();
}
}
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: