并发-锁的介绍与实践

Q1:Java有哪几种锁?

在java技术中,尝尝听到下列关于锁的名词,在应用开发层面,都是基于synchronized和ReentrantLock的集成或实现。

  • 公平锁/非公平锁

    • 公平锁是指多个线程按照申请锁的顺序来获取锁。
    • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
    • 对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
    • 对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。
  • 可重入锁

synchronized void setA() throws Exception{
    Thread.sleep(1000);
    setB();
}

synchronized void setB() throws Exception{
    Thread.sleep(1000);
}
  • 独享锁/共享锁

    • 独享锁是指该锁一次只能被一个线程所持有。ReadWriteLock
    • 共享锁是指该锁可被多个线程所持有。Synchronized
  • 互斥锁/读写锁

    • 互斥锁在Java中的具体实现就是ReentrantLock
    • 读写锁在Java中的具体实现就是ReadWriteLock
  • 乐观锁/悲观锁

    • 乐观锁和悲观锁不是特定的锁,而是指看待并发同步的角度。
    • 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。悲观锁在Java中的使用,就是利用各种锁
    • 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
  • 分段锁

    • 分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
    • 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
  • 偏向锁/轻量级锁/重量级锁

    • 这三种锁是指锁的状态,并且是针对Synchronized。是JVM通过对象监视器在对象头中的字段来表明的。
  • 自旋锁

    • 在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
      public class SpinLock {
      
      private AtomicReference<Thread> sign =new AtomicReference<>();
      
      public void lock(){
          Thread current = Thread.currentThread();
          while(!sign .compareAndSet(null, current)){
         }
      }
      
      public void unlock (){
          Thread current = Thread.currentThread();
          sign .compareAndSet(current, null);
        }
      }

本回答参考:https://www.cnblogs.com/qifengshi/p/6831055.html


Q2:synchronized 和 ReentrantLock 有什么区别?

  • synchronized 是 Java 内建的同步机制,所以也有人称其为 Intrinsic Locking,它提供了互斥的语义和可见性,当一个线程已经获取当前锁时,其他试图获取的线程只能等待或者阻塞在那里。
  • ReentrantLock,通常翻译为再入锁,它的语义和 synchronized 基本相同。
    • 再入锁通过代码直接调用 lock() 方法获取。
    • 代码书写也更加灵活。能够实现很多 synchronized 无法做到的细节控制,比如可以控制 fairness,也就是公平性,或者利用定义条件等。
    • 必须要明确调用 unlock() 方法释放锁。

Q3:有人说 synchronized 最慢,这话靠谱吗?

早期版本 synchronized 在很多场景下性能相差较大,在后续版本进行了较多改进,在低竞争场景中表现可能优于 ReentrantLock。对于synchronized,JVM通过偏向锁/轻量级锁/重量级锁去优化实现,性能有很大改进。

Q4:讲一下线程安全:

  • 线程安全是一个多线程环境下正确性的概念,也就是保证多线程环境下共享的、可修改的状态的正确性。
  • 保证线程安全的两个办法:

    • 封装
    • 不可变
  • 线程安全需要几个基本特性:

    • 原子性
    • 可见性:volatile
    • 有序性:防止被指令重排序(JVM部分会总结到)

Q5:synchronized 底层如何实现?什么是锁的升级、降级?

  • synchronized 代码块是由一对儿 monitorenter/monitorexit 指令实现的,Monitor 对象是同步的基本实现。如果用 javap 反编译,可以看到类似片段,利用 monitorenter/monitorexit 对实现了同步的语义:
11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield      #2                  // Field sharedState:I
18: dup_x1
…
56: monitorexit
  • 在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
  • JDK6以后,JVM 对此提供了三种不同的 Monitor 实现。也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁。
  • 所谓锁的升级、降级,就是 JVM 优化 synchronized 运行的机制,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这种切换就是锁的升级、降级。
  • 当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作,在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏斜锁可以降低无竞争开销。
  • 如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
  • 当 JVM 进入安全点的时候,会检查是否有闲置的 Monitor,然后试图进行降级。

Q5 并发包里的其他锁,为什么我们需要读写锁(ReadWriteLock)等其他锁呢?:

这里写图片描述

  • ReadWriteLock 是一个单独的接口,它通常是代表了一对儿锁,分别对应只读和写操作,标准类库中提供了再入版本的读写锁实现(ReentrantReadWriteLock),对应的语义和 ReentrantLock 比较相似。
  • StampedLock 竟然也是个单独的类型,从类图结构可以看出它是不支持再入性的语义的,也就是它不是以持有锁的线程为单位。
  • ReentrantLock 和 synchronized 简单实用,但是行为上有一定局限性,通俗点说就是“太霸道”,要么不占,要么独占。有的时候不需要大量竞争的写操作,而是以并发读取为主,需要进一步优化锁操作的粒度。
  • Java 并发包提供的读写锁等扩展了锁的能力。
    • 基于的原理是多个读操作是不需要互斥的,因为读操作并不会更改数据,所以不存在互相干扰。
    • 而写操作则会导致并发一致性的问题,所以写线程之间、读写线程之间,需要精心设计的互斥逻辑。
public class RWSample {
    private final Map<String, String> m = new TreeMap<>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
    public String get(String key) {
        r.lock();
        System.out.println(" 读锁锁定!");
        try {
            return m.get(key);
        } finally {
            r.unlock();
        }
    }

    public String put(String key, String entry) {
        w.lock();
    System.out.println(" 写锁锁定!");
            try {
                return m.put(key, entry);
            } finally {
                w.unlock();
            }
        }
    // …
    }
  • 但在实际应用中,读写锁因为相对比较大的开销,所以JDK 在后期引入了 StampedLock,在提供类似读写锁的同时,还支持优化读模式。
public class StampedSample {
    private final StampedLock sl = new StampedLock();

    void mutate() {
        long stamp = sl.writeLock();
        try {
            write();
        } finally {
            sl.unlockWrite(stamp);
        }
    }

    Data access() {
        long stamp = sl.tryOptimisticRead();
        Data data = read();
        if (!sl.validate(stamp)) {
            stamp = sl.readLock();
            try {
                data = read();
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return data;
    }
    // …
}

本文来源于:https://time.geekbang.org/column/article/9042 文章的总结,老师讲的很棒。推荐看原文


还没掌握的知识点:


  1. AQS:https://www.cnblogs.com/waterystone/p/4920797.html
  2. 使用过 ReentrantLock 中的哪些方法呢?分别解决什么问题?

所有的Lock都是基于AQS来实现了。AQS和Condition各自维护了不同的队列,在使用lock和condition的时候,其实就是两个队列的互相移动。如果我们想自定义一个同步器,可以实现AQS。它提供了获取共享锁和互斥锁的方式,都是基于对state操作而言的。ReentranLock这个是可重入的。其实要弄明白它为啥可重入的呢,咋实现的呢。其实它内部自定义了同步器Sync,这个又实现了AQS,同时又实现了AOS,而后者就提供了一种互斥锁持有的方式。其实就是每次获取锁的时候,看下当前维护的那个线程和当前请求的线程是否一样,一样就可重入了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值