在面试官面前侃侃而谈之对synchronized、Lock的深入理解

自律和变得更好是一个煎熬的过程

synchronized的缺陷

众所周知,synchronized锁是JAVA的关键字,按理说是JAVA语言内置的特性,那为什么还要使用Lock呢

我们先说一说synchronized,当一个方法或者代码块被synchronized修饰,并执行到此方法或者代码块时,获取到锁并执行,其他线程进来拿不到锁就会一直等待,等待获取到锁的线程释放锁。而这里获取到锁的线程释放锁只有2种情况

1:获取到锁的线程执行完毕,线程自动释放对锁的占用。
2:线程执行过程中发生异常,JVM虚拟机将取消线程对锁的占用。

这里插一手synchronized获取锁释放锁底层是怎么操作的(底层都是通过monitor(监视器)来实现同步)

方法体加锁:

方法体加锁后,⽣成的字节码⽂件中会多⼀个 ACC_SYNCHRONIZED 标志位,当⼀个线程访问⽅法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,执⾏线程将先获取monitor(监视器),获取成功之后才能执⾏⽅法体,⽅法执⾏完后再释放monitor。在⽅法执⾏期间,其他任何线程都⽆法再获得同⼀个monitor对象,也叫隐式同步

代码块加锁:

⽣成的字节码⽂件会多出 monitorenter 和monitorexit 两条指令,每个monitor维护着⼀个记录着拥有次数的计数器, 未被拥有的monitor的该计数器为0,当⼀个线程获执⾏monitorenter后,该计数器⾃增1;当同⼀个线程执⾏monitorexit指令的时候,计数器再⾃减1。当计数器为0的时候,monitor将被释放.也叫显式同步


好了,回归正题,获取到锁的线程只有这2种情况可以释放锁,其他的情况就会造成等待锁的线程一直等待下去,这样下去肯定是不行的,这时候肯定需要一直机制可以让线程不要一直等待下去,最起码可以等待一段时间进行关闭,或者能够响应中断。

再举一个例子,读写场景不陌生吧,写的时候别人不能写也不能读,但读的时候大家可以一起读,如果使用synchronized,那肯定也只有一个线程可以读了,这时候是不是就需要一种机制可以让大家一起读。

那么,Lock就出来了,不好意思synchronized,人家Lock这2种情况都可以解决,这波怎么说?(不过,synchronized也不是一无是处,在某些场景比Lock好用的多,这里暂且不提,只能说各有所用)。

Lock和ReentrantLock

那么就到了介绍Lock的时候了,通过源码可以知道,Lock是一个接口。

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

ReentrantLock,意思是“可重入锁”。ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。

基本语法上,ReentrantLock与synchronized很相似,它们都具备一样的线程重入特性,只是代码写法上有点区别而已。一个表现为API层面的互斥锁(Lock),一个表现为原生语法层面的互斥锁(synchronized)。

常用方法

内部提供了几个方法,我们了解一下

1:lock()和unlock():
lock() 见名知意,获取锁。Lock最重要的就是自己手动获取锁,自己手动关闭锁,因此就需要和unlock搭配使用,通常把unlock放到finally里面,确保及时出异常也可以对锁进行关闭。这里就体现出没有synchronized那么方便了,synchronized全自动运行。

通常这么搭配

ReentrantLock lock = new ReentrantLock();
//获取锁
lock.lock();
try {
    //执行业务代码
} catch (Exception e) {
    e.printStackTrace();
}finally {
    //关闭锁
    lock.unlock();
}

2:tryLock():
tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回,在拿不到锁时不会一直在那等待。
另一个方法是重载的tryLock(),参数是时间和时间单位,不容置疑,在拿不到锁时会等待设定的时间。再时间期限之内还拿不到那只能遗憾的返回false了,如果一开始就拿到了,或者在等待的时间段内拿到锁,那么返回true,适合对操作成功与否要求没有那么高的场景

通常这样使用:

ReentrantLock lock = new ReentrantLock();
//拿不到锁就等5秒,再拿不到就不拿了
if(lock.tryLock(5,TimeUnit.SECONDS)){
    try {
        //处理操作
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        //获取到锁则必须关闭
        lock.unlock();
    }
}

3:lockInterruptibly():
lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就是说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。

由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出InterruptedException。

因此lockInterruptibly()一般的使用形式如下:

ReentrantLock lock = new ReentrantLock();
lock.lockInterruptibly();
try {  
 //.....
}
finally {
    lock.unlock();
}  

