我是靠谱客的博主 干净高山,最近开发中收集的这篇文章主要介绍java多线程 synchronized关键字与wait机制理论总结说一说自己对于 synchronized 关键字的了解synchronized 关键字的三种使用方式构造方法可以使用 synchronized 关键字修饰么?谈谈 synchronized 和 ReentrantLock 的区别synchronized的三性和额外特性synchronized 关键字的底层指令jdk1.6对锁的优化wait,notify,notifyAll机制,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

目录

说一说自己对于 synchronized 关键字的了解

synchronized 关键字的三种使用方式

构造方法可以使用 synchronized 关键字修饰么?

谈谈 synchronized 和 ReentrantLock 的区别

两者都是可重入锁

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

ReentrantLock 比 synchronized 增加了一些高级功能

synchronized的三性和额外特性

有序性

可见性

原子性

可重入性

不可中断性

synchronized 关键字的底层指令

synchronized 同步语句块的情况

synchronized 修饰方法的的情况

monitor

底层指令总结

jdk1.6对锁的优化

重量级锁

优化锁升级

偏向锁

轻量级锁

自旋锁与自适应自旋

锁消除

锁粗化

wait,notify,notifyAll机制

机制简介

notify 和 notifyAll的区别

说说 sleep() 方法和 wait() 方法区别和共同点?

在多线程中要测试某个条件的变化,使用if 还是while?

整体底层流程

wait的底层实现

notify的底层实现


注意:本文参考  https://javaguide.cn/java/concurrent/java%E5%B9%B6%E5%8F%91%E5%9F%BA%E7%A1%80%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98%E6%80%BB%E7%BB%93/#

死磕Synchronized底层实现

说一说自己对于 synchronized 关键字的了解

synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。

为什么呢?

因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

synchronized void method() {
//业务代码
}

庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

所以,你会发现目前的话,不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。

synchronized 关键字的三种使用方式

synchronized 关键字最主要的三种使用方式:

1.修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
//业务代码
}

2.修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

synchronized static void method() {
//业务代码
}

3.修饰代码块 :指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁 

synchronized(this) {
//业务代码
}

总结:

synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁。

synchronized 关键字加到实例方法上是给对象实例上锁。

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能!

构造方法可以使用 synchronized 关键字修饰么?

先说结论:构造方法不能使用 synchronized 关键字修饰。

构造方法本身就属于线程安全的,不存在同步的构造方法一说。

谈谈 synchronized 和 ReentrantLock 的区别

两者都是可重入锁

“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronizedReentrantLock增加了一些高级功能。

等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

可以试着获取锁和一定时间内获取锁:tryLock(),tryLock(long time)

可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

通过Lock可以知道线程有没有拿到锁,而synchronized不能。

不可自动释放:synchronized会自动释放锁,而Lock必须手动释放锁。

不能锁住方法:synchronized能锁住方法和代码块,而Lock只能锁住代码块。

锁升级的可逆性:synchronized的锁升级是不可逆的,如果并发高峰期过了,重量级锁是不会降级的。

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。性能已不是选择标准

synchronized的三性和额外特性

有序性

有序性通过as-if-serial和happens-before的可见性来实现

CPU会为了优化我们的代码,会对我们程序进行重排序。

as-if-serial

不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的,还有就是有数据依赖的也是不能重排序的。

就比如:

int a = 1;
int b = a;

这两段是怎么都不能重排序的,b的值依赖a的值,a如果不先赋值,那就为空了。

可见性

当线程释放锁时JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量

锁释放与volatile写有 相同的内存语义;锁获取与volatile读有相同的内存语义。

happens-before的原则

监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。

传递性如果A happens-before B, 且B happens-before C, 那么A happens-before C。

start()规则:如果线程A执行操作ThreadB.start() (启动线程B), 那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。

另一个线程
D
synchronized(this) {
E
}
F
本线程
A
synchronized(this) {
B
}
C

