java中的锁

203 阅读18分钟

引言

Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。

Java中往往是按照是否含有某一特性来定义锁,我们通过特性将锁进行分组归类,再使用对比的方式进行介绍,帮助大家更快捷的理解相关知识。下面给出本文内容的总体分类目录:

image.png

1. 乐观锁 VS 悲观锁

  • 乐观锁

乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作(例如报错或者自动重试)。

乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。

  • 悲观锁 悲观锁是一种悲观思想,即认为写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程block,直到这个线程释放锁然后其他线程获取到锁。

Java中,synchronized关键字和Lock的实现类都是悲观锁。

image.png

根据从上面的概念描述我们可以发现:

  • 悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
  • 乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。

2. 自旋锁 VS 适应性自旋锁

  • 自旋锁 阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。

为了让线程等待,我们只须让线程执行一个忙循环(自旋)。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。

image.png

自旋锁的优点: 避免了线程切换的开销。挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。

自旋锁的缺点: 占用处理器的时间,如果占用的时间很长,会白白消耗处理器资源,而不会做任何有价值的工作,带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程.

自旋次数默认值:10次,可以使用参数-XX:PreBlockSpin来自行更改。

  • 适应性自旋锁 自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状态预测就会越来越精准。

例如如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

3. 无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁

这四种锁是指锁的状态,专门针对synchronized的。那么为什么Synchronized能实现线程同步?

Synchronized锁的到底是什么, 锁住的是代码还是对象? (对象)。

java对象在内中有及部分组成。

1,对象头

2,实例数据

3,对齐填充字节

举个例子

public class A {
    private String name; 
    private Integer age; 
    //占一个字节的boolean字段
    private boolean sex;
}

使用JOL查看对象的内存布局

jdk8版本是默认开启指针压缩的 image.png

取消指针真压缩为 image.png 这就是在别的文章里为什么会说 java头分为 Mark Word为64bit,Klass Word为64bit。

对图里参数做解释:

  • OFFSET:偏移地址,单位字节;
  • SIZE:占用的内存大小,单位为字节;
  • TYPE DESCRIPTION:类型描述,其中object header为对象头;
  • VALUE:对应内存中当前存储的值;

所以默认开启指针压缩,得到的java头为12*8bit = 96bit,关闭指针压缩后,得到的java头对象为128bit。

开启指针压缩可以减少对象的内存使用。从两次打印的User对象布局信息来看,关闭指针压缩时,name字段和age字段由于是引用类型,因此分别占8个字节,而开启指针压缩之后,这两个字段只分别占用4个字节。因此,开启指针压缩,理论上来讲,大约能节省百分之五十的内存。jdk8及以后版本已经默认开启指针压缩,无需配置。

从两次打印的User对象的内存布局,还可以看出,bool类型的age字段只占用1个字节,但后面会跟随几个字节的浪费,即内存对齐。开启指针压缩情况下,age字段的内存对齐需要3个字节,而关闭指针压缩情况下,则需要7个字节。因为在64位的jvm中对象的大小被要求向8字节对齐,所以对于不足的要补齐。

所以可以看出(对象头为object header,实例数据为对象的实例,对齐填充字节保证对象以8字节的倍数存储)

对象头

以默认开启指针压缩情况下的Class对象的内存布局来看,对象头占用12个字节,那么这12个字节存储的是什么信息?

截取一张hotspot的源码当中的注释 image.png

从openjdk文档当中对对象头的解释和上图中可以看出java对象头包含了2个word。<mark word,klass pointer>.从文档以及上图中可以看出mark word为里面包含了锁的信息,hashcode,gc信息等等。klass word为对象头的第二个word主要指向对象的元数据。

以上面的Class对象打印的内存布局为例: image.png 在无锁的情况下markword当中的前56bit存的是对象的hashcode。其中前25为0,hashcode0x7a46a697。所以MarkWordk0x0000007a46a69701,我们应该反着看。涉及到一个知识点“大端存储与小端存储”。

  • Little-Endian:低位字节存放在内存的低地址端,高位字节存放在内存的高地址端。(windows)
  • Big-Endian:高位字节存放在内存的低地址端,低位字节存放在内存的高地址端。(mac)

这里说一下通过倒数三位判断当前MarkWord的状态,就可以判断出其余位存储的是什么。 因为总共有5中状态,而2bit总共可以表示4中情况,所以需要一个biased_lock来表示。

