前言:
众所周知,B站是全中国最大的在线学习平台,此次学习的教程主要来自【狂神说】与【寒食君】两位B站up主,同时也有各位技术大牛分享的文章🚀
学习视频来源:
博客回顾:
1. Java锁机制
- 线程私有:
- pc寄存、Java虚拟机、本地方法栈
- 线程共享:
- Java堆:储存实例对象
- 方法区:储存类信息、常量、静态变量等数据
1.1 Synchronized锁
说到给Java程序上锁,没有人会想不到Synchronized
关键字,那其本质是如何运行的呢?我们来反编译看看:
可以看到编译后的synchronized使用了monitorenter
和monitorexit
两个字节码指定对业务代码进行了包裹,使线程同步。
monitor直译过来是监视器的意思,专业一点叫管程。monitor是属于编程语言级别的,它的出现是为了解决操作系统级别关于线程同步原语的使用复杂性,类似于语法糖,对复杂操作进行封装。而java则基于monitor机制实现了它自己的线程同步机制,就是synchronized内置锁。
monitor的作用就是限制同一时刻,只有一个线程能进入monitor框定的临界区,达到线程互斥,保护临界区中临界资源的安全,这称为线程同步使得程序线程安全。同时作为同步工具,它也提供了管理进程,线程状态的机制,比如monitor能管理因为线程竞争未能第一时间进入临界区的其他线程,并提供适时唤醒的功能。
monitor实质是以来操作系统的mutex
和lock
等指令实现的,所以每当执行synchronized时,都会切换操作系统对程序的执行状态;同时这种操作是比较重量级的,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长,这就有可能严重影响程序整体执行的性能。
在Java 6之后,synchronized
为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。
目前锁状态一种有四种,从级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁,锁状态只能升级,不能降级
1.2 锁的四种状态
锁的四种状态:
级别由低到高依次为:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。这几个状态会随着竞争情况逐渐升级。
注意:锁可以升级但不能降级。
- 无锁:具体看CAS讲解
- 偏向锁:
- 不调用mutex 和cas,设想对象与线程匹配,只要对象找到其偏爱的线程就开始执行。
- 轻量级锁:
- 不调用mutex,但会在竞争线程时采取一定自旋锁定/获取资源。
- 重量级锁:
- 直接调用monitor将对象与线程锁死
具体偏向锁、轻量级锁和重量级锁是如何切换的呢?这就得讲到对象头了。
1.3 对象头:
参考博客:
- 对象头:
- Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode;
- Klass Word是一个指向方法区中Class信息的指针,意味着该对象可随时知道自己是哪个Class的实例;
- 数组长度也是占用64位(8字节)的空间,这是可选的,只有当本对象是一个数组对象时才会有这个部分;
- 对象体:是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型;
- 对齐字(填充字节)是为了减少堆内存的碎片空间(不一定准确)。
存在锁对象的对象头的Mark Word中,那么MarkWord在对象头中到底长什么样,它到底存储了什么呢?
在64位的虚拟机中:
可以通过 java -version
指令查看jvm位数,一般都是64位。
-
无锁:对象头开辟 25bit 的空间用来存储对象的 hashcode ,4bit 用于存放对象分代年龄,1bit 用来存放是否偏向锁的标识位,2bit 用来存放锁标识位为01
-
偏向锁: 在偏向锁中划分更细,还是开辟 25bit 的空间,其中23bit 用来存放线程ID,2bit 用来存放 Epoch,4bit 存放对象分代年龄,1bit 存放是否偏向锁标识, 0表示无锁,1表示偏向锁,锁的标识位还是01
-
轻量级锁:在轻量级锁中直接开辟 30bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为00
-
重量级锁: 在重量级锁中和轻量级锁一样,30bit 的空间用来存放指向重量级锁的指针,2bit 存放锁的标识位,为11
-
GC标记: 开辟30bit 的内存空间却没有占用,2bit 空间存放锁标志位为11。
其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态
阿里面试,问了我乐观锁、悲观锁、AQS、sync和Lock,这个回答让我拿了offer
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
实例文章:
2. CAS
1.1 无锁编程
锁的状态大致可分为两种:
- 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。
- 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。
- 在Java中
java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。- 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
- Java中
synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现。
-
悲观锁:
- 可以保证线程安全,但不是万能 ,对于读操作上悲观锁是十分消耗性能的。
-
乐观锁(不锁定资源,能同步资源):
-
无锁的同步机制:
-
一般会使用版本号机制
-
java.util.concurrent.atomic.AtomicStampedReference<V>
compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp)
方法:以原子方式设置该引用和标签给定的更新值的值,如果当前的参考是==
至预期的参考,并且当前标志等于预期标志。
-
-
CAS(Compare-and-Swap,即比较并替换)算法实现:
-
java.util.concurrent.atomic.AtomicInteger
中:compareAndSet(int expect, int update)
方法:如果当前值==
为预期值,则将该值原子设置为给定的更新值。
-
-
正如上图所示,实现无锁编程主要是通过调用 java.util.concurrent.atomic
包下的子类实现,同时通过源码得知,原子类的底层通常调用了 unsafe
类(主要执行底层与平台相关的方法)
1.2 CAS与synchronized的对比
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),
synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况:
- 使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;
- CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况:
- CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
- synchronized优化后,其底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
- CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
3. AQS
Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AbstractQueuedSynchronizer(简称为AQS)实现的。
AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。
直接翻译过来就是:抽象队列同步器
ReentrantLock 与 Synchronized 比较
3.1 自定义Lock
个人喜欢自顶向下的学习方法,直接从文章的AQS的实例应用开始倒推:
-
仿照
ReentrantLock
写一个自己的🔒 -
自定义锁:
public class WayneLock { private static class Sync extends AbstractQueuedSynchronizer { @Override protected boolean tryAcquire(int arg) { return compareAndSetState(0, 1); } @Override protected boolean tryRelease(int arg) { setState(0); return true; } @Override protected boolean isHeldExclusively() { return getState() == 1; } } private Sync sync = new Sync(); public void lock(){ sync.acquire(1); } public void unlock(){ sync.release(1); } }
-
例子测试:
复用之前kuangStudy例子:
- WayneMain
public class WayneMain { public static void main(String[] args) { WayneMain2 wayneMain2 = new WayneMain2(); new Thread(wayneMain2, "APPLE").start(); new Thread(wayneMain2, "BANANA").start(); new Thread(wayneMain2, "CAT").start(); } } class WayneMain2 implements Runnable { int ticketNum = 10; //定义LOCK锁 private final WayneLock wayneLock = new WayneLock(); @Override public void run() { while (true) { try { Thread.sleep(100); wayneLock.lock(); if (ticketNum > 0) { System.out.println(Thread.currentThread().getName() + "-->" + ticketNum--); } else { break; } } catch (InterruptedException e) { e.printStackTrace(); } finally { wayneLock.unlock(); } } } }
自定义的wayneLock效果一致。
3.2 AQS的主要方法源码解析
- Redspider社区:【第十一章 AQS】http://concurrent.redspider.group/article/02/11.html
阿里团队真给力👍👍👍有空可以通读一遍
AQS的设计是基于模板方法模式的,它有一些方法必须要子类去实现的,它们主要有:
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
这些方法虽然都是protected
方法,但是它们并没有在AQS具体实现,而是直接抛出异常(这里不使用抽象方法的目的是:避免强迫子类中把所有的抽象方法都实现一遍,减少无用功,这样子类只需要实现自己关心的抽象方法即可,比如 Semaphore 只需要实现 tryAcquire 方法而不用实现其余不需要用到的模版方法):
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
接下来主要看获取与释放资源的方法,总结为主,具体的分析可以前往http://concurrent.redspider.group/article/02/11.html 详细查看
3.2.1 获取资源
获取资源的入口是acquire(int arg)
方法。
arg是要获取的资源的个数,在独占模式下始终为1。我们先来看看这个方法的逻辑:
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先调用tryAcquire(arg)
尝试去获取资源(前面提到了这个方法是在子类具体实现的):
3.2.2 释放资源
释放资源相比于获取资源来说,会简单许多。在AQS中只有一小段实现。源码:
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
// 如果状态是负数,尝试把它设置为0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 得到头结点的后继结点head.next
Node s = node.next;
// 如果这个后继结点为空或者状态大于0
// 通过前面的定义我们知道,大于0只有一种可能,就是这个结点已被取消
if (s == null || s.waitStatus > 0) {
s = null;
// 等待队列中所有还有用的结点,都向前移动
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
// 如果后继结点不为空,
if (s != null)
LockSupport.unpark(s.thread);
}