保证了A happens-before B,B happens-before C,并且B的操作能够看到之前对this解锁前的代码DE,也就是DE happens-before B

原子性

其实他保证原子性很简单,确保同一时间只有一个线程能拿到锁,能够进入代码块这就够了。

这几个是我们使用锁经常用到的特性,那synchronized他自己本身又具有哪些特性呢?

可重入性

synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。

那可重入有什么好处呢?

可以避免一些死锁的情况,也可以让我们更好封装我们的代码。

不可中断性

不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。

值得一提的是,Lock的tryLock方法是可以被中断的。

synchronized 关键字的底层指令

synchronized 关键字底层原理属于 JVM 层面。

synchronized 同步语句块的情况

public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

从上面我们可以看出:synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor (opens new window)实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况

public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}

 synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,然后ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

monitor

我说了这么多次这个对象,大家是不是以为就是个虚无的东西,其实不是,monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

我看了下源码,他的数据结构长这样:


ObjectMonitor() {
_header
= NULL;
_count
= 0;
_waiters
= 0,
_recursions
= 0;
// 线程重入次数
_object
= NULL;
// 存储Monitor对象
_owner
= NULL;
// 持有当前线程的owner
_WaitSet
= NULL;
// wait状态的线程列表
_WaitSetLock
= 0 ;
_Responsible
= NULL ;
_succ
= NULL ;
_cxq
= NULL ;
// 单向列表
FreeNext
= NULL ;
_EntryList
= NULL ;
// 处于等待锁状态block状态的线程列表
_SpinFreq
= 0 ;
_SpinClock
= 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}

synchronized底层的源码就是引入了ObjectMonitor,这一块大家有兴趣可以看看,反正我上面说的,还有大家经常听到的概念,在这里都能找到源码。

大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。

底层指令总结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

不过两者的本质都是对对象监视器 monitor 的获取。

jdk1.6对锁的优化

高效并发是JDK 1.6的一个重要主题,HotSpot虚拟机开发团队在这个版本上花费了 大量的精力去实现各种锁优化技术,如适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁 (Biased Locking)等,这些技术都是为了在线程之间更高效地共享数据,以及解决竞争 问题,从而提高程序的执行效率。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

Java6及以上版本对synchronized的优化 - 蜗牛大师 - 博客园

重量级锁

大家在看ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()。

重量级锁是由OS调度的,有竞争锁队列和等待队列,每次抢锁,失败的线程全部立即wait,等锁释放后全部唤醒,这消耗可想而知是很大的。

重量级锁的底层实现是objectMonitor.hpp(C++层面的)

当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

那用户态和内核态又是啥呢?

Linux系统的体系结构大家大学应该都接触过了,分为用户空间(应用程序的活动空间)和内核。

我们所有的程序都在用户空间运行,进入用户运行状态也就是(用户态),但是很多操作可能涉及内核运行,比我I/O,我们就会进入内核运行状态(内核态)。

这个过程是很复杂的,也涉及很多值的传递,我简单概括下流程:

1 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。

2 用户态执行系统调用(系统调用是操作系统的最小功能单位)。

3 CPU切换到内核态,跳到对应的内存指定的位置执行指令。

4 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。

5 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。

所以大家一直说,1.6之前是重量级锁,没错,但是他重量的本质,是ObjectMonitor调用的过程,以及Linux内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。

还有两种情况也会发生内核态和用户态的切换:异常事件和外围设备的中断 大家也可以了解下。

优化锁升级

那都说过了效率低,官方也是知道的,所以他们做了升级,大家如果看了我刚才提到的那些源码,就知道他们的升级其实也做得很简单,只是多了几个函数调用,不过不得不设计还是很巧妙的。

我们就来看一下升级后的锁升级过程:

简单版本: 

升级方向: 

 Tip:切记这个升级过程是不可逆的,最后我会说明他的影响,涉及使用场景。

看完他的升级,我们就来好好聊聊每一步怎么做的吧。

偏向锁

偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不做了。

