java锁原理总结

java锁归类

image.png

通过几个问题看是否掌握java中的锁

乐观锁是否加锁?

不加锁,乐观锁是一种通过无锁来支持并发访问资源的思路,具体实现可以有多种方式,例如数据库的mvcc或者java中的CAS。就是访问资源时都假设能成功访问,如果碰到实际上有冲突,再采取一些策略。

不加锁线程并发修改会有什么问题?举个例子

并发修改会产生错误的值,本质是由于一些内存修改都是分成先读取、修改、再写入三个操作。并发时上面几个操作并行执行,会导致取到错误的初始值。例如a初始值为0,a=a+1都是在原有值的基础上+1修改内存,两个线程并发执行会碰到一种情况,大家都认为起始值是0,最终并发执行完的结果为1,实际我们希望并发执行最后的结果是等于2.

CAS是如何实现的,java中如何使用CAS以及它的缺点是什么?

cas是compare and set的缩写,是OS内核提供的能力,通过内存值放到寄存器中然后和期望值比较,如果比较成功将目标值写入寄存器再写入内存,完成原子更新,通过CPU原子指令执行。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则就调用原子指令重试(也就是自旋,不释放CPU时间片)。

缺点主要是:

  • 使用CAS需要考虑aba问题:CAS是本身是没问题的,就是使用侧需要了解CAS进行get和compare之间,可能已经发生过一些a->b->a的操作了。一般举例是以栈操作为例,栈顶是某个值x,两个线程1,2并发执行修改,这个并发修改的场景,我们希望其中一个线程修改生效后,另外一个线程能够感知到这个写入冲突,从而失败重试。如果使用CAS的话,则达不到这个效果。两个线程针对这个当前内存值x进行CAS,线程1可以将x出栈,然后对栈内数据做一通操作,然后再将x入栈,这样线程2进行CAS判断时认为内存值和CPU寄存器值仍然是一样的然后进行set操作,线程2没法感知到这种修改。解法的话可以考虑AtomicStampedReference的compareAndSet方法,会有个额外的stamped标记来区分相同的值,也就是说能感知a->b->a的变化
  • 冲突多的场景下自旋开销:自旋长时间不释放时间片会导致cpu非常大的开销
  • 只适合一个共享变量原子操作:可以通过AtomicReference来保证引用对象之间的原子性

锁升级

synchronized直接使用操作系统monitor lock这种重量级锁开销大(涉及用户态内核态的切换),所以从jdk1.6开始使用synchronized的时候会从开销最小的无锁开始,在冲突很大的情况下最终才会变成重量级锁。
image.png

  • 无锁:资源一开始没有被线程访问时的状态
  • 偏向锁:被一个线程访问时,mark word会记录这个线程的thread id。相同线程访问相当于无锁。如果偏向锁有竞争,那么执行CAS,尝试替换为新的thread id。CAS成功则指向新的thread id。如果失败则升级为轻量级锁
  • 轻量级锁:尝试栈帧中创建Lock Record存储mark word内容的拷贝,mark word指向Lock Record指针。通过CAS将对象头mark word指向lock record,如果成功则该线程获得该锁。如果失败就自旋。自旋重试一定次数或者竞争者更多了就升级为重量级锁

通过几个问题加深理解锁升级

锁支持降级吗?

不同jvm实现不同,我们以hot spot vm为例的话是可以的。一般是STW的时候,也就是safe point,这时候没有任何字节码执行,可以扫描可以降级的锁。

源码解读

核心类概览

image.png

核心类解读

LockSupport

LockSupport 提供基础的锁操作原语,由 Unsafe 类实现,调用 JNI native 方法.

1
2
3
Unsafe:
public native void unpark(Object var1);
public native void park(boolean var1, long var2);

可重入锁

基于Sync和AQS实现的都是可重入锁。读写锁不是可重入锁。ReentrantLock 逻辑都由内部类 Sync 实现,默认非公平锁。锁的公平性与非公平性:公平是排在队尾;非公平是直接竞争锁。原因:提高效率,减少线程切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void lock() {
sync.lock();
}

// 公平锁
final void lock() {
acquire(1);
}

// 非公平锁
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

读写锁

ReadWriteLock 可以解决多线程同时读,但只有一个线程能写的问题。深入分析 ReadWriteLock ,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。要进一步提升并发执行效率,Java 8 引入了新的读写锁:StampedLock。
StampedLock 和 ReadWriteLock 相比,改进之处在于:读的过程中也允许获取写锁后写入!我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁,有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

AQS

基本结构

抽象队列同步器是锁实现的核心。将请求共享资源的线程封装成一个node放入双向链表的队列(FIFO),同步状态释放时唤醒头结点。
image.png
获取和释放锁的过程

1.获取同步状态

假设线程A要获取同步状态,初始状态state=0,线程A可以顺利获取锁,A获取锁后将state置为1。在A没有释放锁期间,线程B来获取锁,此时因为state=1,锁被占用,所以将B的线程信息和等待状态等数据构成一个Node节点,放入同步队列中,
head和tail分别指向队列头部和尾部(此时队列中有一个空的Node节点作为头点,head指向这个空节点,空Node的后继节点是B对应的Node节点,tail指向它),同时阻塞线程B,阻塞使用的是LockSupport.park()方法。后续如果还有线程要获取锁,都会加入队列尾部并阻塞。

2.释放同步状态

当线程A释放锁时,将state置为0,此时线程A会唤醒头节点的后继节点,唤醒其实是调用LockSupport.unpark(B)方法,即线程B从LockSupport.park()方法返回,此时线程B发现state已为0,所以线程B可以顺利获取锁,线程B获取锁后,B的Node节点出队。

核心能力

主要提供以下核心能力:

  • 同步队列维护竞争资源的线程的阻塞与唤醒
  • 同步状态管理

问题加深理解

AQS为什么使用双向链表?

一共有3个原因:

1)第一个是因为没有竞争到锁的线程会加入到阻塞队列,阻塞等待的前提是当前线程所在的节点的前驱节点是正常状态,为了避免列表存在异常线程。所以为了判断前驱节点,需要双向链表。(单向需要从Head一直遍历)

2)第二个原因是没有竞争到锁的线程会加入到阻塞队列时,是允许外部线程通过interrupt方法中断的,中断后是cancel状态,是不需要竞争锁的,所以队列竞争锁时需要把它移除,所以双向链表删除更高效。

3)第三个原因是避免阻塞和唤醒的开销。在阻塞队列中,因为FIFO的队列,只有头节点的下一个节点才需要竞争锁(自旋方式)。所以列表中的节点竞争锁之前需要判断前驱节点是不是头节点。所以双向链表(head指针)更高效。

非公平锁的时候双向链表还是FIFO的么?

是的,非公平锁只是意味着新线程(未加入同步队列的线程)会和被唤醒的线程参与竞争。同步队列内部实际上是公平的,肯定是head节点后面的Node关联的线程先被唤醒。非公平锁的非公平是指的苏醒线程和未加入同步队列的线程而言的

参考资料