Java并发-锁

17年10月份接触的java并发,距离现在也有一段时间了,对锁这一块一直处于非常迷茫疑惑的状态,一是因为有些概念比较抽象,二是名词性的东西太多,没有一个整体的理解很难去区分不同的名词和概念,因此写篇博客把锁想过知识点整理一下。


从代码的层面来划分锁,其实很简单,Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 Lock。Lock又可以再细分为ReentrantLock和ReadWriteLock。一般会将synchronized与ReentrantLock进行对比。

synchronized

synchronized是jvm自带的关键字,不需要引入任何包即可使用,可分为以下四种用法:

  • 同步代码块
public void func() {
    synchronized (this) {
        // ...
    }
}

作用于对象,只有调用同一对象的同步代码块才会同步。

  • 同步方法
public synchronized void func () {
    // ...
}

和同步代码块作用范围一致。

  • 同步类
public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

作用于类,即使调用同一个类实例化的不同对象也会同步。

  • 同步静态代码块
public synchronized static void fun() {
    // ...
}

和同步类的作用范围一致。

对象锁 & 类锁

在synchronized中,从上诉四种使用方式可以看出,若按作用范围可以划分为对象锁和类锁。对象锁和类锁只是为了更好的理解synchronized而起的别名。

锁优化

这里的锁优化主要是指 JVM 对 synchronized 的优化。主要是为了提升
synchronized的性能。

自旋锁

使用synchronized进行同步时,如果出现了并发必然有线程会阻塞,而线程必须从运行态转换为阻塞态。学过操作系统的人都知道,线程调度是需要CPU在用户态和内核态之间进行切换的,而这种切换开销很大。同时,根据统计,在实际的业务场景中,共享数据的锁定状态只会持续很短的一段时间。因此,自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。

自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,因此自旋时间必须设定范围。

在 JDK 1.6 中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。

锁消除

锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。

对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:

public static String concatString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

每个 append() 方法中都有一个同步块(synchronized标识)。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。

锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。

上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。

重量级锁

前文解释了synchronized的实现和运用,了解monitor的作用,但是由于monitor监视器锁的操作是基于操作系统的底层Mutex Lock实现的,对所要加锁线程加上互斥锁,但是加锁时间相比其他指令就长很多了,因此将这种基于互斥锁的加锁机制成为重量级锁。

轻量级锁

在某些情况下,synchronized区域不存在竞争,依然按照重量级锁的方式运行,会无端消耗资源,因此JDK1.6之后引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

以下是 HotSpot 虚拟机对象头的内存布局,这些数据被称为 Mark Word。其中 tag bits 对应了五个状态,这些状态在右侧的 state 表格中给出。除了 marked for gc 状态,其它四个状态已经在前面介绍过了。

bb6a49be-00f2-4f27-a0ce-4ed764bc605c.png

下图左侧是一个线程的虚拟机栈,其中有一部分称为 Lock Record 的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的 Mark Word。而右侧就是一个锁对象,包含了 Mark Word 和其它信息。

051e436c-0e46-4c59-8f67-52d89d656182.png

轻量级锁是相对于传统的重量级锁而言,它使用 CAS 操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步,如果 CAS 失败了再改用互斥量进行同步。

当尝试获取一个锁对象时,如果锁对象标记为 0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record,然后使用 CAS 操作将对象的 Mark Word 更新为 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态。

baaa681f-7c52-4198-a5ae-303b9386cf47.png

如果 CAS 操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。

390c913b-5f31-444f-bbdb-2b88b688e7ce.jpg

ReentrantLock

ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。J.U.C包时JAVA 5.0 提供的工具包,包含了目前并发编程经常使用的各种工具类。

public class LockExample {

    private Lock lock = new ReentrantLock();

    public void func() {
        lock.lock();
        try {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        } finally {
            lock.unlock(); // 确保释放锁,从而避免发生死锁。
        }
    }
public static void main(String[] args) {
    LockExample lockExample = new LockExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> lockExample.func());
    executorService.execute(() -> lockExample.func());
}
}

输出:

0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9

比较

1.锁的实现

synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。

2.性能

新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。

3.等待可中断

当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。

ReentrantLock 可中断,而 synchronized 不行。

4.公平锁

公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。

synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。

5.锁绑定多个条件

一个 ReentrantLock 可以同时绑定多个 Condition 对象。

ReadWriteLock

ReadWriteLock管理一组锁,一个是只读的锁,一个是写锁。读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。

读写锁的机制:

  • "读-读" 不互斥
  • "读-写" 互斥
  • "写-写" 互斥

Java并发包中ReadWriteLock是一个接口,主要有两个方法,如下:

public interface ReadWriteLock {
/**
 * 返回读锁
 */
Lock readLock();

/**
 * 返回写锁
 */
Lock writeLock();
}

Java并发库中ReetrantReadWriteLock实现了ReadWriteLock接口并添加了可重入的特性。

线程进入读锁的前提条件:

  1. 没有其他线程的写锁
  2. 没有写请求,或者有写请求但调用线程和持有锁的线程是同一个线程

线程进入写锁的前提条件:

  1. 没有其他线程的读锁
  2. 没有其他线程的写锁

锁分类(概念)

公平锁 & 非公平锁

在ReentrantLock中很明显可以看到其中同步包括两种,分别是公平的FairSync和非公平的NonfairSync。公平锁的作用就是严格按照线程启动的顺序来执行的,不允许其他线程插队执行的;而非公平锁是允许插队的。

默认情况下ReentrantLock是通过非公平锁来进行同步的,包括synchronized关键字都是如此,因为这样性能会更好。

可重入锁 & 不可重入锁

  • 可重入锁指同一个线程可以再次获得之前已经获得的锁。
  • 可重入锁可以在某种程度上避免死锁。
  • Java中的可重入锁:synchronized 和java.util.concurrent.locks.ReentrantLock

基本我们使用的都是可重入锁,只是强调一下可重入的概念,不需太过纠结。

互斥锁 & 共享锁

  • 互斥锁:同时只能有一个线程获得锁。比如,ReentrantLock 是互斥锁,ReadWriteLock 中的写锁是互斥锁。
  • 共享锁:可以有多个线程同时或的锁。比如,Semaphore、CountDownLatch 是共享锁,ReadWriteLock 中的读锁是共享锁。

乐观锁 & 悲观锁

简而言之:

  • 悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
  • 乐观锁:假设不会发生并发冲突,只在提交操作时检查是否违反数据完整性。乐观锁最典型的例子就是CAS,大量的运用于并发包中的原子类(eg.AutomicInteger,AutomicLong,AutomicBoolean)

CAS(Compare And Swap)

CAS(Compare And Swap),即比较并交换。是解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

参考

CyC2018/CS-Notes

你可能感兴趣的:(Java并发-锁)