深入浅出Java锁(一)

深入浅出Java锁(一)

在互联网大潮之下,Java其优秀的语言特性带来了各个大厂的热衷。这势必要求计划进入大厂的同学具备扎实的计算机基础。主题接下来重点讲解各种锁的基本知识点&Java锁的实现和使用,帮助同学们更好的应对大厂各种刁钻的面试题。

锁存在的意义

在多CPU架构的计算机下,可以有效防止多个线程并发操作同一个计算机资源而引起数据不一致或者脏读的情况发生。锁在多线程场景下是一个很好的解决方案,但是使用不当会引起严重的性能问题,比如:cpu耗尽,死锁,服务吞吐量低。

Java锁常见特性

有个点请同学们注意:一个锁可以拥有多个特性,比如即是乐观锁,又是可重入锁等。接下来按照特性具体展开。

悲观锁与乐观锁

基本概念

悲观锁是指当前线程在操作同步资源时,悲观的认为肯定有其他线程会来修改,因此当前线程要加一把锁,确保同步资源不会被其他线程操作。在Java中常见的悲观锁实现是synchronized和Lock的实现类。
乐观锁是指当前线程在操作同步资源时,乐观的认为不会有其他线程来修改,因此当前线程不会加锁。只是在更新同步资源时,会主动去check同步资源是否被其他线程修改;如果修改了,可以采用不同方式解决(比如重试或者抛异常),如果没有修改,则直接更新即可。

使用场景

两种锁分别有不同的使用场景,不能一概而论谁好谁坏。悲观锁主要应对读少写多的场景;乐观锁主要应对读多写少的场景。

示例代码

  • 悲观锁

此demo的目的让同学们可以看清楚,各个线程排他性的执行,一个接一个,但是不确保线程按照1-10的顺序执行

/**
 * @author : 乌鸦
 * @since : 2020/9/18
 */
public class Demo{
     //同步资源
    private String name;
    private ReentrantLock lock = new ReentrantLock();

    //悲观锁实现一
    public synchronized void updateName(String name, Integer threadNum) throws InterruptedException {
        //do something about name
        System.out.println(threadNum + "is doing something at " + Calendar.getInstance().getTimeInMillis());
        this.name = name;
        //为了演示效果故休眠1s
        Thread.sleep(1000);
    }

    //悲观锁实现二
    public void setName(String name, Integer threadNum) throws InterruptedException {
        //阻塞直到获取到锁
        lock.lock();
        try{
            //do something about name
            System.out.println(threadNum + " is doing something at " + Calendar.getInstance().getTimeInMillis());
            this.name = name;
            //为了演示效果故休眠1s
            Thread.sleep(1000);
        }finally{
            lock.unlock();
        }
    }

    public void setNameWithNoLock(String name, Integer threadNum)throws InterruptedException {
        //do something about name
        System.out.println(threadNum + " is doing something at " + Calendar.getInstance().getTimeInMillis());
        this.name = name;
        //为了演示效果故休眠1s
        Thread.sleep(1000);
    }

