为了账号安全,请及时绑定邮箱和手机立即绑定

java中的锁介绍

标签:
Java 面试

本节内容:

java锁介绍

偏向锁、轻量级锁、重量级锁

这三种锁特指synchronized锁的状态,通过对象头中的mark work字段表示锁状态。

偏向锁:

自始至终,对这把锁都不存在竞争,只需要做个标记,这就是偏向锁,每个对象都是一个内置锁(内置锁是可重入锁),一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有线程来访问并尝试获取锁的时候,他就会把这个线程记录下来,以后如果获取锁的线程正式偏向锁的拥有者,就可以直接获得锁,偏向锁性能最好。

轻量级锁:

轻量级锁是指原来是偏向锁的时候,这时被另外一个线程访问,存在锁竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

重量级锁:

重量级锁是互斥锁,主要是利用操作系统的同步机制实现的,当多个线程直接有并发访问的时候,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就升级为重量级锁,重量级锁会使得其他拿不到锁的线程陷入阻塞状态,重量级锁的开销相对较大。

可重入锁、非可重入锁

可重入锁:

可重入锁的意思是如果当前线程已经持有了这把锁,能再不释放的锁的情况下再次获得这把锁,如果一个线程试图获取它已经持有的锁,那么这个请求会成功,每个锁关联一个获取计数值和一个所有者线程,当计数值为0的时候,认为没有线程持有该锁,当线程请求一个未被持有的锁的时候,JVM将记下锁的持有者,并且计数值置为1,如果同一个线程再次获取该锁的时候,计数值将递增,

不可重入锁:

同理不可重入锁就是指虽然当前线程已经持有了这把锁,但是如果想要再次获得这把锁,必须要先释放锁后才能再次尝试获取。

共享锁、独占锁

共享锁:

共享锁就是我们可以同一把锁被多个线程同时获得,最典型的就是读写锁中的读锁。

独占锁:

同理,独占锁就是线程只能对一个线程持有,类比读写锁中的写锁,

公平锁、非公平锁

公平锁:

公平锁就是如果线程当前拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程就会优先拿到这把锁,先来先得。

非公平锁:

非公平锁是指在一定的情况下,某些线程会忽略掉已经在排队的线程,发生插队的情况。

悲观锁、乐观锁

悲观锁:

悲观锁顾名思义,比较悲观,悲观锁认为如果不锁住这个共享资源,别的线程就回来竞争,就会造成数据结果错误,所以在获取共享资源前,必须要先拿到锁,以便达到“独占”的状态,,让其他线程无法访问该数据,这样就不会发生数据错误。常见的悲观锁例如synchronized关键字、Lock接口

乐观锁:

同理乐观锁是相对悲观锁而言的,乐观锁就是比较乐观了,它认为一般情况下数据不会发生冲突,只有在数据进行更新的时候,才会对比数据在被当前线程更新期间有咩有被修改过,如果没有被修改过,则可以正常更新数据,如果数据发生过修改和预期不一样,那么本次更新操作就不能成功,所以可以放弃这次更新或者选择报错、重试等策略。常见的乐观锁例如:各种原子类

自旋锁、非自旋锁

自旋锁:

自旋锁是指如果线程拿不到锁,那么线程并不直接陷入阻塞或者释放CPU资源而是开始自我旋转,不停的尝试获取锁,这个循环的过程成为自旋。

非自旋锁:

非自旋锁就是没有自旋的过程,如果拿不到锁就直接放弃或者进行其他逻辑处理,比如排队、阻塞。

可中断锁、不可中断锁

可中断锁:

可中断锁指在获取锁的过程中,可以中断锁之后去做其他事情,不需要一直等到获取到锁才继续处理

不可中断锁:

synchronized是不可中断锁,就是指一旦申请了锁,只能等到拿到锁以后才能进行其他的逻辑处理。

synchronized锁介绍

什么是synchronized锁

java中每个对象中都持有一把锁与之关联,控制着对象的synchronized代码,想要执行对象的synchronized代码,
必须先获得对象的锁,这个锁就是对象的Monitor锁,synchronized实现加锁解锁就是利用Monitor锁实现的。
获取Monitor锁的唯一方式是进入由这个锁保护的同步代码块或者同步方法中,线程进入synchronized保护的代码之前获得锁,
然后在正常执行代码完成后或者异常退出,都会自动释放锁。

