Java 的锁

Java 中的锁有三类,一种是关键字 Synchronized,一种是对象 lock,还有一种 volatile 关键字。

  • Synchronized 用于代码块或方法中,他能是一段代码处于同步执行。
  • lock 跟 synchronized 类似,但需要自行加锁和释放锁。必须要手动释放锁,不然会造成死锁。
    • lock 比 synchronized 更有优势,因为他比 synchronized 多了嗅探锁定,多路分支通知,判断锁的状态等功能。
    • 嗅探锁定:lock 可以使用 tryLock() 方法尝试获取锁,若获取不到就继续执行,不会造成线程的阻塞。而 synchronized 只能进入阻塞他。
    • 多路分支通知:lock 可以创建多个 condition,然后可以将线程对应一个 condition,当要唤醒此线程时可以用对应的 condition 来唤醒。
  • volatile 作用范围小,只作用在一个变量上。volatile 具有以下三个特性:
    • 可见性:他会从工作内存中复制一份到主内存中,并且每次更新也会随之更新到主内存。当不同线程需要获取其值时,可以从主内存中获取,从而达到一致性。
    • 原子性:这里的原子性是指在基本操作下(读,取)保持原子性。若在复合操作下(v++)则没有具备原子性。
    • 禁止指令重排:添加了 volatile 关键字的变量,其前面的代码不能运行在此变量后,在后面的代码不能运行在此变量前。
    • volatile 实际上并不是锁,不具备加锁,阻塞等操作。他使用的方式是根据 volatile 对象是否变化来判断接下来如何执行。
    • volatile 也存在缺陷,有时在改变变量时可能还会取到先前的值,但这是非常小的小概率事件。

Java 的锁机制

1. 公平锁/非公平锁

公平锁就是获得锁的顺序按照先到先得的顺序。当一个线程或的锁并没有是否,接下来的线程就会进入阻塞队列等待,并按照队列的方式顺序的获取锁。

非公平锁就是新来的线程可以跟阻塞队列的队头争夺一把锁,争夺不过才会添加到队尾。这种情况下,后到的线程有可能无需进入等待队列直接获取锁。

Synchronized 和 lock 默认都是非公平锁。lock 可以通过构造函数的方式改为公平锁。

非公平锁性能高于公平锁。因为当一个线程执行完释放锁时,阻塞的线程需要被唤醒,这个过程有些漫长。在等待的时间如果有一个活跃的线程想争夺这把锁,就把锁让给他,减少等待的时间。

2. 乐观锁/悲观锁

乐观锁和悲观锁是一种概念。

乐观锁:很乐观,每次拿数据时都认为数据没有被修改,所以先不加锁。通过判断其版本号来判断此数据是否发生改变。

悲观锁:很悲观,每次拿数据时都认为别人会修改数据,这时就要上锁来阻止别人进行修改。

乐观锁一般采用 CAS(compare and swapper)比较并交换的方式来实现。

  • 使用 volatile 关键字修饰的变量作为版本号,这是因为 volatile 具有可见性。

实现思路:参考 AtomicInteger 的实现:

  • 先通过 get() 方式从内存中获取到变量的原先值,(这个值当成版本号)。
  • 接下来修改值时调用 unsafe 里的 compareAndSwapper() 方法。
  • 该方法需要传入内存中的基址,偏移量,旧值(版本号),更新的值)
  • 如果旧值和内存里的值一样,就进行交换,如果不一样,说明被人改了,则停止交换,返回 false。
  • 交换失败后若还想改变,则必须重新 get() 内存里的新值,在进行 CAS。

上述有种缺陷:因为该思路将自身值作为版本号,可以任意改变,而正常的版本号是不断增加的。造成的问题:

  • 若两个线程同时从内存种取值,取到都是 A。
  • 第一个线程停止,第二个线程把 A 换为 B。
  • 再来一个线程,把 B 重新换回 A。
  • 这时第一个线程执行,他会认为此变量根本没有发生变化。

解决方法:不把自身作为版本号,而是再新建一个字段作为版本号,此版本只能增加,不能回溯。但此时只是版本号来进行 CAS,而需要同步的变量只是做普通的改变,这也会造成并发异常。解决方法还是得加锁。

if (version == UNSAFE.getObject()){ // 版本号相比较,比较成功修改
    synchronized(对象){
        // 对象赋值    // 赋值完再更改版本号
        // 更改版本号
    }
}

Java的锁机制全面解析-LMLPHP

悲观锁就比较暴力,直接加锁。

3. 自旋锁

由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短。所以当一个线程需要等到锁时没有必要挂起,因为用户态和内核态之间的切换十分影响性能。

自旋的利用 CAS 操作,比较版本号是否相同,如果相同则得到所,不相同就一直循环获取锁,让其处于活跃态,从而不用挂起线程。

Java的锁机制全面解析-LMLPHP

4. 自适应自旋锁

由于一直循环也十分耗费资源。自旋的时间并不是固定的,于是采取了一种方法,当超过了时间就不再进行循环,而是直接将线程挂起。

jdk1.7 中 concurrentHashMap 添加就是采用此操作。

