多线程同步控制

volatile

内存可见性

当变量被volatile关键字修饰后,CPU将越过寄存器向主内存中直接请求。可理解为使用同一监视器对单个读写操作同步

在这里我们可以说基于volatile读写在多线程中是安全的,当时基于volatile的运算是不安全的。
原因在于JMM允许多个线程同时计算volatile变量,但运算操作却不是原子的

底层实现

如何volatile变量对不同线程的可见性

  1. 缓存一致性协议
    在CPU写入数据时,如操作的是volatile变量且其他CPU中存在该变量的副本,则发出通知告知其他CPU将该变量缓存行设置为无效。当其他CPU读取时向主内存重新读取
  2. 嗅探
    每个处理器嗅探总线上数据,检查缓存是否过期,当发现缓存行对应的内存地址被修改,置缓存行无效

总线风暴:当大量使用volatile时,将导致大量主内存嗅探及无效CAS,从而使总线带宽达到峰值

保证有序性

在单线程下,由于as-if-serial语义的存在,无论对指令集如何排序,其执行结果不变,具体表现为:不对 存在数据依赖关系的操作重排
但多线程切换的随意性,使得我们从外部看线程实际上无序的,这样的弱有序性不能满足并发要求

volatile通过防止指令重排来保证有序性,底层使用内存屏障,部分指令先行而部分指令后行

是否重排second operate
first operate 普通读写volatile读volatile写
普通读写NO
volatile读NONONO
volatile写NONO

可见

  1. 当第二个操作为volatile写时,无论第一个操作时什么都不能重排
    确保volatile写之后的操作不会被编译器重排到volatile写之前
  2. 当第一个操作为volatile读时,无论第二个操作是什么都不能重排
    确保volatile读之后的操作不会被编译器重排到volatile读之后
  3. 对于volatile变量,write happens-before read

保证64位数据的读写原子性

synchronized

synchronized关键字使代码同步于某个对象上。所有同步在一个对象的同步块只能被一个线程进入并执行,其他阻塞。以下为sync的四种常用形式
(如果不理解什么是监视器可以跳到sync机制实现的说明)

  • 实例方法同步

    public synchronized void func(){
        System.out.println("sync in instance method");
    } 
    

    同步在 拥有该方法的对象上 ,也就是this指针上。所以只允许一个线程执行一个实例方法

  • 静态方法同步

    public static synchronized void func(){
        System.out.println("sync in instance static method");
    }
    

    同步在该方法所在的 类对象 ,即XX.class。允许有多个线程同时操作该类不同实例,但仅允许一个线程执行该类的静态方法

  • 实例方法中同步代码块

    Object monitor = new Object();
    public void func(){
        synchronized (monitor){
            System.out.println("sync in instance method code");
        }
    }
    

    同步在 监视器 对象上,就是对sync关键字传入的对象。仅允许获得监视器对象的线程执行,每个监视器仅容许一个线程获取
    当采用this作为监视器,近似等效于sync修饰方法名

    public void func(){
        synchronized (this){
            System.out.println("sync in instance method code");
        }
    }
    
  • 静态方法中同步块

    static Object monitor = new Object();
    public static void func(){
        synchronized (monitor){
            System.out.println("sync in instance method code");
        }
    }
    

    仍然同步在 监视器 对象上,但是由于静态对象初始化时间的特殊性,要注意监视器对象的选取。
    当采用类对象作为监视器时,近似等效于sync修饰方法名 前提是你把代码块都包起来

    public static void func(){
        synchronized (T1.class){
            System.out.println("sync in instance method code");
        }
    }
    

注意:

  1. 类对象与锁对象并不冲突。根本在于这是两个不同的对象,也就是两个不同的监视器,我们可以在执行同步实例方法的同时,执行同步静态方法
  2. sync修饰方法时,在.class文件中设置方法的ACC_SYNCHRONIZED访问标识;对于代码块的同步则是依赖于monitorenter/monitorexit指令,当执行到monitorenter指令时尝试获取监视器所有权

sync同步机制实现

