码出高效读书笔记:线程同步

1、什么是同步

Q1:什么是原子性操作?

A1:众所周知在物理世界中,原子是最小的粒子,是不可分割的。所谓原子性操作就是指不可分割的一系列操作指令,在执行完毕之前不会被其他操作中断,要么全部执行,要么全部不执行。举个例子,i++就不是原子性操作,因为它分为三步:取i的值、i+1、赋值,即ILOAD---->IINC---->ISTORE。而i=1就是最简单的原子性操作。

回归正题,资源共享的两个原因是:

  1. 资源紧缺;
  2. 共建需求。

线程共享CPU是从资源紧缺的维度来考虑的,而多线程共享同一变量,通常是从共建需求的维度来考虑的。在多个线程对同一个变量进行写操作时,如果操作没有原子性,就可能会产生脏数据。如果每个线程的修改都是原子操作,就不会存在线程同步问题。有些看似很简单的操作其实不具备原子性,如上面所说的i++操作,而另一方面,更为复杂的CAS(Compare And Swap)操作却具有原子性。

线程同步现象在现实生活中随处可见。比如乘客在火车站排队打车,每个人都是一个线程,管理员每次放10个人进来,为了保证安全,在全部离开后,再放下一批人进来,如果没有协调机制,场面一定是混乱不堪的,人们会一窝蜂地上去抢车,存在严重的安全隐患。计算机的线程同步,就是线程之间按某种协调先后次序执行,当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作。实现线程同步的方式有很多,比如同步方法、锁、阻塞队列等。

2、volatile

先从happen before了解线程操作的可见性。把happen before定义为方法hb(a,b),表示a happen before b。如果hb(a,b)且hb(b,c),能够推导出hb(a,c)。类似于x>y且y>z,可以推导出x>z。这不就是一种防止四海而皆准的规律吗?其实很多场景并不符合这种规律,比如2018年俄罗斯世界杯上,韩国队战胜德国队,德国队战胜瑞典队,并不能推导出韩国队战胜了瑞典队。

线程执行或线程切换都是纳秒级的,执行速度如此之快,直觉上会认为线程本地缓存的必要性特别弱。做个类比,我们人类以年为计而宇宙以亿年为计,宇宙老人看待人类的心态不正如我们看待CPU世界的心态吗?时间成本的巨大差异只要存在,缓存策略自然就会产生。再比如,去学校图书馆仅需要10分钟,借一本书,无需缓存。但如果去市图书馆,往返需要5个小时,一般为了减少路程开销会考虑多借几本书。CPU访问内存远远比访问高数缓存L1和L2慢得多,对应借书的例子,CPU访问内存就相当于去国外图书馆了。

接着再谈谈指令优化计算机并不会根据代码顺序按部就班地执行相关指令,再回到借书的例子上,假如你刚好要去图书馆还书,然后再借一本书,而你的室友恰好也让你帮他归还《老人与海》这本书,然后再借一本《三国演义》。这个过程中有两件事:你的事和他的事。先办完你的事,再办他的事,是一种单线程的死板行为。此时你会潜意识地进行“指令优化”:把你要还的书和《老人与海》一起还了,再一起去借你们要借的书,这相当于合并数据进行存取的操作过程。CPU在处理信息时也会进行指令优化,分析哪些取数据动作可以合并进行,哪些存数据动作可以合并进行。CPU拜访一趟遥远的内存,一定会到处看看,是否可以存取合并,以提高执行效率,指令重排实例代码如下:

@Override
public void run(){
    //(第一处)
    int x = 1;
    int y = 2;
    int z = 3;
    //(第二处)
    x = x + 1;
    //(第三处)
    int sum = x + y + z;
}

happen before是时间顺序的先后,并不能保证线程交互的可见性。在第二处和第三处都是写操作,不会进行指令重排,但是前三行是不互斥的,并且第一处的操作如果放在z=3赋值操作之后,明显是效率最大化的处理方式。所以指令重排的最大可能是把第一处和第二处串联依次执行。happen before并不能保证线程交互的可见性,那么什么是可见性呢?可见性是指线程修改共享变量的指令对其他线程来说都是可见的,它反映的是指令执行的实时透明度。


happen-before原则

Java语言中有一个“先行发生”的规则,即happen-bedore原则。它是Java内存模型中定义两项操作之间的偏序关系。如果A操作先行发生于B操作,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括了修改内存中变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有什么关系。这个原则是判断数据是否存在竞争,线程是否安全的主要依据。

请参考happen-before规则


每个线程都是独占的内存区域,如操作栈、本地变量表等。线程本地内存保存了引用变量在堆内存中的副本,线程对变量的操作都是在本地内存区域中执行,执行完以后再同步到堆内存中。这里必然有一个时间差,在这个时间差内,该线程对副本的操作,对于其他线程是不可见的(只有副本数据同步到了堆内存中,其他线程才看得到)。

