Java并发编程基础—锁

Java并发编程基础—锁

锁的7大分类
  • 偏向锁/轻量级锁/重量级锁;特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态

    1)如果锁没有竞争,就不需要上锁了,打个标记即可,这就是偏向锁

    2)不存在实际竞争,或者竞争时间很短,用CAS就可以解决,这就是轻量级锁

    3)开销较大,会让申请却拿不到锁的进程陷入阻塞状态

    锁升级的路径:无锁→偏向锁→轻量级锁→重量级锁。

  • 可重入锁/非可重入锁;

    1)可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁。

    2)不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取

  • 共享锁/独占锁;

    1)共享锁指的是我们同一把锁可以被多个线程同时获得

    2)独占锁指的就是,这把锁只能同时被一个线程获得

  • 公平锁/非公平锁;

    1)公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁

    2)非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象

  • 悲观锁/乐观锁;

    1)悲观锁的概念是在获取资源之前,必须先拿到锁

    2)乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源

  • 自旋锁/非自旋锁;

    1)自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁

    2)非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等

  • 可中断锁/不可中断锁。

    1)在 Java 中,synchronized 关键字修饰的锁代表的是不可中断锁,一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理

    2)ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开

释放和获取monitor锁的时机

​ 每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。

同步代码块

​ synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令

  • monitorenter

    执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:

    a. 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。

    b. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。

    c. 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。

  • monitorexit

    monitorexit 的作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。

同步方法

​ 同步代码块是使用 monitorenter 和 monitorexit 指令实现的。而对于 synchronized 方法,并不是依靠 monitorenter 和 monitorexit 指令实现的,被 javap 反汇编后可以看到,synchronized 方法和普通方法大部分是一样的,不同在于,这个方法会有一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符,来表明它是同步方法。

​ 当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁

Synchronized和Lock的异同
相同点
  • synchronized 和 Lock 都是用来保护资源线程安全的。
  • 都可以保证可见性。
  • synchronized 和 ReentrantLock 都拥有可重入的特点。
不同点
  • 用法区别

    ​ synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this),也可以新建一个同步代码块并且自定义 monitor 锁对象;而 Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。

  • 加解锁顺序不同

    ​ 对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2,加解锁有一定的灵活度

  • synchronized 锁不够灵活

    ​ synchronized是重量级锁,获取不到锁的对象会陷入等待、阻塞。而Lock如果不想等待了可以放弃等待去做别的事

  • synchronized 锁只能同时被一个线程拥有, Lock 锁没有这个限制

  • 原理区别

    ​ synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁。Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的

  • 是否可以设置公平/非公平

    ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置。

  • 性能区别

    ​ 在 Java 5 以及之前,synchronized 的性能比较低,但是到了 Java 6 以后,发生了变化,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。

Lock锁

​ Lock 接口是 Java 5 引入的,Lock 并不是用来代替 synchronized 的,而是当使用 synchronized 不合适或不足以满足要求的时候,Lock 可以用来提供更高级功能的。

方法纵览
public interface Lock {
    //lock() 是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。并且不能忘记在 finally 中添加 unlock() 方法,以便保障锁的绝对释放。lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待,所以一般我们用 tryLock() 等其他更高级的方法来代替 lock()
    void lock();
    //除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。这个方法本身是会抛出 InterruptedException 的,所以使用的时候,如果不在方法签名声明抛出该异常,那么就要写两个 try 块
    void lockInterruptibly() throws InterruptedException;
    //尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true,否则返回 false,代表获取锁失败
    boolean tryLock();
    //tryLock() 的重载方法,区别在于 tryLock(long time, TimeUnit unit) 方法会有一个超时时间
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    //unlock() 方法,是用于解锁的
    void unlock();
    Condition newCondition();
}
公平锁和非公平锁

​ 公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。但需要注意这里的非公平并不是指完全的随机,不是说线程可以任意插队,而是仅仅“在合适的时机”插队(仅在持有锁线程释放锁的一瞬间)。

​ 这样是因为唤醒等待线程的开销是比较大的,如果执行的代码较少,就可以先让未阻塞的线程执行,当阻塞的线程被完全唤醒时,该线程已经执行完了。这是一个双赢的“局面”。提高了整体运行效率。公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件hasQueuedPredecessors() 为 false, 这个方法就是判断在等待队列中是否已经有线程在排队了。

​ 有一个特例需要我们注意,针对 tryLock() 方法,它不遵守设定的公平原则。因为它本身调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。

ReadWriteLock读写锁

​ 读写锁分为读锁和写锁,要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)

​ ReentrantReadWriteLock 是 ReadWriteLock 的实现类,最主要的有两个方法:readLock() 和 writeLock() 用来获取读锁和写锁。

​ 相比于 ReentrantLock 适用于一般场合,ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。

读写锁插队策略

​ 如果是公平锁,我们就在构造函数的参数中传入 true,如果是非公平锁,就在构造函数的参数中传入 false,默认是非公平锁。在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。

公平锁实现
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}
非公平锁实现
final boolean writerShouldBlock() {
    return false; // writers can always barge
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}
锁的升降级
public class CachedData {
 
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            //在获取写锁之前,必须首先释放读锁。
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
                if (!cacheValid) {
                    data = new Object();
                    cacheValid = true;
                }
                //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
                rwl.readLock().lock();
            } finally {
                //释放了写锁,但是依然持有读锁
                rwl.writeLock().unlock();
            }
        }
 
        try {
            System.out.println(data);
        } finally {
            //释放读锁
            rwl.readLock().unlock();
        }
    }
}

​ 这是一个非常典型的利用锁的降级功能的代码。你可能会想,我为什么要这么麻烦进行降级呢?我一直持有最高等级的写锁不就可以了吗?这样谁都没办法来影响到我自己的工作,永远是线程安全的。

​ 如果我们在刚才的方法中,一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也是没有必要的,因为我们只有一处修改数据的代码。后面我们对于 data 仅仅是读取。如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。

​ 所以,可以知道,只能从写锁降级为读锁,不能从读锁升级为写锁。

自旋锁
自旋锁的好处

自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。

AtomicLong 的实现

在 Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。

public final long getAndIncrement() {
    return unsafe.getAndAddLong(this, valueOffset, 1L);
}
public final long getAndAddLong (Object var1,long var2, long var4){
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));


    return var6;
}
适用场景

​ 自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。

JVM对锁的优化
自适应的自旋锁

自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定

public final long getAndAddLong(Object var1, long var2, long var4) {
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while(!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
 
    return var6;
}
锁消除

​ 一个锁消除的例子:经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

锁粗化

​ 如果我们释放了锁,紧接着什么都没做,又重新获取锁。其实这种释放和重新获取锁是完全没有必要的,如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除。不过,我们这样做也有一个副作用,那就是我们会让同步区域变大。

​ 这里的锁粗化不适用于循环的场景,仅适用于非循环的场景。锁粗化功能是默认打开的,用 -XX:-EliminateLocks 可以关闭该功能。

偏向锁/轻量级锁/重量级锁

​ 这三种锁是特指 synchronized 锁的状态的,通过在对象头中的 mark word 来表明锁的状态。在上面我们已经讲过。

锁的升级路径

​ 从无锁到偏向锁,再到轻量级锁,最后到重量级锁。结合前面我们讲过的知识,偏向锁性能最好,避免了 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。

在这里插入图片描述

JVM 默认会优先使用偏向锁,如果有必要的话才逐步升级,这大幅提高了锁的性能。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值