偏向锁的"偏",就是偏心的“偏"、偏袒的"偏"。它的意思是这个锁会偏向于 一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有 偏向锁的线程将永远不需要再进行同步。

对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。

这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了。如果CAS操作成功,持 有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步 操作(例如Locking、Unlocking及对Mark.Word的Update等)。

不同线程过来,CAS会失败,也就意味着获取锁失败。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是 处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为"01 ")或轻量级锁定(标志位为"00 ")的状态,后续的同步操作就如轻量级锁那样执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图

偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。

偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡 (Trade Off)性质的优化,也就是说它并不一定总是对程序运行有利,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析 的前提下,有时候使用参数- XX: -UseBiasedLocking来禁止偏向锁优化反而可以提升 性能。

epoch在批量重偏向的使用过程

当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。这个过程是要消耗一定的成本的,所以如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

1.首先引入一个概念epoch,其本质是一个时间戳,代表了偏向锁的有效性,epoch存储在可偏向对象的MarkWord中。除了对象中的epoch,对象所属的类class信息中,也会保存一个epoch值。

2.每当遇到一个全局安全点时(这里的意思是说批量重偏向没有完全替代了全局安全点,全局安全点是一直存在的),比如要对class C 进行批量再偏向,则首先对 class C中保存的epoch进行增加操作,得到一个新的epoch_new

3.然后扫描所有持有 class C 实例的线程栈,根据线程栈的信息判断出该线程是否锁定了该对象,仅将epoch_new的值赋给被锁定的对象中,也就是现在偏向锁还在被使用的对象才会被赋值epoch_new。

4.退出安全点后,当有线程需要尝试获取偏向锁时,直接检查 class C 中存储的 epoch 值是否与目标对象中存储的 epoch 值相等, 如果不相等,则说明该对象的偏向锁已经无效了(因为(3)步骤里面已经说了只有偏向锁还在被使用的对象才会有epoch_new,这里不相等的原因是class C里面的epoch值是epoch_new,而当前对象的epoch里面的值还是epoch),此时竞争线程可以尝试对此对象重新进行偏向操作。

偏向锁关闭,或者多个线程竞争偏向锁怎么办呢?

轻量级锁

轻量级锁是JDK 1.6中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥性来实现的传统锁而言的,因此传统的锁机制就被称为“重量级“锁。首先 需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞 争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从HotSpot 虚拟机的对象(对象头部分)的内存布局开始介绍。

HotSpot虚拟机的对象头(Object Header)分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码 (HashCocie)、GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和 64位的虚拟机中分别为32个和64个Bits, 官方称它为"Mark Word", 它是实现轻量级 锁和偏向锁的关键。

另外一部分用于存储指向方法区对象类型数据的指针,如果是数组 对象的话,还会有一个额外的部分用于存储数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间 效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的 信息,它会根据对象的状态复用自己的存储空间。

例如在32位的HotSpot虚拟机中 对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希 码(HashCode), 4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0, 在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容:

哈希码+分带年龄总共29Bit,随着锁标志位2Bit的变化,存取的数据也不断变化

如果这个对象是无锁的(锁标志位为"01"状态),jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),然后把Lock Record中的owner指向当前对象。

JVM接下来会利用CAS尝试把对象原本的Mark Word 更新会Lock Record的指针,成功就说明加锁成功,改变锁标志位(Mark Word的最后两个Bits)将转变为"00 ", 即表示此对象处于轻量级锁定的状态,这时候线程堆栈与对象头的状态如图

如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。

如果有两条以上的线程争用同一个锁, 那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为" 10", Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如 果对象的Mark Word仍然指向若线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来,如果替换成功,整个同步过程就 完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤 醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都 是不存在竞争的",这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了 使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操 作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

自旋锁与自适应自旋

我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来 了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状心会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有 一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的 那个线程“稍等一会儿”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就 会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是 所谓的自旋锁。

Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?

