You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

一、synchronized

synchronized是Java中的关键字是一种同步锁。它修饰的对象有以下几种

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
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一个其他对象的话会生成新锁。

  1. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
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代码块来进行同步
  1. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象; 静态方法是属于类的而不属于对象的。同样的synchronized修饰的静态方法锁定的是这个类的所有对象。
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()方法的锁依然生效。

  1. 修饰一个类其作用的范围是synchronized后面括号括起来的部分作用主的对象是这个类的所有对象。
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

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开头的包装类。例如AtomicBooleanAtomicIntegerAtomicLong。它们分别用于BooleanIntegerLong类型的原子性操作。它们都是基于CAS实现。

4.1 CAS机制

CAS是英文单词Compare And Swap的缩写翻译过来就是比较并替换。 CAS机制当中使用了3个基本操作数内存地址V旧的预期值A要修改的新值B。 更新一个变量的时候只有当变量的预期值A和内存地址V当中的实际值相同时才会将内存地址V对应的值修改为B。

用伪代码解释就是: if(V=A){ ----- 比较相等 swap() ----- 替换旧值此时V=BA=B新的B需要到下一次修改 }else { retry() ----- 重试(自旋) }

在 Java 中Java 并没有直接实现 CASCAS 相关的实现是通过 C++ 内联汇编的形式实现的。Java 代码需通过 JNI 才能调用。CAS 是一条 CPU 的原子指令cmpxchg指令不会造成所谓的数据不一致问题Unsafe 提供的 CAS 方法如compareAndSwapXXX底层实现即为 CPU 指令 cmpxchg。 !Snipaste_2023-02-28_13-09-43 5.png

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=11B=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后一个人需要等待前人离开unlockstate=0

线程加入等待队列

当其他线程tryAcquire()失败时会调用addWaiter加入到等待队列中去。总的来说一个线程获取锁失败了被放入等待队列acquireQueued会把放入队列中的线程不断去获取锁直到获取成功或者不再需要获取中断

5.2.2 共享模式

!27605d483e8935da683a93be015713f331378 1.png 以CountDownLatch以例任务分为N个子线程去执行state也初始化为N注意N要与线程个数一致。这N个子线程是并行执行的每个子线程执行完后countDown()一次state会CAS减1。等到所有子线程都执行完后(即state=0)会unpark()主调用线程然后主调用线程就会从await()函数返回,继续后余动作。