synchronized关键字在同步代码块中的应用:
我们通过分析一下代码的反汇编内容来理解synchronized是如何利用monitor锁来工作的
我们先来分析同步代码块的反汇编内容

public class TestSync {
    public void sync1(){
        synchronized (this){
            int ss = 10;
            System.out.println(ss);
        }
    }
}

如上图代码,我们定义的TestSync类中的sync1()方法中包含一个同步代码块,我们通过指令:javap -verbose TestSync.class查看方法对应的反汇编内容如下:

public void sync1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter    //加锁
         4: bipush        10
         6: istore_2
         7: getstatic     #2  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: iload_2
        11: invokevirtual #3   // Method java/io/PrintStream.println:(I)V
        14: aload_1
        15: monitorexit    //解锁
        16: goto          24
        19: astore_3
        20: aload_1
        21: monitorexit    解锁
        22: aload_3
        23: athrow
        24: return

上述中我可以看出同步代码块实际上多了monitorenter和monitorexit指令,
我们可以理解为对应的加解锁,之所以有一个monitorenter对应两个monitorexit是因为jvm要
保证每个monitorenter必须有与之对应的monitorexit,那么就需要在正常结束流程和异常结束流程中
分别执行monitorexit以保证正常或者抛出异常情况下都能释放锁。

monitorenter含义:
每个对象维护着一个计数器,没有被锁定的对象计数器为0,执行monitorenter的线程尝试获取monitor的所有权,那么会有以下三种情况:
如果该monitor的计数为0,则线程获得该monitor后并将其计数设置成1,然后该线程就该monitor的持有者。如果线程已经获取了该monitor,那么该monitor的计数将累加。
如果线程已经是其他线程已经获取了该monitor,那么当前想要获取该monitor的线程会被阻塞,知道该monitor的计数为0的时候,代表该monitor已经被释放了,然后当前线程就可以尝试获取该monitor了。

monitorexit含义:
monitorexit的作用是将monitor的计数减1,知道减为0为止,代表这个monitor已经被释放了,
那么此时其它线程就可以尝试获取该monitor的所有权了。

synchronized关键字在同步方法中的应用:

我们再来看看同步方法反汇编后的内容又是怎么样的,我们对一下内容执行反汇编。

public class TestSync {
    public synchronized void sync2(){
        int aa = 10;
        System.out.println(aa);
    }
}

反汇编代码如下:

public synchronized void sync2();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=2, args_size=1
         0: bipush        10
         2: istore_1
         3: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         6: iload_1
         7: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
        10: return

从上述代码中我们可以看出同步方法和同步代码块的不同之处是同步代码块
中依赖monitorenter和monitorexit来加解锁,同步方法中会多出一个
ACC_SYNCHRONIZED的flags修饰符,来表明他是同步方法。所以被synchronized修饰的方法会有一
个ACC_SYNCHRONIZED标志,那么当线程要访问某个方法的时候就会检查方法是否携带ACC_SYNCHRONIZED标志,
带的话就先去获取monitor锁,然后获取到锁后在执行方法,方法执行完后释放monitor锁。

synchronized关键字和Lock接口对比

相同点:

  • synchronized和Lock都是用来保护资源线程安全的。
  • 都保证可见性,对于synchronized而言,线程A进入synchronized代码块或者方法中进行的操作,对于后续的获得同一个monitor锁的线程B是可见的。同理对于Lock而言,他和synchronized是一样的都可以保证可见行
  • synchronized和ReentrantLock都拥有可重入的特点