自旋,过来的现在就不断自旋,防止线程被挂起,但是消耗处理器的执行时间,一旦可以获取资源,就直接尝试成功,直到超出阈值

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning 参数来开启,在JDK 1.6中就已经改为默认开启了。

自旋等待不能代替阻塞,且先不说 对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器 时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被 用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作, 反而会带来性能的浪费。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限 定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数-XX:PreBlockSpin来更改。

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

如果在同一个锁对象 ,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为 这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。

另一方面,如果对于某个锁, 自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虚拟机就会变得越来越“聪明”了。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支 持,如果判断到一段代码中,在堆上的所有数据都 不会逃逸出去被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程 私有的,同步加锁自然就无须进行。

变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定, 但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下要求同步 呢?有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中的普遍 程度也许超过了大部分读者的想象。

我们也知道,由于String是一个不可变的类,对字符串的连接操作总是通过生成新 的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK 1.5之 前,会转化为StringBuff er对象的连续append()操作,在JDK 1.5及以后的版本中,会 转化为StringBuiider对象的连续append()操作。

每个StringBuffer. append()方法中都 有一个同步块,锁就是sb对象。虚拟机观察变量sb, 很快就会发现它的动态作用 域被限制在concatString()方法的内部。也就是sb的所有引用永远不会"逃逸”到 concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安 地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能 变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。

大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对 象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地 进行互斥同步操作也会导致不必要的性能损耗。

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

wait,notify,notifyAll机制

机制简介

wait()notify()方法被设计为提供一种机制,以允许一个线程阻塞,直到一个特定的条件被满足。

wait():使调用该方法的线程释放共享资源锁,然后从运行状态退出,进入等待队列,直到被再次唤醒。

wait(long):超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回。

wait(long,int):对于超时时间更细力度的控制,单位为纳秒。

notify():随机唤醒等待队列中等待同一共享资源的一个线程,并使该线程退出等待队列,进入可运行状态,也就是notify()方法仅通知一个线程。

notifyAll():使所有正在等待队列中等待同一共享资源的全部线程退出等待队列,进入可运行状态。此时,优先级最高的那个线程最先执行,但也有可能是随机执行,这取决于JVM虚拟机的实现。
 

wait()、notify/notifyAll() 方法是Object的本地final方法,无法被重写。

wait和notify方法必须写在synchronized方法内,即在调用wait和notify方法前,需先获得对象锁;

notify 和wait 的顺序不能错,如果A线程先执行notify方法,B线程在执行wait方法,那么B线程是无法被唤醒的。

当线程执行wait()方法时候,会释放当前的锁,然后让出CPU,进入等待状态。

wait() 需要被try catch包围,以便发生异常中断也可以使wait等待的线程唤醒。

只有当 notify/notifyAll() 被执行时候,才会唤醒一个或多个正处于等待状态的线程,然后继续往下执行,直到执行完synchronized 代码块的代码或是中途遇到wait() ,再次释放锁。

也就是说,notify/notifyAll() 的执行只是唤醒沉睡的线程,而不会立即释放锁,锁的释放要看代码块的具体执行情况。所以在编程中,尽量在使用了notify/notifyAll() 后立即退出临界区,以唤醒其他线程让其获得锁

notify 和 notifyAll的区别

notify方法只唤醒一个等待(对象的)线程并使该线程开始执行。所以如果有多个线程等待一个对象,这个方法只会唤醒其中一个线程,选择哪个线程取决于操作系统对多线程管理的实现

notifyAll 会唤醒所有等待(对象的)线程,尽管哪一个线程将会第一个处理取决于操作系统的实现。如果当前情况下有多个线程需要被唤醒,推荐使用notifyAll 方法。比如在生产者-消费者里面的使用,每次都需要唤醒所有的消费者或是生产者,以判断程序是否可以继续往下执行。

说说 sleep() 方法和 wait() 方法区别和共同点?

两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁

两者都可以暂停线程的执行。

wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。

wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。

在多线程中要测试某个条件的变化,使用if 还是while?