sync同步借助 monitor机制以及wait/notify共同实现
考虑如下场景:现在有台电脑在一个房间中,一群人排队上网,但是这个房间只允许一个人进入,为了防止别人进去,先进去的从里面把门锁上,这样外面的人想进去的时候发现门打不开了,便只能在外面等,时不时来试试这门能不能开。但是时间一长总不能干等吧,于是有的人就开始征战王者峡谷了,不管这门能不能进了。当里面的人玩完了,可能出来的时候吼一嗓子告诉所有人我下机了,然后所有人就去抢房间,谁先抢到是谁的,抢不到老倒霉蛋了;也有可能他出来看见打王者的太可怜,随便挑了一个和他说:无内鬼,门没锁,然后这货就去了。
现在把人换成线程,房间变为共享内存,就是整个sync的同步机制了。

什么是monitor

在上述场景中,monitor就是人进去反手锁上的那扇门。在Java中通过对象头信息实现。
当线程想要获取锁时,会在对象头里的Mark Word里查找锁状态,若无锁则CAS添加自己的信息给锁;如果锁对象已被其他线程占用,那就只能一边凉快去了。
关于对象头更详细的说明,如为什么在sync中调用hashCode()会导致直接生成轻量级锁:null

锁升级

在第一次进入同步之后,首先生成偏向锁,CAS修改对象头里的锁标志位。偏向锁偏向于第一个获得它的线程A,执行完毕之后不主动释放,monitor上持有锁的线程不改变仍为A。这样如果线程A继续对该同步块进行操作,则无需获取monitor。
如果有其他线程B也需要对该同步块进行操作,出现锁竞争时,锁升级为轻量级锁别看说的花里胡哨,实际就一自旋锁 此时两线程循环重试执行条件。当自旋次数达到最大次数后,继续自旋影响程序性能,于是再次升级锁为重量级锁
此时调用wait方法将超出最大值的自旋线程挂起,放弃对CPU使用权的竞争,减少性能损耗。直到待获取锁对象被释放,调用notify唤醒线程,重新竞争锁

重入性

允许同一个线程多次获取同一把锁。这里不做过多介绍,详见lock

final

推荐阅读:你以为你真的了解final吗?

final域写重排

JMM禁止final域写重排到构造器之外。
保证在对象引用为任意线程可见前被正确初始化(无论哪个线程在何时,该对象引用都连到了一个初始完毕的内存空间) 如果构造对象不从构造器逸出
实现:编译器在final写之后插入storestoer屏障

final读重排

在初次读对象引用和该对象包含的finial域,JMM禁止重排
读对象final域前,一定先读包含final域对象引用
实现:在读final前加入loadload屏障

final域为引用对象

对final修饰对象成员写入 happens-before 构造对象引用的赋予。
意思就是最后交给你一个从引用连到内存的整体,而不是缺斤少两的奇葩。。

Lock接口

不同于volatile和sync这样的关键字,Java提供了可自定的同步控制组件:Lock接口

Lock lock = new ReentrantLock();
public void method(){
	lock.lock();
	try{
		System.out.println("Using lock!");
	}finally{
		lock.unlock();
	}
}

需要注意的是synchronized同步块执行完成或者遇到异常是锁会自动释放,而lock必须调用unlock()方法释放锁,因此在finally块中释放锁。
使用lock的优点是可以自定义lock。我们可以继承AQS并定义若干同步状态的获取和释放,从而实现自己的lock实现。
以下源码环境为JDK 12不是JDK 8

AQS简介

AQS全称AbstractQueuedSynchronizer,实现了对同步状态的管理,以及对阻塞线程进行排队,等待通知等等一些底层的实现处理

结构

在介绍AQS内部结构之前,我们不妨思考一下,如何设计一个同步管理器,我们需要哪些组件才能实现对线程的同步管理。
所谓同步管理,通俗的说就是让一块代码只能被一个线程执行(这里我们不考虑如何加锁如何解锁,那是锁:Lock接口 要考虑的事情,我们现在只是需要合理的对并发线程进行管理),所以首先,我们需要设置占有标志位,用于标识当前线程是否被占用,考虑到函数迭代是需要重入,因此这玩意不仅要标识占用,最好还能标识占用线程信息。此外,出现锁竞争时,我们也需要记录获取失败的线程信息,虽然人家现在抢不到,但是不代表人家以后就抢不到了,所以还需要实现消息队列,这个队列需要管理这些线程,既要保证阻塞线程能再次获得锁也要保证对长时间空占资源的线程暂停
据此,下面正式开始接受AQS类

  1. private volatile int state;
    实际上就一计数器,标识了同步块上有无线程,被重入了几次
  2. private transient Thread exclusiveOwnerThread;
    这是来自于AQS父类AbstractOwnableSynchronizer中的属性,记录了谁持有该同步块,作为重入的判断依据

    这里就不得不说一下这个框架的架构是真的强。当我们需要处理同步问题时,必然是一个执行线程+一堆阻塞线程,我们定义同步管理器去管理那一堆线程,也可以说让执行线程持有同步管理器,这样只定义了管理器持有者,而留下管理器的实现可供自定义,就给框架留下了极大的扩展性

