基础知识之一:锁的类型
锁从宏观上分类,分为悲观锁与乐观锁。
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
Java中乐观锁的案例
ConcurrentHashMap
ConcurrentHashMap是Java中的一个线程安全的哈希表实现,它使用分离锁(Segment)来保证线程安全。每个Segment都是一个独立的哈希表,每个操作只锁定相关的Segment,因此可以支持更高的并发性。
ConcurrentHashMap使用了一种基于CAS的技术来实现乐观锁,它通过比较当前的value和预期的value是否相等来判断是否存在冲突。如果存在,则返回失败;如果不存在,则执行更新操作。
LongAdder
在 JDK1.8 中,Java 提供了一个新的原子类 LongAdder。LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好,代价就是会消耗更多的内存空间。
LongAdder 的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的 value 值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的 value 值进行 CAS 操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的 value 值相加,返回一个近似准确的数值。
数据库并发控制
在日常开发中,使用乐观锁最常见的场景就是数据库的更新操作了。
为了保证操作数据库的原子性,我们常常会为每一条数据定义一个版本号,并在更新前获取到它,到了更新数据库的时候,还要判断下已经获取的版本号是否被更新过,如果没有,则执行该操作。
CAS 乐观锁在平常使用时比较受限,它只能保证单个变量操作的原子性,当涉及到多个变量时,CAS 就无能为力了。
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。Java中的悲观锁就是Synchronized, AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
Java中的锁机制
在 Java 中主要2种加锁机制:
synchronized 关键字
java.util.concurrent.Lock (Lock是一个接口,ReentrantLock是该接口一个很常用的实现)
这两种机制的底层原理存在一定的差别
- synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现, 这对指令被 JVM 规范所描述。
- java.util.concurrent.Lock 通过 Java 代码搭配sun.misc.Unsafe 中的本地调用实现的
基础知识之二:Java线程阻塞的代价
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.6开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。
明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一。
基础知识之三:Mark word
Java对象结构
在讲到锁之前,先来简单了解一下Java的对象结构。Java的对象结构主要包括对象头,实例数据,对齐填充三大部分。
对象头
对象头中存储了对象的Mark word,类型指针(元数据指针,指向方法区中的类元信息)和数组长度(只有当前对象为数组对象时才会有)。而Mark word又包括对象的Hashcode码,对象的分代年龄,对象的偏向锁ID,获取偏向锁的时间戳,锁标志位等。
Mark word主要用于存储对象自身运行时的数据,并且Mark word字段的长度与JVM的位数有关,32位的JVM虚拟机中Mark word占用32位的存储空间,64位的JVM虚拟机中占用64位的储存空间。
以32位的JVM虚拟机为例:
- 是否是偏向锁表示:当值为0时,标记对象没有开启偏向锁,值为1表示开启了偏向锁。
- 锁标志位表示:当前线程拥有的锁,不同状态下拥有的锁不同。
- 对象分代年龄表示:当JVM发生GC垃圾回收时,新生代未被回收的对象在Eden区和Survivor之间的复制,每次复制是的分代年龄+1,默认情况下分代年龄达到15会被移到老年代区域,当然这个参数也可以自行设置。
- 对象的Hashcode值:主要存储对象的Hashcode值。
- 线程ID:表示在偏向锁状态下,持有偏向锁的线程编号。
- 指向栈中锁记录的指针:在轻量级锁状态下,指向栈中所记录的指针。
- 指向(互斥量)重量级锁的指针:在重量级锁状态下,指向对象监视器Monitor的指针。
实例数据
实例数据主要存储了对象的成员变量信息,比如成员变量的具体值,父类成员变量的具体值等。
对象填充
在HotSpot虚拟机中,对象的起始地址必须为8的整数倍,如果当前对象的实例变量占用的储存空间不是8的整数倍,则需要使用填充数据来保证对齐。
基础知识之四:JVM锁升级机制
synchronized 关键字之锁的升级(偏向锁->轻量级锁->重量级锁)
synchronized详解:https://juejin.cn/post/6977744582725681182
前面提到过, synchronized 代码块是由一对 *monitorenter/moniterexit * 字节码指令实现, monitor 是其同步实现的基础, Java SE1.6 为了改善性能, 使得 JVM 会根据竞争情况, 使用如下 3 种不同的锁机制
- 偏向锁(Biased Lock )
- 轻量级锁( Lightweight Lock)
- 重量级锁(Heavyweight Lock)
上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作。这三种机制的切换是根据竞争激烈程度进行, 在几乎无竞争的条件下, 会使用偏向锁, 在轻度竞争的条件下, 会由偏向锁升级为轻量级锁, 在重度竞争的情况下, 会升级到重量级锁。
synchronized的执行过程:
- 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
- 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态。
- 如果自旋失败,则升级为重量级锁。
详细图
简略图