不同点:

  • 用法区别:synchronized关键字可以加在方法上不需要指定锁对象,也可以新建一个同步代码块并且自定义monitor锁对象,而Lock接口必须显示用Lock锁对象开始加锁lock()和解锁unLock(),并且一般会在finally块中确保用unLock()来解锁以防止发生死锁。
  • 加解锁顺序不同,Lock可以不按照加锁顺序进行解锁比如我们先获取A锁,在获取B锁,那么解锁时我们可以先解锁A锁在解锁B锁,但是synchronized的加解锁必须有顺序,比如获取A锁,在获取B锁,那么解锁就是先解锁B锁,在解锁A锁。
  • synchronized相比Lock不够灵活,synchronized锁一旦被某个线程获取了,那么其它线程只能阻塞等待释放锁,如果持有锁的线程执行很久那么整个程序的运行效率就会很低,而且如果一直不释放锁其他线程将一直等待下去,相比Lock的lockInterruptibly方法,如果觉得持有锁的线程执行太久了可以中断退出,还可以用tryLock()尝试获取锁,获取不到就去执行别的逻辑。
  • Lock接口的一些实现类例如读写锁中的读锁可以被多个线程持有,synchronized只能被一个线程持有
  • synchronized是内置锁(Monitor锁),有JVM实现加解锁,还分为偏向锁、轻量级锁、重量级锁,Lock接口根据实现不同有不同的底层原理。
  • Lock可以设置是否公平锁,synchronized不可以设置
  • java6以后jdk对synchronized进行了很多优化,所以synchronized性能并不比Lock差

公平锁和非公平锁

公平锁和非公平锁

公平锁是指线程按照请求顺序来分配锁,非公平锁是指不完全按照线程请求顺序分配锁,但是非公平锁并不是完全的随机分配而是”在合适的时机“插队执行。

什么是合适的时机
所谓合适的时机就是比如新来一个线程要获取锁,恰巧上一个持有锁线程正好执行完毕释放了锁,那么此时这个新来的线程能不管后面排队的线程而选择立即插队执行,但是如果上一个线程还未释放锁,那么新来的线程就需要去等待队列排队。
为什么设置非公平锁
之所以设计非公平锁是因为相比公平锁排队执行,上一个线程释放锁后需要先唤醒下一个要执行的线程,然后去获取锁在执行,而采用非公平锁下,就可以上一个线程释放了锁,刚来一个新线程直接获取锁就插队去执行代码了,不需要额外的唤醒线程成本,而且也有可能在线程唤醒的这段时间内,插队线程已经获取锁并且执行完任务并释放了锁。
所以设置非公平锁,这样设计的原因是为了提高系统整体的运行效率,而且ReentrantLock默认的是非公平锁。

公平锁和非公平锁效果展示

公平锁和非公平锁通过设置ReentrantLock中boolean值来设置公平非公平锁,如下代码所示是设置为非公平锁。

Lock lock=new ReentrantLock(false);

公平锁代码展示:

/**
 * 描述:演示公平锁,分别展示公平和不公平的情况,
 * 非公平锁会让现在持有锁的线程优先再次获取到锁。
 * 代码借鉴自Java并发编程实战手册2.7
 */