然后是AQS的核心:

同步队列

该队列由双端链表实现,结点Node定义如下

static final class Node {
	 volatile int waitStatus;// 等待情况,见下表
	 volatile Node prev;	 // 我后面谁
	 volatile Node next;	 // 我前面谁
	 volatile Thread thread; // 我是谁
	 ....
}

其中waitStatus有如下几种情况(来源见水印)。这里的stattus不同于AQS中定义的state。在Node中waitStatus用于标识队列中点状态,而AQS中state记录执行线程状态
在这里插入图片描述在AQS中,通过维护头节点head和尾结点tail来实现队列
在这里插入图片描述这里head指向的是一个空结点,参照ReentrantLock中非公平锁的创建方式

ReentrantLock

接下来,我们来介绍一下ReentrantLock是怎么工作的。ReentrantLock的同步控制实际依赖内部Sync实现,ReentrantLock实际是封装了同步器(Sync)和同步器的控制方法(Lock接口)。

//接下来要用到方法
abstract static class Sync extends AbstractQueuedSynchronizer{
	protected final boolean tryRelease(int releases)
	final boolean nonfairTryAcquire(int acquires) ;
	protected final boolean isHeldExclusively() ;
	.....
}

注意,这里Sync仍然是个抽象类,ReentrantLock有两种不同锁实现,分别是

//公平锁
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;
    protected final boolean tryAcquire(int acquires) {}
}
//非公平锁
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
    protected final boolean tryAcquire(int acquires) {}
}
/*
 * 关于公平锁于非公平锁的不同
 * 公平锁会遵循先来后到的原则,按时间维护消息队列,并且按时间先后分配锁
 * 非公平锁自身也有消息队列(继承了AQS都有),但是这货比较随缘,来得早不如来得巧:
 * 人品好刚来一抢就有了,人品不好抢到你原地自旋升天直通挂起
 * 在实现上,公平锁在state==0时多判断了一个
 * * hasQueuedPredecessors()
 * Queries whether any threads have been waiting to acquire longer
 * than the current thread.
 */

在默认构造器中ReentrantLock使用非公平锁

public ReentrantLock() {
    sync = new NonfairSync();
}

所以下面的讲解已非公平锁为例,说明ReentrantLock是怎么加锁的。

加锁流程

首先我们调用lock方法,这个方法有调用到AQS.acquire(int arg)

//该方法就是lock上锁的核心,完成线程分配执行和自旋/挂起,在该lock对象上消息队列控制
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

先看第一个条件tryAcquire(arg),由于子类重写了该方法,所以调用子类方法

