爆肝,Java中的锁你了解多少?

527 阅读17分钟

前言

你知道Java中有哪些锁吗? 或者说你在工作中用到过哪些锁?作为一个程序员,关于锁的问题你是绕不开的,无论是在实际开发中还是你去面试中都是一个比较高频的问题,因为我也是这么一路踩坑过来的,哈哈!本文将通过以下几点来分享一下我对锁的理解

  1. 锁是什么?
  2. 锁能干嘛?
  3. Java中的锁有哪些?在哪里?
  4. 锁怎么用?

锁是什么

锁的应用在我们生活中随处可见,电脑、手机,汽车,门锁等等。锁存在意义就是在于将某一物品或者说资源私有化,在锁住期间只能被你使用。在计算机中可以理解为锁是用来协调多个进程或线程并发访问某一资源的一种机制。当然这在java中也不例外

锁能干嘛

简单的说就是锁住资源让别人无法使用。

举个栗子:你和朋友某火锅店吃饭,去了之后发现生意非常火爆里面坐满了人,你只有等着哪桌先吃完走了,才能排到你。你可以把餐桌理解为临界区 (指的是一个访问共用资源的程序片段,而这些共用资源又无法同时被多个线程访问的特性) 把你和你的朋友理解为一个线程,因为现实中同一个餐桌不可能被两波不认识的人同时使用。

Java中的锁

锁的分类

我了解到的锁分类大概有:公平锁/非公平锁、乐观锁/悲观锁、自旋锁、轻量锁/重量锁、偏向锁,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,本文主要还是以公平和非公锁的使用方式展开来说。

公平锁

顾名思义,按照先来后到的顺序来给予锁

非公平锁

反之,不是按照先后顺序来给予锁,而是随机挑选一个进行分配,有可能这个拿到锁的线程是后来的

举个栗子:你去某营业厅办理业务,一堆人围在窗口前,业务员也顾不上谁先来谁后来,就随便选了一个人先办理了业务,这显然是不公平的,而公平的方式应该按照先后顺序排队办理。

有哪些锁

  • synchronized (非公平)Java关键字,同步锁,打娘胎里出来就有的
  • ReentrantLock (公平和非公平)可重入互斥,生于JDK1.5
  • ReadWriteLock (非公平)读写锁,生于JDK1.5,定义读锁和写锁的标准
  • ReentrantReadWriteLock 可(公平和非公平)的读写锁,生于JDK1.5,基于ReentrantLock、ReadWriteLock衍生出来的
  • StampedLock 官方说法他是一种基于能力的锁,具有三种用于控制读写访问的模式,生于JDK1.8,查阅资料说他提供了乐观读锁,据说是比ReadWriteLock并发性能更好

ReentrantLock、ReadWriteLock、ReentrantReadWriteLock、StampedLock 均来自道格·利(Doug Lea) 老师之手,别问我是怎么知道的,哈哈哈 !

在哪里

在JDK的 rt.jar 下的 java.util.concurrent.locks 中,喜欢看源码的同学应该很容易就找到了

锁怎么用

说了这么多大白话也告诉你有哪些锁了,是不是也到了该上点干货的时候了,那还等什么呢,上代码!!!

synchronized

一个打娘胎里带出来的关键字,实现线程同步,被他标记的方法会变成同步方法,也可以对某一块代码进行标记。

public class TestSynchronized {

    private static Long n = 0L;

    public static void main(String[] args) {

        //模拟5个线程 访问计数器
        for (int i = 1; i <= 5; i++) {

            Thread t = new Thread(() -> {
                System.out.println(getCurrentThreadName() + " --> " + LocalDateTime.now() + " --> 开始计算");

                calcNum();
//                syncCalcNum();
//                syncBlockCalcNum();
//                reentrySyncBlockCalcNum();

                System.out.println(getCurrentThreadName() + " --> " + LocalDateTime.now() + " --> 结果:" + n);
            });

            t.start();
        }
    }