    public static void main(String[] args) throws InterruptedException {
        Demo demo = new Demo();

        //悲观锁
        final CountDownLatch latch = new CountDownLatch(10);
        System.out.println("悲观锁输出结果,请注意看输出的时间戳:");
        for(int i=0;i<10;i++){
            final int num = i;
            new Thread(() -> {
                try {
                    demo.setName("乌鸦"+num,num);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();
            }).start();
        }
        //主要是为了hold主线程,让10个线程跑完
        latch.await();

        //无锁
        final CountDownLatch latch2 = new CountDownLatch(10);
        System.out.println("乐观锁输出结果,请注意看输出的时间戳:");
        for(int i=0;i<10;i++){
            final int num = i;
            new Thread(() -> {
                try {
                    demo.setNameWithNoLock("乌鸦"+num,num);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch2.countDown();
            }).start();
        }
        //主要是为了hold主线程,让10个线程跑完
        latch2.await();
    }
}

=================输出结果===================
悲观锁输出结果,请注意看输出的时间戳:
0 is doing something at 1600418985619
1 is doing something at 1600418986632
2 is doing something at 1600418987637
3 is doing something at 1600418988642
4 is doing something at 1600418989646
5 is doing something at 1600418990647
6 is doing something at 1600418991651
7 is doing something at 1600418992656
8 is doing something at 1600418993660
9 is doing something at 1600418994666
乐观锁输出结果,请注意看输出的时间戳:
0 is doing something at 1600418995672
1 is doing something at 1600418995672
2 is doing something at 1600418995672
3 is doing something at 1600418995672
4 is doing something at 1600418995672
5 is doing something at 1600418995673
6 is doing something at 1600418995673
7 is doing something at 1600418995673
8 is doing something at 1600418995673
9 is doing something at 1600418995673
  • 乐观锁
    
    package com.example.demo;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

/**

  • @author : 乌鸦
  • @since : 2020/9/18
    */
    public class OptimisticLockDemo {
    public AtomicInteger atomicCount = new AtomicInteger();
    public Integer commonCount = 0;

    public void atomicInc(){
    try{
    Thread.sleep(1); //延迟1毫秒
    }catch (InterruptedException e){
    e.printStackTrace();
    }
    atomicCount.getAndIncrement();
    }
    public void commonInc(){
    try{
    Thread.sleep(1); //延迟1毫秒,为了看清楚并发问题
    }catch (InterruptedException e){
    e.printStackTrace();
    }
    commonCount = commonCount + 1;
    }

    public static void main(String[] args) throws InterruptedException {

    OptimisticLockDemo demo = new OptimisticLockDemo();
    
    //有锁case
    final CountDownLatch latch1 = new CountDownLatch(100);
    for(int i=0;i<100;i++){
        new Thread(() -> {
            demo.atomicInc();
            latch1.countDown();
        }).start();
    }
    latch1.await();
    System.out.println("atomicCount运行结果(确定为100):"+demo.atomicCount);
    
    //无锁case
    final CountDownLatch latch2 = new CountDownLatch(100);
    for(int i=0;i<100;i++){
        new Thread(() -> {
            demo.commonInc();
            latch2.countDown();
        }).start();
    }
    latch2.await();
    System.out.println("commonCount运行结果(不确定):"+demo.commonCount);

    }
    }

=====输出结果======
atomicCount运行结果(确定为100):100
commonCount运行结果(不确定):91

## 自旋锁与非自旋锁
### 基本概念
在展开此概念之前,先介绍下线程的几个状态,详见如下图(图摘自:[线程的六种状态及转化](https://baijiahao.baidu.com/s?id=1658121385190352035&wfr=spider&for=pc))。同学们可以看到运行中的线程到就绪再到阻塞,会进行线程上下文切换,比较耗CPU资源。在JVM系统中,往往频繁地线程上下文切换会导致CPU使用率偏高,故我们需要避免,不断优化。
![image.png](https://cdn.nlark.com/yuque/0/2020/png/262173/1600421083542-8e913b64-d0fb-4c42-aa41-dde223dfb07d.png#align=left&display=inline&height=403&margin=%5Bobject%20Object%5D&name=image.png&originHeight=806&originWidth=1228&size=409389&status=done&style=none&width=614)
自旋锁是让当前线程"稍微等一下",但是_**CPU资源仍旧没有放弃**_,避免了线上下文的切换。_**同学们请注意,自旋不代表阻塞**_。自旋短时间等待,效果非常好。反之,如果锁被占用的时间很长,那就白白浪费了CPU资源。所以,建议_**自旋等待的时间必须要有一定的限度**_。
### 使用场景
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间。
### 代码示例
在AtomicInteger中的addAndGet方法实现,内部依赖的就是Unsafe中的getAndAddInt,其内部实现就是通过一个do-while来实现自旋。同时JVM也提供相关参数来设置自旋次数,具体可以参考[JVM参数](https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html)——PreBlockSpin。
```java
=======AtomicInteger部分源码=====
public class AtomicInteger extends Number implements java.io.Serializable {
    ......

    public final int addAndGet(int delta) {
            return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
    }

    ......
}

public final class Unsafe {
    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;
    }
}

可重入锁与非可重入锁

基本概念

可重入锁是指同一个线程可以多次获取同一个锁,而不会发生死锁,比如synchronized和ReentrantLock可重入锁(后续会有专门专题解析为什么是可重入的)。不可重入锁,与重入锁相反,不可以多次获取同一个锁,否则就会引起死锁。

使用场景

同一个线程需要重复多次进入临界区资源时,需要使用可重入锁,可以有效避免死锁。

示例代码

同学们我们具体看下JDK中锁ReentrantLock是怎么实现可重入锁的,其主要是通过state来计数重入次数,避免了频繁的持有释放操作,这样既提升了效率,又避免了死锁。

public class ReentrantLock implements Lock, java.io.Serializable {

    /**
     * The synchronization state.
     */
    private volatile int state;
    /**
     * Returns the current value of synchronization state.
     * This operation has memory semantics of a {@code volatile} read.
     * @return current state value
     */
    protected final int getState() {
        return state;
    }

    ......

    /**
     * Performs non-fair tryLock.  tryAcquire is implemented in
     * subclasses, but both need nonfair try for trylock method.
     */
    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;
    }

    ......
}

共享锁与独占锁

基本概念

独享锁:只能有一个线程占有,对于synchronized来说,显然是独享锁。
共享锁:多个线程可同时持有,在JDK中典型的代表是ReentrantReadWriteLock的读锁,可以保证高效的读取数据。

使用场景

为了有更高的读吞吐量,在没有更新的前提下,一些临界区资源允许多个线程同时获取共享读锁。独占锁顾名思义主要用于保护临界区资源不被脏掉的场景。

示例代码

共享锁的实现,我们具体看下JDK中的ReentrantReadWriteLock的读锁实现。可以看到在tryAcquireShared方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程增加读状态,成功获取读锁。具体的实现还是通过类似的state,这主要还是归结与JDK中的AQS的牛逼抽象(后续有具体的源码分析专栏)。

protected final int tryAcquireShared(int unused) {
    /*
     * Walkthrough:
     * 1. If write lock held by another thread, fail.
     * 2. Otherwise, this thread is eligible for
     *    lock wrt state, so ask if it should block
     *    because of queue policy. If not, try
     *    to grant by CASing state and updating count.
     *    Note that step does not check for reentrant
     *    acquires, which is postponed to full version
     *    to avoid having to check hold count in
     *    the more typical non-reentrant case.
     * 3. If step 2 fails either because thread
     *    apparently not eligible or CAS fails or count
     *    saturated, chain to version with full retry loop.
     */
    Thread current = Thread.currentThread();
    int c = getState();
    if (exclusiveCount(c) != 0 &&
        getExclusiveOwnerThread() != current)
        return -1;
    int r = sharedCount(c);
    if (!readerShouldBlock() &&
        r < MAX_COUNT &&
        compareAndSetState(c, c + SHARED_UNIT)) {
        if (r == 0) {
            firstReader = current;
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            firstReaderHoldCount++;
        } else {
            HoldCounter rh = cachedHoldCounter;
            if (rh == null || rh.tid != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

公平锁与非公平锁

基本概念

公平锁主要是指多个线程按照申请锁的顺序进入队列,按照FIFO(先进先出)原则。它的优点很明显,不会让部分线程长时间等待,有效防止"hungry"的场景出现,但是缺点也很明显,CPU对线程上下文切换的消耗非常大,导致吞吐量比非公平锁要低。
非公平锁主要是指部分线程可以插队获取锁,插队不成功,才会进行排队处理。其优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程;缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

使用场景

如果线程业务处理时长远远大于等待时间的化,那么非公平锁效率不见得会很高,反而是公平锁给业务增加了很多的可控性。

示例代码

ReenTrantLock有两种自带公平和非公平两种实现方式,具体示例代码如下,其中公平锁中通过队列,具体可以看方法hasQueuedPredecessors

/**
 * Performs non-fair tryLock.  tryAcquire is implemented in
 * subclasses, but both need nonfair try for trylock method.
 */
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;
}

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

synchronized锁状态

synchronized锁在1.6版本之前无锁或者重量级锁,缺点非常明显:在重量级锁下吞吐非常低。为了提高性能,1.6后进行了优化。引入了额外二种状态(偏向锁和轻量级锁),分别应对不同的场景,其递进顺序为:无锁->偏向锁->轻量级锁->重量级锁。

无锁

没有资源锁定。比如我们后续会详细介绍的CAS原理及应用都是无锁。

偏向锁

一个线程多次访问同步块代码,则线程自动获取该偏向锁,降低获取锁的成本,提供性能。

轻量级锁

如果存在另外一个线程访问偏向锁,则偏向锁升级为轻量级锁,其他线程通过自旋循环尝试获取锁,不会阻塞,从而提供性能。

重量级锁

其他线程通过自旋等操作还获取不到锁,则进入重量级锁,阻塞线程,归还CPU使用权。

总结

本文针对Java中常见的锁概念进行了基本介绍,并从源码以及实际应用的角度进行了说明。其实Java本身已经对锁本身进行了良好的封装,降低了研发同学在平时工作中的使用难度。但是在面试阶段,同学们需要熟悉锁的底层原理,才能应答自如。
接下来会继续针对锁在Java中的实现&使用进行详细讲解,请大家耐心等待。

关注公众号:Tpark技术工匠。每周都会推送原创内容哦!!!另外可以内推阿里哦,快来发送简历:[email protected]
深入浅出Java锁(一)

猜你喜欢

转载自blog.51cto.com/14942543/2541939