java中的锁

公平锁和非公平锁

公平锁好处是等待锁的线程不会饿死,但整体效率较低。非公平锁好处是整体效率高一些,但是有些线程可能需要等待很久才能获取到锁。因为非公平锁是可以抢占的。

两者内部都维持一个 AQS 等待队列(FIFO),放置等待等待获取锁的线程。如果释放锁的时候,没有新的线程来获取锁,这时候就会从 AQS 队列头取出线程让其获取到锁。这时候公平锁和非公平锁是一样的。如果释放锁的时候,刚好有新的线程来获取锁,非公平锁就会将锁分配给这个新的线程,这就是非公平锁的抢占式。因为非公平锁中新来的线程有一定几率不会被挂起,减少了整体线程挂起的几率,所以非公平锁性能高于公平锁。

公平锁可以使用new ReentrantLock(true)实现。

自旋锁

java 的线程是映射到操作系统的,线程的阻塞和唤醒需要操作系统来完成,这就需要从用户态转换到核心态,增加了状态切换的耗时。许多应用的共享数据的锁定状态自会持续很短的时间,为了这点时间去挂起和恢复现场不值得。我们可以让请求锁的线程自旋等待,不放弃CPU时间,直到获取到锁。

自旋锁在JDK1.4.2中引入,使用-XX:+UseSpinning来开启。JDK6中已经变为默认开启,并且引入了自适应的自旋锁。自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

自旋锁是通过 CAS 实现的,即不断重试 CAS 直到成功为止。自旋锁存在的问题如下:

  • 占用过多 CPU 时间

  • 死锁问题,在递归调用中,同一个线程获取到自旋锁,由再次申请获取

  • ABA 问题,java 中自旋锁一般是利用 CAS 操作实现

jdk 中 atomic 包下都是采用自旋锁原理

锁消除

锁消除是指 JVM 在 JIT 编译时,通过扫描上下文,去除不可能存在共享资源竞争的锁。通过锁消除,可以减少无畏的请求锁时间。

锁消除的主要判断依据是来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而能被其他线程访问到,那就可以把他们当做栈上数据对待,认为他们是线程私有的,同步加锁自然就无需进行。

比如下面这个代码


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(); 

} 

JIT 回去除 append 方法的加锁

锁粗化

一般情况下,都是推荐加锁范围越小越好。但是如果连续几行代码都是对同一个对象加锁解锁,甚至加锁操作出现循环体中。虚拟机遇到这样的情况,会将加锁范围扩大到整个操作序列的外部

比如下面的代码


StringBuffer sb = new StringBuffer(); 

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 都会加解锁,jvm 会将加锁范围扩大到 整个 append 操作序列的外部

可重入锁

可重入锁也叫递归锁,同一线程可以多次获取同一个锁。可重入锁避免了死锁,synchronizez 和 ReentrantLock 都是可重入锁

类锁和对象锁


public class SynchronizedTest { 

    public static synchronized void method1() {} 

    public void method2() { 

        synchronized(LockStrategy.class) {} 

    } 

    public synchronized void method3() {} 

} 

其中 method1 和 method2 都是类锁,method3 是对象锁

偏向锁、轻量级锁和重量级锁

在 jdk6 之前, synchronizez 一直是一个重量级锁,在 jdk6 中对 synchronized 做了很多优化,引入了偏向锁和轻量级锁。

对象头

锁存在于 java 对象头中,如果对象是数组类型,则虚拟机用 3 个 Word(字宽)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,一字宽等于四字节,即 32bit。

长度 内容 说明
32/64 bit Mark Word HashCode,分代年龄和锁标记位
32/64 bit Class Metadata Address 存储对象的类型指针
32/64 bit Array length 数组的长度(如果当前对象是数组)

Class Metadata Address 用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例

Mark Word

Java 对象头里的 Mark Word 里默认存储对象的 HashCode,分代年龄和锁标记位。32位 JVM 的 Mark Word 存储结果如下

Mark Word 结构

其中 epoch 为偏向时间戳。

偏向锁

偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序性能。

偏向锁会偏向于第一个获取到他的线程,只要没有别的线程获取该锁,那么这第一个线程将永远不需要同步。


public synchronized void method() { 

// do ... 

} 

如上述代码,线程A 第一个获取到这个锁,那么线程A后续执行 method,就不需要再进行获取锁的操作(只要中途没有别的线程来获取锁)。当有线程B来执行 method 时,偏向锁宣告结束,进入轻量级锁。

