Java锁

273 阅读6分钟

参考美团技术文档: 不可不说的java锁事

悲观锁

认为变量很可能被修改, 因此需要给它上锁. 同一时刻只有占有锁的线程可以操作, 其他线程等待(其他线程即使被调度上线程也会阻塞, 等待锁释放时会被唤醒, 重新等待被调度上CPU.

Synchronized, Lock都是悲观锁

适合写操作多的场景

乐观锁

认为变量一般不会被修改, 因此不使用锁, 而是在写入新值是比较其是否被改变

CAS是乐观锁的经典实现, concurrent包下的Atomic类, 及ConcurrentHashMap, ConcurrentLinkedQueue/Deque, ConcurrentSkipListMap/Set等都用CAS实现

以Atomic类为例:

// Unsafe.java // 用于内存数据操作的类
public final int getAndAddInt(Object o, long offset, int delta) {
   int v;
   do {
       v = getIntVolatile(o, offset); // 获取期待值A
   } while (!compareAndSwapInt(o, offset, v, v + delta)); // 比较实际值V与期待值A, 若相等更新, 不相等重试
   return v;
}

getIntVolatile, compareAndSwapInt都是一个CPU周期完成的, 是JNI

自旋锁

CPU阻塞或唤醒线程需要由用户态转到内核态, 且进程切换需要保存和恢复现场, 消耗很大. 而通常的锁操作耗时很短, 因此让线程自旋等待, 不放弃CPU时间片可能效率更高.

CAS就是一种自旋锁(while循环)

但自旋一段时间后依然没有成功执行, 即自旋失败. 一般自旋失败是通过限定次数判断的(默认是10次,可以使用-XX:PreBlockSpin来更改)

Synchronized四种锁类型

java对象头

存储于对象自身定义无关的信息

  • MarkWord: 默认存储对象的HashCode,分代年龄和锁标志位信息
  • Klass Point: 存储指向该对象类元数据的指针

Monitor

线程私有的一个结构, 存储了锁Owner的线程号, 每个对象的锁与一个Monitor关联

锁状态MarkWord存储内容存储内容
无锁对象的hashCode、对象分代年龄、是否是偏向锁(0)01
偏向锁偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1)01
轻量级锁指向栈中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10

Synchronized底层实现

Synchronized方法

JVM将方法编译为字节码时, 会为其添加flag ACC_SYNCHRONIZED, 在执行时先获取对象的锁

Synchronized同步代码块

JVM在编译时, 会在进入时加入monitorenter指令, 在出去时加入monitorexit执行. 为防止执行过程中出现异常, JVM为在编译时隐式地加入try finally结构, 在finally中用monitorexit释放

Lock底层实现

Lock底层基于Unsafe类(park方法)实现.

Synchronized有序性

Synchronized不能防止指令重排, 它的有序性是指对线程和线程来说, 一个线程先执行, 后面线程看到的一定是它操作后端结果

对于Synchronized内部, 有JVM的happens-before原则保证, 但内部的非原子操作依然可能被重排

块与块之间看起来是原子操作,块与块之间有序可见

无锁

只有一个进程能修改成功, 其它重试.类似CAS

若经常只有某一个线程来操作, 升级为偏向锁

偏向锁

在只有一个线程执行同步代码块的情况下提高性能

MarkWord中存储偏向线程

若偏向线程已经结束时, 若其它线程来请求锁, 则重新分配偏向锁, 只需要在置换对象的MarkWord中偏向线程ID时依赖一次CAS原子指令即可

若偏向线程仍在使用锁, 则升级为轻量级锁

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

轻量级锁

其它线程自旋等待, 不放弃CPU时间片

缺点: 忙等待的线程会一直占用CPU, 如果单核, 可能导致占有锁的线程无法释放

重量级锁

其它线程阻塞等待

公平锁 & 非公平锁

锁有等待队列, 每次占有锁的进程执行完成, CPU需要唤醒所有线程竞争锁, 开销很大.

一个线程想要执行同步代码时, 会先判断队列是否空, 若为空, 则不用等待, 直接获取锁; 若非空, 则进入队列阻塞等待

而非公平锁则是在线程想要执行代码块时, 即使队列中有线程, 也先插队, 如果占有锁的线程刚好执行完成, 则这个线程不用阻塞, CPU也不必唤醒所有线程. 但可能导致队列中的线程饿死

ReentrantLock内有公平锁FairSync和非公平锁NonfairSync两个子类, 示例代码如下:

公平锁非公平锁代码

两者都是独占锁

可重入锁 & 非可重入锁

如果一个线程在执行外层方法时获取锁, 然后调用内层方法获取同一个锁, 是否可以获取锁

可重入锁在获取锁时先判断当前线程是否就是持有锁的线程, 如果是则讲state+acquires, 释放锁时将state-release. 如果state==0才是真正释放锁

而非可重入锁在获取时直接尝试获取, 释放时直接将state置0

Synchronized和ReentrantLock都是可重入锁, NonReentrantLock是非可重入锁

独占锁 & 共享锁

独占锁 = 排他锁, 一次只能被一个线程持有, 其他进程不能对他加任何形式的锁

Synchronized, ReentrantLock都是独占锁

共享锁是指一个线程对数据加共享锁后, 其他线程也可对数据加共享锁, 而不可加排它锁.

ReentrantReadWriteLock是排他锁. 它内部有readLock和writeLock, readLock是共享锁, writeLock是独占锁, 这里用state高16位表示读锁个数, 低16位表示写锁个数

Volatile

可见性

现代CPU和内存有多级缓存, 导致程序得到的值可能不是内存中最新的值

volatile使 JVM 保证它的“可见性”。简单地说,JVM 会:

  1. 在写入一个 volatile 变量时,强制它写入到内存中
  2. 在读取一个 volatile 变量时,强制它从内存中读取

有序性

JVM为提高CPU利用率, 会对指令进行重排

Volatile使JVM 限制指令重排,在编译/执行过程中:

  1. 在 volatile 变量的写入指令之前,对其它变量的读写指令不能重排到该指令之后
  2. 在 volatile 变量的读取指令之后,对其它变量的读写指令不能重排到该指令之前

Atomic类

同样是基于Unsafe类实现, 将一些稍复杂的操作封装, 成为原子操作.

CAS do while实现, 同一时刻只有一个线程能够成功