Java锁之前世今生
1、概述
为保障多线程下处理共享数据的安全性,Java 语言给我们提供了线程锁,保证同一时刻只有一个线程能处理共享数据。当一个锁被某个线程持有的时候,另一个线程尝试去获取这个锁将产生线程阻塞,直到持有锁的线程释放了该锁。
不过值得注意的是,能产生线程阻塞的不仅仅是锁,Object.wait(),Thread.sleep(), ArrayBlockingQueue.put()等也能产生类似的效果,包括while(true)也是,自旋锁就是通过循环 do while来实现。这里有必要介绍一下几种我们常说的几个名词的概念
- 自旋锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长,在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁,所谓自旋,不过就是原地进行循环,不停去获取锁 - 适应性自旋锁
自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。 - 悲观锁
对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。 - 乐观锁
使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试[自旋操作])乐观锁的实现方式:
CAS算法涉及到三个操作数:
1.需要读写的内存值 V。
2.进行比较的值 A。
3.要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁
Java加锁的方式有两种:[这两种都是悲观锁的实现]
- Synchronized
- 显式Lock
2、volatile
被volatile修饰的变量具有可见性,可见性也就是说一旦某个线程修改了该被volatile修饰的变量,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,可以立即获取修改之后的值。在Java中为了加快程序的运行效率,对一些变量的操作通常是在该线程的寄存器或是CPU缓存上进行的,之后才会同步到主存中,而加了volatile修饰符的变量则是直接读写主存
volatile和synchronized的区别
- volatile虽然具有可见性但是并不能保证原子性,
原子性:指一个操作是不可中断的,要么全部执行成功要么全部执行失败,在Java中,对基本数据类型的变量和赋值操作都是原子性操作,i++这种就不属于原子操作【此处可拓展:原子性,可见性,有序性】
参考并发编程——原子性,可见性和有序性 - volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
3、Synchronized
Synchronized是java中的一个关键字,可以用来修饰类方法,对象方法和代码块,如下所示
|
|
类锁和对象锁没有多大关系,两者并不冲突,在多线程运行进入对象锁的方法中时,其它线程也还是可以访问被类锁加锁的方法
- 可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁
|
|
3.1、synchronized的原理
- 当对方法加锁时,编译之后会在方法中加入ACC_SYNCHRONIZED标识,加锁应该是交给了虚拟机去实现
- 当对代码块加锁时,编译的字节码中会多出一些指令, monitorenter,monitorexit 来进入和退出同步,而且其中会包含异常处理的代码,来保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行
|
|
monitorenter:
每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,获取规则如下:
1.如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者。
2.如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁。
3.如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。
monitorexit:
只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor
锁升级
如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统所实现的锁我们称之为“重量级锁”
JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级
- 无锁:无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功
- 偏向锁:一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价
- 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能
- 重量级锁:等待锁的线程都会进入阻塞状态
4、显式Lock
基本使用方法
tryLock():
|
|
tryLock(long time, TimeUnit unit)
|
|
其它接口:
ReadWriteLock内部有实现了Lock类接口的ReadLock读锁和写锁WriteLock,以此将文件的读写操作分开,分成2个锁来分配给线程,从而使得多个线程可以同时进行读操作,实现类ReentrantReadWriteLock
|
|
5、结语
Lock锁在刚出来的时候很多性能方面都比Synchronized锁要好,但是从JDK1.6开始Synchronized锁就做了各种的优化,包括适应自旋锁,锁消除,锁粗化,轻量级锁,偏向锁。
所以,到现在Lock锁和Synchronized锁的性能其实差别不是很大!而Synchronized锁用起来又特别简单。Lock锁还得顾忌到它的特性,要手动释放锁才行
名词解释
- 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁
- 锁消除(Lock Elimination):锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。