[markOop.hpp文件] 
enum { locked_value = 0, // 0 00 轻量级锁
    unlocked_value = 1,// 0 01 无锁 
    monitor_value = 2,// 0 10 重量级锁 
    marked_value = 3,// 0 11 gc标志 
    biased_lock_pattern = 5 // 1 01 偏向锁 };

无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。上面我们介绍的CAS原理及应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

偏向锁

image.png 偏向锁是JDK6时加入的一种锁优化机制: 在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。

具体来说就是当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

优点: 把整个同步都消除掉,连CAS操作都不去做了,优于轻量级锁。

缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。

轻量级锁

image.png 轻量级锁是JDK6时加入的一种锁优化机制:轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量。

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

轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。轻量级锁在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。

也就是说若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。

优点: 如果没有竞争,通过CAS操作成功避免了使用互斥量的开销。

缺点: 如果存在竞争,除了互斥量本身的开销外,还额外产生了CAS操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。

重量级锁

image.png

升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。

4. 公平锁 VS 非公平锁

公平锁

image.png 公平锁是一种思想: 多个线程按照申请锁的顺序来获取锁。在并发环境中,每个线程会先查看此锁维护的等待队列,如果当前等待队列为空,则占有锁,如果等待队列不为空,则加入到等待队列的末尾,按照FIFO的原则从队列中拿到线程,然后占有锁。

非公平锁

image.png

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

优点: 非公平锁的性能高于公平锁。

缺点: 有可能造成线程饥饿(某个线程很长一段时间获取不到锁)

可重入锁 VS 非可重入锁

可重入锁(递归锁)

image.png

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。下面用示例代码来进行分析:

public class Widget { 
    public synchronized void doSomething() { 
        System.out.println("方法1执行..."); 
        doOthers(); 
    } 
    public synchronized void doOthers() { 
        System.out.println("方法2执行..."); 
    } 
}

在上面的代码中,类中的两个方法都是被内置锁synchronized修饰的,doSomething()方法中调用doOthers()方法。因为内置锁是可重入的,所以同一个线程在调用doOthers()时可以直接获得当前对象的锁,进入doOthers()进行操作。

如果是一个不可重入锁,那么当前线程在调用doOthers()之前需要将执行doSomething()时获取当前对象的锁释放掉,实际上该对象锁已被当前线程所持有,且无法释放。所以此时会出现死锁。

可重入锁的原理: 通过组合自定义同步器来实现锁的获取与释放。

  • 再次获取锁:识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。获取锁后,进行计数自增,

  • 释放锁:释放锁时,进行计数自减。

可重入锁的作用: 避免死锁。

面试题1: 可重入锁如果加了两把,但是只释放了一把会出现什么问题?

答:程序卡死,线程不能出来,也就是说我们申请了几把锁,就需要释放几把锁。

面试题2: 如果只加了一把锁,释放两次会出现什么问题?

答:会报错,java.lang.IllegalMonitorStateException。;

6. 独享锁 VS 共享锁

独享锁

image.png 独占锁是一种思想: 只能有一个线程获取锁,以独占的方式持有锁。和悲观锁、互斥锁同义。

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

Java中用到的独占锁: synchronized,ReentrantLock

共享锁

image.png

共享锁是一种思想: 可以有多个线程获取读锁,以共享的方式持有锁。和乐观锁、读写锁同义。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据

Java中用到的共享锁:ReentrantReadWriteLock。

读写锁

读写锁是一种技术: 通过ReentrantReadWriteLock类来实现。为了提高性能, Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。 读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的。

读锁: 允许多个线程获取读锁,同时访问同一个资源。 image.png

写锁: 只允许一个线程获取写锁,不允许同时访问同一个资源。 image.png

如何使用:
/*** 创建一个读写锁* 它是一个读写融为一体的锁,在使用的时候,需要转换*/private
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
获取读锁和释放读锁
// 获取读锁rwLock.readLock().lock();// 释放读锁rwLock.readLock().unlock();
获取写锁和释放写锁
// 创建一个写锁rwLock.writeLock().lock();// 写锁 释放rwLock.writeLock().unlock();

Java中的读写锁:ReentrantReadWriteLock

分段锁

image.png 分段锁是一种机制: 最好的例子来说明分段锁是ConcurrentHashMap。

ConcurrentHashMap原理:

image.png 由图可知 ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

如果需要在 ConcurrentHashMap 添加一项key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该key-value应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行put操作,只要被加入的key-value不存放在同一个段中,则线程间可以做到真正的并行。

线程安全:ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

互斥锁vs同步锁

互斥锁

image.png 互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。

1. 共享资源的使用是互斥的,即一个线程获得资源的使用权后就会将该资源加锁,使用完后会将其解锁,

2. 如果在使用过程中有其他线程想要获取该资源的锁,那么它就会被阻塞陷入睡眠状态,直到该资源被解锁才会被唤醒,

3. 如果被阻塞的资源不止一个,那么它们都会被唤醒,但是获得资源使用权的是第一个被唤醒的线程,其它线程又陷入沉睡。
  • 读-读互斥
  • 读-写互斥
  • 写-读互斥
  • 写-写互斥

同步锁

image.png 同步锁与互斥锁同义,表示并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。

Java中的同步锁: synchronized

死锁

image.png

死锁是一种现象:如线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。

Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。

总结

image.png