//虽然名不对,但最后执行的还是这玩意
//非公平锁尝试获取lock对象:能搞到返回true,不用Queue出场;不然就进消息队列
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {	//此时表示锁空闲,无线程在该对象上
    	// CAS修改状态,也就是自旋争夺锁的使用权
        if (compareAndSetState(0, acquires)) {
        	// 抢到了就把自己名写上
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else{	// 此时状态表示:锁已被使用
    	if (current == getExclusiveOwnerThread()) {
    	/*
		 * 既然锁都被抢了又为什么还要判断呢?这里就是重入锁了
		 * 如果我们需要一个迭代的加锁方法,那还能被自己给占了锁不放么
		 * 所以虽然锁被人用了,但是这个用的人可能就是自己
		 */
        	int nextc = c + acquires;
	        if (nextc < 0) // overflow
	            throw new Error("Maximum lock count exceeded");
	        setState(nextc);
	        return true;
    	}
    }
    // 锁也抢不到,老倒霉蛋了,准备进队列吧
    return false;
}

如果自旋一波没搞到锁,那就要加到消息队列里了。我们首先用addWaiter(Node.EXCLUSIVE)添加

//向队列中添加线程。如果同步器@class Sync还没初始化队列,就先初始化队列
private Node addWaiter(Node mode) {
	//封装线程
    Node node = new Node(mode);
	// 自旋至入队成功
    for (;;) {
    	// 尾结点tail在定义时没有声明初值,所以对了没有初始化前一直为null
        Node oldTail = tail;
        if (oldTail != null) {
            node.setPrevRelaxed(oldTail);
            // 设置尾结点
            if (compareAndSetTail(oldTail, node)) {
            //如果oldTail发生变化,说明被别的线程修改
                oldTail.next = node;
                // 仅当设置成功返回新加入结点
                return node;
            }
        } else {
        	//只初始化,并不入队,到下个循环进行入队操作
            initializeSyncQueue();
        }
    }
}

加入完毕,在队列中进行线程睡眠/唤醒管理


final boolean acquireQueued(final Node node, int arg) {
    boolean interrupted = false;
    try {
        for (;;) {
        	// 当且仅当前驱为头节点 且 获取到锁 时出列
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                return interrupted;
            }
            // 不满足出列条件,考虑是否应当挂起
            if (shouldParkAfterFailedAcquire(p, node))
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throw t;
    }
}

关于这点我们稍后再做分析,先分析挂起条件

//何时应当挂起当前线程
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         * 如果前驱状态为SIGNAL,那么前驱还在挂机呢,更别说你了
         * いま不挂你挂谁
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         * 如果前驱已经出列,找在前面找个没出列的当前驱
         * 不然待会睡过了都没人叫
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         * 在除去上述两种情况下,能到这的就剩下 0 和 PROPAGATE。其中PROPAGATE在SHARED情况下才使用因此不考虑
         * 注意到初始入队结点status为0,需要将前驱状态置为SINGAL
         */
        pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
    }
    return false;
}

如果需要挂起则

// 底层实现挂起
public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    U.park(false, 0L);
    setBlocker(t, null);
}

不需要则继续争取出列名额

综上所述,lock流程
在这里插入图片描述

解锁流程

首先调用unlock()方法

 public void unlock() {
    sync.release(1);
}

再调用AQS中release(int arg)

 // 释放标志位,唤醒后续结点
 public final boolean release(int arg) {
    if (tryRelease(arg)) {
        ...
        return true;
    }
    return false;
}

首先来看tryRelease(int arg)仍然是优先调用子类重写方法

 // 释放state标识位,若当前线程的所有锁都释放,返回true
 protected final boolean tryRelease(int releases) {
 	// 调用一次释放一次,尤其对于重入锁,可能调用不止一次
 	// 注意:这是成员变量state仍未改动,即使c==0还是上锁状态
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
    	// 此时state为0,已无线程持有
        free = true;
        // 将Lock的执行线程置空
        setExclusiveOwnerThread(null);
    }
    // 修改state标识,释放锁
    setState(c);
    return free;
}

如果仍有重入锁没有释放,在释放完当前锁对象后,返回上一个同步块继续执行。
如果所有锁都被释放,执行如下程序

 // 获取同步器消息队列的头结点
 Node h = head;
 // 如果head为null,则没有消息队列,也就是没有Blocked线程
 // 若存在头节点 且 waitStatus不是初始值,那么唤醒后续线程
if (h != null && h.waitStatus != 0)
    unparkSuccessor(h);
return true;

// 唤醒后续结点
private void unparkSuccessor(Node node) {
    /*
     * If status is negative (i.e., possibly needing signal) try
     * to clear in anticipation of signalling.  It is OK if this
     * fails or if status is changed by waiting thread.
     */
    int ws = node.waitStatus;
    if (ws < 0)
        node.compareAndSetWaitStatus(ws, 0);

    /*
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
    Node s = node.next;
    // 后一个结点是否为正常挂起结点
    if (s == null || s.waitStatus > 0) {
    	// 若不是,则从后往前找一个正常挂起的唤醒
        s = null;
        for (Node p = tail; p != node && p != null; p = p.prev)
            if (p.waitStatus <= 0)
                s = p;
    }
    // 如果存在这样的一个结点,将其唤醒
    if (s != null)
        LockSupport.unpark(s.thread);
}


文章参考:

1.通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其Java实现! - Pickle Pee的文章 - 知乎
2. CL0610 /Java-concurrency
3. 深入理解AbstractQueuedSynchronizer(AQS)
4. 美团大佬带你从ReentrantLock的实现看AQS的原理及应用 - java架构师的文章 - 知乎

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值