关于并发编程:关于并发编程与线程安全的思考与实践-京东云技术团队

44次阅读

共计 5872 个字符,预计需要花费 15 分钟才能阅读完成。

作者:京东衰弱 张娜

一、并发编程的意义与挑战

并发编程的意义是充沛的利用处理器的每一个核,以达到最高的解决性能,能够让程序运行的更快。而处理器也为了进步计算速率,作出了一系列优化,比方:

1、硬件降级:为均衡 CPU 内高速存储器和内存之间数量级的速率差,晋升整体性能,引入了多级高速缓存的传统硬件内存架构来解决,带来的问题是,数据同时存在于高速缓存和主内存中,须要解决缓存一致性问题。

2、处理器优化:次要蕴含,编译器重排序、指令级重排序、内存零碎重排序。通过单线程语义、指令级并行重叠执行、缓存区加载存储 3 种级别的重排序,缩小执行指令,从而进步整体运行速度。带来的问题是,多线程环境里,编译器和 CPU 指令无奈辨认多个线程之间存在的数据依赖性,影响程序执行后果。

并发编程的益处是微小的,然而要编写一个线程平安并且执行高效的代码,须要治理 可变共享状态 的操作拜访,思考内存一致性、处理器优化、指令重排序问题。比方咱们应用多线程对同一个对象的值进行操作时会呈现值被更改、值不同步的状况,失去的后果和理论值可能会天差地别,此时该对象就不是线程平安的。而当多个线程拜访某个数据时,不论运行时环境采纳何种调度形式或者这些线程如何交替执行,这个计算逻辑始终都体现出正确的行为,那么称这个对象是线程平安的。因而如何在并发编程中保障线程平安是一个容易疏忽的问题,也是一个不小的挑战。

所以,为什么会有线程平安的问题,首先要明确两个关键问题:

1、线程之间是如何通信的,即线程之间以何种机制来替换信息。

2、线程之间是如何同步的,即程序如何管制不同线程间的产生程序。

二、Java 并发编程

Java 并发采纳了共享内存模型,Java 线程之间的通信总是隐式进行的,整个通信过程对程序员齐全通明。

2.1 Java 内存模型

为了均衡程序员对内存可见性尽可能高(对编译器和解决的束缚就多)和进步计算性能(尽可能少束缚编译器处理器)之间的关系,JAVA 定义了Java 内存模型(Java Memory Model,JMM),约定只有不扭转程序执行后果,编译器和处理器怎么优化都行。所以,JMM 次要解决的问题是,通过制订线程间通信标准,提供内存可见性保障。

JMM 构造如下图所示:

以此看来,线程内创立的局部变量、办法定义参数等只在线程内应用不会有并发问题,对于共享变量,JMM 规定了一个线程如何和何时能够看到由其余线程批改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

为管制工作内存和主内存的交互,定义了以下标准:

•所有的变量都存储在主内存 (Main Memory) 中。

•每个线程都有一个公有的本地内存(Local Memory),本地内存中存储了该线程以读 / 写共享变量的拷贝正本。

•线程对变量的所有操作都必须在本地内存中进行,而不能间接读写主内存。

•不同的线程之间无奈间接拜访对方本地内存中的变量。

具体实现上定义了八种操作:

1.lock:作用于主内存,把变量标识为线程独占状态。

2.unlock:作用于主内存,解除独占状态。

3.read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。

4.load:作用于工作内存,把 read 操作传过来的变量值放入工作内存的变量正本中。

5.use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。

6.assign:作用工作内存,把一个从执行引擎接管到的值赋值给工作内存的变量。

7.store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。

8.write:作用于主内存的变量,把 store 操作传来的变量的值放入主内存的变量中。

这些操作都满足以下准则:

•不容许 read 和 load、store 和 write 操作之一独自呈现。

•对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

2.2 Java 中的并发关键字

Java 基于以上规定提供了 volatile、synchronized 等关键字来保障线程平安,基本原理是从限度处理器优化和应用内存屏障两方面解决并发问题。如果是变量级别,应用 volatile 申明任何类型变量,同根本数据类型变量、援用类型变量一样具备原子性;如果利用场景须要一个更大范畴的原子性保障,须要应用同步块技术。Java 内存模型提供了 lock 和 unlock 操作来满足这种需要。虚拟机提供了字节码指令 monitorenter 和 monitorexist 来隐式地应用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块 -synchronized 关键字。

这两个字的作用:volatile 仅保障对单个 volatile 变量的读 / 写具备原子性,而锁的互斥执行的个性能够确保整个临界区代码的执行具备原子性。在性能上,锁比 volatile 更弱小,在可伸缩性和执行性能上,volatile 更有劣势。

2.3 Java 中的并发容器与工具类

2.3.1 CopyOnWriteArrayList

CopyOnWriteArrayList 在操作元素时会加可重入锁,一次来保障写操作是线程平安的,然而每次增加删除元素就须要复制一份新数组,对空间有较大的节约。

    public E get(int index) {return get(getArray(), index);
    }

    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {lock.unlock();
        }
    }
2.3.2 Collections.synchronizedList(new ArrayList<>());

这种形式是在 List 的操作外包加了一层 synchronize 同步控制。须要留神的是在遍历 List 是还得再手动做整体的同步控制。

    public void add(int index, E element) {
        // SynchronizedList 就是在 List 的操作外包加了一层 synchronize 同步控制
        synchronized (mutex) {list.add(index, element);}
    }
    public E remove(int index) {synchronized (mutex) {return list.remove(index);}
    }
2.3.3 ConcurrentLinkedQueue