​
public class FairAndNoFair {
    public static void main(String[] args) {
        PrintQueue printQueue = new PrintQueue();
​
        Thread[] threads= new Thread[10];
        for(int i=0;i<10;i++){
            threads[i] = new Thread(new Job(printQueue),"Thread "+ i);
        }
​
        for (int i = 0; i < 10; i++) {
            threads[i].start();
            try {
                Thread.sleep(100);//为了保证执行顺序
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class Job implements Runnable{
    private PrintQueue printQueue;
    public Job(PrintQueue printQueue){
        this.printQueue=printQueue;
    }
    @Override
    public void run() {
        System.out.printf("%s: Going to print a job\n", Thread.currentThread().getName());
        printQueue.printJob();
        System.out.printf("%s: The document has been printed\n", Thread.currentThread().getName());
    }
}
public class PrintQueue {
    private final Lock lock=new ReentrantLock(false);
​
    public void printJob(){
        lock.lock();
​
        try{
            Long duration = (long) (Math。random()*10000);
            System.out.printf("%s:First PrintQueue: Printing a Job during %d seconds\n",
                    Thread.currentThread().getName(), (duration / 1000));
            Thread.sleep(duration);
        } catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
​
​
        lock.lock();
        try{
            Long duration = (long) (Math.random()*10000);
            System.out.printf("%s:Second PrintQueue: Printing a Job during %d seconds\n",
                    Thread.currentThread().getName(), (duration / 1000));
            Thread.sleep(duration);
        } catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

我们先运行公平锁的打印结果如下:

Thread 0: Going to print a job
Thread 0:First PrintQueue: Printing a Job during 9 seconds
Thread 1: Going to print a job  //线程1-9进入等待队列排队
Thread 2: Going to print a job
Thread 3: Going to print a job
Thread 4: Going to print a job
Thread 5: Going to print a job
Thread 6: Going to print a job
Thread 7: Going to print a job
Thread 8: Going to print a job
Thread 9: Going to print a job
Thread 1:First PrintQueue: Printing a Job during 5 seconds//线程0执行完释放了锁,线程1开始执行
Thread 2:First PrintQueue: Printing a Job during 1 seconds
Thread 3:First PrintQueue: Printing a Job during 9 seconds
Thread 4:First PrintQueue: Printing a Job during 7 seconds
Thread 5:First PrintQueue: Printing a Job during 8 seconds
Thread 6:First PrintQueue: Printing a Job during 5 seconds
Thread 7:First PrintQueue: Printing a Job during 2 seconds
Thread 8:First PrintQueue: Printing a Job during 9 seconds
Thread 9:First PrintQueue: Printing a Job during 7 seconds
Thread 0:Second PrintQueue: Printing a Job during 0 seconds
Thread 1:Second PrintQueue: Printing a Job during 6 seconds
Thread 0: The document has been printed
Thread 1: The document has been printed
Thread 2:Second PrintQueue: Printing a Job during 4 seconds
Thread 2: The document has been printed
Thread 3:Second PrintQueue: Printing a Job during 4 seconds
Thread 3: The document has been printed
Thread 4:Second PrintQueue: Printing a Job during 1 seconds
Thread 4: The document has been printed
Thread 5:Second PrintQueue: Printing a Job during 3 seconds
Thread 5: The document has been printed
Thread 6:Second PrintQueue: Printing a Job during 0 seconds
Thread 6: The document has been printed
Thread 7:Second PrintQueue: Printing a Job during 1 seconds
Thread 7: The document has been printed
Thread 8:Second PrintQueue: Printing a Job during 5 seconds
Thread 8: The document has been printed
Thread 9:Second PrintQueue: Printing a Job during 5 seconds
Thread 9: The document has been printed
Process finished with exit code 0

从上图可以看出线程直接获取锁的顺序是公平的,先到先得。

我们运行非公平锁的打印结果如下:

Thread 0: Going to print a job
Thread 0:First PrintQueue: Printing a Job during 5 seconds
Thread 1: Going to print a job
Thread 2: Going to print a job
Thread 3: Going to print a job
Thread 4: Going to print a job
Thread 5: Going to print a job
Thread 6: Going to print a job
Thread 7: Going to print a job
Thread 8: Going to print a job
Thread 9: Going to print a job
Thread 0:Second PrintQueue: Printing a Job during 2 seconds //线程0直接释放锁又获取了锁,体现了非公平锁
Thread 0: The document has been printed
Thread 1:First PrintQueue: Printing a Job during 9 seconds
Thread 1:Second PrintQueue: Printing a Job during 3 seconds
Thread 1: The document has been printed
Thread 2:First PrintQueue: Printing a Job during 0 seconds
Thread 3:First PrintQueue: Printing a Job during 0 seconds
Thread 3:Second PrintQueue: Printing a Job during 7 seconds
Thread 3: The document has been printed
Thread 4:First PrintQueue: Printing a Job during 3 seconds
Thread 4:Second PrintQueue: Printing a Job during 8 seconds
Thread 4: The document has been printed
Thread 5:First PrintQueue: Printing a Job during 6 seconds
Thread 5:Second PrintQueue: Printing a Job during 1 seconds
Thread 5: The document has been printed
Thread 6:First PrintQueue: Printing a Job during 0 seconds
Thread 6:Second PrintQueue: Printing a Job during 7 seconds
Thread 6: The document has been printed
Thread 7:First PrintQueue: Printing a Job during 8 seconds
Thread 7:Second PrintQueue: Printing a Job during 1 seconds
Thread 7: The document has been printed
Thread 8:First PrintQueue: Printing a Job during 9 seconds
Thread 8:Second PrintQueue: Printing a Job during 8 seconds
Thread 8: The document has been printed
Thread 9:First PrintQueue: Printing a Job during 5 seconds
Thread 9:Second PrintQueue: Printing a Job during 5 seconds
Thread 9: The document has been printed
Thread 2:Second PrintQueue: Printing a Job during 3 seconds
Thread 2: The document has been printed
​
Process finished with exit code 0

上图中可以看出线程0在释放了锁之后,立刻有获取了锁继续执行,出现了抢锁插队现象(此时等待队列已经有了线程1-9再等待)。

公平锁和非公平锁有缺点

  • 公平锁优势:公平锁各个线程平等,每个线程等待一段时间总会执行。
  • 公平锁劣势:相对非公平锁执行效率比较慢,吞吐量更小
  • 非公平锁优势:相比公平锁更快,吞吐量更大
  • 非公平锁劣势:又可能产生饥饿线程,就是某些线程的等待时间很长始终得不到执行。

公平锁和非公平锁源码解析
首先公平锁和非公平锁都是继承了ReentrantLock类中的内部类Sync类,这个Sync类继承AQS(AbstractQueuedSynchronizer),Sync类代码如下:

//源码中可以看出Sync继承了AbstractQueuedSynchronizer类
abstract static class Sync extends AbstractQueuedSynchronizer {...}
//Sync有公平锁FairSync和非公平锁NonFairSync两个子类:
static final class NonfairSync extends Sync {。。。}
static final class FairSync extends Sync {。。}

公平锁获取锁的源码:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
    //这里和非公平锁对比多了个!hasQueuedPredecessors()判断
        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;
}

非公平锁获取锁源码:

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;
}

通过对比可以发现,公平锁和非公平锁的区别主要是在获取锁的时候,
公平锁多了一个hasQueuedPredecessors()为false的判断,hasQueuedPredecessors()方法
就是判断等待队列中是否已经有线程在等待了,如果有则当前线程不能在尝试获取锁,对于非公平锁而言,
无论是否有线程在等待了,都先尝试获取锁,获取不到的话再去排队,tryLock()方法内部调用的是sync.nonfairTryAcquire(1)即非
公平锁,所以即使设置了公平模式,那么使用tryLock()也可以插队。

读写锁

为什么设置读写锁

首先读写锁是为了提高系统的效率,
虽然普通的ReentrantLock可以保证线程安全,
但是如果是多个读取操作,就直接采用ReentrantLock会大大的浪费系统资源,
还有就是写操作是不安全的,当并发写或者在进行写操作的同时进行读取,都会发生线程安全问题,
那么设置的读写锁就起了作用,读写锁支持并发读来提高读的效率,同时又保证安全的写操作。

读写锁规则

  • 如果一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
  • 如果一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时进行。
  • 如果一个线程已经占用了写锁,则此时其他线程申请读锁或者写锁,必须等待之前的线程释放了写锁,因为读写不能同时进行。

读写锁使用展示

这里使用ReentrantReadWriteLock来演示,ReentrantReadWriteLock是ReadWriteLock的是实现类,最主要两个方法readLock()获取读锁,writeLock()获取写锁,
这里使用ReadWriteLock中的读写锁进行并发读写,代码展示如下:

public class ReadWriteLock {
    //定义读写锁
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
    //获取读锁
    private static final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock();
    //获取写锁
    private static final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock();
​
    public static void read(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() +"得到读锁,正在读取");
            Thread.sleep(500);
        }catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            System.out.println("释放读锁");
            readLock.unlock();
        }
    }
​
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }
​
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}