private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
    HashEntry<K,V> first = entryForHash(this, hash);   // 得到链表的第一个节点
    HashEntry<K,V> e = first;
    HashEntry<K,V> node = null;
    int retries = -1;      // 重复尝试
    while (!tryLock()) {         // 自旋操作,没有获取到锁
        HashEntry<K,V> f; 
        if (retries < 0) {
            if (e == null) {         // 首节点为 null
                if (node == null) 
                    node = new HashEntry<K,V>(hash, key, value, null);   // 给 node 创建对象
                retries = 0;     // 重复尝试
            }
            else if (key.equals(e.key))   // 添加的节点已存在
                retries = 0;
            else
                e = e.next;
        }
        else if (++retries > MAX_SCAN_RETRIES) {
            // 如果尝试次数大于默认的最大尝试次数,就使用 lock 阻塞。减少资源消耗,自适应自旋
            lock();
            break;
        }
        else if ((retries & 1) == 0 && (f = entryForHash(this, hash)) != first) {
            // 判断首节点是否已经改变,已经改变
            e = first = f;   // 更换首节点
            retries = -1;    // 重新进行尝试,查看当前线程添加的节点是否是新添加的节点
        }
    }
    return node;    // 获得锁时退出循环,并返回此节点
}
复制代码

5. 可重入锁

可重入锁就是当线程已获取到了 A 锁,当在执行阶段又需要获取 A 锁,并不会因为 A 锁被人拿走了而进行阻塞,而是因为自己有此锁继续执行。

Synchronized 和 ReentrantLock 都是可重入锁,只不过 Synchronized 自动获取和自动释放锁。ReentrantLock 得手动获取和释放,并且获取锁的次数必须和释放锁的次数相同,否则会造成死锁。

6. 读写锁(共享锁/互斥锁)

ReentrantLock 类具有完全互斥的效果,同一时间只有一个线程在执行,效率低下。

JDK 提供了一种读写锁 -- ReentrantReadWriteLock 类,使用它可以在进行一些操作时不需要同步执行,提高效率。

读锁之间不互斥,读锁和写锁互斥,写锁和写锁互斥(只要出现写锁就互斥)。

Synchronized 的锁机制

从 JDK1.6 版本后,Synchronized 本身也在不断优化锁的机制,有些情况下它并不是一个很重量级的锁。优化机制包括自适应锁,自旋锁,轻量级锁,重量级锁。

Java 对象头

java 对象分为三部分:对象头,实体数据,对齐填充符。

  • 对象头:
    • Mark Word
      • 对象的 HashCode
      • 分代年龄
      • GC 标记
      • 锁的标记
    • 指向类的指针
    • 数组长度
  • 实例数据
  • 对齐填充符

在无锁的状态下,Mark Word 会记录:对象的 HashCode,分代年龄,是否是偏向锁,锁标志。

偏向锁

偏向锁的设计理念:

  • 由于每次进入和退出同步块都需要获取和释放锁,十分浪费资源。
  • 经过大量的验证,发现很多情况下都是同一个线程来获取锁。
  • 于是就理想化的让这个锁一直给这个线程。

要保证锁是由一个线程来获取,就必须在锁的对象头上添加此线程的 ID。于是偏向锁状态下,Mark Word 会记录:线程对象的 HashCode,分代年龄,是否偏向锁,锁标志。

执行流程:

  • 当锁第一次被线程获取,就将线程 Hashcode 添加到锁的对象头里。
  • 线程执行完后并不释放锁。
  • 当第二次获取锁,会先判断此线程是否和对象头记录的线程一致,一致的话就直接运行同步代码。
  • 若不一致,则锁会升级/膨胀,变成轻量级锁。

优点:在没有竞争或者只有一个线程使用锁的情况下,偏向锁节省了获取和释放锁对性能的损耗。

轻量级锁

轻量级锁状态下,Mark Word 会记录:指向线程栈中锁记录的指针,锁标志位。

  • 虚拟机会在线程栈中创建一块内存 Lock Record 来存放信息。(从锁的 Mark Word 中 copy)
  • 当线程要获取锁时,会进行 CAS 操作,将锁的 Mark Word 更新为指向栈中锁记录的指针。
  • 如果 CAS 操作成功,则表示该线程获取到锁。
  • CAS 失败,表示锁被别的线程获取到,采用自旋锁的方式来等待获取锁。

优点:避免在了线程的阻塞,当线程获取不到锁时,会进行自旋,而不会阻塞,造成系统调用内核态和用户态。

缺点:如果存在大量竞争,轻量锁采用的 CAS 和自旋操作会大量的消耗资源,程序的性能反而会下降。

适用场景:在没有多线程竞争uo少量线程竞争的前提下,使用轻量级锁会减少系统在用户态和内核态之间的转换,提高性能。

Java的锁机制全面解析-LMLPHP

重量级锁

重量级锁是依赖对象内部的 monitor 锁来实现的,而 monitor 又依赖操作系统的 MutexLock(互斥锁)来实现,所以重量级锁也称为互斥锁。

重量级锁需要阻塞线程,唤醒线程,释放锁,消耗资源很大。

作者:慢慢编程
链接: https://www.douban.com/note/797590013/
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处
04-01 05:48