volatile的英文本意是“挥发、不稳定的”,延伸意义为敏感的。当使用volatile修饰变量时,意味着任何对此变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。由此可知,在使用单例设计模式时,即使用双检锁也不一定会拿到最新的数据。

如下示例代码在高并发场景中会存在问题:

class LazyInitDemo{
    private static TransactionService service = null;
    
    public static TransactionService getTransactionService(){
        if (service == null){
            synchronized (this){
                if (service == null){
                    service = new TransactionService();
                }
            }
        }
        return service;
    }
}

在调用getTransactionService()时,有可能会得到初始化未完成的对象。究其原因,与Java虚拟机的编译优化有关。对Java编译器而言,初始化TransactionService实例和将对象地址写到service字段并非原子操作,且这两个阶段的执行顺序是未定的。假设某个线程执行new TransactionService()时,构造方法还未被调用,编译器仅仅为该对象分配了内存空间并设为默认值,此时另一个线程调用getTransactionService()方法,由于service!=null,但是此时service对象还没有被赋予真正有效的值,从而无法取得正确的service单例对象。这就是著名的双重检查锁定问题,对象引用在没有同步的情况下进行读操作,导致用户可能会获取未构造完成的对象,对于此问题,一种较为简单的解决方案就是用volatile关键字修饰目标属性service,这样service就限制了编译器对它的相关读写操作,对它的读写操作进行指令重排,确定对象实例化之后才返回引用。(使用volatile关键字的作用就是使得service这个共享变量在被线程A操作时,另一个线程B不可使用它,直至它被A操作完成)

锁也可以确保变量的可见性,但是实现方式和volatile略有不同,线程在得到锁时读入副本,释放时写回内存,锁的操作尤其要符合happen before原则。

volatile解决的是多线程共享变量的可见性问题,类似于synchronized,但不具备synchronized的互斥性。所以对volatile变量的操作并非都具有原子性,这是一个容易犯错误的地方。一个线程对共享变量进行10000次i++操作,另一个线程进行10000次i--操作,如下示例代码:

package Test;

public class VolatileNotAtomic {
    private static volatile long count = 0L;
    private static final int NUMBER = 10000;

    public static void main(String[] args) {
        Thread subtractThread = new SubtractThread();
        subtractThread.start();

        for (int i = 0; i <= NUMBER; i++){
            count++;
        }

        //等待减法线程结束
        while(subtractThread.isAlive()){
            System.out.println("count最后的值为:"+count);
        }
    }

    private static class SubtractThread extends Thread{
        @Override
        public void run(){
            for (int i = 0; i < NUMBER; i++){
                count--;
            }
        }
    }
}

多次执行后,发现结果基本都不为0.如果在count++和count--两处都进行加锁操作,才会得到预期是0的结果。count的读取、加1操作的字节码如下:

//1.读取count并压入操作栈顶
GETSTATIC com/alibaba/easy/codeing/other/VolatileNotAtomic.count: I
//2.常量1压入操作栈顶
ICONST_1
//3.取出最顶部两个元素进行相加
IADD
//4.将刚才得到的和赋值给count
PUTSTATIC com/alibaba/easy/coding/other/VolatileNotAtomic.count: I

需要四步才能完成加1操作。在该过程中,其他线程有足够的时间覆盖变量的值,如果想让实例代码最后的结果为0,需要对count++和count--加锁:

for(int i = 0; i < Max_value; i++){
    synchronized(VolatileNotAtomic.class){
        //在count--代码处也同样要进行加锁处理
        count++;
    }
}

能实现count++原子操作的其他类有AtomicLong和LongAdder。JDK8推荐使用LongAdder类,它比AtomicLong性能更好,有效地减少了乐观锁的重试次数。

因此,“volatile是轻量级的同步方式”这种说法是错误的。它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景,使用volatile修饰变量则非常合适。volatile一写多读最典型的应用时CopyOnWriteArrayList。它在修改数据时会把整个集合的数据全部复制出来,对写操作加锁,修改完成后,再用setArray()把array指向新的集合。使用volatile可以使线程尽快的感知array的修改,不进行指令重排,操作后即对其他线程可见,源码如下:

public class CopyOnWriteArrayList<E>{
    //集合真正存储元素的数组
    private transient volatile Object[] array;

    final void setArray(Object[] a){
        array = a;
    }
}

在实际业务中,如何清晰地判断一写多读的场景显得尤为重要。如果不确定共享变量是都会被多线程并发写,保险的做法是使用同步代码块来实现线程同步。另外,因为所有的操作都需要同步给内存变量,所以volatile一定会使线程的执行速度变慢,故要谨慎定义和使用volatile属性

3、信号量同步

