# 一、synchronized synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种: 1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象; ```java class SyncLock implements Runnable{ @Override public void run() { synchronized (this){ for (int i = 0; i < 5; i++) { try { System.out.println("线程名:"+Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } } ``` synchronized (this)锁定的是当前对象,如果new一个其他对象的话,会生成新锁。 2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象; ```java public synchronized void run() { { for (int i = 0; i < 5; i++) { try { System.out.println("线程名:"+Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } ``` 在用synchronized修饰方法时要注意以下几点: + synchronized关键字不能继承 + 在定义接口方法时不能使用synchronized关键字 + 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步 3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象; 静态方法是属于类的而不属于对象的。同样的,synchronized修饰的静态方法锁定的是这个类的所有对象。 ```java class SyncThread implements Runnable { private static int count; public SyncThread() { count = 0; } public synchronized static void method() { for (int i = 0; i < 5; i ++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } public synchronized void run() { method(); } } ``` 假如创建了2个SyncThread对象,但是由于有静态方法锁且锁属于这个类,无论创建多少对象,run()方法的锁依然生效。 4. 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。 ```java class SyncThread implements Runnable { private static int count; public SyncThread() { count = 0; } public static void method() { synchronized(SyncThread.class) { for (int i = 0; i < 5; i ++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public synchronized void run() { method(); } } ``` 效果与修饰静态方法相同 # 二、volatile 不能用于自增操作,例如count ++ 作用如下: 1. 保证可见性,不保证原子性 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去; 这个写会操作会导致其他线程中的volatile变量缓存无效。 2. 禁止指令重排 重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果 假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。 ![[Snipaste_2023-02-28_13-09-43 2.png]] 当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图。 ![[Snipaste_2023-02-28_13-09-43 3.png]] 从横向来看,线程A和线程B之间进行了一次通信,线程A在写volatile变量时,实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了,然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了,那线程B该怎么办了?自然而然就只能去主内存去取啦。 # 三、ReentrantLock ```java public class ReentrantLockDemo { // 实例化一个非公平锁,构造方法的参数为true表示公平锁,false为非公平锁。 private final ReentrantLock lock = new ReentrantLock(false); private int i; public void testLock() { // 拿锁,如果拿不到会一直等待 lock.lock(); try { // 再次尝试拿锁(可重入),拿锁最多等待100毫秒 if (lock.tryLock(100, TimeUnit.MILLISECONDS)) i++; } catch (InterruptedException e) { e.printStackTrace(); } finally { // 释放锁 lock.unlock(); lock.unlock(); } } } ``` 代码中创建了非公平锁,lock.lock()会进行拿锁操作,如果拿不到锁则会一直等待。如果拿到锁之后则会执行try代码块中的代码。接下来在try代码块中又通过tryLock(100, TimeUnit.MILLISECONDS)方法尝试再次拿锁,此时,拿锁最多会等待100毫秒,如果在100毫秒内能获得锁,则tryLock方法返回true,拿锁成功,执行i++操作,如果返回false,获取锁失败,i++不会被执行。(因为第一次线程已经拿到锁了,由于ReentrantLock是可重入,因此,第二次必定能拿到锁。此处仅仅为了演示ReetranLock的使用,不必纠结)。 另外,要注意被ReentrantLock加锁区域必须用try代码块包裹,且释放锁需要在finally中来避免死锁。执行几次加锁,就需要几次释放锁。 ## 3.1 公平与非公平 **公平锁**是指多个线程按照申请锁的顺序来获取锁,线程直接进入同步队列中排队,队列中最先到的线程先获得锁。**非公平锁**是多个线程加锁时每个线程都会先去尝试获取锁,如果刚好获取到锁,那么线程无需等待,直接执行,如果获取不到锁才会被加入同步队列的队尾等待执行。 公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点相较于于非公平锁整体执行速度更慢,吞吐量更低。同步队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。 而非公平锁非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。它的缺点呢也比较明显,即队列中等待的线程可能一直或者长时间获取不到锁。 ## 3.2 可重入与非可重入 可重入锁不会阻塞 **可重入锁**又名递归锁,是指同一个线程在获取外层同步方法锁的时候,再进入该线程的内层同步方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。**非可重入锁**与可重入锁是对立的关系,即一个线程在获取到外层同步方法锁后,再进入该方法的内层同步方法无法获取到锁,即使锁是同一个对象。 synchronized与本篇讲的ReentrantLock都属于可重入锁。可重入锁可以有效避免死锁的产生。 # 四、CAS与原子类 原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。例如`AtomicBoolean`,`AtomicInteger`,`AtomicLong`。它们分别用于`Boolean`,`Integer`,`Long`类型的原子性操作。它们都是基于CAS实现。 ## 4.1 CAS机制 CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换。 CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。 更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。 用伪代码解释就是: if(V=A){ ----- 比较相等 swap() ----- 替换旧值,此时V=B,A=B,新的B需要到下一次修改 }else { retry() ----- 重试(自旋) } 在 Java 中,Java 并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的。Java 代码需通过 JNI 才能调用。CAS 是一条 CPU 的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe 提供的 CAS 方法(如compareAndSwapXXX)底层实现即为 CPU 指令 cmpxchg。 ![[Snipaste_2023-02-28_13-09-43 5.png]] ```cpp inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx } } ``` 如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(Lock Cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。 ## 4.2 示例 1. 在内存地址V中,存储着值为10的变量 ![[5630287-350bc3c474eef0e8.jpg]] 2. 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。 ![[5630287-350bc3c474eef0e8 1.jpg]] 3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。 ![[5630287-350bc3c474eef0e8 2.jpg]] 4. 线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。 ![[5630287-350bc3c474eef0e8 3.jpg]] 5. 线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。 ![[5630287-f638cadea7b6cb96.webp]] 6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的 ![[5630287-0a3d0b3926366d63.jpg]] 7. 线程1进行SWAP,把地址V的值替换为B,也就是12。 ![[5630287-0a3d0b3926366d63 1.jpg]] ## 4.3 优缺点 #### 优点: - 可以保证变量操作的原子性; - 并发量不是很高的情况下,使用CAS机制比使用锁机制效率更高; - 在线程对共享资源占用时间较短的情况下,使用CAS机制效率也会较高。 #### 缺点: 1. CPU开销较大 在并发量比较高的情况下,如果许多线程反复尝试更新(自旋锁 )某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。 2. 不能保证代码块的原子性 CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。 3. ABA问题 CAS在操作的时候会检查变量的值是否被更改过,如果没有则更新值,但是带来一个问题,最开始的值是A,接着变成B,最后又变成了A。经过检查这个值确实没有修改过,因为最后的值还是A,但是实际上这个值确实已经被修改过了。为了解决这个问题,在每次进行操作的时候加上一个版本号,每次操作的就是两个值,一个版本号和某个值,A——>B——>A问题就变成了1A——>2B——>3A。在jdk中提供了AtomicStampedReference类解决ABA问题,用Pair这个内部类实现,包含两个属性,分别代表版本号和引用,在compareAndSet中先对当前引用进行检查,再对版本号标志进行检查,只有全部相等才更新值。 # 五、AQS CAS中的自旋锁有2个缺点: + 第一个是锁饥饿问题。在锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。 + 第二是性能问题。在实际的多核上运行的自旋锁在锁竞争激烈时性能较差。 TASLock 和 TTASLock 与上文代码类似,都是针对**一个原子状态变量**轮询的自旋锁实现,显然,自旋锁的性能和理想情况相距甚远。这是因为自旋锁锁状态中心化,在竞争激烈的情况下,锁状态变更会导致多个 CPU 的高速缓存的频繁同步,从而拖慢 CPU 效率。 为了解决这2个问题就有了CLH锁,CLH是AQS的核心。 ![[721070-20170504110246211-10684485.png]] CLH 锁是对自旋锁的一种改进,有效的解决了以上的两个缺点。首先它将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题。其次锁状态去中心化,**让每个线程在不同的状态变量中自旋**,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。 CLH 锁数据结构很简单,类似一个链表队列,所有请求获取锁的线程会排列在链表队列中,自旋访问队列中前一个节点的状态。当一个节点释放锁时,只有它的后一个节点才可以得到锁。CLH 锁本身有一个队尾指针 Tail,它是一个原子变量,指向队列最末端的 CLH 节点。每一个 CLH 节点有两个属性:所代表的线程和标识是否持有锁的状态变量。当一个线程要获取锁时,它会对 Tail 进行一个 getAndSet 的原子操作。该操作会返回 Tail 当前指向的节点,也就是当前队尾节点,然后使 Tail 指向这个线程对应的 CLH 节点,成为新的队尾节点。入队成功后,该线程会轮询上一个队尾节点的状态变量,当上一个节点释放锁后,它将得到这个锁。 AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。 不同的自定义同步器争用共享资源的方式也不同。**自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可**,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法: - isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。 - tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。 - tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。 - tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。 - tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。 以ReentrantLock为例,state(同步状态)初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。 再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。 ## 5.1 CLH的数据结构 CLH中的节点表示待获取锁的对象,里面包含6个方法及属性 | 方法和属性值 | 含义 | | ------------ | -------------------------------------------------------------------------------------------- | | waitStatus | 当前节点在队列中的状态 | | thread | 表示处于该节点的线程 | | prev | 前驱指针 | | predecessor | 返回前驱节点,没有的话抛出npe | | nextWaiter | 指向下一个处于CONDITION状态的节点(由于本篇文章不讲述Condition Queue队列,这个指针不多介绍) | | next | 后继指针 | waitStatus有下面几个枚举值: | 枚举 | 含义 | | --------- | ---------------------------------------------- | | 0 | 当一个Node被初始化的时候的默认值 | | CANCELLED | 为1,表示线程获取锁的请求已经取消了 | | CONDITION | 为-2,表示节点在等待队列中,节点线程等待唤醒 | | PROPAGATE | 为-3,当前线程处在SHARED情况下,该字段才会使用 | | SIGNAL | 为-1,表示线程已经准备好了,就等资源释放了 | ## 5.2 同步状态State AQS中维护了一个名为state的字段,意为同步状态,是由Volatile修饰的,用于展示当前临界资源的获锁情况。 | 方法名 | 描述 | | ------------------------------------------------------------------ | -------------------- | | protected final int getState() | 获取State的值 | | protected final void setState(int newState) | 设置State的值 | | protected final boolean compareAndSetState(int expect, int update) | 使用CAS方式更新State | 可以通过修改State字段表示的同步状态来实现多线程的独占模式和共享模式(加锁过程)。 ### 5.2.1 独占模式 ![[27605d483e8935da683a93be015713f331378.png]] 以ReentrantLock为例,state(同步状态)初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。 举个例子:排队打饭,只有一个窗口(state),第一个站住窗口(lock(),tryAcquire(),state+1),后一个人需要等待前人离开(unlock,state=0) #### 线程加入等待队列 当其他线程tryAcquire()失败时,会调用addWaiter加入到等待队列中去。总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。 ### 5.2.2 共享模式 ![[27605d483e8935da683a93be015713f331378 1.png]] 以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。