    /**
     * 没有 synchronized 修饰 计数器方法
     *
     * @return 计数结果
     */
    public static long calcNum() {
        //这里等待模拟计数的执行过程
        sleep(1);
        n++;
        return n;
    }
    /**
     * 增加synchronized关键字,此方法变为同步方法
     *
     * @return 计数结果
     */
    public static synchronized long syncCalcNum() {
        sleep(1);
        n++;
        return n;
    }
    /**
     * 只给 n 单独加同步锁,效果与同步方法相同
     *
     * @return 计数结果
     */
    public static long syncBlockCalcNum() {
        sleep(1);
        //锁对象 多线程来竞争 n 时,只允许一个线程对 n 进行操作
        synchronized (n) {
            n++;
        }
        return n;
    }
    /**
     * 测试是否可重入
     *
     * @return 计数结果
     */
    public static long reentrySyncBlockCalcNum() {
        sleep(1);
        //锁对象 多线程来竞争 n 时,只允许一个线程对 n 进行操作
        synchronized (n) {
            synchronized (n) {
                ++n;
            }
        }
        return n;
    }
    /**
     * 睡眠方法,后续案例会多次使用到
     */
    private static void sleep(long timeout) {
        try {
            TimeUnit.SECONDS.sleep(timeout);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    /**
     * 获取当前线程的名称,后续案例会多次使用到
     */
    private static String getCurrentThreadName() {
        return Thread.currentThread().getName();
    }

}

calcNum()方法执行结果很明显在多个线程同时对 n 进行累加时,此时 n 在内存中的值还是0,没有发生变化,在线程并发计数时就会出现计数不准的问题。

image.png

syncCalcNum() 增加了synchronized 关键字将其变为同步方法,预期结果与执行结果一致 n=5 从执行结果可以看出计数器是一个线程执行完才会执行下一个线程的,这也是synchronized同步锁的特性。但为什么说它是非公平锁呢?细心的小伙伴看执行结果就会发现当5个线程同时进来时顺序是 T1、T3、T4、T2、T0 按道理来说返回结果时应该是与进入计数器时的顺序一致,很显然从打印结果来看并不是。

image.png

syncBlockCalcNum() 利用时synchronized 只对 n 进行了加锁,这种写法被称为同步代码块,也能起到与同步方法相同的作用 ,说到这里肯定会有同学对synchronized关键字产生好奇了,为什么加了它之后方法就会变为同步了呢?

image.png

其实synchronized的底层实现就是跟这个monitor 监视器锁有关的,你可以把它理解成一个计数器,Java中每一个对象都可以跟monitor 关联,当线程T1执行到 synchronized 方法或代码块会先获得一个monitorenter,如果monitor=0说明没有被占用此时会对monirot进行加1,此时线程T2进来时发现这个monitor已经是1,拿不到锁就会进入阻塞状态,直到线程T1释放锁之后monitorexit减1,此时monirot=0 线程T2才会再次尝试对monitor获取所有权。

image.png

小结

synchronized的可见性,可重入reentrySyncBlockCalcNum() 使用起来相对简单,不需要关系内部实现,只要加入关键字丢给JVM去帮你处理,但同时也会带一些问题例如效率比较低,因为没获取到锁的线程不会中断会阻塞住在此期间会不断的尝试加锁,而且也无法感知锁的获取。

ReentrantLock

可重入锁,JDK提供的锁,可以说是synchronized的增强版,底层实现了公平和非公平两种模式

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TestReentrantLock {

    private static Long n = 0L;

    public static void main(String[] args) {

        //默认是非公平锁,提供了一个带参的构造方法来实现公平和非公平
        ReentrantLock lock = new ReentrantLock();
        for (int i = 1; i <= 5; i++) {
            Thread t = new Thread(() -> {
                try {
                    //获取锁
                    lock.lock();
                    System.out.println(getCurrentThreadName() + " --> " + LocalDateTime.now() + " --> 开始计算");
                    //执行计数
                    calcNum();
                    System.out.println(getCurrentThreadName() + " --> " + LocalDateTime.now() + " --> 结果:" + n);
                } catch (Exception ignored) {

                } finally {
                    //释放锁
                    lock.unlock();
                }
            });
            t.start();
        }
    }

    /**
     * 计数器方法
     *
     * @return 计数结果
     */
    public static long calcNum() {
        n++;
        return n;
    }

    private static String getCurrentThreadName() {
        return Thread.currentThread().getName();
    }
}

实现公平和非公平的构造函数

//fair=true: 返回公平锁策略,false=非公平锁策略
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

奉上我看源码时画的ReentrantLock底层实现流程图,源码值得一看,有兴趣看源码的同学可以结合流程图来看。

ReentrantLock底层实现流程

丰富的API

ReentrantLock与synchronized比较

ReentrantLock比synchronized效率高,灵活、可感知锁的获取、可中断。但查阅了相关资料说JDK1.6后synchronized也会自旋,与其性能还是有一些差别的,那是不是所有加synchronized的地方都可以被ReentrantLock取代呢?其实不然,黑格尔说过存在即合理,哈哈哈!synchronized本身就是为多线程而生,synchronized使用起来更加简单,不需要你通过显示的加减锁,简化了Jav多线程开发,相比之下ReentrantLock使用起来复杂一些,因为你加完锁之后还需要考虑锁释放的场景,处理不当的话就会留下BUG。

小结

ReentrantLock的底层实现是遵循CAS原理的,通过自旋来循环调用CAS获取锁,效率高,灵活,可感知锁的获取,提供丰富的api可查看当前锁的状态、等待队列中的数量等等,可中断,加锁lock()和释放锁unlock() 必须成对出现,缺点是为防止死锁出现需要在finally 中进行锁的释放

ReentrantReadWriteLock

可重入读写锁,内部类方式实现了读锁和写锁,支持公平和非公平策略,读写分离时可使用它来控制,特性在读读模式下并行,读写模式下串行,写写模式下串行。下面会以案例的方式来验证它的这些特性。

读读模式下并行 模拟两个线程t-read-1、t-read-2 为了显示直观的效果,我在t-read-1线程中做了一个延迟来模拟后端可能遇到一些数据库或者缓存查询延迟或网络抖动问题,从结果来看t-read-2并没有等待t-read-1执行完再去支持,而是直接拿到结果就返回了,由此得出结论读读模式下是并行的

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestReadWriteLock {

    //默认一个初始值
    private static Long number = 1234L;

    public static Long getNumber() {
        return number;
    }

    public static void setNumber(Long number) {
        TestReadWriteLock.number = number;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

        //获取读锁实例
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

        // 读读模式 不互斥,结果是并行,互不影响
        new Thread(() -> {
            readLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 开始读操作...");
            Long number = getNumber();
            //这里睡2秒是为了模拟线程1可能会遇到一些延迟
            sleep(2);
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 读取到结果了,number=" + number);
            readLock.unlock();
        }, "t-read-1").start();

        new Thread(() -> {
            readLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 开始读操作...");
            Long number = getNumber();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 读取到结果了,number=" + number);
            readLock.unlock();
        }, "t-read-2").start();

    }

    private static void sleep(long timeout) {
        try {
            TimeUnit.SECONDS.sleep(timeout);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private static String getCurrentThreadName() {
        return Thread.currentThread().getName();
    }
}

结果

image.png

读写模式下串行 模拟三个线程 t-read-1、t-read-2、t-write-1 两个加读锁、一个加写锁;从结果来看t-read-1首先拿到读锁后开始读操作,t-write-1 写线程进来加写锁发现t-read-1正在读操作,此时t-write-1 进入阻塞等待状态,直到t-read-1线程释放读锁之后t-write-1立即进入加写锁开始写操作,一旦写操作加锁成功,t-read-2的读锁就必须等到写锁完全释放才开始介入。要注意的是我案例中的这种写法可能在某种程度上保证了线程先后进入的顺序,我想要论证的结果是读写是相互排斥,只要有读锁存在写锁一定是处于等待状态的,反过来也是一样的。

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestReadWriteLock {

    private static Long number = 1234L;

    public static Long getNumber() {
        return number;
    }

    public static void setNumber(Long number) {
        TestReadWriteLock.number = number;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

        //获取读锁实例
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

        //获取写锁实例
        ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

        //读写模式 对写互斥 正在被读取的数据,不能写
        //模拟读的线程1,读锁加锁成功后,t-write-1写锁会陷入阻塞中
        new Thread(() -> {
            readLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 加锁成功,开始读操作...");

            Long number = getNumber();
            sleep(2);
            readLock.unlock();

            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 读取到结果了,释放锁,number=" + number);
        }, "t-read-1").start();

        //模拟写的线程1
        new Thread(() -> {
            writeLock.lock();

            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 加锁成功,开始写操作...");
            setNumber(5555L);

            writeLock.unlock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 写入成功,释放锁");

        }, "t-write-1").start();
        
        //模拟读的线程2,如果t-write-1拿到写锁,这个读的先会进行阻塞
        new Thread(() -> {
            readLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 加锁成功,开始读操作...");
            Long number = getNumber();
            readLock.unlock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 读取到结果了,释放锁,number=" + number);

        }, "t-read-2").start();
    }

    private static void sleep(long timeout) {
        try {
            TimeUnit.SECONDS.sleep(timeout);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private static String getCurrentThreadName() {
        return Thread.currentThread().getName();
    }
}

结果

image.png

写写模式下串行 模拟两个写线程t-write-1、t-writ-2 为了显示效果同样我在 t-write-1 加了延迟,从结果可以看出 t-writ-2 是在等待 t-write-1 执行完写操作并释放了锁之后才开始介入的,由此可见写写模式下是串行的

import java.time.LocalDateTime;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class TestReadWriteLock {

    private static Long number = 1234L;

    public static Long getNumber() {
        return number;
    }

    public static void setNumber(Long number) {
        TestReadWriteLock.number = number;
    }

    public static void main(String[] args) {

        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

        //获取读锁实例
        ReentrantReadWriteLock.ReadLock readLock = lock.readLock();

        //获取写锁实例
        ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

        //写写模式下 互斥,属于串行
        //模拟写的线程1
        new Thread(() -> {
            writeLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 加锁成功,开始写操作...");
            //这里模拟写操作有一定的延迟,好看效果
            sleep(2);
            setNumber(5555L);
            writeLock.unlock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 写入成功,释放锁");
        }, "t-write-1").start();

        //模拟写的线程2
        new Thread(() -> {
            writeLock.lock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 加锁成功,开始写操作...");
            setNumber(6666L);
            writeLock.unlock();
            System.out.println(LocalDateTime.now() + " - " + getCurrentThreadName() + " -> 写入成功,释放锁");
        }, "t-write-2").start();
    }

    private static void sleep(long timeout) {
        try {
            TimeUnit.SECONDS.sleep(timeout);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private static String getCurrentThreadName() {
        return Thread.currentThread().getName();
    }
}

结果

image.png

小结

简单来说,读锁会阻塞写锁,但不会阻塞读锁,而写锁会阻塞读锁和写锁。

StampedLock

一种基于能力的锁,有三种模式来控制读写访问。StampedLock的状态包括版本和模式。 锁获取方法返回一个标记,表示并控制对锁状态的访问;这些方法的"try"版本可能会转而返回特殊值0来表示获取访问失败。锁释放和转换方法需要戳记作为参数,如果它们不匹配锁的状态,就会失败。

三种模式是:

 方法writeLock可能会阻塞等待独占访问,返回一个戳(可以理解为一个标记值),可以在方法unlockWrite中使用该戳来释放锁。也提供了tryWriteLock的非定时和定时版本。当锁以写模式持有时,不会获得读锁,所有乐观读验证都会失败。

 方法readLock可能会阻塞等待非独占访问,返回一个戳,可以在方法unlockRead中使用该戳来释放锁。也提供了tryReadLock的非定时和定时版本。

乐观读 方法tryOptimisticRead仅当锁当前未处于写模式时才返回一个非零戳。方法validate()如果锁在获取给定戳后没有以写模式获取,则返回true。(敲重点)这种模式可以被认为是读锁的一个非常弱的版本,可以被写入器在任何时候打破。对于较短的只读代码段使用乐观模式通常会减少争用并提高吞吐量。然而,它的使用本质上是脆弱的。乐观读取部分应该只读取字段,并将它们保存在局部变量中,以备验证后使用。在乐观模式下读取的字段可能非常不一致,所以只有当你足够熟悉数据表示来检查一致性和/或重复调用方法validate()时,这种用法才适用。例如,当首先读取一个对象或数组引用,然后访问它的一个字段、元素或方法时,通常需要这些步骤。

他的类还支持有条件地提供跨三种模式的转换的方法。例如,方法 tryConvertToWriteLock 尝试“升级”一种模式,如果 (1) 已经处于写入模式 (2) 处于读取模式并且没有其他读取器或 (3) 处于乐观模式并且锁可用。这些方法的形式旨在帮助减少在基于重试的设计中出现的一些代码膨胀。

StampedLocks设计用于在线程安全组件的开发中作为内部工具使用。 它们的使用依赖于它们所保护的数据、对象和方法的内部属性的知识。它们是不可重入的,因此锁定的实体不应该调用其他可能尝试重新获取锁的未知方法(尽管您可以将戳记传递给其他可以使用或转换它的方法)。读锁模式的使用依赖于相关的代码段是无副作用的。未经验证的乐观读取部分不能调用已知不能容忍潜在不一致性的方法。戳记使用有限的表示,并且不是加密安全的(例如,一个有效的戳记可能是可猜测的)。邮票价值可在(不超过)持续使用一年后再循环使用。未使用或验证而持有的戳记超过此期限可能无法正确验证。StampedLocks是可序列化的,但是总是反序列化到初始的解锁状态,所以它们对于远程锁定并不有用

官方示例地址:StampedLock

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock sl = new StampedLock();

    public void move(double deltaX, double deltaY) { // 独占锁的方法
        long stamp = sl.writeLock(); //加写锁,独占,必要时阻塞直到可用
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    public double distanceFromOrigin() { // 只读方法

        //返回可以稍后验证的标记,如果被独占锁定,则返回零
        long stamp = sl.tryOptimisticRead();
        double currentX = x, currentY = y;

        //如果自给定标记发行以来尚未独占获取锁,则返回 true。如果标记为零,则始终返回 false。如果标记表示当前持有的锁,则始终返回 true
        if (!sl.validate(stamp)) {
            stamp = sl.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }

    public void moveIfAtOrigin(double newX, double newY) { // upgrade
        // 可以改为从乐观而非阅读模式开始,返回一个可用于解锁或转换模式的标记
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) {
                //根据标记来判断 尝试转换为写锁
                long ws = sl.tryConvertToWriteLock(stamp);
                if (ws != 0L) {
                    stamp = ws;
                    x = newX;
                    y = newY;
                    break;
                } else {
                    sl.unlockRead(stamp);
                    stamp = sl.writeLock();
                }
            }
        } finally {
            sl.unlock(stamp);
        }
    }
}

补充应该知道的概念

什么是CAS

CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS需要有3个操作数,一个内存地址V、旧的的预期值A、新的预期值B;CAS指令执行时,也就是对变量进行修改时,内存地址V会和预期值A进行比较,如果相同才会将内存地址V中的值修改为预期值B,可以去JDK中的 java.util.concurrent.atomic 包下看一下这些原子类

AtomicInteger 计数器流程,可以拿他跟前面我用synchronized来实现的计数器做一下比较

image.png

什么是可重入锁

指的是同一个线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。简单来说,线程可以进入任何一个他已经拥有锁的所有同步代码块

总结

这里我对我Java的锁分为两种,synchronized和Lock

  1. 首先synchronized是java内置关键字属于jvm层面;而Lock是个java类
  2. synchronized无法从代码判断是否获取锁的状态;Lock可以根据代码判断是否获取到锁,并且还可以去主动尝试获取锁
  3. synchronized执行完会自动释放锁;Lock为了防止死锁需要在finally中释放unlock(虽然好用但是使用时需要谨慎)
  4. 用synchronized关键字的线程T1和线程T2同时访问,如果被T1占有T2就会一直阻塞等待,这是比较消耗资源的;而Lock就不一定一直等待下去了,如果一直尝试获取锁获取不到,线程也可以不用一直等待下去
  5. synchronized可重入、不可中断、非公平;Lock可重入、可中断、公平和非公平策略都有实现
  6. Lock锁适合大量同步的代码的同步问题,可自行中断降级在并发时比较友好,synchronized锁适合代码少量的同步问题,并发量大时可能会导致大量线程阻塞。

问题探讨

在日常使用锁的过程中你们遇到了哪些奇葩的问题?

哈哈哈,实在是肝不动了,能看到这里的同学那就是真爱无疑了,相信你一定是对技术精益求精的,希望这篇文章能对你有所帮助,以上案例和观点若有不对的地方,欢迎来评论区探讨!!!