不知道你会不会有这样的困惑,计算机经常能同时执行很多个任务,事实上呢?在比较早期的时候,计算机的CPU都是单核的时候,虽然也能同时进行,但真正深入CPU内部,会发现这些代表不同任务的线程都是串行的,只是计算机的时间间隔小,加上CPU的运算速度飞快,以至于我们以为它们都是同时进行的,这样的状态称为并发,说到并发,那我们不得不提并行,所以我们决定再以官方的口吻说一下这两个概念。
并发(Concurrency)
对于一个CPU来说,一次只能执行一个任务,这样的情况下,操作系统会来回切换多个任务,这样的多进程或多线程称为并发。
并行(Parallelism)
多个任务真正意义上的“同时执行”,所以按照这样的逻辑,这个“同时”必须得在多核CPU系统里才能实现。
临界区
介绍完以上两个概念,我们还需要再引入一个临界区的概念,临界区就是一种共享数据或公共资源,可以被多个线程使用,但每次只能有一个线程使用,一旦临界区资源被占用,其他线程想使用这个资源,就必须等待。
这个临界区的概念有没有让大家联系到我在介绍IO机制那篇文章里说过的同步和阻塞的概念,同步就是关注点在于每次只能有一个事物执行,所以临界区的资源只能被线程同步拥有,所以有一个线程在同步占有某个临界区,那么其他线程必须等待,等待中就会挂起阻塞。
如何保证线程同步?
线程同步的意思也就是我刚刚说的当前只有一个线程独占临界区的状态,那么在多线程的场景下,如果保证线程同步?这就不得不说到我们今天的主题——锁机制。
锁
Java里的锁跟生活中的锁其实也有些相似之处,举个例子,小明进入卫生间,然后反锁卫生间,小明拥有了使用卫生间的权限,卫生间就相当于临界区,使用卫生间的小明相当于某个线程,同理,线程加锁使得线程拥有了临界区资源的权限。再拔高一个level,锁其实是一种保护机制,为了保护多线程场景下,数据的一致性和正确性。
锁的种类
乐观锁&悲观锁
乐观锁:顾名思义,这种锁很乐观,加锁的条件很宽松,它认为大多数操作不会改变数据,所以不会上锁,在更新的时候借助版本号的机制判断数据是否更新。如果这个数据没被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读比较多的场景,不加锁的特点大幅度提升性能。
CAS算法:是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。CAS算法有三个关键值:读写内存值V,比较值A,新值B;一般情况下,“更新”是一个不断进行尝试的操作。通过CPU的cmpxchg指令,比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。不断循环,直到设置成功为止。
缺点:
可能出现ABA问题:如果CAS操作的值经历由A变B,再变A的变化,CAS的视角来看,值是没有发生变化的,但实际情况是A改变了,ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
耗时太久:如果CAS一直不成功,它会一直循环,导致CPU开销过大。
只能保证一个共享变量的原子操作:CAS操作针对的是一个共享变量的原子操作,如果操作一批共享遍历,CAS无法保证原子性。
不过第一个和第三个缺点在JDK 1.5里已经做出改善。
悲观锁:悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为数据会被修改,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。适合写比较多的场景,先加锁可以保证数据的正确性。
独占锁&共享锁
独占锁:也叫排他锁,互斥锁,一次仅有一个线程可以占有临界资源。
共享锁:允许多个线程同时访问临界资源。这种情况多见于读锁ReadLock,即允许多个线程读取数据,有助于性能的提升。不过需要注意的是,读锁共享的目的是为了在多线程高频率访问下提升效率,如果线程访问量不大,维护读锁的成本会过高,有得不偿失的感觉。
公平锁&非公平锁
公平锁:公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列排队,队列中的第一个线程才能获得锁。
优点:等待锁的线程不会饿死。
缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁:非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
优点:可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。
缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
这一对反义词应该不难理解,公平锁类似于排队买票,在队列第一个的人才能在窗口交易;非公平锁就类似于有人在排队但有人插队,但这种情况没人管,所以插队的人有了可乘之机接近窗口最后买到票,一些弱小的群体在这样的情况下可能会一直买不到票(有的线程饿死),在人口素质一样的情况下,让群众排队需要栅栏围起来一个单人通道(公平锁的额外开销)。
自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
自旋锁尽可能的减少线程的阻塞,适用于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗。
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。
以上是介绍了一些锁的常见类型,但锁还有另外一种划分方式叫内置锁和显式锁。
内置锁
内置锁也称为隐式锁,Java中具有通过synchronized实现的内置锁,synchronized维护多线程同步非常方便,无需手动获取和释放锁,只需要将需要同步的部分放入synchronized代码块即可,执行完代码块,即释放锁。
特点
synchronized是一种互斥锁,一次只允许一个线程访问修饰的代码;
原子性:synchronized修饰的代码块具有原子性,是一次执行的。
可见性:synchronized修饰的代码执行完后,结果对其他线程可见。
具体的实现
可以修饰普通方法
//普通同步方法,锁是当前实例对象
public synchronized void talk(){
}
可以修饰静态方法
//静态同步方法,锁是当前类的class对象
public static synchronized void run(){
}
可以修饰代码块
//同步方法块,锁是括号里的对象
public int getValue(int v){
synchronized (this){
}
}
可重入性
public class SynTalk {
public synchronized void talk(){
}
}
public class SynTalking extends SynTalk{
public synchronized void talking(){
System.out.println("h");
super.talk();
}
}
synchronized是可重入锁,可重入在这里的意思是,当线程执行到talking()方法时,拿到SynTalking这个类的实例对象的锁;之后,父类talk()方法被调用的时候,它也是被synchronized修饰的,此时子类实例对象的锁还未释放,进入父类的方法不再需要额外的锁,此时对同一线程来说,互不影响。
如何释放
被修饰的代码块执行完毕的时候,锁自动释放;
当被修饰的代码块线程出现问题的时候,其持有的锁会自动释放;
不会因为异常,出现死锁的问题。
显式锁
有关显式锁的几个关键点先列在这里:
JDK1.5之前,保证多线程同步都是通过synchronized实现的;JDK1.5之后才有了Lock显式锁;
Lock显式锁是一个接口;
Lock方式来获取锁支持中断、超时不获取、是非阻塞的;
提高了语义化,哪里加锁,哪里解锁都得写出来;
Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁;
支持Condition条件对象;
允许多个读线程同时访问共享资源;
比较有代表性的显式锁是ReentrantLock,以此为例,再来详细说一下。
ReentrantLock的使用
ReentrantLock的字面意思是可重入锁,可重入的意思是线程可以同时多次请求同一把锁,而不会自己导致自己死锁。使用时最标准用法是在try之前调用lock方法,在finally代码块释放锁,如下所示:
public class ReDemo {
private final ReentrantLock lock = new ReentrantLock();
public void talk(){
lock.lock();
try {
System.out.println("h");
}finally {
lock.unlock();
}
}
}
AQS是ReentrantLock的基础
AQS(AbstractQueuedSynchronized )抽象队列式的同步器,AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock,下图是一个简单的模式图:
AQS有一个 volatile int state的变量(代表共享资源,volatile的作用主要在于当前线程改变valotile修饰的变量后,该变量对于其他线程具有可见性)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列,本质是一个双向链表,每个节点保存一个阻塞的线程,多线程竞争有限的资源,对线程阻塞排列)。
总得来说,state初始化为0,表示未锁定状态。线程A如果能拿到锁,0→1,设定当前线程为独享锁;否则,进入等待队列。
可重入性
ReentrantLock中,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其他线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证state是能回到零态的。
支持公平锁 ReentrantLock(true)
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;
}
公平锁的简要过程:
若state=0,证明当前锁没有被任何线程获取。然后就判断当前线程是否为等待队列里的时间最久的节点,若不是,证明还有比当前线程等待更久的线程,返回false;若是,证明当前线程是AQS中等待获取锁的第一个节点,基于CAS操作,尝试获取锁(0→1),若成功,则设置当前线程为独占锁,state=1。
若state!=0,证明锁被某个线程持有,则判断持有锁的线程是否为当前线程,若是,尝试再次获取锁(ReentrantLock重入性的体现),如果获取锁的次数没有超过上限,则更新state为当前线程获取锁的最终次数,结果返回true;否则,若当前线程获取锁的次数超过上限,或者当前线程不是正在持有锁的线程,则返回false。
支持非公平锁ReentrantLock()/ReentrantLock(false)
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;
}
非公平锁的简要过程:
判断表示锁状态的state是否为0:若state=0,证明锁在当前未被任何线程持有,当前线程可以基于CAS操作尝试获取非公平锁(0→1),若成功,则把当前线程设置为持有锁的线程,若成功,state=1,返回true;若失败,证明有其他线程先获得了锁,返回false。若state!=0,则证明锁正在被某个线程持有,所以要判断当前线程是否为持有锁的线程,若是,尝试再次获取锁,如果当前线程拥有锁的总次数未超过上限,则表示当前线程获取锁成功,state的值更新为当前线程持有锁的总次数,返回true。否则,返回false。
基于CAS操作,若成功,则将当前设置为独占锁的线程;若失败,再次判断state的状态,如果是0,再次尝试CAS操作0→1,若成功,则设置当前线程为独占锁的线程;若失败或state!=0,查看当前线程是否为独占锁的线程(是:state+1;不是:将该线程封装在节点里加入等待队列)。
公平锁和非公平锁的主要区别:
两者最大的区别主要在于,公平锁的公平性主要体现在获取锁的线程是否为等待队列里的头节点,所以每次要判断等待队列中的当前线程是否有前驱节点:若有则说明有比当前线程更早的请求,根据公平性,当前线程请求资源失败;若prev=null这个前提成立,才会进行后续的判断。
ReentrantLock默认是非公平锁,因为公平锁虽然不会产生饥饿现象,但维护的成本较高,为了维持有序队列,保证绝对顺序而频繁切换上下文。