Java锁相关知识整理
乐观锁、悲观锁
乐观锁与悲观锁是一种广义上的概念
- 乐观锁:乐观锁认为对同一个数据进行并发操作时,其他线程是不会对它修改的,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。
- 悲观锁:悲观锁认为对于同一个数据的并发操作,其他线程一定是对数据进行修改,因此对于同一个数据的并发操作,悲观锁会对它先进行加锁,确保数据不会被其他线程修改。例如synchronized关键字(JDK1.6以前)和Lock的实现都是悲观锁。
使用场景:
- 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
- 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
CAS
CAS全称Compare And Swap,即比较并交换,是一种无锁算法,也是乐观锁的主要实现方式。无锁算法,即在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。
CAS算法涉及到三个操作数:
- 需要读写的内存值 V。
- 进行比较的值 A。
- 要写入的新值 B。
下面看看一个例子:AtomicInteger类自增方法会调用下面这个方法:
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2); //获取当前线程中对象offset偏移量处的值,赋给v
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); //通过和内存中对象offset偏移量处的值是否还等于v
return var5;
}
如果当前线程中对象offset偏移量处的值和内存中的值相等,就把要写入的新值 B 存入内存中。如果不相等就在while循环中再次调用compareAndSwapInt方法直到设置成功为止。上面的compareAndSwapInt是一个native方法,底层实现是一条汇编指令cmpxchg。
CAS存在的问题
-
ABA问题:CAS是在对变量进行修改前对检查内存值是否发生变化,没有发生变化才会更新内存值。如果内存中一个变量的值原来是A,后来变成了B,最后又变成了A,那么CAS进行检查时会发现值没有变化,但实际上它的值已经被修改过了。这就是人们常说的ABA问题。
ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
-
长时间循环导致资源消耗大:如果while循环中的CAS操作长时间不成功,会导致当前线程一直在while循环中自旋,给CPU带来很大的开销。
-
只能保证一个共享变量的原子操作:对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
但是可以通过AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作,从而解决这个问题。
自旋锁和适应性自旋锁
自旋锁
1.自旋锁的概念
如果物理机上有一个以上的处理器,能让两个或两个以上的线程同时执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁。上面CAS中的那个例子就是一个自旋锁。
2.为什么需要自旋锁
阻塞或唤醒一个Java线程这种状态转换需要耗费处理器时间,如果同步代码块中的内容过于简单,线程状态转换消耗的时间有可能比用户代码执行的时间还要长。所以为了节省CPU资源,我们就需要自旋锁来解决这个问题。
3.自旋锁的优点
- 自旋锁使线程一直处于运行状态,即线程一直处于用户态,减少了线程切换,执行速度较快。
4.自旋锁的缺点
- 长时间循环导致资源消耗大,为了解决这个问题,自旋等待的时间必须有一定的限制,如果自旋超过了限定次数(默认值为10次)仍然没有成功获得锁,就应该使用传统方式来挂起线程了。
- 自旋锁是不公平的,即等待时间最长的线程不一定能获得锁,有可能使某些线程处于忙等状态。
适应性自旋锁
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
Synchronized关键字
在 Java 早期版本中,synchronized属于重量级锁,Java 的线程是映射到操作系统的原生线程之上的,如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,这就需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,这是早期 synchronized 效率低的原因。JDK6 之后, JVM 对synchronized 有了较大优化,引入了“偏向锁”和“轻量级锁”。
1.Synchronized关键字的作用
在多线程并发时,确保同一时刻只有一个线程能够访问被synchronized关键字修饰的方法或代码块。
2.synchronized的使用
synchronized关键字使用很简单,这里不再演示。
- 修饰实例方法:对当前对象实例加锁(也就是调用方法的实例对象),进入同步代码前要获得当前对象实例的锁
- 修饰静态方法:会对当前类的Class对象加锁,会作用于类的所有对象实例
- 修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
3.synchronized实现原理(JDK1.6以前的实现)
首先需要搞清楚两个概念:Java对象头和monitor
java对象的内存分布在我的另一篇博客有提到过:JVM——Java对象的创建、内存布局和访问定位,这里再简单说说。java对象的内存分布由对象头
,实例数据
和对齐填充
三部分组成,对象头又分为Mark Word
(对象运行时数据,保存了哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等信息)和类元指针
。
monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象(具体来说就是对象头中的Mark Word)都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
下图展示了Hotspot64位虚拟机不同的锁状态下对象头中的Mark Word内存分布:
JDK1.6以前,synchronized通过monitor实现线程间同步,而monitor是依赖于操作系统的Mutex Lock(互斥量)来实现线程间同步的。
- synchronized方法原理
将Class文件反汇编后可以发现,被synchronized修饰的方法多出了一个ACC_SYNCHRONIZED 标识,表示这是一个同步方法,在执行方法前,当前线程会获得当前类的Class对象的monitor锁,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
- synchronized代码块原理
再通过反编译,可以知道:synchronized代码块中的语句会被monitorenter和monitorexit指令包起来。
在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,就把锁的计算器加1。在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
4.synchronized锁的优化(JDK1.6及以后的实现)
JDK6之前synchronized依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,上面提到过,因为线程切换需要从用户态陷入内核态,这个时间开销很大,效率较低。JDK6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
synchronized锁升级的过程涉及4种锁的状态,级别从低到高依次是:无锁
–>偏向锁
–>轻量级锁
–>重量级锁
,它们随竞争的激烈程度的提升而升级。锁状态只能升级不能降级。
偏向锁
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,此时JVM会给线程加一个偏向锁。如果在运行时遇到了其他线程抢占偏向锁,则持有偏向锁的线程会被挂起,JVM会释放该线程的偏向锁,并将锁升级为轻量级锁。
为什么引入偏向锁
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争。
引入偏向锁就是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS操作,而偏向锁只需要在置换ThreadID的时候进行一次CAS操作即可。
加锁过程
当一个线程访问同步块并获取锁时,会把对象头中的锁标志位设置为 “01” 偏向锁状态,同时使用 CAS 操作,在对象头的Mark Word中和栈帧中的锁记录里存储锁偏向的线程ID。
以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。否则膨胀为轻量级锁。
轻量级锁
加锁过程
当升级为轻量级锁时,JVM 会先在当前线程的栈桢中创建名为锁记录(Lock Record)的空间,并将对象头中的Mark Word复制到锁记录中(称Displaced Mark Word)。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果成功,当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁。
为什么要引入轻量级锁?
因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
synchronized和ReentrantLock 的区别和联系
区别
-
关键字 VS API
synchronized是一个关键字,依赖于JVM实现;而reentrantlock是API层面的互斥锁,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成。
-
ReentrantLock 比 synchronized 增加了一些高级功能:
-
**等待可中断:**ReentrantLock可以使用lock.lockInterruptibly()方法来让正在等待的线程选择放弃等待,改为处理其他事情。
-
公平锁:synchronized是非公平锁,而ReentrantLock虽然默认也是不公平锁,但是可以通过构造方法指定使用公平锁。
公平锁:保障多线程下各线程获取锁的顺序,先到的线程优先获取锁;非公平锁:无法提供这个保障。
-
锁可以绑定多个条件
- ReentrantLock可以同时绑定多个Condition对象,只需多次调用newCondition方法即可。
- synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件。但如果要和多于一个的条件关联的时候,就不得不额外添加一个锁。
-
-
性能上:JDK1.6以前synchronized比ReentrantLock性能差很多,原因上面也提到过了,就是JDK1.6以前synchronized依赖于操作系统的互斥量实现的;
而在JDK1.6及以后,synchronized有了较大的优化,即上面提到的锁升级的过程,此时两者性能以及差不多。
-
自动释放锁 VS 手动释放锁
用sychronized修饰的方法或者语句块在代码执行完之后锁自动释放,而是用Lock需要我们手动释放锁
线程安全需要保证的三个特性
- 原子性:一个线程的相关操作不会中途被其他线程干扰,一般通过同步机制实现。
- 可见性:是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上。
- 有序性:避免指令重排序(指令重排序:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。)。
volatile
volatile关键字用于修饰变量,可以保证指令的可见性
和有序性
为什么需要volatile关键字
因为在JDK1.2前,java线程是共享主内存的,即直接读写内存中的数据;后来变成了线程可以将数据保存在本地变量中,但是这就导致了一个问题:当一个线程将数据修改并保存到主存中后,其他线程的保存的本地变量中的数据还是原来的值,造成数据不一致的问题。