我们在《Java多线程与高并发一》中讲到怎样新建一个线程,用synchronized怎样给代码加锁,以及synchronized锁的优化升级。
在《Java多线程与高并发二》中,我们认识了Java的内存模型和计算机的内存架构,以及二者之间的不同,怎样弥补。
在《Java多线程与高并发三》中,我们我们认识了volatile关键字,以及其作用和深入原理。
《Java多线程与高并发四》给大家介绍Lock,在官方介绍中也提到了,Lock可以替代synchronized,有更好的语义,更灵活的方法,以及介绍Lock背后的原理。
Lock简单用
我们看一个多线程对int类型数进行自增的demo
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* lock测试
*
* @author zab
* @date 2019-11-03 11:22
*/
public class LockTest {
static Semaphore semaphore1 = new Semaphore(0);
static Semaphore semaphore2 = new Semaphore(0);
static Lock lock = new ReentrantLock();
static int i1 = 0;
static int i2 = 0;
public void f1() {
try {
lock.lock();
for (int j = 0; j < 1000000; j++) {
i1++;
}
} finally {
lock.unlock();
}
semaphore1.release();
}
public void f2() {
try {
lock.lock();
for (int j = 0; j < 1000000; j++) {
i1++;
}
} finally {
lock.unlock();
}
semaphore2.release();
}
public void f3() {
for (int j = 0; j < 1000000; j++) {
i2++;
}
semaphore1.release();
}
public void f4() {
for (int j = 0; j < 1000000; j++) {
i2++;
}
semaphore2.release();
}
public static void main(String[] args) {
LockTest lockTest = new LockTest();
new Thread(lockTest::f1).start();
new Thread(lockTest::f2).start();
new Thread(lockTest::f3).start();
new Thread(lockTest::f4).start();
try {
semaphore1.acquire();
semaphore1.acquire();
semaphore2.acquire();
semaphore2.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(i1);
System.out.println(i2);
}
}
f1(),f2()方法是加锁的对i1进行自增
f3(),f4()方法是没加锁的对i2进行自增
我们在输出结果i1,i2前,用信号量对代码进行阻塞,也就是semaphore.acquire()方法,等四个线程都执行完了,对i1,i2进行输出。
可以看到i1是加到了200000,但是i2却始终没有200000.
这个例子在之前介绍synchronized有试验过,可以看到lock的基本用法与synchronized还是有区别:
1、lock需要手动上锁与解锁,而且建议在finally语句块里解锁。
2、lock只能在代码块中上锁,而不能直接修饰方法。
Lock之lock()
Lock的lock()方法,是获得锁,有三种情况:
第一如果没有其他线程持有锁,那么当前在设置完hold count为1后立马就返回了;
第二如果当前线程持有该锁,就会把hold count加1然后返回。
第三如果锁被另一个线程持有,则当前线程将因线程调度而禁用,并处于休眠状态,直到获得锁为止,此时锁hold count设置为1。
public void lock() {
sync.lock();
}
lock方法里直接调用sync.lock()方法,这个方法有公平锁和非公平锁两种实现,sync则是ReentrantLock的一个抽象内部类,其唯一一个抽象方法就是lock(),我们看看公平锁的实现:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
//省略其他代码
}
这样看起来,这个Lock的lock()方法实现还真简单,就是调用acquire(1),表面上意思就是获得1。
这个acquire是AQS(AbstractQueuedSynchronizer),即CAS的核心类,的一个重要方法。我们看看这个方法的注释:
Acquires in exclusive mode, ignoring interrupts. Implemented by invoking at least once {@link #tryAcquire}, returning on success. Otherwise the thread is queued, possibly repeatedly blocking and unblocking, invoking {@link#tryAcquire} until success. This method can be used to implement method {@link Lock#lock}.
翻译:以独占模式获取,忽略中断通过调用至少一次tryAcquire方法来实现,成功时返回。否则线程将排队,可能会重复阻塞和解除阻塞,调用tryAcquire直到成功,此方法可用于实现方法Lock的lock()方法。
从注释上来看,lock()方法的确在高并发的情况会大量堆积在队列里,并且不断尝试获得锁,增加CPU开销。我们本章不追究源码实现。
Lock之公平锁
Lock在new的时候可以指定是否是公平锁,像这样:
Lock lock = new ReentrantLock(true);
如果传true,表示是公平锁。
那么公平锁,非公平锁是什么概念呢??我们点进去看源码可以发现ReentrantLock的构造方法是这样的:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
这是一个三元运算符,true表示new了一个FairSync,我们继续跟。发现FariSync是这样的:
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
其公平的核心在于hasQueuedPredecessors()这个方法,我们跟进去看看怎么说:
Queries whether any threads have been waiting to acquire longer than the current thread.
翻译过来说:查询是否有任何线程等待获取的时间超过当前线程。
在这一章,我们不追究到AQS的源码,只知道公平锁会去检查队列是否有线程排队,如果有就会tryAcquire失败。
非公平锁会调用:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
可以看到第五行代码,这个方法判断c等于0过后,不管有没有队列,直接争抢,进行CAS操作。
Lock之tryLock()
从字面上看,Lock可以尝试获得锁,追进去,其实就是返回上面贴的nonfairTryAcquire()方法:
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
但是参数传了1。那可以这样理解,如果getState()这个方法返回值是0,(预告下,state是AQS抽象类里面的核心参数)
tryLock就是尝试把0改为1,改成功了就获得锁!
tryLock就是尝试把0改为1,改成功了就获得锁!
tryLock就是尝试把0改为1,改成功了就获得锁!
而更改方法是用的CAS的操作。
tryLock()方法可以带参数
lock.tryLock(1, TimeUnit.SECONDS);
第一个参数是时间,第二个是时间单位。带时间的tryLock表示只要没被打断,就会在指定的时间内尝试获得锁。这个就给我们处理某些问题带来了好处,比如某个热点数据更新,一直拿不到锁在那死等,还不如等待几秒钟如果再拿不到就直接处理失败的逻辑。
Lock之Condition
lock可以获得condition,而condition可以在锁的代码块中实现类似wait和notify的功效。
看这段消费者和生产者代码:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockTest1 {
static Lock lock = new ReentrantLock(true);
static Condition condition1 = lock.newCondition();
static Condition condition2 = lock.newCondition();
static int i = 0;
public void f1(){
try {
lock.lock();
while (true) {
while (i < 10) {
i++;
condition2.signal();
System.out.println("生产了"+i);
}
try {
condition1.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}finally {
lock.unlock();
}
}
public void f2(){
try{
lock.lock();
while(true){
while (i>1){
i--;
condition1.signal();
System.out.println("消费了"+i);
}
try {
condition2.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}finally {
lock.unlock();
}
}
public static void main(String[] args) {
LockTest1 lockTest1 = new LockTest1();
new Thread(lockTest1::f1).start();
new Thread(lockTest1::f2).start();
}
}
main方法很简单,就是起了两个线程,分别执行LockTest1的f1和f2方法,f1和f2方法内部都用lock加锁,起一个死循环,在循环内部对公有变量i进行增增减减操作。当i的条件不满足死循环里层的while条件时,也就是大于等于10或者小于等于1时,线程就阻塞,但是while条件中会唤醒沉睡的另一方工作。
输出效果大致长这样:
可以用condition来控制两线程的交替打印(面试题)
什么是CAS
cas即compare and set(swap),就是比较并且设置(或者交换)。我们看一段伪代码:
m=0
m++
Expected=read m
CAS(Expected,NewValue){
if m==Expected
m=NewValue
}
代码表意,我们想要对m进行自增,首先需要读出期望值,也就是0;再者,在采用CAS修改m的时候,我们希望更改m前做一次判断,到底改之前,这个m还是不是期望的那个值0。如果m在修改前与期望值0相等,那就修改m的值。
整个CAS过程由CPU原语支持,不需要加锁。所以效率比较高,没有synchronized膨胀到重量级锁费时的操作系统内核态的转变过程。
那CAS有没有什么弊端呢?
弊端一:ABA问题
模拟一个场景:本人在银行里有两百块钱(真穷),欲约妹子去吃个麻辣烫,想要去取款机取一百作为约会基金,选择了100按下确定键,由于机器故障发送了两次扣款一百请求(场景需要,麻烦机器坏一次),假设机器扣款用的CAS操作(再麻烦机器配合表演下,假装是CAS扣款的),会出现以下伪代码操作
myMoney = 200
CAS(期望值200,新值100){
if(myMoney==200){
myMoney = 100
}
}
假设这时候两次请求有一次出现不明原因的阻塞(再麻烦机器配合以下表演,卡死一个请求),以上伪代码只会运行一次,并且本人的银行余额已经被扣除了一百,余额一百,那这时候恰好我妈给我打了一百块,这个请求先于阻塞的请求运行,那么本人的银行卡就余额两百,注意这个时候,就在这个时候,那阻塞的线程请求开始CAS操作(再次请出机器帮忙演戏),屁颠屁颠地看到我银行卡还有两百,就把我妈给我的一百块扣除了,又使我的银行卡余额变成一百(流氓ABA,还我吃麻辣烫的钱)!
ABA问题在某些情况下会造成问题,解决ABA的问题需要在操作的对象上加一个版本号,每次CAS操作不仅要对比期望值还要对比版本号,版本号和期望值都满足,才进行CAS操作。
弊端二:高并发情况下,多个线程都尝试去改某个值,改不成功,程序会一直尝试,CPU会大量消耗,这时候效率反而可能不如synchronized的重量级锁来的高。
弊端三:CAS只能对某个值进行修改,如果想要对多个值进行修改,就显得有点力不从心,想要锁多个值(其实就是锁代码块)并且还要线程安全,就用synchronized。