本文目录
1 同步机制
当多个线程都执行各自的任务操作,互不干扰,则不会出现并发问题。但是,若多个线程,在某个阶段,需要访问并操作同一个共享的数据,就会存在并发问题。例如:当某个线程正在处理共享的数据,此时,另一个进程也开始对这个共享的数据进行处理,这样,就可能导致数据出现错误。
为了避免这种情况的发生,我们可以在线程操作同一个共享数据的地方,应用同步机制。在同一时间内,只有获得锁(执行权)的线程,可以进入并执行同步限制区域的代码,其它线程必须等待。当线程执行完毕或主动释放锁后,等待的线程开始争夺锁,争夺成功的线程获取执行权,进入并执行同步限制区域的代码。
类比情景:一个仓库,只有一张门锁卡,任何想进入仓库的人,需要先获得门锁卡,然后才能进入仓库(此时,门锁卡随身携带),出仓库后,将门锁卡归还。任何人来到仓库时,都需要先检查门锁卡是否已经被其它人拿走。若已经被其它人拿走,就需要等待,直到那个人把门锁卡归还。
2 原子性
在使用同步机制的时候,我们经常会想到一个问题 —— “某些操作很简单,很快就执行完,是否还需要同步机制?”
这个问题的答案在于 “该操作是否为原子性操作” 。在Java中,只有少部分操作是原子性操作,例如:
num = 1;
某些操作,看似原子性,实则不然,例如:
num++; // 相当于 num = num + 1;
num += 1; // 相当于 num = num + 1;
在低并发量的情况下(非压力测试时),类似于 "num++;" 的操作,可能不会发生异常情况(因为几率太小)。但当处于高并发量的情况时,问题就会暴漏出来。
因此,在对于共享数据进行任何操作时,都需要认真思考是否需要同步机制,需要在哪里使用同步机制?
3 内置锁
在Java中,通过synchronized关键字来标记同步区域,由synchronized关键字使用的锁,称为内置锁。
内置锁实际上是一个Object对象,例如:
synchronized 块:以指定的Object对象为锁;
synchronized 实例方法:使用实例对象this为锁;
synchronized 静态方法:以当前类的Class对象为锁;
3.1 同步块(Synchronized Block)
通过synchronized对某一部分代码进行同步限制,代码示例如下:
synchronized(Object lock) {
// 同步限制的代码
}
通过synchronized标出同步区域,且以lock作为内置锁。
当线程执行到synchronized同步块的时候,会自动获得相应的锁对象lock,若获得成功,则开始执行同步块代码。
当线程执行完同步块代码之后,会将锁对象lock释放,此时,其它正在等待的线程便开始争夺该锁。
3.2 同步方法(Synchronized Method)
通过synchronized标记某个方法,表示该方法是同步方法,代码示例如下:
public synchronized int count() {
// 同步限制的代码
}
等效代码如下,即使用当前对象this作为同步锁:
public int count() {
synchronized (this) {
// 同步限制的代码
}
}
在同一个对象里的所有同步方法,实际上共用了同一个锁(this),因此,同一时间内,只有一个线程可以执行这些同步方法,即该对象下面的所有同步方法互斥。
对于同一个类的多个对象(实例),同步方法(或以this为锁的同步块)互不干扰,因为每个对象都是以自身(this)作为锁。
若希望在同一个类的所有对象(实例)上实现全局互斥的同步机制,则需要定义一个静态全局变量作为锁。
3.3 释放锁
当一个线程获得锁之后,可以通过wait方法主动释放锁,并挂起进入暂停状态,直到被唤醒。
lock.wait()
释放锁,并进入暂停状态,直到通过obj.notify/notifyAll被唤醒。
lock.wait(long timeout)
释放锁,并进入暂停状态一段时间后被唤醒,暂停期间也可以通过obj.notify/notifyAll被提前唤醒。
Thread.sleep 与 obj.wait 的区别:
正在执行同步限制代码的线程,若调用Thread.sleep方法,将会暂停执行一段时间,然后,继续执行。暂停的这段时间,该线程依然持有锁,其它线程依然处于等待中。
若该线程调用obj.wait方法,将会释放锁,然后处于暂停状态,直到被唤醒。
由于二者都会使线程暂停,因此,常常被混淆。但实际上,二者是不同领域的概念,一个是线程操作,一个是锁操作。
3.4 唤醒
lock.notify()
随机激活一个处于暂停状态的线程(通过lock.wait方法主动释放锁并挂起的线程)。
lock.notifyAll()
激活所有处于暂停状态的线程(通过lock.wait方法主动释放锁并挂起的线程)。
注意1:被激活的线程,并不是马上恢复执行,而是进入等待状态,同其它等待状态中的线程一样,需要等待拥有锁的线程释放锁,然后,开始争夺锁。
注意2:lock1.notify/notifyAll 与 lock2.notify/notifyAll 激活的是不同线程。lock1.notify/notifyAll 激活的是通过 lock1.wait 方法处于暂停状态的线程,而lock2.notify/notifyAll 激活的是通过 lock2.wait 方法处于暂停状态的线程。
3.5 注意事项
lock.wait(), lock.notify(), lock.notifyAll()三个方法都需要在synchronized方法或synchronized块中使用,即获得了锁对象(lock)后,才能对该锁进行相关的操作。若在未获得锁的情况下调用这些方法,会抛出异常。
4 显式锁
4.1 java.util.concurrent.locks.ReentrantLock
ReentrantLock 与 synchronized 有着相似的作用,提供了与synchronized相同的互斥性和内存可见性。在获取ReentrantLock时,有着与进入synchronized同步代码块相同的内存语义,在释放ReentrantLock时,有着与退出synchronized同步代码块相同的内存语义。但与synchronized相比, ReentrantLock 功能更为强大,提供了更高的灵活性。(源自《Java并发编程实战》第13章)
ReentrantLock 对象通过 lock() 和 unlock() 进行显式地加锁与解锁操作,需要注意的是,unlock()通常放在finally块中执行,以保证锁最终一定会被释放,代码如下:
ReentrantLock reentrantlock = new ReentrantLock();
reentrantlock.lock();
try {
// 代码
} finally {
reentrantlock.unlock();
}
公平性:
多个线程竞争同一个锁时,若希望按照申请锁的顺序来依次获得锁,则为公平锁,反之,为非公平锁。
在ReentrantLock的无参构造方法中,默认为非公平锁,也可通过有参构造方法来指定公平性,代码如下:
ReentrantLock reentrantlock = new ReentrantLock(boolean fair);
在激烈竞争的情况下,非公平锁的性能高于公平锁,其中一个原因是:在恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。
但当持有锁的时间相对较长,或者请求锁的平均间隔时间较长,则公平锁相对于非公平锁不会有太大的性能差距。
设置等待时间:
当申请一个锁时,可以指定等待时间,若该锁已被其它线程获取,且一直不释放,到达指定时间后,将放弃申请该锁,代码如下:
reentrantlock.tryLock(long timeout, TimeUnit unit);
Condition:
在使用内置锁的时候,所有线程遇到需要暂停的时候(某些条件不满足,无法继续执行),都只能够调用lock.wait方法进入暂停状态。当其中某一个条件成立时,由于lock.notify是随机唤醒某一个线程,为避免信号丢失,通常使用lock.notifyAll方法唤醒所有线程,各线程再对条件进行判断,满足条件的继续执行,不满足条件的再次调用lock.wait方法进入暂停状态,造成资源浪费。
而Condition解决了这种不精准唤醒的问题。在ReentrantLock对象中,可以创建多个Condition对象,分别用于标记不同的条件状态,即Lock-Condition是一对多的关系。当某种条件不满足的时候,调用相应Condition对象的await方法进入暂停状态,当条件满足时,调用相应Condition对象的signal方法唤醒相应的线程,从而达到精准唤醒的目的。
await相当于内置锁的wait方法,会使当前线程释放锁并挂起进入暂停状态;
signal/signalAll相当于内置锁的notify/signalAll方法,会唤醒由对应Condition对象await方法进入暂停状态的线程。
示例代码如下:
class BoundedBuffer {
final Lock lock = new ReentrantLock(); // 锁对象
final Condition notFull = lock.newCondition(); // 写线程条件:队列非满
final Condition notEmpty = lock.newCondition(); // 读线程条件:队列非空
final Object[] items = new Object[100]; // 缓存队列
int writeIndex; // 写索引
int readIndex; // 读索引
int count; // 队列元素数量
public void put(Object item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) // 队列已满
{
notFull.await(); // 释放锁,挂起,进入notFull等待池。
}
items[writeIndex] = item;
if (++writeIndex == items.length) {
writeIndex = 0;
}
++count;
notEmpty.signal(); // 非空条件满足,唤醒读线程:随机唤醒一个由notEmpty.await()进入等待的线程
} finally {
lock.unlock();
}
}
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0) // 队列已空
{
notEmpty.await(); // 释放锁,挂起,进入notEmpty等待池。
}
Object item = items[readIndex];
if (++readIndex == items.length) {
readIndex = 0;
}
--count;
notFull.signal(); // 非满条件满足,唤醒写线程:随机唤醒一个由notFull.await()进入等待的线程
return item;
} finally {
lock.unlock();
}
}
}
4.2 java.util.concurrent.locks.ReentrantReadWriteLock
互斥锁是一种很“强硬”的加锁规则,在对于“读多-写少”的情况中,这种加锁规则会显得过于严格。在多线程“读”且无“写”的情况中,并不会产生脏数据,此时,可以不进行加锁操作,直接读取即可。
为了应对这种“读多-写少”的情况,Java提供了ReentrantReadWriteLock,只允许存在单独一个写操作,而多个读操作可以同时进行,即“写-写”互斥、“写-读”互斥、“读-读”非互斥。
使用方法与ReentrantLock方法相似,代码如下:
ReentrantReadWriteLock lock = new ReentrantReadWriteLock( boolean fair);
WriteLock writeLock = lock.writeLock();
ReadLock readLock = lock.readLock();
writeLock.lock(); // 争夺锁资源后,阻塞其它线程(调用lock处)。
try {
// 写代码
} finally {
writeLock.unlock();
}
readLock.lock(); // 不存在写锁情况下,此行代码不会阻塞。
try {
// 读代码
} finally {
readLock.unlock();
}
5 同步工具
5.1 java.util.concurrent.Semaphore
Semaphore被译为信号量,这里姑且也当成锁来看待,为便于理解。
在创建 Semaphore 对象的时候,可以指定锁的数量,代码如下:
Semaphore semaphore = new Semaphore(3); // 初始化 并 指定可用锁的数量
每次调用 semaphore.acquire() 获得一个锁时,可用锁的数量减一;每次调用 semaphore.release() 释放一个锁时,可用锁的数量加一。
当可用锁的数量为0时,semaphore.acquire() 将阻塞当前调用线程,直到其它线程调用 semaphore.release() 释放了一个锁之后,该线程获得锁并继续执行。
5.2 java.util.concurrent.CountDownLatch
CountDownLatch是一种类似计数器的锁,当所有计数减至0时,将开锁放行。
在创建 CountDownLatch 对象的时候,指定计数总量,代码如下:
CountDownLatch latch = new CountDownLatch(10); // 初始化 并 指定计数量为10
每次调用 latch.countDown() 方法时,计数减1;
所有调用 latch.await() 方法的地方,将阻塞等待,直到通过 latch.countDown() 方法将计数减至0值时,阻塞解开,继续运行。
例如:当10个子线程都运行完毕后,主线程才继续运行,则可用 CountDownLatch 来实现,代码如下:
CountDownLatch latch = new CountDownLatch(10); // 初始化 并 指定计算量为10
try {
// 依次启动10个子线程
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
/*
* 子线程逻辑代码...
*/
latch.countDown(); // 执行完毕后,调用方法减少一次计数。
}).start();
}
latch.await(); // 主线程阻塞等待,直到所有子线程执行完毕后,继续执行。
} catch (InterruptedException e) {
e.printStackTrace();
}
5.3 java.util.concurrent.CyclicBarrier
Barrier 被译为栅栏,创建 CyclicBarrier 时,可指定两个参数,代码如下:
CyclicBarrier barrier = new CyclicBarrier( int parties, Runnable action);
其中 parties 表示分支部分数量,当所有分支部分都到达指定位置后,“栅栏”打开一同放行。
另一个 action 参数表示触发动作,即当所有分支部分到达时,“栅栏”打开时,要执行的逻辑。
CyclicBarrier 的主要方法为 barrier.await() 方法,所有调用该方法的线程将被阻塞等待,直到所有分支都调用该方法(即await方法被调用 parties 次时),表示所有分支准备就绪,阻塞打开,所有线程继续开始执行各自剩下的逻辑,同时,执行 action 指定的逻辑。
例如:运动场上,8名运动员,待其都准备就绪后,开始比赛,代码如下:
CyclicBarrier barrier = new CyclicBarrier(8, () -> {
// 所有运动员准备就绪后,放枪:比赛开始
});
for (int player = 1; player <= 8; player++) {
new Thread(() -> {
// 准备就绪(阻塞等待),当所有人都准备就绪(8次调用await进入阻塞等待状态),将继续执行。
try {
barrier.await();
} catch (Exception e) {
e.printStackTrace();
}
// 快速奔跑
}).start();
}
// 后续逻辑
6 相关内容
6.1 集合的同步机制
在Java中,List、Set和Map也可以实现同步机制,使其相关操作支持并发。
List<string> list = Collections.synchronizedList(new ArrayList<string>());
Set<string> set = Collections.synchronizedSet(new HashSet<string>());
Map<Integer, string> map = Collections.synchronizedMap(new HashMap<Integer, string>());
通过Collections.synchronizedXxxx方法,使标准集合对象具有synchronized特性,所有同步方法共用同一个锁,从而,使这些对象的增删改查等方法,在同一时间,只允许一个线程调用。
6.2 volatile关键字
关键字volatile仅用来保证变量对所有线程的可见性,并不保证原子性。
由volatile声明的变量,在操作时,不会在线程本地内存(寄存器)创建变量的副本,而是直接操作主内存中的数据。
因此,所有线程读取该变量时,都会获得当前最新值。
但是,并不具备操作原子性,因此,GetAndSet类型的操作(例如:num++等),不保证线程安全。