运行结果如下:

Thread-0得到读锁,正在读取
Thread-1得到读锁,正在读取
释放读锁
释放读锁
Thread-2得到写锁,正在写入
Thread-2释放写锁
Thread-3得到写锁,正在写入
Thread-3释放写锁

通过运行结果可以看出,读写锁支持并发读,而写操作是单独进行的。

读锁插队策略

首先读写锁ReentrantReadWriteLock支持公平锁和非公平锁,可以通过以下进行设置:

//后面的boolean值用来设置公平锁、非公平锁,其中的false设置为非公平锁,设置为true就是公平锁,
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

如果设置为公平锁的时候对应的读写锁实现为:

static final class FairSync extends Sync {
    private static final long serialVersionUID = -2274990926593161451L;
    final boolean writerShouldBlock() {
        return hasQueuedPredecessors();
    }
    final boolean readerShouldBlock() {
        return hasQueuedPredecessors();
    }
}

其中的hasQueuedPredecessors()方法就是检测等待队列中是否已经有线程在排序了,
如果有的话每当前获取锁的线程就会block去排序,所以符合公平锁定义。

如果设置为false非公平锁,则对应的实现为:

static final class NonfairSync extends Sync {
        private static final long serialVersionUID = -8159625535654395037L;
        final boolean writerShouldBlock() {
            return false; // writers can always barge
        }
        final boolean readerShouldBlock() {
            /* As a heuristic to avoid indefinite writer starvation,
             * block if the thread that momentarily appears to be head
             * of queue, if one exists, is a waiting writer。  This is
             * only a probabilistic effect since a new reader will not
             * block if there is a waiting writer behind other enabled
             * readers that have not yet drained from the queue。
             */
            return apparentlyFirstQueuedIsExclusive();
        }
}