4:newCondition():
这个对象就比较强大了。能够精细的控制多线程的休眠与唤醒。对于同一个锁,我们可以创建多个Condition,在不同的情况下使用不同的Condition。

比如,现在遇到一个问题,假如多线程读/写同一个缓冲区:当向缓冲区中写入数据之后,唤醒"读线程";当从缓冲区读出数据之后,唤醒"写线程";并且当缓冲区满的时候,"写线程"需要等待;当缓冲区为空时,"读线程"需要等待。

这时候你是不是想到的是Object的wait()和notify()方法。
如果采用Object类中的wait(), notify(),notifyAll()实现该缓冲区,当向缓冲区写入数据之后需要唤醒"读线程"时,不可能通过notify()或notifyAll()明确的指定唤醒"读线程",而只能通过notifyAll唤醒所有线程(但是notifyAll无法区分唤醒的线程是读线程,还是写线程)。 但是,通过Condition,就能明确的指定唤醒读线程。

这时候newCondition的强大之处就体现出来了。

现在有这样一道题:多个线程按照顺序调用,实现A–>B–>C三个线程启动,要求A打印1次,B打印3次,C打印5次,紧接着,继续A打印1次,B打印3次,C打印5次.

怎么写?

public class ConditionDemo {
    public static void main(String[] args) {
        ShareData shareData = new ShareData();
            for (int i = 0; i < 2; i++) {
                new Thread(()->{
                    shareData.printf1();
                },String.valueOf(i)).start();
                new Thread(()->{
                    shareData.printf3();
                },String.valueOf(i+10)).start();
                new Thread(()->{
                    shareData.printf5();
                },String.valueOf(i+20)).start();
        }
    }
}
class ShareData{
	//    A1,B2,C3
    private volatile int count=1;
    private Lock lock=new ReentrantLock();
    //操作线程A
    private Condition condition1=lock.newCondition();
    //操作线程B
    private Condition condition2=lock.newCondition();
    //操作线程C
    private Condition condition3=lock.newCondition();
    
