Java中的锁

Posted kexinxin

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java中的锁相关的知识,希望对你有一定的参考价值。

java中的锁

Java锁的类别

java中的锁按照不同的类别可以有如下的分类:

  • 乐观锁/悲观锁
  • 独享锁/共享锁
  • 互斥锁/读写锁
  • 可重入锁
  • 公平锁/非公平锁
  • 分段锁
  • 偏向锁/轻量级锁/重量级锁
  • 自旋锁

这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计

乐观锁/悲观锁

  乐观锁与悲观锁并不是特指某两种类型的锁,是人们定义出来的概念或思想,主要是指看待并发同步的角度。

  乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。

  悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。比如Java里面的同步原语synchronized关键字的实现就是悲观锁。

  悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

  悲观锁在Java中的使用,就是利用各种锁。

  乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

乐观锁

乐观锁总是认为不存在并发问题,每次去取数据的时候,总认为不会有其他线程对数据进行修改,因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用"数据版本机制"或"CAS操作"来实现。

(1) 数据版本机制

  实现数据版本一般有两种,第一种是使用版本号,第二种是使用时间戳。以版本号方式为例。

  版本号方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
核心SQL代码:

update table set xxx=#{xxx}, version=version+1 where id=#{id} and version=#{version};

(2) CAS操作

  CAS(Compare and Swap 比较并交换),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

  CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。

悲观锁

悲观锁认为对于同一个数据的并发操作,一定会发生修改的,哪怕没有修改,也会认为修改。因此对于同一份数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁并发操作一定会出问题。

  在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。

  如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。

  如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。

  期间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。

共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

对于Synchronized而言,当然是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock。

读写锁在Java中的具体实现就是ReadWriteLock。

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。说的有点抽象,下面会有一个代码的示例。

对于Java ReetrantLock而言,从名字就可以看出是一个重入锁,其名字是Re entrant Lock 重新进入锁。

对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7和JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。

 

    在JDK1.5之前都是使用synchronized关键字保证同步的,synchronized的实现如下:

    JVM是通过进入、退出对象监视器(Monitor)来实现对方法、同步块的同步的。

    具体实现是在编译之后在同步方法调用前加入一个monitor.enter指令,在退出方法和异常处插入monitor.exit的指令。其本质就是对一个对象监视器(Monitor)进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit之后才能尝试继续获取锁。

流程图如下:

    JDK1.6中对synchronized 锁进行了优化,首先了解下对象头(Mark Word):

    运行时JVM内存布局

Mark Word在不同锁状态下的标志位存储

从jdk1.6开始为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁"。锁共有四种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。随着竞争情况锁状态逐渐升级、锁可以升级但不能降级。

偏向锁:
HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。

线程1检查对象头中的Mark Word中是否存储了线程1,如果没有则CAS操作将Mark Word中的线程ID替换为线程1。此时,锁偏向线程1,后面该线程进入同步块时不需要进行CAS操作,只需要简单的测试一下Mark Word中是否存储指向当前线程的偏向锁,如果成功表明该线程已经获得锁。如果失败,则再需要测试一下Mark Word中偏向锁标识是否设置为1(是否是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将偏向锁指向当前线程

偏向锁的竞争结果:

根据持有偏向锁的线程是否存活

1.如果不活动,偏向锁撤销到无锁状态,再偏向到其他线程
2.如果线程仍然活着,则升级到轻量级锁

偏向锁在Java6和Java7中默认是开启的,但是在应用程序启动几秒后才激活,如果有必要可以关闭延迟:

-XX:BiasedLockingStartupDelay=0

 

如果确定应用程序中所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:

-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁。

 

-XX:BiasedLockingStartupDelay=0 -XX:+TraceBiasedLocking

 

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁

1.线程在执行同步块之前,JVM会在当前栈桢中创建用于存储锁记录的空间(Lock record),并将对象头中的Mark Word复制到锁记录中(Displaced Mark Word)。

2.然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针

3.如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁

 

 

synchronized的执行过程:

1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁

2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1

3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。

4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁

5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

6. 如果自旋成功则依然处于轻量级状态。

7. 如果自旋失败,则升级为重量级锁。

 

锁优化

以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

减少锁的时间

 

不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

减少锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;

java中很多数据结构都是采用这种方法提高并发操作的效率:

ConcurrentHashMap

java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组

Segment< K,V >[] segments

Segment继承自ReenTrantLock,所以每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

LongAdder

LongAdder 实现思路也类似ConcurrentHashMap,LongAdder有一个根据当前并发状况动态改变的Cell数组,Cell对象里面有一个long类型的value用来存储值;

开始没有并发争用的时候或者是cells数组正在初始化的时候,会使用cas来将值累加到成员变量的base上,在并发争用的情况下,LongAdder会初始化cells数组,在Cell数组中选定一个Cell加锁,数组有多少个cell,就允许同时有多少线程进行修改,最后将数组中每个Cell中的value相加,在加上base的值,就是最终的值;cell数组还能根据当前线程争用情况进行扩容,初始长度为2,每次扩容会增长一倍,直到扩容到大于等于cpu数量就不再扩容,这也就是为什么LongAdder比cas和AtomicInteger效率要高的原因,后面两者都是volatile+cas实现的,他们的竞争维度是1,LongAdder的竞争维度为"Cell个数+1"为什么要+1?因为它还有一个base,如果竞争不到锁还会尝试将数值加到base上;

LinkedBlockingQueue

LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;

拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cup数量个锁即可;

锁粗化

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

在以下场景下需要粗化锁的粒度:

假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

读写分离

CopyOnWriteArrayList 、CopyOnWriteArraySet

CopyOnWrite容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

 CopyOnWrite并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的;

使用cas

如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

消除缓存行的伪共享

除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。

在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。

例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁;

为了防止伪共享,不同jdk版本实现方式是不一样的:

1. 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;

2. 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;

3. 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:

-XX:-RestrictContended

 

sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;

关于什么是缓存行,jdk是如何避免缓存行的,网上有非常多的解释,在这里就不再深入讲解了;

以上是关于Java中的锁的主要内容,如果未能解决你的问题,请参考以下文章

JAVA锁中的CAS

Objective-c 中的锁

带你快速了解Java锁中的公平锁与非公平锁

Java - 单例陷阱——双重检查锁中的指令重排问题

Python中的锁都具都有哪些?

获取synchronized锁中的阻塞队列中的线程是非公平的