上图中writerShouldBlock()方法直接返回false,可以看出对于想要获取写锁的线程而言,
由于返回的是false所以它可以随时插队,也符合非公平锁的设计,非公平锁下的获取读锁需要依据apparentlyFirstQueuedIsExclusive()方法的返回值,上图中对apparentlyFirstQueuedIsExclusive方法注释主要是说防止等待队
列头的写线程无饥饿等待下去,举个例子说明:

场景:如果有线程1和2同时读取,并且1和2已经持有了读锁,此时线程3想要写入,所以线程3进入等待队列,此时线程4突然插队想要获取读锁。
此时就有两种策略:

  • 允许插队,允许线程4获取读锁和线程1线程2一起去读取,看似提高了读取效率,但是却有一个严重的问题,就是如果后面来的线程一直都是想要获取读锁的线程,那么线程3将一直得不到执行的机会,那么就会陷入“饥饿”状态,在长时间内得不到执行。
  • 不允许插队,此时如果新来的线程4想要获取读锁,必须去等排队等待,这种策略下,线程3或优先于线程4,就可以避免上面的“饥饿”状态,直到线程3运行结束,线程4才有机会运行。

而ReentrantReadWriteLock非公平锁下的获取读锁正是采用了不允许插队策略来实现的,避免了线程饥饿情况。

我们通过代码展示一下上面的不允许插队策略,效果展示代码展示:

public class ReadWriteLock {
    //定义读写锁
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
    //获取读锁
    private static final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock();
    //获取写锁
    private static final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock();
​
    public static void read(){
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() +"得到读锁,正在读取");
            Thread.sleep(500);
        }catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            System.out.println("释放读锁");
            readLock.unlock();
        }
    }
​
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }
​
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        //以上代码没有改变,这里换成了读锁
        new Thread(() -> read()).start();
    }
}

运行结果如下:

Thread-0得到读锁,正在读取
Thread-1得到读锁,正在读取
释放读锁
释放读锁
Thread-2得到写锁,正在写入
Thread-2释放写锁
Thread-3得到读锁,正在读取
释放读锁

通过运行结果我们可以看出,ReentrantReadWriteLock选择了不允许插队的策略。

读写锁的升降级

写锁的降级
写锁的降级,代码展示:

//定义读写锁
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
//获取读锁
private static final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock();
//获取写锁
private static final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock();
​
//锁的降级
public static void downgrade(){
    System.out.println(Thread.currentThread().getName()+"尝试获取写锁");
    writeLock.lock();//获取写锁
    try {
        System.out.println(Thread.currentThread().getName()+"获取了写锁");
        //在不释放写锁的情况下直接获取读锁,这就是读写锁的降级
        readLock.lock();
        System.out.println(Thread.currentThread().getName()+"获取了读锁");
    }finally {
        System.out.println(Thread.currentThread().getName()+"释放了写锁");
        //释放了写锁,但是依然持有读锁,这里不释放读锁,导致后面的线程无法获取写锁
        writeLock.unlock();
    }
}

public static void main(String[] args) {
    new Thread(() -> downgrade()).start();
    new Thread(() -> downgrade()).start();
}

上图运行结果如下:

image.png
图中我们可以看出线程0可以在持有写锁的情况下获取到了读锁,这就是写锁的降级,因为线程0后面只是放了写锁,
并未释放读锁,导致后面的线程1不能获取到写锁,所以程序一直阻塞。

读锁的升级
接下来我们在来看读锁的升级,代码展示:

private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
//获取读锁
private static final ReentrantReadWriteLock.ReadLock readLock= reentrantReadWriteLock.readLock();
//获取写锁
private static final ReentrantReadWriteLock.WriteLock writeLock= reentrantReadWriteLock.writeLock();

//读锁升级
public static void upgarde(){
    System.out.println(Thread.currentThread().getName()+"尝试获取读锁");
    readLock.lock();
    try{
        System.out.println(Thread.currentThread().getName()+"获取到了读锁");
        System.out.println(Thread.currentThread().getName()+"阻塞获取写锁");
        //在持有读锁的情况下获取写锁,此处会阻塞,表示不支持读锁升级到写锁
        writeLock.lock();//此处会阻塞
        System.out.println(Thread.currentThread().getName()+"获取到了写锁");
​
    }finally {
        readLock.unlock();
    }
}
​
public static void main(String[] args) {
    new Thread(() -> upgarde()).start();
    new Thread(() -> upgarde()).start();
}

运行结果如下:

image.png

上图中我们可以看出线程0和线程1都可以成功的获取到读锁,但是在进行锁升级获取写锁的时候都阻塞了,这是因为ReentrantReadWriteLock 不支持读锁升级到写锁。
因为读锁是可以多个线程持有的,但是写锁只能一个线程持有,并且不可能存在读锁和写锁同时持有的情况,也正是因为这个原因所以升级写锁的过程中,需要等待所有的读锁都释放了,此时才能进行锁升级。

举个例子,比如ABC三个线程都持有读锁,其中线程A想要进行锁升级,必须要等到B和C都释放了读锁,此时线程A才可以成功升级并获取写锁。

但是这里也有一个问题,那就是假如A和B都想要锁升级,对于线程A来说,他需要等待其他所有线程包括B线程释放读锁,而B线程也需要等待其他线程释放读锁包括A线程,那就会发生死锁。
所以如果我们保证每次升级只有一个线程可以升级,那么就可以保证线程安全,并且实现。

自旋锁

自旋锁介绍

自旋锁其实就是指循环,比如while或者for循环,一直循环去尝试获取锁,不像普通的锁获取不到就陷入阻塞。

自旋锁和非自旋锁流程图对比如下:

image.png

上图中我们可以看出自旋锁获取获取锁失败并不会释放CPU资源而是通过自旋的方式等待锁的释放,直到成功获取到锁为止。
而非自旋锁如果尝试获取锁失败了,它就把自己的线程切换状态,让线程休眠,释放CPU时间片,然后直到之前持有这把锁的线程释放了锁,于是CPU再把之前的线程恢复回来,让这个线程再尝试去获取锁,如果再次失败就在让线程休眠,如果成功,就可以获取到同步资源的锁。

自旋锁的好处
自旋锁免去了耗时的阻塞和唤醒线程操作,避免了线程的状态切换等开销,提高了效率。

自旋锁的坏处
自旋锁虽然避免了线程切换的开销,但是也带来了新的开销,因为他需要不停的循环去尝试获取锁,如果所以只不释放,那么他就需要一直去尝试,这样会白白的浪费资源。

所以,自旋锁适用于并发度不是特别高,而且线程持有锁的时间较短的场景。举个例子比如java.util.concurrent包下的原子类大多数都是基于自旋锁的实现,比如AtomicInteger,我们查看他的getAndIncrement()方法,如下:

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
​
        return var5;
}

很明显,do…while()循环就是一个自旋操作,如果在修改过程中遇到了其他线程导致没有修改成功,则就会执行循环不从的重试,直到修改成功为止。

如何自定义实现一个可重入的自旋锁

实现代码如下:

