以上锁分类并不全指锁的状态,有些是锁的特性、设计。
公平锁:按线程申请锁的先后顺序获得锁 非公平锁:线程不是按先后顺序获得锁,效率比公平锁效率高
可重入锁:已获得的锁在内层可重复使用 不可重入锁:已获得的锁在内层不可重复使用
悲观锁:每次都假定访问时会有多个线程,执行代码前都要加锁,加锁成功后,其它线程处于阻塞状态 乐观锁:其实就是无锁,CAS就是无锁,每次都假定访问时只有自己一个线程
自旋锁:当一个线程在获得锁的时候,有其它线程获得锁,那么该线程就循环判断是否能获得锁,直至其它线程释放锁,然后获得锁。
分段锁:它是一种锁的设计,ConcurrentHashMap就是将hash数据分成多段,持有多把锁,其实就是分成多个hashmap,只有当线程访问同一把锁时才会阻塞,提高了效率。
互斥锁:加锁的代码块只能允许一个线程访问,其它线程则会阻塞 读写锁:它的read模式是共享锁、write模式是互斥锁。读锁和读锁能共存,读锁和写锁、写锁和写锁均不能共存
独享锁:就是互斥锁,该锁每次只能有一个线程访问 共享锁:该锁可以有多个线程访问
偏向锁:同一代码块一直被同一线程获得锁,那么下次将自动获得锁,降低获得锁的代价
轻量级锁:当锁是偏向锁时,被其它线程访问,偏向锁就会升级为轻量级锁,通过自旋转来获取锁,不会阻塞,提高性能
重量级锁:当轻量级锁时,线程一直自旋,当自旋次数达到一定次数时,将提升为重量级锁,让其它申请锁的线程进入阻塞状态,降低cpu的负载。synchronized是重量级锁。
公平锁:公平锁是指多个线程申请锁的顺序来获取锁,类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。
非公平锁:非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能申请的线程比先申请的线程优先获取锁。在高并发环境下,有可能造成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。
对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大
对于Synchronized而言,是一种非公平锁。由于其不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁
可重入锁的原理: 通过组合自定义同步器来实现锁的获取与释放。
再次获取锁:识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。获取锁后,进行计数自增,
释放锁:释放锁时,进行计数自减。
synchronized void setA() throws InterruptedException { TimeUnit.SECONDS.sleep(1); setB(); } synchronized void setB() throws InterruptedException { TimeUnit.SECONDS.sleep(1); }
上面的代码就是可重入锁的一个特点,如果不是可重入锁的话,setB()可能不会被当前线程执行,可能造成死锁。
面试题1: 可重入锁如果加了两把,但是只释放了一把会出现什么问题? 答:程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。 面试题2: 如果只加了一把锁,释放两次会出现什么问题? 答:会报错,java.lang.IllegalMonitorStateException。
不可重入锁:不可重入锁不可递归调用,递归调用就发生死锁
// 使用自旋锁来模拟一个不可重入锁 public class UnreentrantLock { private AtomicReference owner = new AtomicReference<>(); public void lock() { Thread current = Thread.currentThread(); for (;;) { if (owner.compareAndSet(null, current)) { return; } } }
public void unlock() { Thread current = Thread.currentThread(); owner.compareAndSet(current, null); }
}
ReentrantLock中可重入锁实现:
非公平锁的锁获取方法:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
在AQS中维护了一个volatile修饰的state成员变量来计数重入次数,避免了频繁的持有释放操作,这样提升了效率,又避免了死锁
补充: 什么是AQS?
AQS(AbstractQueuedSynchronizer)使用一个int成员变量来表示 同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作,状态信息通过protected类型的getState,setState,compareAndSetState来进行操作
AQS支持独占式和共享式这两种同步方法,独占式如ReentrantLock,共享式如Semaphore,CountDownLatch,组合式如ReentrantReadWriteLock。
同步器的设计基于模板方法模式,使用方法如下:
继承AbstractQueuedSynchronizer并重写指定方法(重写方法simple,就是对共享资源state的获取和释放)
将AQS组合在自定义同步组件的实现中,并调用模板方法,这些模板方法会调用使用者重写的方法。
排它锁:排它锁在同一时刻只允许一个线程进行访问
共享锁:共享锁在同一时刻可以允许多个线程访问,典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确实每次只能被独占。
排它锁和共享锁也是通过AQS来实现的
独占锁:是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。
互斥锁(排它锁):互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性,互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。
- 读-读互斥
- 读-写互斥
- 写-读互斥
- 写-写互斥
读写锁:既是互斥锁,又是共享锁,read模式是共享,write模式是互斥的
读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。
读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。
写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。在Java中的具体实现就是ReadWriteLock。
public interface ReadWriteLock { /** * 获取读锁 */ Lock readLock(); /** * 获取写锁 */ Lock writeLock(); }
乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
举个生活中的例子,假设厕所只有一个坑位了,乐观锁认为:这荒郊野外的,又没有什么人,不会有人抢我坑位的,每次关门上锁多浪费时间,还是不加锁好了。
回到代码世界中,乐观锁操作数据时不会上锁,在更新的时候会判断一下在此期间是否有其他线程去更新这个数据。
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程)。传统的关系型数据库里面就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。Java中的synchronized和ReentrantLock等独占锁就是悲观锁思想的实现
举个生活中的例子,假设厕所只有一个坑位了,悲观锁上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这种状态就是「阻塞」了。
回到代码世界中,一个共享数据加了悲观锁,那线程每次想操作这个数据前都会假设其他线程也可能会操作这个数据,所以每次操作前都会上锁,这样其他线程想操作这个数据拿不到锁只能阻塞了。
分段锁是一种锁的设计,并不是具体的一种锁。在某些情况下我们将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,因此,分段锁也是提升多并发程序性能的重要手段之一。这就是JDK1.7中ConcurrentHashMap使用的锁分段技术。
ConcurrentHashMap原理:
它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 是基于粒度更小的分段锁,被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项key-value,就仅仅针对数组中的一项进行加锁操作,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该key-value应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行put操作,只要被加入的key-value不存放在同一个段中,则线程间可以做到真正的并行。
线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会降低性能。使用独占锁时保护受限资源的时候,基本上是采用串行方式——每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。
一般有三种方式降低锁的竞争程序:
减少锁的持有时间
降低锁的请求频率
使用带有协调机制的独占锁,这些机制允许更高的并发性
JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。
锁的状态是通过对象监视器在对象头中的字段来表明的。四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级。这四种状态都不是Java语言中的锁,而是JVM为了提高锁的获取与释放效率而做的优化(使用synchronized时)
无锁状态其实就是上面讲的乐观锁,不再赘述。
偏向锁是指是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁,以降低获取锁的代价。
偏向锁是JDK6时加入的一种锁优化机制:在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。
优点: 把整个同步都消除掉,连CAS操作都不去做了,优于轻量级锁。
缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁(等待上一个线程释放锁)。轻量级锁是JDK6时加入的一种锁优化机制: 轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。轻量级锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。
轻量级锁优缺点:
优点: 如果没有竞争,通过CAS操作成功避免了使用互斥量的开销。
缺点: 如果存在竞争,除了互斥量本身的开销外,还额外产生了CAS操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁,简单来说就是一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞,性能降低。
升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。
重量级锁是一种称谓:synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本身依赖底层的操作系统的Mutex Lock来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高。这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。为了优化synchonized,引入了轻量级锁,偏向锁。
在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。
CAS算法是乐观锁的一种实现方式,CAS算法中有涉及到自旋锁
CAS是英文单词Compare and Swap(比较并交换),是一种无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步。CAS算法涉及到3个操作数
需要读写的内存值V
进行比较的值A
写入的新值B
更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B,否则不会执行任何操作。一般情况下是一个自旋操作,即不断地重试
自旋锁:是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断地判断锁是否能够被成功获取,直到获取到锁才会退出循环。
它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就是说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。
自旋锁的优点:避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。
自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。
自旋锁次数默认值:10次,可以使用参数-XX
来自行更改。自适应自旋: 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。
public class SpinLock { private AtomicReference<Thread> cas = new AtomicReference<>(); public void lock() { Thread current = Thread.currentThread(); // 利用CAS while (!cas.compareAndSet(null, current)) { // do nothing } } public void unlock() { Thread current = Thread.currentThread(); cas.compareAndSet(current, null); } }
lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不会判断是否满足CAS,直到A线程调用unlock()方法释放了该锁
如果某个线程持有锁的时间过长,就会导致其他等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高
上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在线程饥饿问题
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换(线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
自旋锁与互斥锁都是为了实现保护资源共享的机制 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态;获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放
锁粗化是一种优化技术: 如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。简单来说就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。
举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。
private static final Object LOCK = new Object(); for(int i = 0;i < 100; i++) { synchronized(LOCK){ // do some magic things } }
经过锁粗化后就变成下面这个样子了:
synchronized(LOCK){ for(int i = 0;i < 100; i++) { // do some magic things } }
锁消除是一种优化技术:就是把锁干掉。指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。
那如何判断共享数据不会被线程竞争?
利用
逃逸分析技术
:分析对象的作用域,如果对象在A方法中定义后,被作为参数传递到B方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。
举个例子让大家更好理解。
public String test(String s1, String s2){ StringBuffer stringBuffer = new StringBuffer(); stringBuffer.append(s1); stringBuffer.append(s2); return stringBuffer.toString(); }
上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。
test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。
我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除。
StringBuffer.class // append 是同步方法 public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; }
同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。
**死锁是一种现象:**如线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。
Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。
synchronized是Java中的关键字:用来修饰方法、对象实例。属于独占锁、悲观锁、可重入锁、非公平锁。
每个对象都有个 monitor 对象, 加锁就是在竞争 monitor 对象,代码块加锁是在代码块前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个标记位来判断的。
Lock
: 是Java中的接口,可重入锁、悲观锁、独占锁、互斥锁、同步锁。
synchronized的优势:
ReentrantLock是Java中的类 : 继承了Lock类,可重入锁、悲观锁、独占锁、互斥锁、同步锁。
相同点:
不同点:
本文作者:wjc
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC 许可协议。转载请注明出处!