synchronized概述
Synchronized是Java中解决并发问题的一种最常用的方法(还有Lock也是常用方法),也是最简单的一种方法。
Synchronized的作用主要有三个(并发编程需要满足的三个特性):
- 原子性:一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。确保线程互斥的访问同步代码;
- 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。显然,对于单线程来说,可见性问题是不存在的。
- 有序性:有序性即程序执行的顺序按照代码的先后顺序执行。Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。有序性有效解决重排序问题,即 “happen-before:一个unlock操作对同一个锁的lock操作可见”;
从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。
synchronized作为java中的锁机制,解决的就是多线程访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行,在java中,每一个对象有且仅有一个同步锁。这也意味着,同步锁是依赖于对象而存在。
- 当我们调用某对象的synchronized方法时,就获取了该对象的同步锁(对象监视器)。例如,synchronized(obj)就获取了“obj这个对象”的同步锁。
- 不同线程对同步锁的访问是互斥的。也就是说,某时间点,对象的同步锁只能被一个线程获取到。
Synchronized总共有三种用法:
- 同步锁:当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
- 全局锁:当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁,相当于锁定当前类;
- 同步代码块::锁对象是Synchronized后面括号里配置的对象,这个对象可以是某个对象(xlock),也可以是某个类(Xlock.class);在任何时候,最多允许一个线程拥有该同步锁,谁拿到锁就进入代码块,其他的线程只能是阻塞状态。
同步代码块可以选择以什么来加锁,比同步方法要更细颗粒度,我们可以选择只同步会发生同步问题的部分代码而不是整个方法。
synchronized底层实现
jvm基于进入和退出Monitor对象来实现方法同步和代码块同步。
- 方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成)时释放monitor。
- 代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了(发生异常也会释放锁)。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
总之,对于同步方法,反编译后得到ACC_SYNCHRONIZED 标志,对于同步代码块反编译后得到monitorenter和monitorexit指令。
synchronized 性质:可重入性与不可中断性
- 可重入性:同一线程的外层函数获得锁之后,内层函数可以直接再次获取该锁,而不是释放当前的锁去重新获取一个锁。每部锁对象中会有一个计数器记录线程获取几次锁啦,在执行完同步代码块时,计数器的数量会 -1,直到计数器的数量为0,就释放这个锁;关键字:同一个线程,同一把锁。
好处:提升封装性,避免死锁,子类同步方法调用了父类同步方法,如没有可重入的特性,则会发生死锁;
作用粒度:是线程范围(对象)而非调用范围:同一个方法、不同方法和不同类都是可重入的。 - 不可中断性:一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断;
相比之下,Lock类提供的锁,有权中断现在已经获取到锁的线程的执行;如果不想再等待,可以退出;
synchronized优化
(1)锁升级
synchronized的概述
- synchronized 关键字解决的是多个线程之间访问资源的同步性。在java1.6之前,synchronized是属于重量级锁,效率比较低。synchronized在JVM里的实现是基于进入和退出Monitor对象来实现同步的。而监视器锁(Monitor)是依赖于底层的操作系统的Mutex Lock 来实现的
- 如果要挂起(用户态转换到内核态来执行,但不控制内核态中执行的命令)或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。
- 而在java1.6之后,为了减少锁操作的开销,即避免阻塞状态,这个过程涉及用户态到内核态的切换。引入了无锁、偏向锁、轻量级锁(自旋锁),重量级锁的升级过程。
获取锁和释放锁要经过操作系统:
- 内核态:其实从本质上说就是我们所说的内核,它是一种特殊的软件程序,特殊在哪儿呢?控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行! - 用户态:用户态就是提供应用程序运行的空间,为了使应用程序访问到内核管理的资源,例如CPU,内存,I/O。内核必须提供一组通用的访问接口,这些接口就叫系统调用。
不能直接使用系统资源,也不能改变CPU的工作状态,并且只能访问这个用户程序自己的存储空间!
说说JDK1.6后的synchronized关键字底层做了哪些优化?
- 锁一共有4种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。
- 四种状态会随着竞争情况而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
解释一下优化后的三种锁和升级过程:
- 偏向锁:大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁。
升级:当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁默认开启,那么可以通过-XX:-UseBiasedLocking = false来设置关闭; - 轻量级锁:竞争锁对象的线程不多,线程持有锁的时间不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋等待锁释放。
升级:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。 - 重量级锁:当出现大量竞争锁对象的线程时,高并发场景(大量的线程自旋,ps:自旋比用户态到内核态的带来的消耗小的多)。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转,响应时间缓慢。为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;
Mark Word的存储结构
锁的优缺点对比
(2)锁粗化
锁粗化是发生在编译器级别(JVM对锁的优化)的一种锁优化方式。
- 按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步(大多数情况),这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是如果一系列的连续操作都对同一个对象反复加锁解锁,甚至加锁操作时出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。 - 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
(3)锁消除
锁消除也是发生在编译器级别的一种锁优化方式。
- Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
比如:多个String变量进行拼接,StringBuffer 的append方法时一个同步方法,这时候,编译器就会判断出sb这个对象并不会被这段代码块以外的地方访问到,更不会被其他线程访问到,这时候的加锁就是完全没必要的,编译器就会把这里的加锁代码消除掉,体现到java源码上就是把append
方法的synchronized
修饰符给去掉了。
补充
synchronized与Lock区别,为什么还要提供Lock?
Synchronized编码更简单,锁机制由JVM维护,在竞争不激烈的情况下性能更好。Lock功能更强大更灵活,竞争激烈时性能较好。
- 性能不一样:资源竞争激励的情况下,lock性能会比synchronize好,竞争不激励的情况下,synchronize比lock性能好,但是:synchronize会 根据锁的竞争情况,从偏向锁–>轻量级锁–>重量级锁升级,而且编程更简单
- 锁机制不一样:synchronize是Java内置关键字,是在JVM层面实现的,系统会监控锁的释放与否。lock(类)是JDK代码实现的,需要手动释放,在finally块中释放。可以采用非阻塞的方式获取锁。
- 使用方法不同:Synchronized的编程更简洁,但不知道有没有成功获取锁,并且是非中断的;lock的功能更多更灵活,可以知道有没有成功获取锁,并且可以是可中断的。
- 作用域不同:synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁。
拓展:Lock支持的功能:
- 公平锁:Synchronized是非公平锁,Lock支持公平锁,默认非公平锁
- 可中断锁:ReentrantLock提供了lockInterruptibly()的功能,可以中断争夺锁的操作,抢锁的时候会check是否被中断,中断直接抛出异常,退出抢锁。而Synchronized只有抢锁的过程,不可干预,直到抢到锁以后,才可以编码控制锁的释放。
- 快速反馈锁:ReentrantLock提供了trylock() 和 trylock(tryTimes)的功能,不等待或者限定时间等待获取锁,更灵活。可以避免死锁的发生。
- 读写锁:ReentrantReadWriteLock类实现了读写锁的功能,类似于Mysql,锁自身维护一个计数器,读锁可以并发的获取,写锁只能独占。而synchronized全是独占锁
- Condition:ReentrantLock提供了比Sync更精准的线程调度工具,Condition,一个lock可以有多个Condition,比如在生产消费的业务下,一个锁通过控制生产Condition和消费Condition精准控制。
ps:在一个同步程序中,如果定义了一个Lock锁,同时在这一个锁上创建两个condition监视器con1和con2,如果操作con1.signalAll(),那么唤醒的是Lock这个锁里全部等待的线程还是只唤醒被con1这个监视器await的线程呢?
con1只是唤醒在con1这个对象上的阻塞队列里的对象
锁的实现在本质上都对应着一个入口等待队列, 如果一个线程没有获得锁, 就会进入等待队列, 当有线程释放锁的时候, 就需要从等待队列中唤醒一个等待的线程。
- 如果是公平锁, 唤醒的策略就是谁等待的时间长, 就唤醒谁, 很公平;
- 如果是非公平锁, 则不提供这个公平保证, 有可能等待时间短的线程反而先被唤醒。 而Lock是支持公平锁的,synchronized不支持公平锁。
最后,值得注意的是,在使用Lock加锁时,一定要在finally{}代码块中释放锁,例如,下面的代码片段所示。
try{ lock.lock(); }finally{ lock.unlock(); }
synchronized存在什么问题呢?
- 如果我们的程序使用synchronized关键字发生了死锁时,synchronized关键是是无法破坏“不可剥夺”这个死锁的条件的。这是因为synchronized申请资源的时候, 如果申请不到, 线程直接进入阻塞状态了, 而线程进入阻塞状态, 啥都干不了, 也释放不了线程已经占有的资源。
- 然而,在大部分场景下,我们都是希望“不可剥夺”这个条件能够被破坏。也就是说对于“不可剥夺”这个条件,占用部分资源的线程进一步申请其他资源时, 如果申请不到, 可以主动释放它占有的资源, 这样不可剥夺这个条件就破坏掉了。
如何进行设计呢?
- 能够响应中断。synchronized的问题是, 持有锁A后, 如果尝试获取锁B失败, 那么线程就进入阻塞状态, 一旦发生死锁, 就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号, 也就是说当我们给阻塞的线程发送中断信号的时候, 能够唤醒它, 那它就有机会释放曾经持有的锁A。这样就破坏了不可剥夺条件了。
lockInterruptibly()支持中断。 - 支持超时。如果线程在一段时间之内没有获取到锁, 不是进入阻塞状态, 而是返回一个错误, 那这个线程也有机会释放曾经持有的锁。这样也能破坏不可剥夺条件。
tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。
tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。 - 非阻塞地获取锁。如果尝试获取锁失败, 并不进入阻塞状态, 而是直接返回, 那这个线程也有机会释放曾经持有的锁。这样也能破坏不可剥夺条件。
Lock常用方法:
- lock():获取锁,如果锁被占用则一直等待
- unlock(): 释放锁
- tryLock(): 注意返回类型是boolean,如果获取锁的时候锁被占用就返回false,否则返回true
- tryLock(long time, TimeUnit unit):比起tryLock()就是给了一个时间期限,保证等待参数时间
- lockInterruptibly():用该锁的获得方式,如果线程在获取锁的阶段进入了等待,那么可以中断此线程,先去做别的事
介绍一下自旋锁
- 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
- 获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务(占用CPU),使用这种锁会造成busy-waiting。
- 为了实现可重入锁,我们需要引入一个计数器,用来记录获取锁的线程数。
自旋锁存在的问题
- 在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。
自旋锁的优点
- 自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的CPU 耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间
- 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
以上仅供学习使用!