diff --git a/README.md b/README.md index 03be266b06ced2bb208738a6e0fd6077708036ef..c779f34e0ff23602783abe738f3b860f619fdf48 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,8 @@ ### 原创公众号 +![](https://itwanger-oss.oss-cn-beijing.aliyuncs.com/paicoding/1_995464436_171_85_3_731480121_1577da7e9942707dbf727407411f7288_dd680c35.png) + GitHub 上标星 8700+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 8700+ 的 Java 教程](https://javabetter.cn/overview/) diff --git a/docs/home.md b/docs/home.md index 6d5d47b9c01fb8921bb03b047e6216ab94430ae6..e81da65cf8637a13f3feb430817f9cb1b48c52eb 100644 --- a/docs/home.md +++ b/docs/home.md @@ -265,9 +265,8 @@ head: - [CAS详解](thread/cas.md) - [AQS详解](thread/aqs.md) - [JUC 包下的那些锁](thread/lock.md) -- [公司空降一个美团大佬,彻底把Java中的锁”讲清楚了](thread/suo.md) -- [Java 15 终于把难搞的偏向锁移除了](thread/pianxiangsuo.md) -- [深入理解Java并发重入锁ReentrantLock](thread/reentrantLock.md) +- [JDK15 移除了偏向锁](thread/pianxiangsuo.md) +- [重入锁ReentrantLock](thread/reentrantLock.md) - [深入理解Java并发读写锁ReentrantReadWriteLock](thread/ReentrantReadWriteLock.md) - [深入理解Java并发线程协作类Condition](thread/condition.md) - [深入理解Java并发线程线程阻塞唤醒类LockSupport](thread/LockSupport.md) diff --git a/docs/thread/lock.md b/docs/thread/lock.md index 9f3dce02636b5f563b735e5f5522f6d921188183..dbfef5f4b8b9a3200888af484a91119d80447255 100644 --- a/docs/thread/lock.md +++ b/docs/thread/lock.md @@ -28,28 +28,28 @@ head: ### 锁的几种分类 -Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。我们可以通过特性将锁进行分组归类。 +Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。我们可以通过特性将锁进行分组归类。 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-b2ded433-defd-4535-b767-fd2e5be0b5b9.png) #### 乐观锁 VS 悲观锁 -乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。 +乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在 Java 和数据库中都有此概念对应的实际应用。 -先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。 +先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java 中,[synchronized 关键字](https://javabetter.cn/thread/synchronized-1.html)和[Lock 的实现类](https://javabetter.cn/thread/lock.html)都是悲观锁。 -而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。 +而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会加锁,只是在更新数据的时候会去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。 -乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。 +乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是[CAS 算法](https://javabetter.cn/thread/cas.html),[Java 原子类](https://javabetter.cn/thread/atomic.html)中的递增操作就通过 CAS 自旋实现的。 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-840de182-83e2-4639-868a-bd5cc984575f.png) -根据从上面的概念描述我们可以发现: +根据上面的概念描述我们可以发现: -* 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。 -* 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。 +- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。 +- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。 -光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式示例: +光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式: ```Java // ------------------------- 悲观锁的调用方式 ------------------------- @@ -69,31 +69,30 @@ public void modifyPublicResources() { private AtomicInteger atomicInteger = new AtomicInteger(); // 需要保证多个线程使用的是同一个AtomicInteger atomicInteger.incrementAndGet(); //执行自增1 ``` - -通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。 +通过调用方式的举例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们这里再次来温习一下 [“CAS” 的技术原理](https://javabetter.cn/thread/cas.html),之前也讲过,就当是复习了。 -CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。 +CAS 全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。`java.util.concurrent`包中的原子类就是通过 CAS 实现的乐观锁。 -CAS算法涉及到三个操作数: +CAS 算法涉及到三个操作数: -* 需要读写的内存值 V。 -* 进行比较的值 A。 -* 要写入的新值 B。 +- 需要读写的内存值 V。 +- 进行比较的值 A。 +- 要写入的新值 B。 -当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。 +当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。 -之前提到java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,那么我们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义: +之前提到`java.util.concurrent`包中的原子类,就是通过 CAS 实现的乐观锁,那么我们进入原子类 AtomicInteger 的源码,来看一下 AtomicInteger 的定义: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-86e17b45-2993-48df-b7cd-ee86bb15c922.png) 根据定义我们可以看出各属性的作用: -* unsafe: 获取并操作内存的数据。 -* valueOffset: 存储value在AtomicInteger中的偏移量。 -* value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。 +- unsafe: 获取并操作内存的数据。 +- valueOffset: 存储 value 在 AtomicInteger 中的偏移量。 +- value: 存储 AtomicInteger 的 int 值,该属性需要借助 volatile 关键字保证其在线程间是可见的。 -接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码: +接下来,我们查看 AtomicInteger 的自增方法`incrementAndGet()`,发现自增方法底层调用的是`unsafe.getAndAddInt()`。但是由于 JDK 本身只有 Unsafe.class,通过 class 文件中的参数名,并不能很好地了解方法的作用,所以我们通过 OpenJDK 8 来查看 Unsafe 的源码: ```Java // ------------------------- JDK 8 ------------------------- @@ -121,29 +120,85 @@ public final int getAndAddInt(Object o, long offset, int delta) { return v; } ``` - -根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。 +根据 OpenJDK 8 的源码我们可以看出,`getAndAddInt()`循环获取给定对象 o 中的偏移量处的值 v,然后判断内存值是否等于 v。如果相等则将内存值设置为 v + delta,否则返回 false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。 -后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。 +整个“比较+更新”操作都封装在`compareAndSwapInt()`中,在 JNI 里是借助于一个 CPU 指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。 -CAS虽然很高效,但是它也存在三大问题,这里也简单说一下: +后续 JDK 通过 CPU 的 cmpxchg 指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过 Java 代码中的 while 循环再次调用 cmpxchg 指令进行重试,直到设置成功为止。 -1. **ABA问题**。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。 -* JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。 -2. **循环时间长开销大**。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。 -3. **只能保证一个共享变量的原子操作**。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。 -* Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。 +CAS 虽然很高效,但是它也存在三大问题,[我们前面也讲过](https://javabetter.cn/thread/cas.html),不知道大家还记得不: + +1. **ABA 问题**。CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么 CAS 进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在`compareAndSet()`中。`compareAndSet()`首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。 +2. **循环时间长开销大**。CAS 操作如果长时间不成功,会导致其一直自旋,给 CPU 带来非常大的开销。 +3. **只能保证一个共享变量的原子操作**。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。Java 从 1.5 开始 JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行 CAS 操作。 + +#### 自旋锁 VS 适应性自旋锁 + +阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。 + +在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复线程花费的时间可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃 CPU 的执行时间,看看持有锁的线程是否会很快释放锁。 + +为了让当前线程“稍等一下”,我们需要让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不用阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。 + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-be0964a8-856a-45c9-ab75-ce9505c2e237.png) + +自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用-XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。 + +自旋锁的实现原理同样也是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。 + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-0756521c-becf-4657-ab42-1973d74e9c73.png) + +自旋锁在 JDK1.4.2 中引入,使用`-XX:+UseSpinning`来开启。JDK 6 中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。 + +自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功的,进而它将允许自旋等待更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。 + +#### 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁 + +这四种锁是指锁的状态,专门针对 synchronized 的。我们在[synchronized 锁的到底是什么](https://javabetter.cn/thread/synchronized.html)一文中已经详细地介绍过,这里就不再赘述了。 #### 可重入锁和非可重入锁 -所谓重入锁,顾名思义。就是支持重新进入的锁,也就是说这个锁支持一个**线程对资源重复加锁**。 +可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提:锁的是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。Java 中[ReentrantLock](https://javabetter.cn/thread/reentrantLock.html)和[synchronized](https://javabetter.cn/thread/synchronized-1.html)都是可重入锁,可重入锁的一个优点就是可以一定程度避免死锁。下面用示例代码来进行分析: + +```Java +public class Widget { + public synchronized void doSomething() { + System.out.println("方法1执行..."); + doOthers(); + } + + public synchronized void doOthers() { + System.out.println("方法2执行..."); + } +} +``` + +在上面的代码中,类中的两个方法都是被内置锁 synchronized 修饰的,`doSomething()`方法中调用了`doOthers()`方法。因为内置锁是可重入的,所以同一个线程在调用`doOthers()`时可以直接获得当前对象的锁,进入`doOthers()`进行操作。 + +如果是一个不可重入锁,那么当前线程在调用`doOthers()`之前,需要将执行`doSomething()`时获取当前对象的锁释放掉,实际上该对象锁已经被当前线程所持有,且无法释放。所以此时会出现死锁。 + +那为什么可重入锁就可以在嵌套调用时自动获得锁呢? + +还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。 -synchronized 关键字使用的是重入锁。比如说,你在一个 synchronized 实例方法里面调用另一个本实例的 synchronized 实例方法,它可以重新进入这个锁,不会出现任何异常。 +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-35dc49bf-87c9-4133-b68f-269fb0508f75.png) -如果我们在继承 AQS 实现同步器的时候,没有考虑到占有锁的线程再次获取锁的场景,可能就会导致线程阻塞,那这个就是一个“非可重入锁”。 +但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。 -[ReentrantLock](https://javabetter.cn/thread/reentrantLock.html) 的中文意思就是可重入锁。 +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-08479ca1-5d43-475c-8592-9f183e52cc26.png) + +之前我们说过 ReentrantLock 和 synchronized 都是重入锁,那么我们通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。 + +首先[ReentrantLock](https://javabetter.cn/thread/reentrantLock.html)和 NonReentrantLock 都继承了父类[AQS](https://javabetter.cn/thread/aqs.html),其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。 + +当线程尝试获取锁时,可重入锁先尝试获取并更新 status 值,如果`status == 0`表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。如果`status != 0`,则判断当前线程是否获取到了这个锁,如果是的话执行`status+1`,且当前线程可以再次获取锁。 + +而非可重入锁是直接获取并尝试更新当前 status 的值,如果`status != 0`的话会导致其获取锁失败,当前线程阻塞。 + +释放锁时,可重入锁同样会先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果`status-1 == 0`,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为 0,将锁释放。 + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-d6e12a34-c889-45e1-83bf-a4d7e36eedde.png) #### 公平锁与非公平锁 @@ -155,15 +210,113 @@ ReentrantLock 支持非公平锁和公平锁两种。 #### 读写锁和排它锁 -我们前面讲到的 synchronized 用的锁和 ReentrantLock,其实都是“排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。 +我们前面讲到的 [synchronized](https://javabetter.cn/thread/synchronized.html) 用的锁和 [ReentrantLock](https://javabetter.cn/thread/reentrantLock.html),其实都是“排它锁”。也就是说,这些锁在同一时刻只允许一个线程进行访问。 而读写锁可以在同一时刻允许多个读线程访问。Java 提供了 [ReentrantReadWriteLock](https://javabetter.cn/thread/ReentrantReadWriteLock.html) 类作为读写锁的默认实现,内部维护了两个锁:一个读锁,一个写锁。通过分离读锁和写锁,使得在“读多写少”的环境下,大大地提高了性能。 > 注意,即使用读写锁,在写线程访问时,所有的读线程和其它写线程均被阻塞。 -**可见,只有 synchronized 是远远不能满足多样化的业务对锁的要求的**。接下来我们介绍一下 JDK 中有关锁的一些接口和类。 +排它锁也叫独享锁,如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。 + +与之对应的,就是共享锁,指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。 + +独享锁与共享锁也是通过[AQS](https://javabetter.cn/thread/aqs.html)来实现的,通过实现不同的方法,来实现独享或者共享。 -### JUC包下的那些锁 +下图为 ReentrantReadWriteLock 的部分源码(后面也会细讲): + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-baa93e76-ac90-4955-8955-50dabc6efbdd.png) + +我们看到[ReentrantReadWriteLock](https://javabetter.cn/thread/ReentrantReadWriteLock.html)有两把锁:ReadLock 和 WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现 ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是 AQS 的一个子类,这种结构在[CountDownLatch](https://javabetter.cn/thread/CountDownLatch.html)、[ReentrantLock](https://javabetter.cn/thread/reentrantLock.html)、Semaphore 里面也都存在。 + +在 ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以 ReentrantReadWriteLock 的并发性相比一般的互斥锁有了很大提升。 + +那读锁和写锁的具体加锁方式有什么区别呢? + +在了解源码之前我们需要回顾一下其他知识。 在最开始提及 AQS 的时候我们也提到了 state 字段(int 类型,32 位),该字段用来描述有多少线程持有锁。 + +在独享锁中,这个值通常是 0 或者 1(如果是重入锁的话 state 值就是重入的次数),在共享锁中 state 就是持有锁的数量。但是在 ReentrantReadWriteLock 中有读、写两把锁,所以需要在一个整型变量 state 上分别描述读锁和写锁的数量(或者也可以叫状态)。 + +于是将 state 变量“按位切割”切分成了两个部分,高 16 位表示读锁状态(读锁个数),低 16 位表示写锁状态(写锁个数)。如下图所示: + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-62e2bf55-452e-4353-9635-0ea368e355dd.png) + +了解了概念之后我们再来看代码,先看写锁的加锁源码: + +```Java +protected final boolean tryAcquire(int acquires) { + Thread current = Thread.currentThread(); + int c = getState(); // 取到当前锁的个数 + int w = exclusiveCount(c); // 取写锁的个数w + if (c != 0) { // 如果已经有线程持有了锁(c!=0) + // (Note: if c != 0 and w == 0 then shared count != 0) + if (w == 0 || current != getExclusiveOwnerThread()) // 如果写线程数(w)为0(换言之存在读锁) 或者持有锁的线程不是当前线程就返回失败 + return false; + if (w + exclusiveCount(acquires) > MAX_COUNT) // 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。 + throw new Error("Maximum lock count exceeded"); + // Reentrant acquire + setState(c + acquires); + return true; + } + if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) // 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。 + return false; + setExclusiveOwnerThread(current); // 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者 + return true; +} +``` + +- 这段代码首先取到当前锁的个数 c,然后再通过 c 来获取写锁的个数 w。因为写锁是低 16 位,所以取低 16 位的最大值与当前的 c 做与运算( `int w = exclusiveCount©;` ),高 16 位和 0 与运算后是 0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。 +- 在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为 0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。 +- 如果写入锁的数量大于最大数(65535,2 的 16 次方-1)就抛出一个 Error。 +- 如果当前写线程数为 0(那么读线程也应该为 0,因为上面已经处理`c!=0`的情况),并且当前线程需要阻塞那么就返回失败;如果通过 CAS 增加写线程数失败也返回失败。 +- 如果 c=0,w=0 或者 c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功! + +`tryAcquire()`除了重入条件(当前线程为获取写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。 + +因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与 ReentrantLock 的释放过程基本类似,每次释放均减少写状态,当写状态为 0 时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。 + +接着是读锁的代码: + +```Java +protected final int tryAcquireShared(int unused) { + Thread current = Thread.currentThread(); + int c = getState(); + if (exclusiveCount(c) != 0 && + getExclusiveOwnerThread() != current) + return -1; // 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态 + int r = sharedCount(c); + if (!readerShouldBlock() && + r < MAX_COUNT && + compareAndSetState(c, c + SHARED_UNIT)) { + if (r == 0) { + firstReader = current; + firstReaderHoldCount = 1; + } else if (firstReader == current) { + firstReaderHoldCount++; + } else { + HoldCounter rh = cachedHoldCounter; + if (rh == null || rh.tid != getThreadId(current)) + cachedHoldCounter = rh = readHolds.get(); + else if (rh.count == 0) + readHolds.set(rh); + rh.count++; + } + return 1; + } + return fullTryAcquireShared(current); +} +``` + +可以看到在`tryAcquireShared(int unused)`方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠 CAS 保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“`1<<16`”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。 + +此时,我们再回头看一下互斥锁 ReentrantLock 中公平锁和非公平锁的加锁源码: + +![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/other-bukfsdjavassmtjstd-7fa4ea6b-02ef-4fd4-992d-9ed08e5d4c76.png) + +我们发现在 ReentrantLock 虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用 lock 方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用 CAS 更新 state 成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定 ReentrantLock 无论读操作还是写操作,添加的锁都是都是独享锁。 + +**综上,只有 synchronized 是远远不能满足多样化的业务对锁的要求的**。接下来我们介绍一下 JDK 中有关锁的一些接口和类。 + +### JUC 包下的那些锁 众所周知,JDK 中关于并发的类大多都在`java.util.concurrent`(以下简称 JUC)包下。 @@ -225,28 +378,28 @@ Condition newCondition(); 那为什么既然有 Object 的监视器方法了,还要用 Condition 呢?这里有一个二者简单的对比: -| 对比项 | Object 监视器 | Condition | -| ---------------------------------------------- | ------------------------------ | ----------------------------------------------------------------- | -| 前置条件 | 获取对象的锁 | 调用 Lock.lock 获取锁,调用 Lock.newCondition 获取 Condition 对象 | -| 调用方式 | 直接调用,比如 `object.notify()` | 直接调用,比如 `condition.await()` | -| 等待队列的个数 | 一个 | 多个 | -| 当前线程释放锁进入等待状态 | 支持 | 支持 | -| 当前线程释放锁进入等待状态,在等待状态中不中断 | 不支持 | 支持 | -| 当前线程释放锁并进入超时等待状态 | 支持 | 支持 | -| 当前线程释放锁并进入等待状态直到将来的某个时间 | 不支持 | 支持 | -| 唤醒等待队列中的一个线程 | 支持 | 支持 | -| 唤醒等待队列中的全部线程 | 支持 | 支持 | +| 对比项 | Object 监视器 | Condition | +| ---------------------------------------------- | -------------------------------- | ----------------------------------------------------------------- | +| 前置条件 | 获取对象的锁 | 调用 Lock.lock 获取锁,调用 Lock.newCondition 获取 Condition 对象 | +| 调用方式 | 直接调用,比如 `object.notify()` | 直接调用,比如 `condition.await()` | +| 等待队列的个数 | 一个 | 多个 | +| 当前线程释放锁进入等待状态 | 支持 | 支持 | +| 当前线程释放锁进入等待状态,在等待状态中不中断 | 不支持 | 支持 | +| 当前线程释放锁并进入超时等待状态 | 支持 | 支持 | +| 当前线程释放锁并进入等待状态直到将来的某个时间 | 不支持 | 支持 | +| 唤醒等待队列中的一个线程 | 支持 | 支持 | +| 唤醒等待队列中的全部线程 | 支持 | 支持 | Condition 和 Object 的 wait/notify 基本相似。其中,Condition 的 await 方法对应的是 Object 的 wait 方法,而 Condition 的**signal/signalAll**方法则对应 Object 的 notify/`notifyAll()`。但 Condition 类似于 Object 的等待/通知机制的加强版。我们来看看主要的方法: -| 方法名称 | 描述 | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 方法名称 | 描述 | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `await()` | 当前线程进入等待状态直到被通知(signal)或者中断;当前线程进入运行状态并从 `await()`方法返回的场景包括:(1)其他线程调用相同 Condition 对象的 signal/signalAll 方法,并且当前线程被唤醒;(2)其他线程调用 interrupt 方法中断当前线程; | -| `awaitUninterruptibly()` | 当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程 | -| awaitNanos(long) | 当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于 0,可以认定就是超时了 | -| awaitUntil(Date) | 当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回 true,否则返回 false | -| signal() | 唤醒一个等待在 Condition 上的线程,被唤醒的线程在方法返回前必须获得与 Condition 对象关联的锁 | -| signalAll() | 唤醒所有等待在 Condition 上的线程,能够从 await()等方法返回的线程必须先获得与 Condition 对象关联的锁 | +| `awaitUninterruptibly()` | 当前线程进入等待状态直到被通知,在此过程中对中断信号不敏感,不支持中断当前线程 | +| awaitNanos(long) | 当前线程进入等待状态,直到被通知、中断或者超时。如果返回值小于等于 0,可以认定就是超时了 | +| awaitUntil(Date) | 当前线程进入等待状态,直到被通知、中断或者超时。如果没到指定时间被通知,则返回 true,否则返回 false | +| signal() | 唤醒一个等待在 Condition 上的线程,被唤醒的线程在方法返回前必须获得与 Condition 对象关联的锁 | +| signalAll() | 唤醒所有等待在 Condition 上的线程,能够从 await()等方法返回的线程必须先获得与 Condition 对象关联的锁 | #### ReentrantLock @@ -304,18 +457,16 @@ public class Counter { } ``` -在这个示例中,Counter类使用了一个ReentrantLock来保护count变量的访问。increment方法首先获取锁,然后增加计数,并在finally块中释放锁。这确保了即使方法中抛出异常,锁也会被正确释放。 +在这个示例中,Counter 类使用了一个 ReentrantLock 来保护 count 变量的访问。increment 方法首先获取锁,然后增加计数,并在 finally 块中释放锁。这确保了即使方法中抛出异常,锁也会被正确释放。 -在main方法中,我们创建了两个线程来并发执行increment操作。由于使用了锁,因此对count变量的访问是串行化的,结果是正确的。 +在 main 方法中,我们创建了两个线程来并发执行 increment 操作。由于使用了锁,因此对 count 变量的访问是串行化的,结果是正确的。 -这个示例展示了ReentrantLock的基本用法。与synchronized关键字相比,ReentrantLock提供了更高的灵活性,例如可中断的锁获取、公平锁选项、锁的定时获取等。 +这个示例展示了 ReentrantLock 的基本用法。与 synchronized 关键字相比,ReentrantLock 提供了更高的灵活性,例如可中断的锁获取、公平锁选项、锁的定时获取等。 来看一下最终输出结果: ![](https://cdn.tobebetterjavaer.com/stutymore/lock-20230806103823.png) - - #### ReentrantReadWriteLock ReentrantReadWriteLock 是 ReadWriteLock 接口的默认实现。它与 ReentrantLock 的功能类似,同样是可重入的,支持非公平锁和公平锁。不同的是,它还支持”读写锁“。 @@ -429,9 +580,9 @@ public class SharedResource { } ``` -在上述代码中,我们定义了一个SharedResource类,该类使用ReentrantReadWriteLock来保护其内部数据。write方法获取写锁,并更新共享数据。read方法获取读锁,并读取共享数据。 +在上述代码中,我们定义了一个 SharedResource 类,该类使用 ReentrantReadWriteLock 来保护其内部数据。write 方法获取写锁,并更新共享数据。read 方法获取读锁,并读取共享数据。 -在main方法中,我们创建了两个读线程和一个写线程。由于ReentrantReadWriteLock允许多个读取操作同时进行,因此读线程可以同时运行。然而,写入操作会被串行化,并且在写入操作进行时,读取操作将被阻塞。 +在 main 方法中,我们创建了两个读线程和一个写线程。由于 ReentrantReadWriteLock 允许多个读取操作同时进行,因此读线程可以同时运行。然而,写入操作会被串行化,并且在写入操作进行时,读取操作将被阻塞。 来看一下输出结果: @@ -564,7 +715,7 @@ StampedLock 用这个 long 类型的变量的前 7 位(LG_READERS)来表示 我们来一个 StampedLock 和 ReentrantReadWriteLock 的对比使用示例。 -使用ReentrantReadWriteLock。 +使用 ReentrantReadWriteLock。 ```java public class SharedResourceWithReentrantReadWriteLock { @@ -616,15 +767,15 @@ public class SharedResourceWithReentrantReadWriteLock { ![](https://cdn.tobebetterjavaer.com/stutymore/lock-20230806105654.png) -1、可重入性:ReentrantReadWriteLock支持可重入,即在一个线程中可以多次获取读锁或写锁。StampedLock则不支持可重入。 +1、可重入性:ReentrantReadWriteLock 支持可重入,即在一个线程中可以多次获取读锁或写锁。StampedLock 则不支持可重入。 -2、乐观读锁:StampedLock提供了乐观读锁机制,允许一个线程在没有任何写入操作发生的情况下读取数据,从而提高了性能。而ReentrantReadWriteLock没有提供这样的机制。 +2、乐观读锁:StampedLock 提供了乐观读锁机制,允许一个线程在没有任何写入操作发生的情况下读取数据,从而提高了性能。而 ReentrantReadWriteLock 没有提供这样的机制。 -3、锁降级:StampedLock提供了从写锁到读锁的降级功能,这在某些场景下可以提供额外的灵活性。ReentrantReadWriteLock不直接提供这样的功能。 +3、锁降级:StampedLock 提供了从写锁到读锁的降级功能,这在某些场景下可以提供额外的灵活性。ReentrantReadWriteLock 不直接提供这样的功能。 -4、API复杂性:由于提供了乐观读锁和锁降级功能,StampedLock的API相对复杂一些,需要更小心地使用以避免死锁和其他问题。ReentrantReadWriteLock的API相对更直观和容易使用。 +4、API 复杂性:由于提供了乐观读锁和锁降级功能,StampedLock 的 API 相对复杂一些,需要更小心地使用以避免死锁和其他问题。ReentrantReadWriteLock 的 API 相对更直观和容易使用。 -综上所述,StampedLock提供了更高的性能和灵活性,但也带来了更复杂的使用方式。ReentrantReadWriteLock则相对简单和直观,特别适用于没有高并发读的场景。 +综上所述,StampedLock 提供了更高的性能和灵活性,但也带来了更复杂的使用方式。ReentrantReadWriteLock 则相对简单和直观,特别适用于没有高并发读的场景。 ### 其他工具类 @@ -709,7 +860,7 @@ CountDownLatch 有一个计数器,可以通过`countDown()`方法对计数器 CountDownLatch 一般用来控制线程等待,它可以让某个线程一直等待直到倒计时结束,再开始执行。 -来看一个CountDownLatch的使用示例: +来看一个 CountDownLatch 的使用示例: ```java public class InitializationDemo { @@ -771,7 +922,7 @@ public class InitializationDemo { CyclicBarrier 是一个同步工具类,它允许一组线程互相等待,直到到达某个公共屏障点(common barrier point)。 -CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 sheet 保存一个账户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。 +CyclicBarrier 可以用于多线程计算数据,最后合并计算结果的应用场景。比如我们用一个 Excel 保存了用户所有银行流水,每个 sheet 保存一个账户近一年的每笔银行流水,现在需要统计用户的日均银行流水,先用多线程处理每个 sheet 里的银行流水,都执行完之后,得到每个 sheet 的日均银行流水,最后,再用 barrierAction 用这些线程的计算结果,计算出整个 Excel 的日均银行流水。 CyclicBarrier 的计数器可以通过`reset()`方法重置,所以它能处理循环使用的场景。比如,我们将一个大任务分成 10 个小任务,用 10 个线程分别执行这 10 个小任务,当 10 个小任务都执行完之后,再合并这 10 个小任务的结果,这个时候就可以用 CyclicBarrier 来实现。 @@ -938,7 +1089,7 @@ Thread 2 完成了第三步操作 JUC 包下的锁接口和锁类,可以说是 Java 并发编程的核心,也是面试中经常会问到的知识点。所以,一定要掌握好。 -> 编辑:沉默王二,编辑前的内容来源于朋友开源的这个仓库:[深入浅出 Java 多线程](http://concurrent.redspider.group/),强烈推荐。 +> 编辑:沉默王二,编辑前的内容来源于朋友小七萤火虫开源的这个仓库:[深入浅出 Java 多线程](http://concurrent.redspider.group/),强烈推荐;还有一部分内容来源于[美团点评后端工程师家琪的这篇文章](https://tech.meituan.com/2018/11/15/java-lock.html),强烈推荐。 --- diff --git a/docs/thread/pianxiangsuo.md b/docs/thread/pianxiangsuo.md index 01ba370e5f5acc0d551bde3ef57f09a2d406240c..1754e9a2aa2825bc83894ed8300c8c2cc153495b 100644 --- a/docs/thread/pianxiangsuo.md +++ b/docs/thread/pianxiangsuo.md @@ -1,7 +1,7 @@ --- title: Java 15 终于把难搞的偏向锁移除了 -shortTitle: Java15终于把难搞的偏向锁移除了 -description: Java15终于把难搞的偏向锁移除了 +shortTitle: JDK15 移除了偏向锁 +description: 偏向锁是 Java 虚拟机(JVM)中一种用于提高程序运行效率的锁机制。在许多应用场景中,大部分锁是只被一个线程所访问的。如果锁对象始终被一个线程访问,那么同步操作显得非常多余。为了减少这样的重量级操作,Java 6 引入了偏向锁的概念。 category: - Java核心 tag: @@ -12,17 +12,17 @@ head: content: Java,并发编程,多线程,Thread,偏向锁 --- -## 背景 +# 14.14 JDK15 移除了偏向锁 -在 JDK1.5 之前,面对 Java 并发问题, synchronized 是一招鲜的解决方案: +在 JDK 1.5 之前,面对 Java 并发问题, [synchronized](https://javabetter.cn/thread/synchronized-1.html) 是一招鲜的解决方案: -1. 普通同步方法,锁上当前实例对象 -2. 静态同步方法,锁上当前类 Class 对象 -3. 同步块,锁上括号里面配置的对象 +1. 同步方法,锁上当前实例对象 +2. 同步静态方法,锁上当前类的 Class 对象 +3. 同步块,锁上代码块里面配置的对象 拿同步块来举例: -```text +```java public void test(){ synchronized (object) { i++; @@ -34,29 +34,40 @@ public void test(){ ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-91aa2252-4282-4263-bf1d-6cb36be659ca.jpg) -`monitorenter` 指令是在编译后插入到同步代码块的开始位置;`monitorexit`是插入到方法结束和异常的位置(实际隐藏了try-finally),每个对象都有一个 monitor 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应的 `monitor` 的所有权,也就获得到了对象的锁 +`monitorenter` 指令是在编译后插入到同步代码块的开始位置;`monitorexit`是插入到方法结束和异常的位置(实际隐藏了[try-finally](https://javabetter.cn/exception/gailan.html)),每个对象都有一个 [monitor](https://javabetter.cn/thread/synchronized.html) 与之关联,当一个线程执行到 monitorenter 指令时,就会获得对象所对应的 `monitor` 的所有权,也就获得到了对象的锁。 -当另外一个线程执行到同步块的时候,由于它没有对应 `monitor` 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 `user mode` 切换到 `kernel mode`, 由操作系统来负责线程间的调度和线程的状态变更, 需要频繁的在这两个模式下切换(**上下文转换**)。这种有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它**重量级锁**,自然效率也很低,这也就给很多童鞋留下了一个根深蒂固的印象 —— **synchronized关键字相比于其他同步机制性能不好** +这里再简单说一下 monitor 的概念。 -[免费的 Java 并发编程小册在此](https://dayarch.top/p/java-concurrency-book.html) +在 Java 中,monitor 可以被看作是一种守门人或保安,它确保同一时刻只有一个线程可以访问受保护的代码段。你可以将它想象成一个房间的门,门的里面有一些重要的东西,而 monitor 就是那个保护门的保安。 -## 锁的演变 +这里是 monitor 的工作方式: -来到 JDK1.6,要怎样优化才能让锁变的轻量级一些? 答案就是: +- 进入房间: 当一个线程想要进入受保护的代码区域(房间)时,它必须得到 monitor 的允许。如果房间里没有其他线程,monitor 会让它进入并关闭门。 +- 等待其他线程: 如果房间里已经有一个线程,其他线程就必须等待。monitor 会让其他线程排队等候,直到房间里的线程完成工作离开房间。 +- 离开房间: 当线程完成它的工作并离开受保护的代码区域时,monitor 会重新打开门,并让等待队列中的下一个线程进入。 +- 协调线程: monitor 还可以通过一些特殊的机制(例如 wait 和 notify 方法)来协调线程之间的合作。线程可以通过 monitor 来发送信号告诉其他线程现在可以执行某些操作了。 -### 轻量级锁:CPU CAS +当另外一个线程执行到同步块的时候,由于它没有对应 `monitor` 的所有权,就会被阻塞,此时控制权只能交给操作系统,也就会从 `user mode` 切换到 `kernel mode`, 由操作系统来负责线程间的调度和线程的状态变更, 需要频繁的在这两个模式下切换(**上下文转换**)。 -如果 CPU 通过简单的 CAS 能处理加锁/释放锁,这样就不会有上下文的切换,较重量级锁而言自然就轻了很多。但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程 +有点竞争就找内核的行为很不好,会引起很大的开销,所以大家都叫它**重量级锁**,自然效率也很低,这也就给很多小伙伴留下了一个根深蒂固的印象 —— **synchronized 关键字相比于其他同步机制性能不好** + +### 锁的演变 + +来到 JDK 1.6,要怎样优化才能让锁变的轻量级一些? 答案就是: + +#### 轻量级锁:CPU CAS + +如果 CPU 通过简单的 [CAS](https://javabetter.cn/thread/cas.html) 能处理加锁/释放锁,这样就不会有上下文的切换,较重量级锁而言自然就轻了很多。但是当竞争很激烈,CAS 尝试再多也是浪费 CPU,权衡一下,不如升级成重量级锁,阻塞线程排队竞争,也就有了轻量级锁升级成重量级锁的过程 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-c776b638-dcea-4ebe-8631-34c3b932f548.jpg) -程序员在追求极致的道路上是永无止境的,HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由**同一个线程**多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一些呢? +HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由**同一个线程**多次获得,同一个线程反复获取锁,如果还按照轻量级锁的方式获取锁(CAS),也是有一定代价的,如何让这个代价更小一些呢? -### 偏向锁 +#### 偏向锁 -偏向锁实际就是锁对象潜意识「偏心」同一个线程来访问,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果同一个 ID 直接就获取锁就好了,是一种 `load-and-test` 的过程,相较 CAS 自然又轻量级了一些 +偏向锁实际就是锁对象潜意识「偏心」同一个线程来访问,让锁对象记住线程 ID,当线程再次获取锁时,亮出身份,如果是同一个 ID 直接获取锁就好了,是一种 `load-and-test` 的过程,相较 CAS 自然又轻量级了一些。 -可是多线程环境,也不可能只是同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,也就有了偏向锁升级的过程 +可是多线程环境,也不可能只有同一个线程一直获取这个锁,其他线程也是要干活的,如果出现多个线程竞争的情况,也就有了偏向锁升级的过程 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-5c6c90e2-b6da-4b7f-ab21-00be305ba633.jpg) @@ -66,22 +77,26 @@ public void test(){ > 占用的资源越少,程序执行的速度越快 -偏向锁,轻量锁,它俩都不会调用系统互斥量(Mutex Lock),只是为了提升性能,多出的两种锁的状态,这样可以在不同场景下采取最合适的策略,所以可以总结性的说: +偏向锁,轻量锁,它俩都不会调用系统互斥量(Mutex Lock),只是为了提升性能,多出的两种锁的状态,这样可以在不同场景下采取最合适的策略,所以可以总结如下: -* 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁 -* 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁 -* 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理 +- 偏向锁:无竞争的情况下,只有一个线程进入临界区,采用偏向锁 +- 轻量级锁:多个线程可以交替进入临界区,采用轻量级锁 +- 重量级锁:多线程同时进入临界区,交给操作系统互斥量来处理 -到这里,大家应该理解了全局大框,但仍然会有很多疑问: +到这里,大家应该理解了,但仍然会有很多疑问: -1. 锁对象是在哪存储线程 ID 才可以识别同一个线程的? +1. 锁对象是在哪存储线程 ID 的? 2. 整个升级过程是如何过渡的? -想理解这些问题,需要先知道 Java 对象头的结构 +想理解这些问题,需要先知道 Java 对象头的结构。 + +### 认识 Java 对象头 -## 认识 Java 对象头 +其实关于对象头、偏向锁、轻量级锁、重量级锁,我们[前面在讲 synchronized](https://javabetter.cn/thread/synchronized.html)的时候就讲过,这里再加深一下印象。 -按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上 +按照常规理解,识别线程 ID 需要一组 mapping 映射关系来搞定,如果单独维护这个 mapping 关系又要考虑线程安全的问题。根据奥卡姆剃刀原理,Java 万物皆是对象,对象皆可用作锁,与其单独维护一个 mapping 关系,不如中心化将锁的信息维护在 Java 对象本身上。 + +> 奥卡姆剃刀原理是一种问题解决原则,简单来说就是:在解释某事物时,没有必要假设更多的东西,当有多个解释时,应选择假设最少、最简单的那个解释。 Java 对象头最多由三部分构成: @@ -89,19 +104,19 @@ Java 对象头最多由三部分构成: 2. ClassMetadata Address 3. Array Length (**如果对象是数组才会有这部分**) -其中 `Markword` 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用`位`存储,在 64 位操作系统中,是这样存储的(**注意颜色标记**),想看具体注释的可以看 hotspot(1.8) 源码文件 `path/hotspot/src/share/vm/oops/markOop.hpp` 第 30 行 +其中 `Markword` 是保存锁状态的关键,对象锁状态可以从偏向锁升级到轻量级锁,再升级到重量级锁,加上初始的无锁状态,可以理解为有 4 种状态。想在一个对象中表示这么多信息自然就要用`位`来存储,在 64 位操作系统中,是这样存储的(**注意颜色标记**),想看具体注释的可以看 hotspot(1.8) 源码文件 `path/hotspot/src/share/vm/oops/markOop.hpp` 第 30 行。 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-c2642c2a-d51a-4a4d-bc4c-7ffc3d819441.jpg) -有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的 +有了这些基本信息,接下来我们就只需要弄清楚,MarkWord 中的锁信息是怎么变化的。 -## 认识偏向锁 +### 认识偏向锁 单纯的看上图,还是显得十分抽象,作为程序员的我们最喜欢用代码说话,贴心的 openjdk 官网提供了可以查看对象内存布局的工具 [JOL (java object layout)](https://search.maven.org/artifact/org.openjdk.jol/jol-core/0.16/jar) **Maven Package** -```text +```xml org.openjdk.jol jol-core @@ -109,138 +124,125 @@ Java 对象头最多由三部分构成: ``` -**Gradle Package** - -```text -implementation 'org.openjdk.jol:jol-core:0.14' -``` - -接下来我们就通过代码来深入了解一下偏向锁吧 +接下来我们就通过代码来深入了解一下偏向锁吧。 **注意:** > 上图(从左到右) 代表 `高位 -> 低位` -> +> > JOL 输出结果(从左到右)代表 `低位 -> 高位` 来看测试代码 -### 场景1 +#### 场景 1 - -```text +```java public static void main(String[] args) { - Object o = new Object(); - log.info("未进入同步块,MarkWord 为:"); + Object o = new Object(); + log.info("未进入同步块,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } } +} ``` 来看输出结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-7f73d31a-1674-4cd7-ace8-d482415f046d.jpg) -上面我们用到的 JOL 版本为 `0.14`, 带领大家快速了解一下位具体值,接下来我们就要用 `0.16` 版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果: +上面我们用到的 JOL 版本为 `0.14`,接下来我们要用 `0.16` 版本查看输出结果,因为这个版本给了我们更友好的说明,同样的代码,来看输出结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-54b4433d-ef02-4078-8be0-69d51548e5c0.jpg) -看到这个结果,你应该是有疑问的,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢? +看到这个结果,有些小伙伴会有疑问,JDK 1.6 之后默认是开启偏向锁的,为什么初始化的代码是无锁状态,进入同步块产生竞争就绕过偏向锁直接变成轻量级锁了呢? -> 虽然默认开启了偏向锁,但是开启**有延迟**,大概 4s。原因是 JVM 内部的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略 +> 虽然默认开启了偏向锁,但是开启**有延迟**,大概 4s。原因是 JVM 内部的代码有很多地方用到了 synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,所以就有了延迟策略 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-7af242a3-9bf4-44a6-8675-dee5a292569a.jpg) -我们可以通过参数 `-XX:BiasedLockingStartupDelay=0` 将延迟改为0,但是**不建议**这么做。我们可以通过一张图来理解一下目前的情况: +我们可以通过参数 `-XX:BiasedLockingStartupDelay=0` 将延迟改为 0,但是**不建议**这么做。我们可以通过一张图来理解一下目前的情况: -### 场景2 +#### 场景 2 那我们就代码延迟 5 秒来创建对象,来看看偏向是否生效 - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); - Object o = new Object(); - log.info("未进入同步块,MarkWord 为:"); + // 睡眠 5s + Thread.sleep(5000); + Object o = new Object(); + log.info("未进入同步块,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } } +} ``` 重新查看运行结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-e02c5c5b-491d-4e4d-9be6-c90376d4c0dc.jpg) -这样的结果是符合我们预期的,但是结果中的 `biasable` 状态,在 MarkWord 表格中并不存在,其实这是一种**匿名偏向状态**,是对象初始化中,JVM 帮我们做的 - -这样当有线程进入同步块: +这样的结果是符合我们预期的,但是结果中的 `biasable` 状态,在 MarkWord 表格中并不存在,其实这是一种**匿名偏向状态**,是对象初始化中,JVM 帮我们做的。这样当有线程进入同步块: 1. 可偏向状态:直接就 CAS 替换 ThreadID,如果成功,就可以获取偏向锁了 2. 不可偏向状态:就会变成轻量级锁 那问题又来了,现在锁对象有具体偏向的线程,如果新的线程过来执行同步块会偏向新的线程吗? -### 场景3 +#### 场景 3 - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); - Object o = new Object(); - log.info("未进入同步块,MarkWord 为:"); + // 睡眠 5s + Thread.sleep(5000); + Object o = new Object(); + log.info("未进入同步块,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); + } + + Thread t2 = new Thread(() -> { + synchronized (o) { + log.info("新线程获取锁,MarkWord为:"); log.info(ClassLayout.parseInstance(o).toPrintable()); } + }); - Thread t2 = new Thread(() -> { - synchronized (o) { - log.info("新线程获取锁,MarkWord为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } - }); + t2.start(); + t2.join(); + log.info("主线程再次查看锁对象,MarkWord为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - t2.start(); - t2.join(); - log.info("主线程再次查看锁对象,MarkWord为:"); + synchronized (o){ + log.info(("主线程再次进入同步块,MarkWord 为:")); log.info(ClassLayout.parseInstance(o).toPrintable()); - - synchronized (o){ - log.info(("主线程再次进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } } +} ``` 来看运行结果,奇怪的事情发生了: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-68e1a20c-8142-44b0-9b68-1c0f1bd1b49e.jpg) -* `标记1`: 初始可偏向状态 -* `标记2`:偏向主线程后,主线程退出同步代码块 -* `标记3`: **新线程**进入同步代码块,升级成了轻量级锁 -* `标记4`: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态 -* `标记5`: 由于对象不可偏向,同**场景1**主线程再次进入同步块,自然就会用轻量级锁 +- `标记1`: 初始可偏向状态 +- `标记2`:偏向主线程后,主线程退出同步代码块 +- `标记3`: **新线程**进入同步代码块,升级成了轻量级锁 +- `标记4`: 新线程轻量级锁退出同步代码块,主线程查看,变为不可偏向状态 +- `标记5`: 由于对象不可偏向,同**场景 1**主线程再次进入同步块,自然就会用轻量级锁 至此,场景一二三可以总结为一张图: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-91713395-e0aa-4b49-9500-087e2438d3a2.jpg) -从这样的运行结果上来看,偏向锁像是“**一锤子买卖**”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常有局限性。**事实上并不是这样**,如果你仔细看标记2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销 - -[免费的 Java 并发编程小册在此](https://dayarch.top/p/java-concurrency-book.html) +从这样的运行结果上来看,偏向锁像是“**一锤子买卖**”,只要偏向了某个线程,后续其他线程尝试获取锁,都会变为轻量级锁,这样的偏向非常有局限性。**事实上并不是这样**,如果你仔细看标记 2(已偏向状态),还有个 epoch 我们没有提及,这个值就是打破这种局限性的关键,在了解 epoch 之前,我们还要了解一个概念——偏向撤销。 -### 偏向撤销 +#### 偏向撤销 在真正讲解偏向撤销之前,需要和大家明确一个概念——偏向锁撤销和偏向锁释放是两码事 @@ -253,217 +255,212 @@ public static void main(String[] args) throws InterruptedException { 如果只是一个线程获取锁,再加上「偏心」的机制,是没有理由撤销偏向的,**所以偏向的撤销只能发生在有竞争的情况下** -想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 `safepoint 安全点` (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程 +想要撤销偏向锁,还不能对持有偏向锁的线程有影响,所以就要等待持有偏向锁的线程到达一个 `safepoint 安全点` (这里的安全点是 JVM 为了保证在垃圾回收的过程中引用关系不会发生变化设置的一种安全状态,在这个状态上会暂停所有线程工作), 在这个安全点会挂起获得偏向锁的线程,后续讲 JVM 的时候会详细讲。 -在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的,可能有疑惑的地方会在后面解释) +在这个安全点,线程可能还是处在不同状态的,先说结论(因为源码就是这么写的) 1. 线程不存活或者活着的线程但退出了同步块,很简单,直接撤销偏向就好了 2. 活着的线程但仍在同步块之内,那就要升级成轻量级锁 -这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表程序员写的程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下): +这个和 epoch 貌似还是没啥关系,因为这还不是全部场景。偏向锁是特定场景下提升程序效率的方案,可并不代表所有程序都满足这些特定场景,比如这些场景(在开启偏向锁的前提下): -1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作 +1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种 case 下,会导致大量的偏向锁撤销操作 2. 明知有多线程竞争(生产者/消费者队列),还要使用偏向锁,也会导致各种撤销 很显然,这两种场景肯定会导致偏向撤销的,一个偏向撤销的成本无所谓,大量偏向撤销的成本是不能忽视的。那怎么办?既不想禁用偏向锁,还不想忍受大量撤销偏向增加的成本,这种方案就是设计一个**有阶梯的底线** #### 批量重偏向(bulk rebias) -这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器 `+1`,当这个值达到重偏向阈值(默认20)时: +这是第一种场景的快速解决方案,以 class 为单位,为每个 class 维护一个偏向锁撤销计数器,每一次该 class 的对象发生偏向撤销操作时,该计数器 `+1`,当这个值达到重偏向阈值(默认 20)时: -```text +``` BiasedLockingBulkRebiasThreshold = 20 ``` -JVM 就认为该class的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 `epoch` +JVM 就认为该 class 的偏向锁有问题,因此会进行批量重偏向, 它的实现方式就用到了我们上面说的 `epoch`。 -`Epoch`,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的`epoch`字段,每个**处于偏向锁状态对象**的`mark word` 中也有该字段,其初始值为创建该对象时 class 中的`epoch`的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加1,同时遍历JVM中所有线程的栈 +`Epoch`,如其含义「纪元」一样,就是一个时间戳。每个 class 对象会有一个对应的`epoch`字段,每个**处于偏向锁状态对象**的`mark word` 中也有该字段,其初始值为创建该对象时 class 中的`epoch`的值(此时二者是相等的)。每次发生批量重偏向时,就将该值加 1,同时遍历 JVM 中所有线程的栈 1. 找到该 class 所有**正处于加锁状态**的偏向锁对象,将其`epoch`字段改为新值 2. class 中**不处于加锁状态**的偏向锁对象(没被任何线程持有,但之前是被线程持有过的,这种锁对象的 markword 肯定也是有偏向的),保持 `epoch` 字段值不变 -这样下次获得锁时,发现当前对象的`epoch`值和class的`epoch`,本着**今朝不问前朝事** 的原则(上一个纪元),那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其`mark word`的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁; +这样下次获得锁时,发现当前对象的`epoch`值和 class 的`epoch`,本着**今朝不问前朝事** 的原则(上一个纪元),就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过 CAS 操作将其`mark word`的线程 ID 改成当前线程 ID,这也算是一定程度的优化,毕竟没升级锁; -如果 `epoch` 都一样,说明没有发生过批量重偏向, 如果 `markword` 有线程ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0) +如果 `epoch` 都一样,说明没有发生过批量重偏向, 如果 `markword` 有线程 ID,还有其他锁来竞争,那锁自然是要升级的(如同前面举的例子 epoch=0) **批量重偏向是第一阶梯底线,还有第二阶梯底线** #### 批量撤销(bulk revoke) -当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认40)时, +当达到重偏向阈值后,假设该 class 计数器继续增长,当其达到批量撤销的阈值后(默认 40)时, -```text +``` BiasedLockingBulkRevokeThreshold = 40 ``` -JVM就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑 +JVM 就认为该 class 的使用场景存在多线程竞争,会标记该 class 为不可偏向。之后对于该 class 的锁,直接走轻量级锁的逻辑 这就是第二阶梯底线,但是在第一阶梯到第二阶梯的过渡过程中,也就是在彻底禁用偏向锁之前,还给一次改过自新的机会,那就是另外一个计时器: -```text +``` BiasedLockingDecayTime = 25000 ``` -1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到40,就会发生批量撤销(偏向锁彻底 game over) +1. 如果在距离上次批量重偏向发生的 25 秒之内,并且累计撤销计数达到 40,就会发生批量撤销(偏向锁彻底 game over) 2. 如果在距离上次批量重偏向发生超过 25 秒之外,那么就会重置在 `[20, 40)` 内的计数, 再给次机会 -大家有兴趣可以写代码测试一下临界点,观察锁对象 `markword` 的变化 +大家有兴趣可以写代码测试一下临界点,观察锁对象 `markword` 的变化。 至此,整个偏向锁的工作流程可以用一张图表示: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-64dcd33e-5ff5-4f6b-b1c4-ee448252d8f2.jpg) -到此,你应该对偏向锁有个基本的认识了,但是我心中的好多疑问还没有解除,咱们继续看: +到此,你应该对偏向锁有个基本的认识了。 ### HashCode 哪去了 上面场景一,无锁状态,对象头中没有 hashcode;偏向锁状态,对象头还是没有 hashcode,那我们的 hashcode 哪去了? -首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过**第一次**调用 `Object::hashCode()` 或者`System::identityHashCode(Object)` 才会存储在对象头中的。第一次**生成的 hashcode后,该值应该是一直保持不变的**,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证: +首先要知道,hashcode 不是创建对象就帮我们写到对象头中的,而是要经过**第一次**调用 `Object::hashCode()` 或者`System::identityHashCode(Object)` 才会存储在对象头中的。第一次**生成的 hashcode 后,该值应该是一直保持不变的**,但偏向锁又是来回更改锁对象的 markword,必定会对 hashcode 的生成有影响,那怎么办呢?,我们来用代码验证: #### 场景一 - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); + // 睡眠 5s + Thread.sleep(5000); - Object o = new Object(); - log.info("未生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); + Object o = new Object(); + log.info("未生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - o.hashCode(); - log.info("已生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); + o.hashCode(); + log.info("已生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); } +} ``` 来看运行结果 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-0ea379e1-8f10-454f-a742-c46bd3729527.jpg) -> 结论就是:即便初始化为可偏向状态的对象,一旦调用 `Object::hashCode()` 或者`System::identityHashCode(Object)` ,进入同步块就会直接使用轻量级锁 +结论就是:即便初始化为可偏向状态的对象,一旦调用 `Object::hashCode()` 或者`System::identityHashCode(Object)` ,进入同步块就会直接使用轻量级锁 #### 场景二 假如已偏向某一个线程,然后生成 hashcode,然后同一个线程又进入同步块,会发生什么呢?来看代码: - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); + // 睡眠 5s + Thread.sleep(5000); - Object o = new Object(); - log.info("未生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); + Object o = new Object(); + log.info("未生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); + } - o.hashCode(); - log.info("生成 hashcode"); - synchronized (o){ - log.info(("同一线程再次进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } + o.hashCode(); + log.info("生成 hashcode"); + synchronized (o){ + log.info(("同一线程再次进入同步块,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); } +} ``` 查看运行结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-67176ad3-6d20-47a4-b045-611b2e484792.jpg) -> 结论就是:同场景一,会直接使用轻量级锁 +结论就是:同场景一,会直接使用轻量级锁 #### 场景三 那假如对象处于已偏向状态,在同步块中调用了那两个方法会发生什么呢?继续代码验证: - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); + // 睡眠 5s + Thread.sleep(5000); - Object o = new Object(); - log.info("未生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); + Object o = new Object(); + log.info("未生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o){ - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - o.hashCode(); - log.info("已偏向状态下,生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } + synchronized (o){ + log.info(("进入同步块,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); + o.hashCode(); + log.info("已偏向状态下,生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); } +} ``` 来看运行结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-7a238fbe-0fe1-4838-b4ab-aaafcd26e3f8.jpg) -> 结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁 +结论就是:如果对象处在已偏向状态,生成 hashcode 后,就会直接升级成重量级锁 -最后用书中的一段话来描述 锁和hashcode 之前的关系 +最后用书中的一段话来描述 锁和 hashcode 之前的关系 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-3ec9d61b-891e-4f4a-bea1-e95e41816e5a.jpg) -#### 调用 Object.wait() 方法会发生什么? +#### 调用 Object.wait 方法会发生什么? Object 除了提供了上述 hashcode 方法,还有 `wait()` 方法,这也是我们在同步块中常用的,那这会对锁产生哪些影响呢?来看代码: - -```text +```java public static void main(String[] args) throws InterruptedException { - // 睡眠 5s - Thread.sleep(5000); + // 睡眠 5s + Thread.sleep(5000); - Object o = new Object(); - log.info("未生成 hashcode,MarkWord 为:"); - log.info(ClassLayout.parseInstance(o).toPrintable()); + Object o = new Object(); + log.info("未生成 hashcode,MarkWord 为:"); + log.info(ClassLayout.parseInstance(o).toPrintable()); - synchronized (o) { - log.info(("进入同步块,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); + synchronized (o) { + log.info(("进入同步块,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); - log.info("wait 2s"); - o.wait(2000); + log.info("wait 2s"); + o.wait(2000); - log.info(("调用 wait 后,MarkWord 为:")); - log.info(ClassLayout.parseInstance(o).toPrintable()); - } + log.info(("调用 wait 后,MarkWord 为:")); + log.info(ClassLayout.parseInstance(o).toPrintable()); } +} ``` 查看运行结果: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-a9b5bedf-5714-4105-a3d9-42f97ade07cb.jpg) -> 结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦) +结论就是,wait 方法是互斥量(重量级锁)独有的,一旦调用该方法,就会升级成重量级锁(这个是面试可以说出的亮点内容哦) 最后再继续丰富一下锁对象变化图: ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-27f4d97f-526b-4949-9c28-e766af2fc7d3.jpg) -[免费的 Java 并发编程小册在此](https://dayarch.top/p/java-concurrency-book.html) -## 告别偏向锁 +### 再见偏向锁 -看到这个标题你应该是有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 [Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking](https://openjdk.java.net/jeps/374),相信你看上面的文字说明也深有体会 +看到这个副标题你可能有些慌,为啥要告别偏向锁,因为维护成本有些高了,来看 [Open JDK 官方声明,JEP 374: Deprecate and Disable Biased Locking](https://openjdk.java.net/jeps/374) ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-6dc70d12-ebee-4c06-8ffb-f569a0cfb951.jpg) -这个说明的更新时间距离现在很近,在 JDK15 版本就已经开始了 +这个说明的更新时间距离现在很近,在 JDK 15 版本就已经开始了 ![](https://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/zhihu-nangdpxszybjavaycl-4d515725-eddd-4ab5-94ce-91472b6bb240.jpg) @@ -481,45 +478,22 @@ public static void main(String[] args) throws InterruptedException { 偏向锁给 JVM 增加了巨大的复杂性,只有少数非常有经验的程序员才能理解整个过程,维护成本很高,大大阻碍了开发新特性的进程(换个角度理解,你掌握了,是不是就是那少数有经验的程序员了呢?哈哈) -## 总结 +### 总结 -偏向锁可能就这样的走完了它的一生,有些同学可能直接发问,都被 deprecated 了,JDK都 17 了,还讲这么多干什么? +偏向锁可能就这样的走完了它的一生,有些小伙伴可能直接发问,都被 deprecated 了,JDK 都 17 了,还讲这么多干什么? -1. java 任它发,我用 Java8,这是很多主流的状态,至少你用的版本没有被 deprecated +1. Java 任它发,我用 Java 8,这是很多主流的状态,至少你用的版本没有被 deprecated 2. 面试还是会被经常问到 3. 万一哪天有更好的设计方案,“偏向锁”又以新的形式回来了呢,了解变化才能更好理解背后设计 4. 奥卡姆剃刀原理,我们现实中的优化也一样,如果没有必要不要增加实体,如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差 -之前对于偏向锁我也只是单纯的理论认知,但是为了写这篇文章,我翻阅了很多资料,包括也重新查看 Hotspot 源码,说的这些内容也并不能完全说明偏向锁的整个流程细节,还需要大家具体实践追踪查看,这里给出源码的几个关键入口,方便大家追踪: -1. 偏向锁入口: [http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1816](https://link.zhihu.com/?target=http%3A//hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp%23l1816) -2. 偏向撤销入口:[http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/interpreterRuntime.cpp#l608](https://link.zhihu.com/?target=http%3A//hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/interpreterRuntime.cpp%23l608) -3. 偏向锁释放入口:[http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp#l1923](https://link.zhihu.com/?target=http%3A//hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/9ce27f0a4683/src/share/vm/interpreter/bytecodeInterpreter.cpp%23l1923) -文中有疑问的地方欢迎留言讨论,有错误的地方还请大家帮忙指正 +>编辑:沉默王二,编辑前的内容主要来自于日拱一兵的这篇知乎文章[https://zhuanlan.zhihu.com/p/451061367](https://zhuanlan.zhihu.com/p/451061367) -## 灵魂追问 - -1. 轻量级和重量级锁,hashcode 存在了什么位置? - -## 参考资料 - -感谢各路前辈的精华总结,可以让我参考理解: - -1. [https://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf](https://www.oracle.com/technetwork/java/javase/tech/biasedlocking-oopsla2006-preso-150106.pdf) -2. [https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf](https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf) -3. [https://wiki.openjdk.java.net/display/HotSpot/Synchronization#Synchronization-Russel06](https://wiki.openjdk.java.net/display/HotSpot/Synchronization%23Synchronization-Russel06) -4. [https://github.com/farmerjohngit/myblog/issues/12](https://github.com/farmerjohngit/myblog/issues/12) -5. [https://zhuanlan.zhihu.com/p/440994983](https://zhuanlan.zhihu.com/p/440994983) -6. [https://mp.weixin.qq.com/s/G4z08HfiqJ4qm3th0KtovA](https://mp.weixin.qq.com/s/G4z08HfiqJ4qm3th0KtovA) -7. [https://www.jianshu.com/p/884eb51266e4](https://www.jianshu.com/p/884eb51266e4) - ->参考链接:[https://zhuanlan.zhihu.com/p/451061367](https://zhuanlan.zhihu.com/p/451061367),作者:日拱一兵,整理:沉默王二 - ----- - -GitHub 上标星 8700+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括Java基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 8700+ 的 Java 教程](https://javabetter.cn/overview/) +--- +GitHub 上标星 8700+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第一版 PDF 终于来了!包括 Java 基础语法、数组&字符串、OOP、集合框架、Java IO、异常处理、Java 新特性、网络编程、NIO、并发编程、JVM 等等,共计 32 万余字,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,GitHub 上标星 8700+ 的 Java 教程](https://javabetter.cn/overview/) 微信搜 **沉默王二** 或扫描下方二维码关注二哥的原创公众号沉默王二,回复 **222** 即可免费领取。 diff --git a/docs/thread/suo.md b/docs/thread/suo.md index bc1b681aac4f6ab8960f20d50053e691521e581a..1fc4c30633e6a973c91c25a61e23ba849c94f327 100644 --- a/docs/thread/suo.md +++ b/docs/thread/suo.md @@ -264,7 +264,7 @@ Monitor是线程私有的数据结构,每一个线程都有一个可用monitor ### 5\. 可重入锁 VS 非可重入锁 -可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析: +可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提:锁的是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中[ReentrantLock](https://javabetter.cn/thread/reentrantLock.html)和[synchronized](https://javabetter.cn/thread/synchronized-1.html)都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析: ```Java public class Widget {