通过循环 CAS 操作非阻塞的给队列增加节点,

    public boolean offer(E e) {checkNotNull(e);
        final Node<E> newNode = new Node<E>(e);

        for (Node<E> t = tail, p = t;;) {
            Node<E> q = p.next;
            if (q == null) {
                // p 是尾节点,CAS 将 p 的 next 指向 newNode.
                if (p.casNext(null, newNode)) {if (p != t) 
                        //tail 指向真正尾节点
                        casTail(t, newNode);
                    return true;
                }
            }
            else if (p == q)
                // 阐明 p 节点和 p 的 next 节点都等于空,示意这个队列刚初始化,正筹备增加节点,所以返回 head 节点
                p = (t != (t = tail)) ? t : head;
            else
                // 向后查找尾节点
                p = (p != t && t != (t = tail)) ? t : q;
        }
    }

三、线上案例

3.1 问题发现

在互联网医院医生端,医生关上问诊 IM 聊天页,须要加载几十个性能按钮。在 2022 年 12 月抗疫期间,QPS 全天都很高,顶峰时是素日的 12 倍,偶现报警提醒按钮显示不全,问题呈现概率大略在百万分之一。

3.2 排查问题的具体过程

医生问诊 IM 页面的加载属于业务黄金流程,下面的每一个按钮就是一个业务线的入口,所以处在外围逻辑的上的报警均应用自定义报警,该类报警不设置收敛,无论何种异样包含按钮个数异样就会立刻报警。

1. 依据报警信息,开始排查,却发现以下问题:

(1)没有异样日志:顺着异样日志的 logId 排查,过程中居然没有异样日志,按钮莫名其妙的变少了。

(2)不能复现:在预发环境,应用雷同入参,接口失常返回,无奈复现。

2. 代码剖析,放大异样范畴:

医生问诊 IM 按钮解决分组进行:

    // 多个线程后果汇合
    List<DoctorDiagImButtonInfoDTO> multiButtonList = new ArrayList<>();
    // 多线程并行处理
    Future<List<DoctorDiagImButtonInfoDTO>> multiButtonFuture = joyThreadPoolTaskExecutor.submit(() -> {List<DoctorDiagImButtonInfoDTO> multiButtonListTemp = new ArrayList<>();
        buttonTypes.forEach(buttonType -> {multiButtonListTemp.add(appButtonInfoMap.get(buttonType));
        });
        multiButtonList.addAll(multiButtonListTemp);
        return multiButtonListTemp;
    });

3. 减少日志线上察看

因为并发场景容易引发子线程失败的状况,对各子线程分支减少必要节点日志上线后察看:

(1)产生异样的申请处理过程中,所有子线程失常解决实现

(2)按钮短少个数随机等于子线程中解决的按钮个数

(3)初步判断是 ArrayList 并发 addAll 操作异样

4. 模仿复现

应用 ArrayList 源码模仿复现问题:

(1)ArrayList 源码剖析:


     public boolean addAll(Collection<? extends E> c) {Object[] a = c.toArray();
         int numNew = a.length;
         ensureCapacityInternal(size + numNew); // Increments modCount
 
         // 以以后 size 为终点,向数组中追加本次新增对象
         System.arraycopy(a, 0, elementData, size, numNew);
 
         // 更新全局变量 size 的值,和上一步是非原子操作,引发并发问题的本源
         size += numNew;
         return numNew != 0;
     }
 
     private void ensureCapacityInternal(int minCapacity) {if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
         }
 
         ensureExplicitCapacity(minCapacity);
     }
 
     private void ensureExplicitCapacity(int minCapacity) {
         modCount++;
 
         // overflow-conscious code
         if (minCapacity - elementData.length > 0)
             grow(minCapacity);
     }
 
     private void grow(int minCapacity) {
         // overflow-conscious code
         int oldCapacity = elementData.length;
         int newCapacity = oldCapacity + (oldCapacity >> 1);
         if (newCapacity - minCapacity < 0)
             newCapacity = minCapacity;
         if (newCapacity - MAX_ARRAY_SIZE > 0)
             newCapacity = hugeCapacity(minCapacity);
         // minCapacity is usually close to size, so this is a win:
         elementData = Arrays.copyOf(elementData, newCapacity);
     }
 

(2)实践剖析

在 ArrayList 的 add 操作中,变更 size 和减少数据操作,不是原子操作。

(3)问题复现

复制源码创立自定义类,为不便复现并发问题,减少进展

     public boolean addAll(Collection<? extends E> c) {Object[] a = c.toArray();
         int numNew = a.length;
         // 第 1 次进展,获取以后 size
         try {Thread.sleep(1000*timeout1);
         } catch (InterruptedException e) {e.printStackTrace();
         }
         ensureCapacityInternal(size + numNew); // Increments modCount
 
         // 第 2 次进展,期待 copy
         try {Thread.sleep(1000*timeout2);
         } catch (InterruptedException e) {e.printStackTrace();
         }
         System.arraycopy(a, 0, elementData, size, numNew);
 
         // 第 3 次进展,期待 size+=
         try {Thread.sleep(1000*timeout3);
         } catch (InterruptedException e) {e.printStackTrace();
         }
         size += numNew;
         return numNew != 0;
     }

3.3 解决问题

应用线程平安工具 Collections.synchronizedList 创立 ArrayList:

    List<DoctorDiagImButtonInfoDTO> multiButtonList = Collections.synchronizedList(new ArrayList<>()); 

上线察看后失常。

3.4 总结反思

应用多线程解决问题曾经变得很广泛,然而对于多线程独特操作的对象必须应用线程平安的类。

另外,还要搞清楚几个灵魂问题:

(1)JMM 的灵魂:Happens-before 准则

(2)并发工具类的灵魂:volatile 变量的读 / 写 和 CAS

正文完
 0