//自定义实现可重入的自旋锁
public class CustomReentrantSpinLock {
    private AtomicReference<Thread> owner=new AtomicReference<>();
    private int count = 0;//重入次数
​
    public void lock() {
        Thread t = Thread.currentThread();
        System.out.println(Thread.currentThread().getName()+"lock了");
        if (t == owner.get()) {
            ++count;
            return;
        }
​
        //自旋获取锁
        while (!owner.compareAndSet(null, t)) {
            System.out.println(Thread.currentThread().getName()+"自旋了");
        }
    }
​
    public void unLock(){
        Thread t=Thread.currentThread();
        //只有当前线程才能解锁
        if(t == owner.get()){
            if(count >0){
                --count;
            } else {
                owner.set(null);
            }
        }
    }
​
    public static void main(String[] args) {
        CustomReentrantSpinLock spinLock=new CustomReentrantSpinLock();
        Runnable runnable=new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName()+"开始尝试获取自旋锁");
                spinLock.lock();
                try {
                    System.out.println(Thread.currentThread().getName()+"获取到了自旋锁");
                    Thread.sleep(1);
                }catch (InterruptedException e){
                    e.printStackTrace();
                } finally {
                    spinLock.unLock();
                    System.out.println(Thread.currentThread().getName()+"释放了自旋锁");
                }
            }
        };
        Thread thread1=new Thread(runnable);
        Thread thread2=new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

运行结果如下:

Thread-0开始尝试获取自旋锁
Thread-1开始尝试获取自旋锁
Thread-0获取到了自旋锁
Thread-1自旋了
.
.
.
Thread-1自旋了
Thread-0释放了了自旋锁
Thread-1获取到了自旋锁
Thread-1释放了了自旋锁

从上图运行结果中可以看出,打印了很多Thread-1自旋了,说明自旋期间CPU依然不停运转,Thread-1并没有释放CPU时间片。

JVM对锁的优化

从jdk1.6后HotSpot虚拟机对synchronized做了很多优化,包括自适应自选、锁消除、锁粗化、偏向锁、轻量级锁等,使得synchronized锁得到了很大的性能提升。

自适应自旋锁

自适应自旋就是自旋的时间不在固定,而是根据自旋的成功率、失败率、以及当前锁的持有者的状态等多种因素来共同决定的,就是说自旋的时间是变化的,这样可以减少无用的自旋,提高效率。

锁消除

锁消除是发生在编译器级别的一种锁优化方式,有时候我们写的代码不需要加锁,就比如加锁的代码实际上只有一个线程会执行,并不会出现多个线程并发访问的情况,但是我们却加上了synchronized锁,那么编译器就可能会消除掉这个锁,比如下面StringBuffer的append操作:

@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String。valueOf(obj));
    return this;
}

代码中我们可以看到这个方法被synchronized修饰,因为它可能会被多个线程同时使用,
但是多数情况下它只会在一个线程内使用,如果编译器能确定这个对象只会在一个线程内使用,那么就表示肯定是线程安全的,编译器就会做出优化,把对应的synchronized消除,省去加解锁的操作以提升效率。

锁粗化

锁粗化主要是应对刚释放锁,什么还没做,就重新获取锁,例如如下代码:

public void lockCoarsening() {
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
}

上述代码中,当线程在执行第一个同步代码块时需要先获取synchronized锁,然后执行完了同步代码块在释放synchronized锁,但是当线程执行完第一个同步代码块后已经释放了锁后,紧接着线程立刻开始执行第二个同步代码块时就需要对相同的锁进行获取和释放,
这样释放和获取锁是完全没有必要的,如果把同步区域扩大,也就是在最开始的时候加一次锁,在结束的时候释放锁,那么就可以把中间无意义的解锁和加锁的过程消除,相当于把几个synchronized块合并成为一个较大的同步块,好处就是无需频繁的释放锁和获取锁,减少系统开销。

但是锁粗化不适用在循环的场景,仅适用非循环的场景,因为如下代码所示,如果我们在第一次循环中获取锁,在最后一次循环中释放锁,那么这就会导致其它线程长时间无法获取锁。

for (int i = 0; i < 1000; i++) {
    synchronized (this) {
        //do something
    }
}

锁粗化默认是开启的,通过-XX:-EliminateLocks关闭该功能

偏向锁、轻量级锁、重量级锁

这三种锁我们最开始就介绍过,它们是指synchronized的锁状态的,通过在对象头中的mark work字段来表明锁的状态。

锁升级的路径

锁升级的路径如下图所示,偏向锁性能做好,避免了CAS操作,轻量级锁利用了自旋和CAS操作避免了重量级锁带来的线程阻塞和唤醒,性能中等,重量级锁会把获取不到锁的线程阻塞,性能最差。

image.png

-END

点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
JAVA开发工程师
手记
粉丝
0
获赞与收藏
14

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消