实现原理:当线程请求到锁对象后,将锁对象的状态标志位改为01,即偏向模式。然后使用CAS操作将线程的ID记录在锁对象的Mark Word中。以后该线程可以直接进入同步块,连CAS操作都不需要。但是,一旦有第二条线程需要竞争锁,那么偏向模式立即结束,进入轻量级锁的状态。

优点:偏向锁可以调高有同步但是没有竞争的程序性能,但是如果锁对象同时被多个线程竞争,那么偏向锁是多余的

偏向锁可以通过 JVM 参数来关闭:-XX:-UseBiasedLocking=false

轻量级锁

使用 CAS 操作代替互斥同步,在线程请求锁时

实现原理:在线程请求锁时,判断锁对象的 Mark Word 是否是无锁状态(锁标志位为01,是否偏向为0),然后在线程的栈帧中创建一块 Lock Record 空间,并将锁对象的 Mark Word 复制到 Lock Record中,然后通过 CAS 操作将锁对象的 Mark Word 替换为指向 Lock Record 的指针,并将 Lock Record 的 owner 指针指向 锁对象的 Mark Word。如下图:

获取轻量级锁过程

获取锁失败的处理:如果成功则表示获取锁成功,如果失败,首先会检查锁对象的 Mark Word 是否指向当前线程的栈帧。如果是表示获取锁成功,如果还不是表示获取锁失败。此时轻量级锁膨胀为重量级锁,当前线程会尝试用自旋获取锁,后面的线程会阻塞等待

前提:轻量级锁比重量级锁性能高的前提是,在轻量级锁被占用期间,不会发生锁的竞争。一旦发生锁竞争,会膨胀为重量级锁,除了使用互斥量外还额外增加了 CAS 操作。

获取锁失败了,为什么要重复检查 Mark Word

因为可能是可重入锁

三者区别

  • 重量级锁是一种悲观锁,而轻量级锁和偏向锁是乐观锁

  • 轻量级锁是在无锁竞争情况下,使用 CAS 操作来代替互斥量使用。而偏向锁是在无锁竞争情况下,完全取消同步(只有第一次获取锁的时候会使用 CAS 操作)

  • 轻量级锁适用场景是线程交替进入同步块,如果同一时间多个线程竞争同一把锁就会膨胀为重量级锁

  • 一个线程重复访问同步块,轻量级锁每次都要进行 CAS 操作。而偏向锁是为了避免在无锁竞争情况下,不必要的 CAS 操作。只有第一次获取锁的时候会使用 CAS 操作

  • synchronized 通过监视器锁来实现同步(monitorenter 和 monitorexit),而监视器锁有依赖于底层的互斥锁,进入互斥锁需要用户态与和心态的切换,所以synchronized是重量级锁

乐观锁和悲观锁

悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。

乐观锁:假定不会发生并发冲突,只在提交操作时检测是否违反数据完整性。(使用版本号或者时间戳来配合实现)

读写锁

读写锁是数据库常见的锁,又叫 共享-排它锁,S锁和X锁

共享锁:如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。

排它锁:如果事务T对数据A加上排它锁后,则其他事务不能再对A加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。

读锁不能直接升级为写锁,需要重新获取写锁。

Java当中的读写锁通过ReentrantReadWriteLock实现

互斥锁

同一时刻最多只有一个线程持有锁,在JDK中synchronized和JUC的Lock就是互斥锁。

无锁

有些方法不涉及到共享数据,就不会出现线程安全问题,一定线程安全的有:

  • 无状态编程

  • ThreadLocal等线程封闭方案

  • volatile(volatile只能保证可见性和防止重排序,并不能保证线程安全)

  • CAS

  • 协程,单线程内维持多个任务的调度

分段锁

ConcurrentHashMap

闭锁

闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。闭锁可以用来确保某些活动指导其他活动都完成后才继续执行。CountDownLatch就是一种灵活的闭锁实现。

死锁

多个线程因资源竞争而相互等待的现象。出现死锁必须满足4个条件:

  • 互斥条件:一个资源一次只能被一个进程使用

  • 请求与保持条件:一个进程因请求资源而阻塞等待,对已获取的资源保持不放

  • 不剥夺条件:进程已获取的资源,不会被剥夺

  • 循环等待条件:若干进程形成循环等待资源的关系。

活锁

LiveLock是一种形式活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发送在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。

参考

https://www.infoq.cn/article/java-se-16-synchronized

https://www.zhihu.com/question/55075763

https://hiddenpps.blog.csdn.net/article/details/51204385