手写一个重入锁

1,068 阅读9分钟

通常我们说的并发安全问题,都是由多个线程同时修改公共的资源引起的。由于不同线程同时修改公共资源而导致最终执行的结果不确定。解决这个问题的简单的做法是使用java关键字synchronized来加锁。关于synchronized不是本期的重点,本期我们主要说重入锁。

什么是锁的重入

什么是重入锁?先看下面这段代码:

package com.wuxiaolong.TestConcurrent;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-08-02
 */
public class TestSynchronized {

    /**
     * 方法A 使用synchronized修饰
     */
    public static synchronized void methodA(){
        Thread t = Thread.currentThread();
        System.out.println(t + "A start");
        try {
            Thread.sleep(1000);
            // 访问带有synchronized修饰的方法B
            methodB();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "A end");
    }

    /**
     * 方法B 使用synchronized修饰
     */
    public static synchronized void methodB(){
        Thread t = Thread.currentThread();
        System.out.println(t + "B start");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "B end");
    }

    public static void main(String[] args) {
        // 一个线程,内部同时调用方法A,A内部调用方法B
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();
        
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();
    }
}

这段代码中定义了两个方法:methodAmethodB;这两个方法同时都被synchronized修饰了。同时methodA内部调用了methodB。在main方法中,新启动了两个子线程,在线程内部调用了methodA

这段代码执行的结果是什么样,会出现死锁吗?不看下面的结果,你的答案是什么?

这里是每一个子线程中访问的多个加锁的方法。这两个子线程会有一个先拿到methodA的同步锁,另一个子线程就会等待。拿到锁的子线程访问第一个方法methodA时,当执行到methodB时,methodA 的锁还没释放,methodB能执行吗?

这里是不会有死锁的。执行结果:

Thread[Thread-0,5,main]A start
Thread[Thread-0,5,main]B start  // 入B
Thread[Thread-0,5,main]B end    // 出B
Thread[Thread-0,5,main]A end
    
Thread[Thread-1,5,main]A start
Thread[Thread-1,5,main]B start  // 入B 
Thread[Thread-1,5,main]B end    // 出B
Thread[Thread-1,5,main]A end

这里可以看出,Thread-0在没释放锁时,再次获得了锁进入方法B。这种现象就是锁的重入。

所谓重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程可以再次获取本对象上的锁,而其他的线程是不可以的

这里可以看出synchronized 是可重入的锁,如果不可重入,上面的代码就会产生死锁。

可重入锁的意义就在于防止死锁。synchronized 和 ReentrantLock 都是可重入锁。

手写不可重入锁

synchronized 是隐式的加锁;现在我们使用concurrent并发包中的Lock接口实现自己的不可重入锁和重入锁。先来看一下Lock接口的定义。这里我们重点关注Lock接口中的lockunlock方法,并实现这两个方法。

image-20200802222506754

实现自己的重入锁的关键就是在MyLock类中定义一个成员变量isLocked,并实现Lock接口中的lockunlock方法,通过这两个方法修改isLocked的值表示加锁解锁的过程。具体实现如下:

package com.wuxiaolong.TestConcurrent;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-08-02
 */
public class MyLock implements Lock {

    /**
     * 定义一个变量,标记锁是否被使用
     */
    private boolean isLocked = false;

