Java锁


在程序世界中,Java 锁的最终目的都是为了保证线程安全,避免在并发场景下的资源竞争问题。但考虑适用场合和性能问题,就出现了各种 Java 锁

独占锁和共享锁

从字面意思也能大概理解这两种锁的作用,独占锁是指该锁一次只能被一个线程所持有(独占),共享锁是指该锁可以被多个线程所持有(共享)

ReentrantLockSynchronized 都是独占锁,而对于 ReentrantReadWriteLock ,其读锁是共享,写锁是独占,即读的时候能多个线程同时读,写的时候只能一个线程写

为什么会有读锁和写锁?

原来我们使用 ReentrantLock 创建锁的时候,是独占锁,一次只能一个线程访问。但是读取数据并不会造成数据不一致的问题,因此可以多个服务器多个线程同时读共享资源里面的内容,这对读取操作比较多的业务可以大大提升并发性能。但为了保证数据一致性,如果一个线程想去写共享资源,就不应该再有其它线程可以对该资源进行读或写。

因此 ReentrantReadWriteLock 应满足:读和读能共存,读和写不能共存,写和写不能共存

现在我们用读写锁实现一个简单的读写缓存的操作:

class MyCacheP{
    // 缓存中的东西,需保持可见性,用volatile修饰
    private volatile Map<String, Object> map = new HashMap<>();

    // 创建一个读写锁
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    // 定义写入缓存操作
    public void put(String key, Object value){
        // 创建一个写锁
        rwLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t 正在写入:" + key);
            try {
                // 模拟网络拥堵,延迟0.3秒
                TimeUnit.MILLISECONDS.sleep(300);
            } catch (Exception e) {
                e.printStackTrace();
            }
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "\t 写入完成");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    // 定义读取缓存操作
    public void get(String key){
        rwLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "\t 正在读取");
            try {
                // 模拟网络拥堵,延迟0.3秒
                TimeUnit.MILLISECONDS.sleep(300);
            } catch (Exception e) {
                e.printStackTrace();
            }
            Object value = map.get(key);
            System.out.println(Thread.currentThread().getName() + "\t 读取完成,对应值为:" + value);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            rwLock.readLock().unlock();
        }
    }
}

有关加锁和没加锁的区别可以去我的 gitee 仓库查看完整代码

公平锁和非公平锁

公平锁: 是指多个线程按照申请锁的顺序来获取锁,先来后到,先来先服务,类似于队列,是公平的
非公平锁:是指多个线程获取锁的顺序,并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。在高并发环境下,有可能造成优先级翻转,或者饥饿的线程(也就是某个线程一直得不到锁)

JUC 包下 ReentrantLock 锁对象的创建可以指定参数 fair 类型来创建公平锁或者非公平锁,默认为 false,是非公平锁,设置为 true 则为公平锁,ReentrantLock 默认为非公平锁的原因是非公平锁比公平锁的吞吐量大,并发性能高。

synchronized 是一种非公平锁

可重入锁和递归锁

可重入锁就是递归锁,指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取到该锁,同一线程外层方法获取锁之后,进入内层方法会自动获取该锁。也就是说:线程可以进入任何一个它已经拥有的锁所同步的代码块
ReentrantLocksynchronized 就是一个典型的可重入锁,可重入锁的最大作用就是避免死锁

自旋锁

自旋锁:spinlock,是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
之前说的比较并交换(CAS),底层使用的就是自旋,自旋就是多次尝试,多次访问,不会阻塞的状态就是自旋

手写自旋锁

下面通过模仿 AtomicIntegergetAndAddInt() 方法的实现来实现自已自旋锁

getAndAddInt() 方法的源码如下,从中可以看出它的实现原理也是自选,不断尝试直到成功为止

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

自旋锁实现Demo:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

public class SpinLockDemo {
    // 原子引用线程,保证可见性
    AtomicReference<Thread> atomicReference = new AtomicReference<>();

    // 上锁
    public void myLock(){
        // 获取当前进来的线程
        Thread thread = Thread.currentThread();
        System.out.println(Thread.currentThread().getName() + "\t come in...");

        // 开始自旋,期望值是null,更新值是当前线程,如果是null,则更新为当前线程,否则自旋
        while(!atomicReference.compareAndSet(null, thread)){

        }
    }

    // 解锁
    public void myUnLock(){
        // 获取当前进来线程
        Thread thread =  Thread.currentThread();

        // 变为null
        atomicReference.compareAndSet(thread, null);

        System.out.println(Thread.currentThread().getName() + "\t invokes myUnLock()...");
    }

    public static void main(String[] args) {
        SpinLockDemo spinLockDemo = new SpinLockDemo();

        new Thread(() -> {
            // 开始占用锁
            spinLockDemo.myLock();
            try {
                TimeUnit.MILLISECONDS.sleep(3000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            // 开始释放锁
            spinLockDemo.myUnLock();
        }, "t1").start();

        // 让main线程暂停1秒,使得t1线程先执行
        try {
            TimeUnit.MILLISECONDS.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            // 开始占有锁
            spinLockDemo.myLock();
            // 释放锁
            spinLockDemo.myUnLock();
        }, "t2").start();
    }
}

乐观锁和悲观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

这具体可以看 JavaGuide 里的文章 乐观锁和悲观锁详解

最后分享一个面试题目:一道题决定去留:为什么synchronized无法禁止指令重排,却能保证有序性?

参考

  • 尚硅谷 Java 大厂面试题第 2 季
  • JavaGuide(Java面试+学习指南)

文章作者: 不才叶某
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 不才叶某 !
评论
  目录