信号量同步是指在不同的线程之间,通过传递同步信号量来协调线程执行的先后次序。这里重点分析基于时间维度和信号维度的两个类:CountDownLatch、Semaphore。

示例:某国际化基础语言管理平台受到一个多语言翻译请求后,根据目标语种拆分成多个子线程,对翻译引擎发起翻译请求。翻译完成后,同步返回给调用方,结果透雨countDown()抛出异常,导致发生故障,警示代码如下:

package Test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class CountDownLatchTest {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch count = new CountDownLatch(3);
        Thread thread1 = new TranslateThread("1st content", count);
        Thread thread2 = new TranslateThread("2nd content", count);
        Thread thread3 = new TranslateThread("3rd content", count);

        thread1.start();
        thread2.start();
        thread3.start();

        count.await(10, TimeUnit.SECONDS);
        System.out.println("所有线程执行完成");
        //给调用端返回翻译结果
    }
}

class TranslateThread extends Thread{
    private String content;
    private final CountDownLatch count;

    public TranslateThread(String content, CountDownLatch count) {
        this.content = content;
        this.count = count;
    }

    @Override
    public void run(){
        //在某种情况下,执行翻译解析时,抛出异常(第一处)
        if (Math.random() > 0.5){
            throw new RuntimeException("原文存在非法字符");
        }

        System.out.println(content + "的翻译已经完成,译文是...");
        count.countDown();
    }
}

CountDownLatch是基于执行时间的同步类。代码中第一处抛出异常,且该异常没有被主线程try-catch到,最终该线程没有执行countDown()方法。程序执行时间较长,该问题难以定位,因为异常被吞的一干二净。(子线程异常可以通过线程方法setUncaughtExceptionHandler()捕获)

在实际编码中,可能需要处理基于空闲信号的同步情况。比如海关安检的场景,任何国家公民在出国时,都要走海关的查验通道。假设某机场的海关通道有三个窗口,一批需要出关的人排成长队,每个人都是一个线程。当3个窗口中的任意一个出现空闲时,工作人员指示队列中第一个人出队到该空闲窗口接收查验。对于上述场景,JDK中提供了一个Semaphore的信号同步类,只有在调用Semaphore对象的acquire()成功后,才可以往下执行,完成后执行release()释放持有的信号量,下一个线程就可以马上获取这个空闲信号量进入执行,基于Semaphore的示例代码如下:

package Test;

import java.util.concurrent.Semaphore;

public class CustomCheckWindow {
    public static void main(String[] args) {
        //设定3个信号量,即相当于3个服务窗口
        Semaphore semaphore = new Semaphore(3);

        //这个队伍排了5个人(开启5个线程)
        for (int i = 1; i <= 5; i++){
            new SecurityCheckThread(i, semaphore).start();
        }
    }

    private static class SecurityCheckThread extends Thread{
        //相当于乘客(线程ID)编号
        private int seq;
        //信号量
        private Semaphore semaphore;

        public SecurityCheckThread(int seq, Semaphore semaphore){
            this.seq = seq;
            this.semaphore = semaphore;
        }

        @Override
        public void run(){
            try{
                //相当于在窗口接收检查
                semaphore.acquire();
                System.out.println("No." + seq + "乘客,正在检查中");

                //假设号码能整除2的人是身份可疑人员,需要花更长时间来安检
                if (seq % 2 == 0){
                    //使该线程休眠
                    Thread.sleep(1000);
                    System.out.println("No." + seq + "乘客,身份可疑,不能出国!");
                }
            }catch(InterruptedException e){
                e.printStackTrace();
            }finally{
                //相当于检查完以后释放窗口
                semaphore.release();
                System.out.println("No." + seq + "乘客已经完成服务!");
            }
        }
    }
}

执行结果如下图所示:

如果某个人的身份可疑,需要更多信息来确认身份,这不会影响到其他窗口的安检速度。只要其他线程能够拿到空闲信号量,都可以马上执行。如果Semaphore的窗口信号量等于1,就是最典型的互斥锁:

Semaphore semaphore = new Semaphore(1);

执行结果如下:

还有其他的同步方式,如CyclicBarrier是基于同步到达某个点的信号量触发机制。CyclicBarrier从命名上即可知道它是一个可以循环(Cyclic)使用的屏障式(Barrier)多线程协作方式。采用这种方式进行刚才的安检服务,就是3个人同时进去,只有3个人都完成了安检,才会放下一批进来。这是一种非常低效的安检方式。但在某种场景下就是非常正确的方式,例如在机场排队打车,现场工作人员统一指挥,每次放3辆车进来,做满以后开走,再放下一批车和人进来。通过CyclicBarrier的reset()来释放线程资源。

但是无论从性能还是安全性上考虑,我们尽量使用并发包中提供的信号同步类,避免使用对象的wait()和notify()方式来进行同步。链接:并发包中常见的同步工具详解

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值