	//输出一次
    public void printf1(){
        lock.lock();
        try {
            while (count!=1){
                condition1.await();
            }
            System.out.println("线程"+Thread.currentThread().getName()+"打印count="+count);
            count=2;
            //精确指定线程B结束休眠,开始操作
            condition2.signal();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
    public void printf3(){
        lock.lock();
        try {
            while (count!=2){
                condition2.await();
            }
            System.out.println("线程"+Thread.currentThread().getName()+"打印count="+count);
            count=3;
            //精确指定线程C结束休眠,开始操作
            condition3.signal();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
    public void printf5(){
        lock.lock();
        try {
            while (count!=3){
                condition3.await();
            }
            System.out.println("线程"+Thread.currentThread().getName()+"打印count="+count);
            count=1;
            //精确指定线程A结束休眠,开始操作
            condition1.signal();
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

控制台输出:

线程0打印count=1
线程10打印count=2
线程20打印count=3
线程1打印count=1
线程11打印count=2
线程21打印count=3

体现出Condition的强大之处了吧

ReadWriteLock和ReentrantReadWriteLock

ReadWriteLock也是一个接口,在它里面只定义了两个方法:

一个用来获取读锁,一个用来获取写锁。也就是说将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作。下面的ReentrantReadWriteLock实现了ReadWriteLock接口。

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

ReentrantReadWriteLock里面提供了很多丰富的方法,不过最主要的有两个方法:readLock()和writeLock()用来获取读锁和写锁。

不过要注意的是,如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,如果要申请读锁,可可以申请到。如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

读读共存
读写互斥
写写互斥

ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
new Thread(()->{
    //获取写锁
    readWriteLock.writeLock().lock();
    try {
        //进行写操作
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        //关闭写锁
        readWriteLock.writeLock().unlock();
    }
}).start();
new Thread(()->{
    //获取读锁
    readWriteLock.writeLock().lock();
    try {
        //进行读操作
    } catch (Exception e) {
        e.printStackTrace();
    }finally {
        //关闭读锁
        readWriteLock.writeLock().unlock();
    }
}).start();

Lock和synchronized区别

1,原始构成:

Synchronized:
关键字,属于JVM层面,底层通过Monitor对象来完成,其中wait/notify等方法也依赖于Monitor对象,只有在同步块或方法中才能调wait/notify等方法

Lock:
具体类,是API层面的锁

2,使用方法:

Synchronized:
不需要用户去手动释放锁,当代码执行完后系统会自动让线程释放对锁的占用

Lock:
需要用户手动释放锁,若没有手动释放锁,就有可能导致出现死锁现象,需要lock和unlock配合try/finally语句块来完成

3,中断

Synchronized:
不可中断,除非抛出异常或者正常运行完成

Lock:
可中断
设置超时时间trylock(long timeout,TimeUnit unit)lockInterruptibly()放代码块中,调用interrupt()方法可中断

4,公平:

Synchronized:
非公平锁

Lock:
默认非公平锁,构造方法可以传入boolean值,true为公平锁,false为非公平锁

5,Condition

Synchronized:

Lock:
用来实现分组唤醒需要唤醒的线程们,可以精确唤醒,而不是像Synchronized要么随机唤醒一个线程要么唤醒全部线程

synchronized锁升级

在多线程并发编程中 synchronized 一直是元老级角色,很 多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不 那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

在这里插入图片描述
偏向锁

当一个线程访问同步块并获取锁时,会在对象头和栈帧的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需测试Mark Word里线程ID是否为当前线程。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要判断偏向锁的标识。如果标识被设置为0(表示当前是无锁状态),则使用CAS竞争锁;如果标识设置成1(表示当前是偏向锁状态),则尝试使用CAS将对象头的偏向锁指向当前线程,触发偏向锁的撤销。偏向锁只有在竞争出现才会释放锁。当其他线程尝试竞争偏向锁时,程序到达全局安全点后(没有正在执行的代码),它会查看Java对象头中记录的线程是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

由于有锁撤销的过程会消耗系统资源,所以,在锁争用特别激烈的时候,用偏向锁未必效率高。还不如直接使用轻量级锁。

轻量级锁
线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的MarkWord复制到锁记录中,即Displaced Mark Word。然后线程会尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁。如果失败,表示其他线程在竞争锁,当前线程使用自旋来获取锁。当自旋次数达到一定次数时(1.6之后,出现了自适应自旋,JDk根据运行情况和每个线程运行情况自适应决定),锁就会升级为重量级锁。

重量级锁
重量级锁就是通过内核来操作线程。因为频繁出现内核态与用户态的切换,会严重影响性能。

注意:锁只能升级不能降级,但是偏向锁状态可以被重置为无锁状态。

流程图
在这里插入图片描述

公平锁和非公平锁

非公平锁:

简单来说,就是多个线程获取锁并不一定按照申请锁的顺序,先来先尝试占有锁
lock默认是非公平锁,synchronized是非公平锁

公平锁:
多个线程按照申请锁的顺序来获取锁,先来先得

看一下ReentrantLock源码,默认无参构造调用的是非公平锁

/**
 * Creates an instance of {@code ReentrantLock}.
 * This is equivalent to using {@code ReentrantLock(false)}.
 */
public ReentrantLock() {
    sync = new NonfairSync();
}

参数为true则公平锁

/**
 * Creates an instance of {@code ReentrantLock} with the
 * given fairness policy.
 *
 * @param fair {@code true} if this lock should use a fair ordering policy
 */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

看一下ReentrantLock公平锁和非公平锁工作流程

/**
 * Sync object for non-fair locks
 */
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
     //非公平锁获取锁
    final void lock() {
    	//进来就用CAS拿锁
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
        	//拿锁失败,才使用公平锁的逻辑
            acquire(1);
    }

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
}
/**
 * Sync object for fair locks
 */
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;
	//公平锁则直接进去正常获取锁逻辑
    final void lock() {
        acquire(1);
    }
public final void acquire(int arg) {
	//尝试获取锁
    if (!tryAcquire(arg) &&
    	//没获取到则加入等待队列
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        //获取到进入拥有锁的流程
        selfInterrupt();
}
**
 * 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() &&
        	//CAS获取锁
            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;
}

这么看来,非公平锁获取锁的过程中比公平锁多获取一次锁,也就是刚开始直接用CAS操作
底层的方法也都是基于AQS的方法,进一步验证了JUC是基于AQS实现的。

文章持续更新,可以微信搜索「 绅堂Style 」第一时间阅读,回复【资料】有我准备的面试题笔记。
GitHub https://github.com/dtt11111/Nodes 有总结面试完整考点、资料以及我的系列文章。欢迎Star。
在这里插入图片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章