今天,我们开始Java高并发与多线程的第四篇,锁。
之前的三篇,基本上都是在讲一些概念性和基础性的东西,东西有点零碎,但是像文科科目一样,记住就好了。
但是本篇是高并发里面真正的基石,需要大量的理解和实践,一环扣一环,环环相扣,不难,但是需要认真去读。
好了,现在开始。
--------------第一部分,咱们要谈到java里面的两个用于保证线程之间有序性的关键字--------------
【synchronized】
synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。
synchronized可以保证java代码块中的原子性,可见性和有序性。
- Java 内存模型中的可见性、原子性和有序性
可见性 |
是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。 |
原子性 |
原子是世界上的最小单位,具有不可分割性 |
有序性 |
java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性; |
synchronized可以把任何一个非null对象作为"锁"。
synchronized总共有三种用法:
- 当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);
- 当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
- 当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;
一般来讲,synchronized同步的范围是越小越好。
因为若该方法耗时很久,那其它线程必须等到该持锁线程执行完才能运行。
若synchronized方法抛出异常,JVM会自动释放锁,不会导致死锁问题。
锁定对象的时候,不可以用String常量,以及基础类型和基础类型的封装类。
对象做锁的时候,要加final,否则锁对象发生变化,可能会造成问题。
【volatile】
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。
volatile变量保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
由volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令。
- 将当前处理器缓存行的数据写回到系统内存;
- 这个写回内存的操作会告知在其他CPU你们拿到的变量是无效的,下一次使用的时候要重新从共享内存内拿。
当对非volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU cache中。
当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
被volatile修饰的变量在进行写操作时,会生成一个特殊的汇编指令,该指令会触发mesi协议「缓存一致性协议」,会存在一个总线嗅探机制的东西,简单来说就是这个机制会不停检测总线中该变量的变化,如果该变量一旦变化了,由于这个嗅探机制,其它cpu会立马将该变量的cpu缓存数据清空掉,重新的去从主内存拿到这个数据。
volitale尽量去修饰简单类型,不要去修饰引用类型,因为volatile关键字对于基本类型的修改可以在随后对多个线程的读保持一致,但是对于引用类型如数组,实体bean,仅仅保证引用的可见性,但并不保证引用内容的可见性。
保证线程可见性;(MESI/CPU的缓存一致性协议)
禁止指令重排序。
laodfence源语指令
storefence源语指令
单例模式里面的双重检查,要加volatile。(主要是因为实例的初始化顺序有可能被改变,这样第二个线程访问的时候可能访问到了未初始化完成的实例)。
不过以上这种情况只会在超高并发的情况下才能出现,一般情况下是很难出现的,专门写在这里呢,是给准备面试的同学写的。
- synchronized 和 volatile
volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;
synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;
synchronized则可以使用在变量、方法、和类级别的。
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性。
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
--------------第二部分,咱们说一说那些充斥在网络博客里面的各种锁--------------
【悲观锁 · 乐观锁】
- 悲观锁
总是假设最坏的情况,每次使用数据都认为别人会修改,所以每次在拿数据的时候都会上锁。
这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
- 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。
乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
乐观锁适用于写比较少的情况,因为节省了很多锁的开销,但是如果写比较多的话,可能会造成频繁的retry操作,反倒会降低性能。
ABA 问题,修改过两次之后,其实值已经被修改过了,但是没有识别。「还要考虑到引用的情况,虽然指针没有发生变化,但是引用内容已经变了」
ABA问题(主要是针对对象会有问题,基础类型一般无所谓,可以通过加Version解决)
- 什么是CAS?
Compare and Swap,即比较再交换
对CAS的理解,CAS是一种无锁算法,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。
当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
CAS的实现依赖于CPU原语支持(中途不会被打断)。
如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。
当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。
- Unsafe
简单讲一下这个类。
Java无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM还是开了一个后门,那就是Unsafe类,它提供了硬件级别的原子操作。
所有的CAS都是用Unsafe去实现的。
CAS直接操作java虚拟机里面的内存。(可以直接通过偏移量,定位到内存里面某个变量的值,然后修改)「这就是为什么cas我们可以认为是原子的」
【自旋锁】
线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。
同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
自旋锁占用CPU,但是不访问操作系统(不经过内核态),所以一直是用户态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。
自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。
【适应性自旋锁】
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
--------------第三部分,我们详细阐述开发者和JVM对于锁的一些优化设计和思路--------------
【锁细化】
锁的细化主要分为两个方面。
第一,synchronized锁住的内容越少越好,所以有些情况下,可能会采取一些(比如说分段锁,HASH锁,弱引用锁等)措施,提高同步效率。
第二,synchronized锁住的代码越少越好,代码越少,临界区的时长就越少,锁等待时间也就越少。
【锁粗化】
正常来讲,对于开发者来说,锁住的代码范围越小越好,但是在有些时候,我们需要将锁进行粗化处理。
意思就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
【锁消除】
为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。
锁消除的依据是逃逸分析的数据支持。
如:StringBuffer的append方法。
StringBuffer.append()锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域被限制在concatString()方法内部。
也就是sb的所有引用永远不会"逃逸"到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地削除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
进行逃逸分析之后,所有的对象都将由栈上分配,而非从JVM内存模型中的堆来分配。
逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。
【锁升级】
对于锁而言,一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。
1. 当没有竞争出现时,默认使用偏向锁。
JVM会利用CAS操作,在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。
2. 如果有另外的线程试图锁定某个已经被偏向过的对象,JVM就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。
轻量级锁依赖 CAS 操作Mark Word来试图获取锁,如果重试成功,就使用轻量级锁;
3. 否则在自旋一定次数后进一步升级为重量级锁。
- 偏向锁
为什么要引入偏向锁?
因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
现在几乎所有的锁都是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象。
按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用;
因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象"偏向"这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量「对象头上的Mark Word部分设置线程ID」,如果发现为true则无需再走各种加锁/解锁流程。
这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。
偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;
如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;
- 轻量级锁
对于绝大部分的锁,在整个同步周期内都是不存在竞争的,轻量级锁使用CAS操作,避免使用互斥量。
如果存在竞争,除了互斥量的开销,还有CAS的操作,不仅没有提升,反而性能会下降
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
- 重量级锁
Synchronized是通过对象内部的一个叫做监视器锁(Monitor)来实现的。
重量级锁不占用CPU。
但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。
而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 "重量级锁"。
注:锁是只可以升级不可以降级的。
- 对比
锁 |
优点 |
缺点 |
适用场景 |
偏向锁 |
加锁和解锁不需要额外的消耗,和执行非同步方法效率几乎一样 |
如果线程间存在锁竞争,会带来额外的锁撤销的消耗。 |
适用于只有一个线程访问同步块的场景 |
轻量级锁 |
竞争的线程不会阻塞,提高了程序的响应速度。 |
如果始终得不到锁竞争的线程,会一直消耗CPU。 |
追求响应时间; |
重量级锁 |
线程竞争不会消耗CPU |
线程阻塞,响应时间缓慢。 |
追求吞吐量; |
--------------第三部分,我们加一些其他的没说到的锁的概念,补全锁的内容--------------
【其他一些锁概念】
- 分段锁
分段锁(SegmentLock)就是简单的将锁细粒度化,将一个锁分成两段或者多段,线程根据自己操作的段来加锁解锁。
这样做可以避免线程之间互相无意义的等待,减少线程的等待时间。常见的应用有ConcurrentHashMap,它内部实现了Segment<K,V>继承了ReentrantLock,分成了16段。
当然,其实在jdk1.8中,ConcurrentHashMap也开始使用CAS的方式。
- 排它锁 - 共享锁
排它锁又叫互斥锁、独占锁、写锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。如ReentrantLock。
共享锁又称读锁,就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。比如说ReadWriteLock。
- 公平锁
公平锁就是遵循了先到先得的原则,多个线程按照申请锁的顺序来获取锁。
- 可重入锁
可重入锁的意思就是,加入方法 A 获得锁并加锁之后调用了方法 B,而方法 B 也需要锁,这样会导致死锁,可重入锁则会让调用方法 B 的时候自动获得锁。
Java 中是通过lockedBy字段判断加锁的线程是不是同一个。
synchronized是可重入锁,也必须是可重入锁,不然的话子类没有办法调用父类的方法。
概念很多,但是一定要牢记,这些不光是在工作的过程中需要熟悉掌握,而且在面试的时候是一定会被问到的,所以近期在找工作的同学,锁的内容是重中之重,一定要多看多练多记。
本篇我们讲述了多线程中的锁的内容,几乎所有的锁相关的东西都讲到了,但是深度其实还远远不够,我们在下一篇会聊一聊JUC下的各种同步锁,再往后的时候,如果有时间,会尽量加一些源码相关的分析,我们从实现层面看一看这些锁是怎么玩的。
作为程序员,没有兴趣和热情,实在是太难坚持下去了,希望这个系列以后的内容,尽量多的去透过现象看本质,现在写的这些琐里琐杂的东西,全都浮于表面,应付工作和面试,无聊又无聊矣。