    @Override
    public void lock() {

        // 死循环判断,isLocked是否被使用,如果已经被占用,则进入下一个循环尝试再次获得锁
        while(isLocked) {
            try {
                // 线程进入那个循环体里面,等待50毫秒后,再次尝试获得锁
               Thread.sleep(50);
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 将isLocked变量设置为true,表示本线程已经获得并占用了该锁;其他线程不能再获得锁,必须等待
        isLocked = true;
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {
        // 线程释放锁
        isLocked = false;
    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

测试这个不可重入锁:

package com.wuxiaolong.TestConcurrent;

import java.util.concurrent.locks.Lock;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-08-02
 */
public class TestMyLock {

    /**
     * 先创建一把自己的锁
     */
    public static Lock lock = new MyLock();

    /**
     * 方法A 方法的开始和结束分别手动加锁、解锁
     */
    public static void methodA(){

        // 手动加锁
        lock.lock();

        Thread t = Thread.currentThread();

        System.out.println(t + "A start");
        try {
            Thread.sleep(1000);
            // 访问带有synchronized修饰的方法B
            methodB();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "A end");

        // 手动解锁 一般放在finally中
        lock.unlock();
    }

    /**
     * 方法B 方法的开始和结束分别手动加锁、解锁
     */
    public static void methodB(){

        // 手动加锁
        lock.lock();

        Thread t = Thread.currentThread();
        System.out.println(t + "B start");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "B end");

        // 手动解锁 一般放在finally中
        lock.unlock();
    }

    public static void main(String[] args) {
        // 第一个线程,内部同时调用方法A,A内部调用方法B
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();

        // 第二个线程,内部同时调用方法A,A内部调用方法B
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();
    }
}

这里在methodAmethodB方法的开始和结束分别手动加锁、解锁。

执行结果:

Thread[Thread-0,5,main]A start
// 程序会卡在这里

执行的结果可以看到出现了死锁,这就是锁的不可重入导致的。Thread-0进入methodA并获得锁,在这个锁没有释放的时候,Thread-0进入methodB,这个时候需要再次获得锁,发现锁已经被“别人”拿走了,所以就在这里等啊等,等别人把锁送过来,其实这个锁就在它的兜里,Thread-0自己不知道,这就会出现死锁。这就是不可重入锁的问题。

对上面的MyLock稍加修改就可以实现锁的重入了

手写可重入锁

重入锁的实现原理是通过为每个锁关联一个请求计数器和一个占有它的线程。当计数为0时,认为锁是未被占有的;线程请求一个未被占有的锁时,JVM将记录锁的占有者,并且将请求计数器置为1 。如果同一个线程再次请求这个锁,计数将递增;每次占用线程退出同步块,计数器值将递减。直到计数器为0,锁被释放。

按照这个思路,我们修改一下MyLock:

package com.wuxiaolong.TestConcurrent;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-08-02
 */
public class MyLock2 implements Lock {

    /**
     * 定义一个变量,标记锁是否被使用
     */
    private boolean isLocked = false;


    /**
     * 第一次线程进来的时候,正在运行的线程为null
     */
    private Thread runningThread = null;

    /**
     * 计数器
     */
    private int count = 0;

    @Override
    public void lock() {

        Thread currentThread = Thread.currentThread();

        // 死循环判断,isLocked是否被使用,是不是同一个线程在占用,如果已经被占用并且不是同一个线程,则进入下一个循环尝试再次获得锁
        while(isLocked && currentThread != runningThread) {
            try {
                // 线程进入那个循环体里面,等待50毫秒后,再次尝试获得锁
               Thread.sleep(50);
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 将isLocked变量设置为true,表示本线程已经获得并占用了该锁;其他线程不能再获得锁,必须等待
        isLocked = true;
        // 记录是哪个线程占用了锁
        runningThread = currentThread;
        // 同一个线程每占用(重入)一次锁,计数器加1
        count++;
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {

        // 只用占用锁的线程才能释放锁
        if(runningThread == Thread.currentThread()) {
            // 该线程每释放一次锁,计数器减1
            count--;

            if(count == 0) {
                // 计数器为0时,才将锁的状态标志为未占用,正在运行的线程也设置为null
                isLocked = false;
                runningThread = null;
            }
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

使用这个锁的实现在次执行一下上面的main方法,结果:

Thread[Thread-0,5,main]A start
Thread[Thread-0,5,main]B start
Thread[Thread-0,5,main]B end
Thread[Thread-0,5,main]A end
Thread[Thread-1,5,main]A start
Thread[Thread-1,5,main]B start
Thread[Thread-1,5,main]B end
Thread[Thread-1,5,main]A end

这里可以看出,锁可重入后已经解决了死锁问题。

ReentrantLock如何实现锁的可重入

其实上面的重入锁在java的concurrent并发包中已经实现,比如:ReentrantLock、ReentrantReadWriteLock可以直接拿来使用。下面是ReentrantLock的简单使用:

package com.wuxiaolong.TestConcurrent;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Description:
 *
 * @author 诸葛小猿
 * @date 2020-08-02
 */
public class TestLock {

    /**
     * java的concurrent并发包中的ReentrantLock
     */
    public static Lock lock = new ReentrantLock();

    /**
     * 方法A 方法的开始和结束分别手动加锁、解锁
     */
    public static void methodA(){

        // 手动加锁
        lock.lock();

        Thread t = Thread.currentThread();

        System.out.println(t + "A start");
        try {
            Thread.sleep(1000);
            // 访问带有synchronized修饰的方法B
            methodB();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "A end");

        // 手动解锁 一般放在finally中
        lock.unlock();
    }

    /**
     * 方法B 方法的开始和结束分别手动加锁、解锁
     */
    public static void methodB(){

        // 手动加锁
        lock.lock();

        Thread t = Thread.currentThread();
        System.out.println(t + "B start");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(t + "B end");

        // 手动解锁 一般放在finally中
        lock.unlock();
    }

    public static void main(String[] args) {
        // 第一个线程,内部同时调用方法A,A内部调用方法B
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();

        // 第二个线程,内部同时调用方法A,A内部调用方法B
        new Thread(){
            public void run(){
                methodA();
            }
        }.start();
    }
}

执行的结果和上面我们手写的MyLock测试的结果是一样的。

ReentrantLock的实现机制是CAS,也就是compareAndSwap,比较和交换,核心源码如下:

compareAndSwap

CAS核心算法:执行函数:CAS(V,E,N)

V表示准备要被更新的变量 
E表示我们提供的 期望的值
N表示新值 ,准备更新V的值

算法思路:V是共享变量,我们拿着自己准备的这个E,去跟V去比较,如果E == V ,说明当前没有其它线程在操作,所以,我们把N 这个值 写入对象的 V 变量中。如果 E != V ,说明我们准备的这个E,已经过时了,所以我们要重新准备一个最新的E ,去跟V 比较,比较成功后才能更新 V的值为N。

在上面的源码中,可以看到Java提供了一个Unsafe类,其内部方法操作可以像C的指针一样直接操作内存,方法都是native的。

Unsafe

为了让Java程序员能够受益于CAS等CPU指令,JDK并发包中有一个Atomic包,它们是原子操作类,它们使用的是无锁的CAS操作,并且统统线程安全。Atomic包下的几乎所有的类都使用了这个Unsafe类。

关注公众号,输入“java-summary”即可获得源码。

完成,收工!!

传播知识,共享价值】,感谢小伙伴们的关注和支持,我是【诸葛小猿】,一个彷徨中奋斗的互联网民工。