要注意,notify唤醒沉睡的线程后,线程会接着上次的执行继续往下执行。所以在进行条件判断时候,可以先把 wait 语句忽略不计来进行考虑;显然,要确保程序一定要执行,并且要保证程序直到满足一定的条件再执行,要使用while进行等待,直到满足条件才继续往下执行。如下代码:

public class K {
//状态锁
private Object lock;
//条件变量
private int now,need;
public void produce(int num){
//同步
synchronized (lock){
//当前有的不满足需要,进行等待,直到满足条件
while(now < need){
try {
//等待阻塞
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("我被唤醒了!");
}
// 做其他的事情
}
}
}

显然,只有当前值满足需要值的时候,线程才可以往下执行,所以,必须使用while 循环阻塞。注意,wait() 当被唤醒时候,只是让while循环继续往下走.如果此处用if的话,意味着if继续往下走,会跳出if语句块。

整体底层流程

step1:线程1之前获得过Monitor,在执行临界区代码时发现部分条件不满足,无法执行完代码,因此主动调用wait让出坑位,自己进入WaitSet ,让其他阻塞的线程能够获得Monitor,避免浪费资源。

step2: 线程1主动放弃Monitor,会唤醒BLOCKED的线程去获得Monitor,图中线程2获得了Monitor。

如果条件满足,此时线程2可以主动调用 notify 或 notifyAll 去唤醒WAITING的线程,唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争

wait的底层实现

ObjectSynchronizer::wait方法通过object的对象中找到ObjectMonitor对象调用方法 void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS)

通过ObjectMonitor::AddWaiter调用把新建立的ObjectWaiter对象放入到 _WaitSet 的队列的末尾中然后在ObjectMonitor::exit释放锁,接着 thread_ParkEvent->park 也就是wait。

wait 对应的是 native  方法是JVM_MonitorWait, 继续查看JVM_MonitorWait的实现

里面调用ObjectSynchronizer::wait()方法

最终通过ObjectMonitor的void wait(jlong millis, bool interruptable, TRAPS);实现

这段函数相当的长,重点在于以下语句:

ObjectWaiter node(Self);   Self 是Thread 对象,将当前线程封装成ObjectWaiter对象node;

ObjectMonitor::AddWaiter() 将 node加入到  ObjectWaiter 的_WaitSet 中;

exit (true, Self) ; // exit the monitor  线程退出monitor;

Self->_ParkEvent->park () ;  最终底层的park方法挂起线程;

park方法, 在linux 下 调用 pthread_cond_wait()阻塞在条件变量上。

notify的底层实现

同理,notify()方法最终通过ObjectMonitor的notify(TRAPS)实现;

1、如果当前_WaitSet为空,即没有正在等待的线程,直接返回;

2、通过ObjectMonitor::DequeueWaitor()方法,获取_WaitSet列表的第一个ObjectWaitor节点,然后根据不同的调度策略,选择头插入法或者尾插入法放到entryList或者cxq;

3、最后调用unpark()方法在linux下调用pthread_cond_signal()唤醒阻塞在条件变量上的线程

最后

以上就是干净高山为你收集整理的java多线程 synchronized关键字与wait机制理论总结说一说自己对于 synchronized 关键字的了解synchronized 关键字的三种使用方式构造方法可以使用 synchronized 关键字修饰么?谈谈 synchronized 和 ReentrantLock 的区别synchronized的三性和额外特性synchronized 关键字的底层指令jdk1.6对锁的优化wait,notify,notifyAll机制的全部内容,希望文章能够帮你解决java多线程 synchronized关键字与wait机制理论总结说一说自己对于 synchronized 关键字的了解synchronized 关键字的三种使用方式构造方法可以使用 synchronized 关键字修饰么?谈谈 synchronized 和 ReentrantLock 的区别synchronized的三性和额外特性synchronized 关键字的底层指令jdk1.6对锁的优化wait,notify,notifyAll机制所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(31)

相关文章

评论列表共有 0 条评论

立即
投稿
返回
顶部