Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本文旨在对锁相关源码(本文中的源码来自JDK 8)、使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。
Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。下面给出本文内容的总体分类目录:
乐观锁与悲观锁是一种广义上的概念,体现了看待线程同步的不同角度。在Java和数据库中都有此概念对应的实际应用。
先说概念。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。
而乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。
乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。
根据从上面的概念描述我们可以发现:
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
光说概念有些抽象,我们来看下乐观锁和悲观锁的调用方式示例:
通过调用方式示例,我们可以发现悲观锁基本都是在显式的锁定之后再操作同步资源,而乐观锁则直接去操作同步资源。那么,为何乐观锁能够做到不锁定同步资源也可以正确的实现线程同步呢?我们通过介绍乐观锁的主要实现方式 “CAS” 的技术原理来为大家解惑。
CAS全称 Compare And Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。java.util.concurrent包中的原子类就是通过CAS来实现了乐观锁。
CAS算法涉及到三个操作数:
需要读写的内存值 V。
进行比较的值 A。
要写入的新值 B。
当且仅当 V 的值等于 A 时,CAS通过原子方式用新值B来更新V的值(“比较+更新”整体是一个原子操作),否则不会执行任何操作。一般情况下,“更新”是一个不断重试的操作。
之前提到java.util.concurrent包中的原子类,就是通过CAS来实现了乐观锁,那么我们进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:
根据定义我们可以看出各属性的作用:
unsafe: 获取并操作内存的数据。
valueOffset: 存储value在AtomicInteger中的偏移量。
value: 存储AtomicInteger的int值,该属性需要借助volatile关键字保证其在线程间是可见的。
接下来,我们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通过class文件中的参数名,并不能很好的了解方法的作用,所以我们通过OpenJDK 8 来查看Unsafe的源码:
根据OpenJDK 8的源码我们可以看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,然后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,继续循环进行重试,直到设置成功才能退出循环,并且将旧值返回。整个“比较+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令完成的,属于原子操作,可以保证多个线程都能够看到同一个变量的修改值。
后续JDK通过CPU的cmpxchg指令,去比较寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。然后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置成功为止。
CAS虽然很高效,但是它也存在三大问题,这里也简单说一下:
1. ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。
JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,具体操作封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
2. 循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
3. 只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
Java从1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
在介绍自旋锁前,我们需要介绍一些前提知识来帮助大家明白自旋锁的概念。
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。这就是自旋锁。
自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。
自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK 6中变为默认开启,并且引入了自适应的自旋锁(适应性自旋锁)。
自适应意味着自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
在自旋锁中 另有三种常见的锁形式:TicketLock、CLHlock和MCSlock,本文中仅做名词介绍,不做深入讲解,感兴趣的同学可以自行查阅相关资料。
这四种锁是指锁的状态,专门针对synchronized的。在介绍这四种锁状态之前还需要介绍一些额外的知识。
首先为什么Synchronized能实现线程同步?
在回答这个问题之前我们需要了解两个重要的概念:“Java对象头”、“Monitor”。
Java对象头
synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,而Java对象头又是什么呢?
我们以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Monitor
Monitor可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。
Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
现在话题回到synchronized,synchronized通过Monitor来实现线程同步,Monitor是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的线程同步。
如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。
通过上面的介绍,我们对synchronized的加锁机制以及相关知识有了一个了解,那么下面我们给出四种锁状态对应的的Mark Word内容,然后再分别讲解四种锁状态的思路以及特点:
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁在JDK 6及以后的JVM里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,关闭之后程序默认会进入轻量级锁状态。
轻量级锁
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,然后拷贝对象头中的Mark Word复制到锁记录中。
拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。
如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
整体的锁状态升级流程如下:
综上,偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。
公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。
非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
直接用语言描述可能有点抽象,这里作者用从别处看到的一个例子来讲述一下公平锁和非公平锁。
如上图所示,假设有一口水井,有管理员看守,管理员有一把锁,只有拿到锁的人才能够打水,打完水要把锁还给管理员。每个过来打水的人都要管理员的允许并拿到锁之后才能去打水,如果前面有人正在打水,那么这个想要打水的人就必须排队。管理员会查看下一个要去打水的人是不是队伍里排最前面的人,如果是的话,才会给你锁让你去打水;如果你不是排第一的人,就必须去队尾排队,这就是公平锁。
但是对于非公平锁,管理员对打水的人没有要求。即使等待队伍里有排队等待的人,但如果在上一个人刚打完水把锁还给管理员而且管理员还没有允许等待队伍里下一个人去打水时,刚好来了一个插队的人,这个插队的人是可以直接从管理员那里拿到锁去打水,不需要排队,原本排队等待的人只能继续等待。如下图所示:
接下来我们通过ReentrantLock的源码来讲解公平锁和非公平锁。
根据代码可知,ReentrantLock里面有一个内部类Sync,Sync继承AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在Sync中实现的。它有公平锁FairSync和非公平锁NonfairSync两个子类。ReentrantLock默认使用非公平锁,也可以通过构造器来显示的指定使用公平锁。
下面我们来看一下公平锁与非公平锁的加锁方法的源码:
通过上图中的源代码对比,我们可以明显的看出公平锁与非公平锁的lock()方法唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors()。
再进入hasQueuedPredecessors(),可以看到该方法主要做一件事情:主要是判断当前线程是否位于同步队列中的第一个。如果是则返回true,否则返回false。
综上,公平锁就是通过同步队列来实现多个线程按照申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待问题,直接尝试获取锁,所以存在后申请却先获得锁的情况。
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:
在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。
如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。
而为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?我们通过图示和源码来分别解析一下。
还是打水的例子,有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。这就是可重入锁。
但如果是非可重入锁的话,此时管理员只允许锁和同一个人的一个水桶绑定。第一个水桶和锁绑定打完水之后并不会释放锁,导致第二个水桶不能和锁绑定也无法打水。当前线程出现死锁,整个等待队列中的所有线程都无法被唤醒。
之前我们说过ReentrantLock和synchronized都是重入锁,那么我们通过重入锁ReentrantLock以及非可重入锁NonReentrantLock的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。
首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。
当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。
释放锁时,可重入锁同样先获取当前status的值,在当前线程是持有锁的线程的前提下。如果status-1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将status置为0,将锁释放。
独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。
独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。
共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。
独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
下图为ReentrantReadWriteLock的部分源码:
我们看到ReentrantReadWriteLock有两把锁:ReadLock和WriteLock,由词知意,一个读锁一个写锁,合称“读写锁”。再进一步观察可以发现ReadLock和WriteLock是靠内部类Sync实现的锁。Sync是AQS的一个子类,这种结构在CountDownLatch、ReentrantLock、Semaphore里面也都存在。
在ReentrantReadWriteLock里面,读锁和写锁的锁主体都是Sync,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。
那读锁和写锁的具体加锁方式有什么区别呢?在了解源码之前我们需要回顾一下其他知识。
在最开始提及AQS的时候我们也提到了state字段(int类型,32位),该字段用来描述有多少线程获持有锁。
在独享锁中这个值通常是0或者1(如果是重入锁的话state值就是重入的次数),在共享锁中state就是持有锁的数量。但是在ReentrantReadWriteLock中有读、写两把锁,所以需要在一个整型变量state上分别描述读锁和写锁的数量(或者也可以叫状态)。于是将state变量“按位切割”切分成了两个部分,高16位表示读锁状态(读锁个数),低16位表示写锁状态(写锁个数)。如下图所示:
了解了概念之后我们再来看代码,先看写锁的加锁源码:
这段代码首先取到当前锁的个数c,然后再通过c来获取写锁的个数w。因为写锁是低16位,所以取低16位的最大值与当前的c做与运算( int w = exclusiveCount(c); ),高16位和0与运算后是0,剩下的就是低位运算的值,同时也是持有写锁的线程数目。
在取到写锁线程的数目后,首先判断是否已经有线程持有了锁。如果已经有线程持有了锁(c!=0),则查看当前写锁线程的数目,如果写线程数为0(即此时存在读锁)或者持有锁的线程不是当前线程就返回失败(涉及到公平锁和非公平锁的实现)。
如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
如果当且写线程数为0(那么读线程也应该为0,因为上面已经处理c!=0的情况),并且当前线程需要阻塞那么就返回失败;如果通过CAS增加写线程数失败也返回失败。
如果c=0,w=0或者c>0,w>0(重入),则设置当前线程或锁的拥有者,返回成功!
tryAcquire()除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:必须确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。
因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,然后等待的读写线程才能够继续访问读写锁,同时前次写线程的修改对后续的读写线程可见。
接着是读锁的代码:
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
此时,我们再回头看一下互斥锁ReentrantLock中公平锁和非公平锁的加锁源码:
我们发现在ReentrantLock虽然有公平锁和非公平锁两种,但是它们添加的都是独享锁。根据源码所示,当某一个线程调用lock方法获取锁时,如果同步资源没有被其他线程锁住,那么当前线程在使用CAS更新state成功后就会成功抢占该资源。而如果公共资源被占用且不是被当前线程占用,那么就会加锁失败。所以可以确定ReentrantLock无论读操作还是写操作,添加的锁都是都是独享锁。
一、由ReentrantLock和synchronized实现的一系列锁
jdk1.5的java.util.concurrent并发包中的Lock接口和1.5之前的synchronized或许是我们最常用的同步方式,这两种同步方式特别是Lock的ReentrantLock实现,经常拿来进行比较,其实他们有很多相似之处,其实它们在实现同步的思想上大致相同,只不过在一些细节的策略上(诸如抛出异常是否自动释放锁)有所不同。前边说过了,本文着重讲锁的实现思想和不同锁的概念与分类,不对实现原理的细节深究,因此我在下面介绍第一类锁的时候经常讲他们放在一起来说。我们先来说一下Lock接口的实现之一ReentrantLock。当我们想要创建ReentrantLock实例的时候,jdk为我们提供两种重载的构造函数,如图:
fair是什么意思?公平的意思,没错,这就是我们要说的第一种锁。
1.从其它等待中的线程是否按顺序获取锁的角度划分--公平锁与非公平锁
我先做个形象比喻,比如现在有一个餐厅,一次最多只允许一个持有钥匙的人进入用餐,那么其他没拿到钥匙的人就要在门口等着,等里面那个人吃完了,他出来他把钥匙扔地上,后边拿到钥匙的人才能进入餐厅用餐。
公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。所用公平锁就好像在餐厅的门口安装了一个排队的护栏,谁先来的谁就站的靠前,无法进行插队,当餐厅中的人用餐结束后会把钥匙交给排在最前边的那个人,以此类推。公平锁的好处是,可以保证每个排队的人都有饭吃,先到先吃后到后吃。但是弊端是,要额外安装排队装置。
非公平锁:理解了公平锁,非公平锁就很好理解了,它无非就是不用排队,当餐厅里的人出来后将钥匙往地上一扔,谁抢到算谁的。但是这样就造成了一个问题,那些身强体壮的人可能总是会先抢到钥匙,而那些身体瘦小的人可能一直抢不到,这就有可能将一直抢不到钥匙,最后导致需要很长时间才能拿到钥匙甚至一直拿不到直至饿死。
公平锁与非公平所的总结:
(1) 公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。其中的原因是公平锁是严格按照请求所的顺序来排队获得锁的,而非公平锁时可以抢占的,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。
(2) 在java中,公平锁可以通过new ReentrantLock(true)来实现;非公平锁可以通过new ReentrantLock(false)或者默认构造函数new ReentrantLock()实现。
(3)synchronized是非公平锁,并且它无法实现公平锁。
2.从能否有多个线程持有同一把锁的角度划分--互斥锁
互斥锁的概念非常简单,也就是我们常说的同步,即一次最多只能有一个线程持有的锁,当一个线程持有该锁的时候其它线程无法进入上锁的区域。在Java中synchronized就是互斥锁,从宏观概念来讲,互斥锁就是通过悲观锁的理念引出来的,而非互斥锁则是通过乐观锁的概念引申的。
3.从一个线程能否递归获取自己的锁的角度划分--重入锁(递归锁)
我们知道,一条线程若想进入一个被上锁的区域,首先要判断这个区域的锁是否已经被某条线程所持有。如果锁正在被持有那么线程将等待锁的释放,但是这就引发了一个问题,我们来看这样一段简单的代码:
public class ReentrantDemo {
private Lock mLock;
public ReentrantDemo(Lock mLock) {
this.mLock = mLock;
}
public void outer() {
mLock.lock();
inner();
mLock.unlock();
}
public void inner() {
mLock.lock();
// do something
mLock.unlock();
}
}
当线程A调用outer()方法的时候,会进入使用传进来mlock实例来进行mlock.lock()加锁,此时outer()方法中的这片区域的锁mlock就被线程A持有了,当线程B想要调用outer()方法时会先判断,发现这个mlock这把锁被其它线程持有了,因此进入等待状态。我们现在不考虑线程B,单说线程A,线程A进入outer()方法后,它还要调用inner()方法,并且inner()方法中使用的也是mlock()这把锁,于是接下来有趣的事情就来了。按正常步骤来说,线程A先判断mlock这把锁是否已经被持有了,判断后发现这把锁确实被持有了,但是可笑的是,是A自己持有的。那你说A能否在加了mlock锁的outer()方法中调用加了mlock锁的inner方法呢?答案是如果我们使用的是可重入锁,那么递归调用自己持有的那把锁的时候,是允许进入的。
可重入锁:可以再次进入方法A,就是说在释放锁前此线程可以再次进入方法A(方法A递归)。
不可重入锁(自旋锁):不可以再次进入方法A,也就是说获得锁进入方法A是此线程在释放锁钱唯一的一次进入方法A。
下面这段代码演示了不可重入锁:
public class Lock{
private boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
}
可以看到,当isLocked被设置为true后,在线程调用unlock()解锁之前不管线程是否已经获得锁,都只能wait()。
4.从编译器优化的角度划分--锁消除和锁粗化
锁消除和锁粗化,是编译器在编译代码阶段,对一些没有必要的、不会引起安全问题的同步代码取消同步(锁消除)或者对那些多次执行同步的代码且它们可以可并到一次同步的代码(锁粗化)进行的优化手段,从而提高程序的执行效率。
锁消除
对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而能被其他线程访问到,那就可以把他们当做栈上数据对待,认为他们是线程私有的,同步加锁自然就无需进行。
来看这样一个方法:
public String concatString(String s1, String s2, String s3)
{
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
源码中StringBuffer 的append方法定义如下:
public synchronized StringBuffer append(StringBuffer sb) {
super.append(sb);
return this;
}
可见append的方法使用synchronized进行同步,我们知道对象的实例总是存在于堆中被多有线程共享,即使在局部方法中创建的实例依然存在于堆中,但是对该实例的引用是线程私有的,对其他线程不可见。即上边代码中虽然StringBuffer的实例是共享数据,但是对该实例的引用确实每条线程内部私有的。不同的线程引用的是堆中存在的不同的StringBuffer实例,它们互不影响互不可见。也就是说在concatString()方法中涉及了同步操作。但是可以观察到sb对象它的作用域被限制在方法的内部,也就是sb对象不会“逃逸”出去,其他线程无法访问。因此,虽然这里有锁,但是可以被安全的消除,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
锁粗化
原则上,我们在编写代码的时候,总是要将同步块的作用范围限制的尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁禁止,那等待的线程也能尽快拿到锁。大部分情况下,这些都是正确的。但是,如果一系列的联系操作都是同一个对象反复加上和解锁,甚至加锁操作是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也导致不必要的性能损耗。
举个案例,类似上面锁消除的concatString()方法。如果StringBuffer sb = new StringBuffer();定义在方法体之外,那么就会有线程竞争,但是每个append()操作都对同一个对象反复加锁解锁,那么虚拟机探测到有这样的情况的话,会把加锁同步的范围扩展到整个操作序列的外部,即扩展到第一个append()操作之前和最后一个append()操作之后,这样的一个锁范围扩展的操作就称之为锁粗化。
5.在不同的位置使用synchronized--类锁和对象锁
这是最常见的锁了,synchronized作为锁来使用的时候,无非就只能出现在两个地方(其实还能修饰变量,但作用是保证可见性,这里讨论锁,故不阐述):代码块、方法(一般方法、静态方法)。由于可以使用不同的类型来作为锁,因此分成了类锁和对象锁。
类锁:使用字节码文件(即.class)作为锁。如静态同步函数(使用本类的.class),同步代码块中使用.class。
对象锁:使用对象作为锁。如同步函数(使用本类实例,即this),同步代码块中是用引用的对象。
下面代码涵盖了所有synchronized的使用方式:
public class Demo {
public Object obj = new Object();
public static synchronized void method1() { //1.静态同步函数,使用本类字节码做类锁(即Demo.class)
}
public void method2() {
synchronized (Demo.class) { //同步代码块,使用字节码做类锁
}
}
public synchronized void method3() { //同步函数,使用本类对象实例即this做对象锁
}
public void method4() {
synchronized (this) { //同步代码块,使用本类对象实例即this做对象锁
}
}
public void method5() {
synchronized (obj) { //同步代码块,使用共享数据obj实例做对象锁。
}
}
}
二、从锁的设计理念来分类--悲观锁、乐观锁
如果将锁在宏观上进行大的分类,那么所只有两类,即悲观锁和乐观锁。
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
乐观锁的实现思想--CAS(Compare and Swap)无锁
CAS并不是一种实际的锁,它仅仅是实现乐观锁的一种思想,java中的乐观锁(如自旋锁)基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
关于CAS的原理,有兴趣可以参看 JAVA CAS实现原理与使用。另外,在Java中,java.util.concurrent.atomic包下的原子类也都是基于CAS实现的。
前两节的结构图
三、数据库中常用到的锁--共享锁、排它锁
共享锁和排它锁多用于数据库中的事物操作,主要针对读和写的操作。而在Java中,对这组概念通过ReentrantReadWriteLock进行了实现,它的理念和数据库中共享锁与排它锁的理念几乎一致,即一条线程进行读的时候,允许其他线程进入上锁的区域中进行读操作;当一条线程进行写操作的时候,不允许其他线程进入进行任何操作。即读+读可以存在,读+写、写+写均不允许存在
共享锁:也称读锁或S锁。如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。
排它锁:也称独占锁、写锁或X锁。如果事务T对数据A加上排它锁后,则其他事务不能再对A加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。
四、对锁的不同效率进行的分类--偏向锁、轻量级锁和重量级锁
由于不同的锁的实现原理不同,故它们的效率肯定也会不尽相同,那么我们在不同的应用场景下究竟该选择何种锁呢?基于这个问题,锁被分成了偏向锁、轻量级锁和重量级锁以便应对不同的应用场景。具体请参考:java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁。
五、由于并发问题产生的锁--死锁、活锁
死锁
1.什么是死锁
所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。下面我通过一些实例来说明死锁现象。
先看生活中的一个实例,2个人一起吃饭但是只有一双筷子,2人轮流吃(同时拥有2只筷子才能吃)。某一个时候,一个拿了左筷子,一人拿了右筷子,2个人都同时占用一个资源,等待另一个资源,这个时候甲在等待乙吃完并释放它占有的筷子,同理,乙也在等待甲吃完并释放它占有的筷子,这样就陷入了一个死循环,谁也无法继续吃饭。
在计算机系统中也存在类似的情况。例如,某计算机系统中只有一台打印机和一台输入 设备,进程P1正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。
2.死锁形成的必要条件
产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生:
互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。即存在一个处于等待状态的进程集合{Pl, P2, ..., pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, ..., n-1),Pn等待的资源被P0占有,如图2-15所示。
活锁
活锁和死锁在表现上是一样的两个线程都没有任何进展,但是区别在于:死锁,两个线程都处于阻塞状态,说白了就是它不会再做任何动作,我们通过查看线程状态是可以分辨出来的。而活锁呢,并不会阻塞,而是一直尝试去获取需要的锁,不断的try,这种情况下线程并没有阻塞所以是活的状态,我们查看线程的状态也会发现线程是正常的,但重要的是整个程序却不能继续执行了,一直在做无用功。举个生动的例子的话,两个人都没有停下来等对方让路,而是都有很有礼貌的给对方让路,但是两个人都在不断朝路的同一个方向移动,这样只是在做无用功,还是不能让对方通过。
————————————————
安全性和活跃度通常相互牵制。我们使用锁来保证线程安全,但是滥用锁可能引起锁顺序死锁。类似地,我们使用线程池和信号量来约束资源的使用,
但是缺不能知晓哪些管辖范围内的活动可能形成的资源死锁。Java应用程序不能从死锁中恢复,所以确保你的设计能够避免死锁出现的先决条件是非常有价值。
一.死锁
经典的“哲学家进餐”问题很好的阐释了死锁。5个哲学家一起出门去吃中餐,他们围坐在一个圆桌边。他们只有五只筷子(不是5双),每两个人中间放有一只。
哲学家边吃边思考,交替进行。每个人都需要获得两只筷子才能吃东西,但是吃后要把筷子放回原处继续思考。有一些管理筷子的算法,使每一个人都能够或多或少,及时
吃到东西(一个饥饿的哲学家试图获得两只临近的筷子,但是如果其中的一只正在被别人占用,那么他英爱放弃其中一只可用的筷子,等待几分钟再尝试)。但是这样做可能导致
一些哲学家或者所有哲学家都饿死 (每个人都迅速捉住自己左边的筷子,然后等待自己右边的筷子变成可用,同时并不放下左边的筷子)。这最后一种情况,当每个人都拥有他人需要的
资源,并且等待其他人正在占有的资源,如果大家一致占有资源,直到获得自己需要却没占有的其他资源,如果大家一致占有资源,直到获得自己需要却没被占有的其他资源,那么就会产生死锁。
当一个线程永远占有一个锁,而其他线程尝试去获得这个锁,那么他们将永远被阻塞。当线程Thread1占有锁A时,想要获得锁B,但是同时线程Thread2持有B锁,并尝试获得A锁,两个线程将永远等待下去。
这种情况是死锁最简单的形式.
例子如下代码:
public class DeadLock {
private static Object lockA = new Object();
private static Object lockB = new Object();
public static void main(String[] args) {
new DeadLock().deadLock();
}
private void deadLock() {
Thread thread1 = new Thread(new Runnable() {
public void run() {
synchronized (lockA){
try {
System.out.println(Thread.currentThread().getName() + "获取A锁 ing!");
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + "睡眠500ms");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "需要B锁!!!");
synchronized (lockB){
System.out.println(Thread.currentThread().getName() + "B锁获取成功");
}
}
}
},"Thread1");
Thread thread2 = new Thread(new Runnable() {
public void run() {
synchronized (lockB){
try {
System.out.println(Thread.currentThread().getName() + "获取B锁 ing!");
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + "睡眠500ms");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "需要A锁!!!");
synchronized (lockA){
System.out.println(Thread.currentThread().getName() + "A锁获取成功");
}
}
}
},"Thread2");
thread1.start();
thread2.start();
}
}
运行结果如下图:
结果很明显了,这两个线程陷入了死锁状态了,发生死锁的原因是,两个线程试图通过不同的顺序获得多个相同的锁。如果请求锁的顺序相同,
就不会出现循环的锁依赖现象(你等我放锁,我等你放锁),也就不会产生死锁了。如果你能够保证同时请求锁A和锁B的每一个线程,都是按照从锁A到锁B的顺序,那么就不会发生死锁了。
如果所有线程以通用的固定秩序获取锁,程序就不会出现锁顺序死锁问题了。
什么情况下会发生死锁呢?
1.锁的嵌套容易发生死锁。解决办法:获取锁时,查看是否有嵌套。尽量不要用锁的嵌套,如果必须要用到锁的嵌套,就要指定锁的顺序,因为参数的顺序是超乎我们控制的,为了解决这个问题,我们必须指定锁的顺序,并且在整个应用程序中,
获得锁都必须始终遵守这个既定的顺序。
上面的例子出现死锁的根本原因就是获取所的顺序是乱序的,超乎我们控制的。上面例子最理想的情况就是把业务逻辑抽离出来,把获取锁的代码放在一个公共的方法里面,让这两个线程获取锁
都是从我的公共的方法里面获取,当Thread1线程进入公共方法时,获取了A锁,另外Thread2又进来了,但是A锁已经被Thread1线程获取了,Thread1接着又获取锁B,Thread2线程就不能再获取不到了锁A,更别说再去获取锁B了,这样就有一定的顺序了。
上面例子的改造如下:
public class DeadLock {
private static Object lockA = new Object();
private static Object lockB = new Object();
public static void main(String[] args) {
new DeadLock().deadLock();
}
private void deadLock() {
Thread thread1 = new Thread(new Runnable() {
public void run() {
getLock();
}
},"Thread1");
Thread thread2 = new Thread(new Runnable() {
public void run() {
getLock();
}
},"Thread2");
thread1.start();
thread2.start();
}
public void getLock() {
synchronized (lockA){
try {
System.out.println(Thread.currentThread().getName() + "获取A锁 ing!");
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + "睡眠500ms");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "需要B锁!!!");
synchronized (lockB){
System.out.println(Thread.currentThread().getName() + "B锁获取成功");
}
}
}
}
运行结果如下:
可以看到把业务逻辑抽离出来,把获取锁的代码放在一个公共的方法里面,获得锁都必须始终遵守这个既定的顺序。
2.引入显式锁的超时机制特性来避免死锁
超时机制是监控死锁和从死锁中恢复的技术,是使用每个显式所Lock类中定时tryLock特性,来替代使用颞部所机制。在内部锁的机制中,只要没有获得锁,就永远保持等待,而
显示的锁使你能狗定义超时的时间,在规定时间之后tryLock还没有获得锁就会返回失败。通过使用超时,尽管这段时间比你预期能够获得所的时间长很多,你仍然可以在意外发生后重新
获得控制权。当尝试获得定时锁失败时,你并不需要知道原因。也许是因为有死锁发生,也许是线程在持有锁的时候错误地进入无限循环;也有可能是执行一些活动所花费的时间比你
预期慢了许多。不过至少你有机会了解到你的尝试已经失败,记录下这次尝试中有用的信息,并重新开始计算,这远比关闭整个线程要优雅得多。
即使定时锁并没有应用于整个系统,使用它来获得多重锁还是能够有效应对死锁。如果获取锁的请求超时,你可以释放这个锁,并后退,等待一会后再尝试,这很可能消除了死锁发生的条件,
并且循序程序恢复。(这项技术只有在同时获得两个锁的时候才有效;如果多个锁是在嵌套的方法中被请求的,你无法仅仅释放外层的锁,尽管你知道自己已经持有该锁)
显式锁Lock,Lock是一个接口,定义了一些抽象的所操作。与内部锁机制不同,Lock提供了无条件,可轮询,定时的,可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
Lock的实现必须提供举报与内部锁相同的内存可见性的语义。但是加锁的语义,调度算法,顺序保证,性能特性这些可以不同。
Lock接口源码如下:
public interface Lock {
//加锁
void lock();
//可中断的锁,打算线程的等待状态,即A线程已经获取该锁,B线程又来获
//取,但是A线程会通知B,来打算B线程的等待。
void lockInterruptibly() throws InterruptedException;
//尝试去获取锁,失败返回False
boolean tryLock();
//超时机制获取锁
boolean tryLock(long time, TimeUnit unit) throws
InterruptedException;
//释放锁
void unlock();
Condition newCondition();
}
ReentranLock实现了Lock接口,提供了与synchronized相同的互斥和内存可见性的保证。获得ReentrantLock的锁与进入synchronized块有着相同内存含义,释放ReentrantLock锁与退出synchronized块有着相同内存含义。
ReentrantLock提供了与synchronized一样可重入加锁的语义。ReentrantLock支持Lock接口定义的所有获取锁的方式。与synchronized相比,ReentranLock为处理不可用的锁提供了更多灵活性。
但是对于现在的JDK的更新,synchronized的性能被优化的越来越好,内部锁(synchronized)已经获得相当可观的性能,性能不仅仅是个不断变化的目标,而且变化的非常快。
如下图:
看到图,随着JDK的更新迭代,内部锁的性能越来越快,这不是ReentrantLock的衰退,而是内部锁(synchronized)越来越快,特别在JDK目前跟新到现在1.9.
下面用显式锁Lock再来改造上面的例子
public class DeadLock {
Lock lock = new ReentrantLock();
private static Object lockA = new Object();
private static Object lockB = new Object();
public static void main(String[] args) {
new DeadLock().deadLock();
}
private void deadLock() {
Thread thread1 = new Thread(new Runnable() {
public void run() {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "获取A锁 ing!");
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + "睡眠500ms");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread().getName() + "需要B锁!!!");
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "B锁获取成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}, "Thread1");
Thread thread2 = new Thread(new Runnable() {
public void run() {
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "获取B锁 ing!");
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + "睡眠500ms");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
System.out.println(Thread.currentThread().getName() + "需要A锁!!!");
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "A锁获取成功");
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}, "Thread1");
thread1.start();
thread2.start();
}
}
运行结果如下:
可以看到显示锁Lock是可以避免死锁的。
注意:Lock接口规范形式。这种模式在某种程度上比使用内部锁更加复杂:锁必须在finally块中释放。另一方面,如果锁守护的代码在try块之外抛出了异常,它将永远都不会被释放了;如果对象
能够被置于不一致状态,可能需要额外的try-catch,或try-finally块。(当你在使用任何形式的锁时,你总是应该关注异常带来的影响,包括内部锁)。
忘记时候finally释放Lock是一个定时炸弹。当不幸发生的时候,你将很难追踪到错误的发生点,因为根本没有记录锁本应该被释放的位置和时间。这就是ReentrantLock不能完全替代synchronized的原因:它更加危险,
因为当程序的控制权离开守护的块,不会自动清除锁。尽管记得在finally块中释放锁并不苦难,但忘记的可能仍然存在。
sy
可轮询的和可定时的锁请求
可定时的与可轮询的锁获取模式,是由tryLock方法实现,与物体爱建的锁获取相比,它具有更完善的错误恢复机制。在内部锁中,死锁是致命的,唯一的恢复方法是重新启动程序,唯一的预防方法是在构建程序时不要出错,
所以不可能循序不一致的锁顺序。可定时的与可轮询的锁提供了另外一个选择:可以规避死锁的放生。
如果你不能获得所有需要的锁,那么使用可定时的与可轮询的获取方式(tryLock)使你能够重新拿到控制权,它会释放你已经获得的这些锁,然后再重新尝试(或者至少会记录这个失败,抑或者采取其他措施)。使用tryLock试图获得两个锁,
如果不能同时获得两个,就回退,并重新尝试。休眠时间由一个特定的组件管理,并由一个随机组件减少活锁发生的可能性。如果一定时间内,没有获得所有需要的锁,就会返回一个失败状态,这样操作就能优雅的失败了。
tryLock()经常与if esle一起使用。
读-写锁
ReentrantLock实现了标准的互斥锁:一次最多只有一个线程能够持有相同ReentrantLock。但是互斥通常做为保护数据一致性的很强的加锁约束,因此,过分的限制了并发性。互斥是保守的加锁策略,避免了
“写/写”和“写/读"的重读,但是同样避开了"读/读"的重叠。在很多情况下,数据结构是”频繁被读取“的——它们是可变的,有时候会被改变,但多数访问只进行读操作。此时,如果能够放宽,允许多个读者同时访问数据结构就
非常好了。只要每个线程保证能够读到最新的数据(线程的可见性),并且在读者读取数据的时候没有其他线程修改数据,就不会发生问题。这就是读-写锁允许的情况:一个资源能够被多个读者访问,或者被一个写者访问,两者不能同时进行。
ReadWriteLock,暴露了2个Lock对象,一个用来读,另一个用来写。读取ReadWriteLock锁守护的数据,你必须首先获得读取的锁,当需要修改ReadWriteLock守护的数据,你必须首先获得写入锁。
ReadWriteLock源码接口如下:
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading
*/
Lock readLock();
/**
* Returns the lock used for writing.
*
* @return the lock used for writing
*/
Lock writeLock();
}
读写锁实现的加锁策略允许多个同时存在的读者,但是只允许一个写者。与Lock一样,ReadWriteLock允许多种实现,造成性能,调度保证,获取优先,公平性,以及加锁语义等方面的不尽相同。
读写锁的设计是用来进行性能改进的,使得特定情况下能够有更好的并发性。时间实践中,当多处理器系统中,频繁的访问主要为读取数据结构的时候哦,读写锁能够改进性能;在其他情况下运行的情况比独占
的锁要稍微差一些,这归因于它更大的复杂性。使用它能否带来改进,最好通过对系统进行剖析来判断:好在ReadWriteLock使用Lock作为读写部分的锁,所以如果剖析得的结果发现读写锁没有能提高性能,把读写锁置换为独占锁是比较容易。
下面我们用synchonized来进行读操作,对于读操作性能如何呢?
例子如下:
public class ReadWriteLockTest {
private ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock();
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
}
public synchronized void get(Thread thread) {
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 1){
System.out.println(thread.getName() + "正在读操作");
}
System.out.println(thread.getName() + "读操作完成");
}
}
运行结果如下:
可以看到要线程Thread0读操作完了,Thread1才能进行读操作。明显这样性能很慢。
现在我们用ReadWriteLock来进行读操作,看一下性能如何
例子如下:
public class ReadWriteLockTest {
private ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock();
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
}
public void get(Thread thread) {
try {
rw1.readLock().lock();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 1){
System.out.println(thread.getName() + "正在读操作");
}
System.out.println(thread.getName() + "读操作完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rw1.readLock().unlock();
}
}
}
运行结果如下:
可以看到线程间是不用排队来读操作的。这样效率明显很高。
我们再看一下写操作,如下:
public class ReadWriteLockTest {
private ReentrantReadWriteLock rw1 = new ReentrantReadWriteLock();
public static void main(String[] args) {
final ReadWriteLockTest test = new ReadWriteLockTest();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
new Thread(){
@Override
public void run() {
test.get(Thread.currentThread());
}
}.start();
}
public void get(Thread thread) {
try {
rw1.writeLock().lock();
long start = System.currentTimeMillis();
while (System.currentTimeMillis() - start <= 1){
System.out.println(thread.getName() + "正在写操作");
}
System.out.println(thread.getName() + "写操作完成");
} catch (Exception e) {
e.printStackTrace();
} finally {
rw1.writeLock().unlock();
}
}
}
运行结果如下:
可以看到ReadWriteLock只允许一个写者。
公平锁
ReentrantReadWriteLock为两个锁提供了可重入的加锁语义,它是继承了ReadWriteLock,扩展了ReadWriteLock。它与ReadWriteLock相同,ReentrantReadWriteLock能够被构造
为非公平锁(构造方法不设置参数,默认是非公平),或者公平。在公平锁中,选择权交给等待时间最长的线程;如果锁由读者获得,而一个线程请求写入锁,那么不再允许读者获得读取锁,直到写者被受理,平且已经释放了写锁。
在非公平的锁中,线程允许访问的顺序是不定的。由写者降级为读者是允许的;从读者升级为写者是不允许的(尝试这样的行为会导致死锁)
当锁被持有的时间相对较长,并且大部分操作都不会改变锁守护的资源,那么读写锁能够改进并发性。ReadWriteMap使用了ReentrantReadWriteLock来包装Map,使得它能够在多线程间
被安全的共享,并仍然能够避免 "读-写" 或者 ”写-写“冲突。显示中ConcurrentHashMap并发容器的性能已经足够好了,所以你可以是使用他,而不必使用这个新的解决方案,如果你需要并发的部分
只有哈希Map,但是如果你需要为LinkedHashMap这种可替换元素Map提供更好的并发访问,那么这项技术是非常有用的。
用读写锁包装的Map如下图:
读写锁的性能如下图:
总结:
显式的Lock与内部锁相比提供了一些扩展的特性,包括处理不可用的锁时更好的灵活性,以及对队列行为更好的控制。但是ReentrantLock不能完全替代synchronized;只有当你需要
synchronized没能提供的特性时才应该使用。
读-写锁允许多个读者并发访问被守护的对象,当访问多为读取数据结构的时候,它具有改进可伸缩性的潜力。
数据库层面上的锁——悲观锁和乐观锁
乐观锁:他对世界比较乐观,认为别人访问正在改变的数据的概率是很低的,所以直到修改完成准备提交所做的的修改到数据库的时候才会将数据锁住。完成更改后释放。
我想一下一个这样的业务场景:我们从数据库中获取了一条数据,我们正要修改他的数据时,刚好另外一个用户此时已经修改过了这条数据,这是我们是不知道别人修改过这条数据的。
解决办法,我们可以在表中增加一个version字段,让这个version自增或者自减,或者用一个时间戳字段,这个时间搓字段是唯一的。我们写数据的时候带上version,也就是每个人更新的时候都会判断当前的版本号是否跟我查询出来得到的版本号是否一致,不一致就更新失败,一致就更新这条记录并更改版本号。
例子如下:
1.查询出商品信息
select (status,status,version) from t_goods where id=#{id}
2.根据商品信息生成订单
3.修改商品status为2
update t_goods
set status=2,version=version+1
where id=#{id} and version=#{version};
用户体验表现层面通常表现为系统繁忙之类的。
在这里还要注意乐观锁的一个细节:就是version字段要自增或者自减,否者会出现ABA问题。
ABA问题:线程Thread1拿到了version字段为A,由于CAS操作(即先进行比较然后设值),线程Thread2先拿到的version,将version改成B,线程Thread3来拿到version,将version值又改回了A。此时Thread1的CAS(先比较后set值)操作结束了,继续执行,它发现version的值还是A,以为没有发生变化,所以就继续执行了。这个过程中,version从A变为B,再由B变为A就被形象地称为ABA问题了。
悲观锁:也称排它锁,当事务在操作数据时把这部分数据进行锁定,直到操作完毕后再解锁,其他事务操作才可操作该部分数据。这将防止其他进程读取或修改表中的数据。
一般使用 select ...for update 对所选择的数据进行加锁处理,例如
select * from account where name=”JAVA” for update,
这条sql 语句锁定了account 表中所有符合检索条件(name=”JAVA”)的记录。本次事务提交之前(事务提交时会释放事务过程中的锁),外界无法修改这些记录。
用户界面常表现为转圈圈等待。
如果数据库分库分表了,不再是单个数据库了,那么我们可以用分布式锁,比如redis的setnx特性,zookeeper的节点唯一性和顺序性特性来做分布式锁。
Synchronzied 修饰非静态方法==》对象锁
Synchronzied 修饰静态方法==》其实是类锁,因为是静态方法,它把整个类锁起来了;
1.Synchronized修饰非静态方法,实际上是对调用该方法的对象加锁,俗称“对象锁”。
Java中每个对象都有一个锁,并且是唯一的。假设分配的一个对象空间,里面有多个方法,相当于空间里面有多个小房间,如果我们把所有的小房间都加锁,因为这个对象只有一把钥匙,因此同一时间只能有一个人打开一个小房间,然后用完了还回去,再由JVM 去分配下一个获得钥匙的人。
情况1:同一个对象在两个线程中分别访问该对象的两个同步方法
结果:会产生互斥。
解释:因为锁针对的是对象,当对象调用一个synchronized方法时,其他同步方法需要等待其执行结束并释放锁后才能执行。
情况2:不同对象在两个线程中调用同一个同步方法
结果:不会产生互斥。
解释:因为是两个对象,锁针对的是对象,并不是方法,所以可以并发执行,不会互斥。形象的来说就是因为我们每个线程在调用方法的时候都是new 一个对象,那么就会出现两个空间,两把钥匙,
2.Synchronized修饰静态方法,实际上是对该类对象加锁,俗称“类锁”。
情况1:用类直接在两个线程中调用两个不同的同步方法
结果:会产生互斥。
解释:因为对静态对象加锁实际上对类(.class)加锁,类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,因此房间(同步方法)之间一定是互斥的。
注:上述情况和用单例模式声明一个对象来调用非静态方法的情况是一样的,因为永远就只有这一个对象。所以访问同步方法之间一定是互斥的。
情况2:用一个类的静态对象在两个线程中调用静态方法或非静态方法
结果:会产生互斥。
解释:因为是一个对象调用,同上。
情况3:一个对象在两个线程中分别调用一个静态同步方法和一个非静态同步方法
结果:不会产生互斥。
解释:因为虽然是一个对象调用,但是两个方法的锁类型不同,调用的静态方法实际上是类对象在调用,即这两个方法产生的并不是同一个对象锁,因此不会互斥,会并发执行。
测试代码:
同步方法类:SynchronizedTest.java
public class SynchronizedTest { /*private SynchronizedTest(){} private static SynchronizedTest st; //懒汉式单例模式,线程不安全,需要加synchronized同步 public static SynchronizedTest getInstance(){ if(st == null){ st = new SynchronizedTest(); } return st; }*/ /*private SynchronizedTest(){} private static final SynchronizedTest st = new SynchronizedTest(); //饿汉式单利模式,天生线程安全 public static SynchronizedTest getInstance(){ return st; }*/ public static SynchronizedTest staticIn = new SynchronizedTest(); //静态对象 public synchronized void method1(){ //非静态方法1 for(int i = 0;i < 10;i++){ System.out.println("method1 is running!"); try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } public synchronized void method2(){ //非静态方法2 for( int i = 0; i < 10 ; i++){ System.out.println("method2 is running!"); try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } public synchronized static void staticMethod1(){ //静态方法1 for( int i = 0; i < 10 ; i++){ System.out.println("static method1 is running!"); try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } public synchronized static void staticMethod2(){ //静态方法2 for( int i = 0; i < 10 ; i++){ System.out.println("static method2 is running!"); try { Thread.sleep(1000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } } } }
线程类1:Thread1.java(释放不同的注释可以测试不同的情况)
public class Thread1 implements Runnable{ @Override public void run() { // SynchronizedTest s = SynchronizedTest.getInstance(); // s.method1(); // SynchronizedTest s1 = new SynchronizedTest(); // s1.method1(); SynchronizedTest.staticIn.method1(); // SynchronizedTest.staticMethod1(); // SynchronizedTest.staticMethod2(); } }
线程类2:Thread2.Java
public class Thread2 implements Runnable{ @Override public void run() { // TODO Auto-generated method stub // SynchronizedTest s = SynchronizedTest.getInstance(); // SynchronizedTest s2 = new SynchronizedTest(); // s2.method1(); // s.method2(); // SynchronizedTest.staticMethod1(); // SynchronizedTest.staticMethod2(); // SynchronizedTest.staticIn.method2(); SynchronizedTest.staticIn.staticMethod1(); } }
主类:ThreadMain.java
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadMain { public static void main(String[] args) { Thread t1 = new Thread(new Thread1()); Thread t2 = new Thread(new Thread2()); ExecutorService exec = Executors.newCachedThreadPool(); exec.execute(t1); exec.execute(t2); exec.shutdown(); } }
总结:
1.对象锁钥匙只能有一把才能互斥,才能保证共享变量的唯一性
2.在静态方法上的锁,和 实例方法上的锁,默认不是同样的,如果同步需要制定两把锁一样。
3.关于同一个类的方法上的锁,来自于调用该方法的对象,如果调用该方法的对象是相同的,那么锁必然相同,否则就不相同。比如 new A().x() 和 new A().x(),对象不同,锁不同,如果A的单利的,就能互斥。
4.静态方法加锁,能和所有其他静态方法加锁的 进行互斥
5.静态方法加锁,和xx.class 锁效果一样,直接属于类的