Java教程

一篇文章全面掌握 Java 中的锁概念

本文主要是介绍一篇文章全面掌握 Java 中的锁概念,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

前言

本文隶属于专栏《100个问题搞定Java并发》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和参考文献请见100个问题搞定Java并发

正文

在这里插入图片描述

公平锁 / 非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

就和人排队一样,对于申请锁的线程是公平的,先到先得。

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。

有可能,会造成优先级反转或者饥饿现象。

synchronized 是一种典型的非公平锁。

ReentrantLock

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。

请参考我的这篇博客——ReentrantLock 是什么?怎么用?

非公平锁的优点在于吞吐量比公平锁大。

可重入锁 / 不可重入锁

可重入锁

广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。

ReentrantLock 和 synchronized 都是可重入锁

不可重入锁

不可重入锁,与可重入锁相反,不可递归调用,递归调用就发生死锁。

独占锁 / 共享锁

独占锁

该锁每一次只能被一个线程所持有。

共享锁

该锁可被多个线程共有,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。另外读锁的共享可保证并发读是非常高效的,但是读写和写写,写读都是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

synchronized 是典型的独占锁。

互斥锁 / 读写锁

互斥锁

在访问共享资源之前对进行加锁操作,在访问完成之后进行解锁操作。

加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。

如果解锁时有一个以上的线程阻塞,那么所有该锁上的线程都被编程就绪状态, 第一个变为就绪状态的线程又执行加锁操作,那么其他的线程又会进入等待。

在这种方式下,只有一个线程能够访问被互斥锁保护的资源。

读写锁

读写锁既是互斥锁,又是共享锁,read模式是共享,write是互斥(排它锁)的。

读写锁有三种状态:

  • 读加锁状态
  • 写加锁状态
  • 不加锁状态

读写锁在Java中的具体实现就是ReadWriteLock

请参考我的这篇博客——ReadWriteLock 是什么?怎么用?

一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。

只有一个线程可以占有写状态的锁,但可以有多个线程同时占有读状态锁,这也是它可以实现高并发的原因。

当其处于写状态锁下,任何想要尝试获得锁的线程都会被阻塞,直到写状态锁被释放;

如果是处于读状态锁下,允许其它线程获得它的读状态锁,但是不允许获得它的写状态锁,直到所有线程的读状态锁被释放;

为了避免想要尝试写操作的线程一直得不到写状态锁,当读写锁感知到有线程想要获得写状态锁时,便会阻塞其后所有想要获得读状态锁的线程。

所以读写锁非常适合资源的读操作远多于写操作的情况。

乐观锁 / 悲观锁

悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。

传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。

乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

CAS

CAS是英文单词 Compare and Swap(比较并交换)或者 Compare and Set(比较并设置)的缩写,是一种有名的无锁算法。

无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。

CAS算法涉及到三个操作数:

  1. 需要读写的内存值 V
  2. 进行比较的值 A
  3. 拟写入的新值 B

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。

一般情况下是一个自旋操作,即不断的重试。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。 在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。 所以对于可伸缩性来说最大的威胁就是独占锁。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。

其实说的简单一点就是:

容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是 ConcurrentHashMap(JDK7) 所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

比如:在 ConcurrentHashMap(JDK7) 中使用了一个包含 16 个锁的数组,每个锁保护所有散列桶的 1 / 16 ,其中第 N 个散列桶由第( N mod 16 )个锁来保护。

假设使用合理的散列算法使关键字能够均匀的分部,那么这大约能使对锁的请求减少到越来的 1 / 16 。

也正是这项技术使得 ConcurrentHashMap (JDK7) 支持多达 16 个并发的写入线程。

关于锁分段技术请参考我的这篇博客——结合 JDK 源码学习提高锁性能的技巧

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

锁的状态:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

锁的状态是通过对象监视器在对象头中的字段来表明的。

四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级

这四种状态都不是Java语言中的锁,而是JVM为了提高锁的获取与释放效率而做的优化(使用synchronized时)。

请参考我的这篇博客——Java虚拟机是怎么实现synchronized的?

偏向锁

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。

偏向锁可以降低获取锁的代价。

轻量级锁

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

自旋锁

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

它是为实现保护共享资源而提出一种锁机制。

其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。

无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。

但是两者在调度机制上略有不同。

对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。

但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

线程唤醒的开销太大,所以在短时间内还不如空跑一会,说不定等下就轮到你了。。

自旋锁与互斥锁对比

  1. 自旋锁与互斥锁都是为了实现保护资源共享的机制。
  2. 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者。
  3. 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放。

自旋锁存在的问题

1、如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。

2、自旋锁自身不是公平的,即无法满足等待时间最长的线程优先获取锁。

不公平的锁就会存在“线程饥饿”问题。

自旋锁的优点

1、自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快

2、非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。

线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能

自旋锁总结

  1. 自旋锁:线程获取锁的时候,如果锁被其他线程持有,则当前线程将循环等待,直到获取到锁。
  2. 自旋锁等待期间,线程的状态不会改变,线程一直是用户态并且是活动的(active)。
  3. 自旋锁如果持有锁的时间太长,则会导致其它等待获取锁的线程耗尽CPU。
  4. 自旋锁本身无法保证公平性,同时也无法保证可重入性。
  5. 基于自旋锁,可以实现具备公平性和可重入性质的锁。
这篇关于一篇文章全面掌握 Java 中的锁概念的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!