没听说过这些,就不要说你懂并发了,two。

2年前 (2022) 程序员胖胖胖虎阿
224 0 0

引言

 

  为了更加形象的描述并发的基础知识,因此本文LZ采用了园子里一度大火的标题形式——“没听说过XXXX,就不要说你XXXX了”。希望能够给猿友们一个醒目的警醒,借此来普及并发的基础知识,也讨论一下这些内容。

  对于大多数人而言,并发亦近矣,亦远矣。

  如果你问一个程序猿,“你知道并发吗?”。

  估计不少人会说,“恩,知道个大概吧!”。

  如果此时你再继续追问下去,可能得到的仍然会是一些千篇一律的答案。比如,“并发应该就是多个线程一起运行”、“并发的时候应该加锁,加synchronized关键字”,“并发的时候采用时间片轮询的方式”等等诸如此类的答案。

  其实大多数人都是知道并发的,但却大部分是一知半解,这也是为什么LZ说,并发亦近亦远,近是因为几乎所有程序猿都听说过,远是因为大部分人还都只停留在初级阶段,包括现在刚入门的LZ本人。如果写一个简单的并发程序,大部分猿友们估计都能胜任,不过若是稍微复杂一点的,可能就会出现很多问题,或者自以为没有问题。

  本文的主要目的,一个是普及一点并发的基础知识,一个是巩固一下LZ自己对并发的理解。如果哪位猿友对此也有兴趣的话,不妨试着看下去,看能否有所收获。

 

线程安全

 

  线程安全这个词汇实在是折磨人,它给人一种错觉,让你仿佛很轻松的理解了它,但实则是一个典型的笑面虎,背后冷不丁就给你一刀,让你血溅职场。

  我们先来看下这个词语组成的词汇都有哪些,首先后面可以加一“性”字,此为线程安全性。另外,如果后面加“类”或者“程序”,就组成了线程安全类或者是线程安全程序。很显然,线程安全性是类和程序的属性,就像一个类或者程序的其它属性一样,例如扩展性、维护性等等。

  到现在重点就出来了,到底什么是线程安全性?从字面上看,线程安全性就是一个类或者程序在多线程的环境中运行是安全的。可是这显然是废话,重点还是落在了安全性上面。怎么才能称作是安全的?

  LZ这里先贴出一个比较官方的解释,接下来再和各位猿友侃侃大山。安全性是指,某个类的行为与其规范完全一致。那么我们现在就可以将整句话连起来了,也就是说,线程安全性就是指,一个类或者程序在多线程的环境下,其行为与规范完全一致的特性。

  有的猿友可能会说,“我们开发从来都没有规范的,OK?既然如此,何来与规范一致一说?”。是的,只是如果哪位猿友心里冒出这么一句话的话,说明你对这里的“规范”两字理解错误了,这里的规范可不是指的编码规范。LZ举个简单的例子来说明,这个规范的意思是什么。

public class Region {
    
    private int left;
    
    private int right;
    
    public Region() {
        super();
    }

    public Region(int left, int right) {
        super();
        if (left <= right) {
            this.left = left;
            this.right = right;
        }else {
            this.left = left;
            this.right = right;
        }
    }

    public void setLeft(int left) {
        if (left > right) {
            this.left = right;
        }else {
            this.left = left;
        }
    }

    public void setRight(int right) {
        if (right < left) {
            this.right = left;
        }else {
            this.right = right;
        }
    }
    
    public boolean in(int value){
        return value >= left && value <= right;
    }
    
    public String toString(){
        return "[" + left + "," + right + "]";
    }

}

  看一下上面这个类,它表示一个整数区间,对于一个区间来讲,我们自然而然的有一些规则,比如区间左边的值必须小于或者等于右边的值。在上面的类当中,我们也在很多地方限制着客户端的输入,试图保持这种规则(但是在多线程环境下,我们这种约束将显得非常薄弱)。

  我们说这种规则就是上面提到的规范,也就是说对于Region类来说,始终保持它是一个有效的区间,就是它的规范。因此对于Region类来说,它的线程安全性就是指它可以在多线程的环境下保持它是一个有效的区间(left小于等于right)。对于Region是一个有效的区间这件事来说,其实就相当于在说in方法不能永久返回false。如果我们更加抽象点来说,就是说方法的行为应该与预期的一致。

  由此我们可以看出,一个类或程序的规范,就是指它能够始终保持一定的约束条件。比如一个应用类的stop方法,在客户端调用后,必须能够保证应用被正确关闭等等,这些方法的使用说明其实就是一种规范。

 

线程安全类

 

  通过上面的描述,我们知道了线程安全性的定义,或者说,我们已经知道要满足线程安全性需要达到什么要求。那么对于一个类来说,它的线程安全性如果被满足,它就是一个线程安全的类。

  对于线程安全的类,我们有一些可描述的规律,接下来LZ就和各位分享一下这些规律,很多时候,它对我们非常有用。

  1、无状态的对象一定是线程安全的。

  这一条规律实在是太有用了,很多时候,我们的代码处于多线程的环境下,而我们往往苦恼于这些代码的安全性。此时,如果你的类是无状态的,那么你就可以高枕无忧的在多线程环境下使用它。

  为什么说无状态的对象一定是线程安全的?

  一个对象如果没有状态,则意味着对象不存在运行时状态的改变,因此无论是单线程还是多线程的情况下,都不会使对象处于不正确的状态。大多数时候,无状态的对象就是一堆代码的持有者而已,它每一个方法的变量都封闭在独立的线程当中,线程相互之间无法共享变量,因此它们也无法互相影响各自的行为。因此,在多线程的环境下,我们首先推荐的就是无状态对象。

  下例就是一个无状态对象,它没有任何域,自然也就没有状态。

public class NonStatusObject{
  
  public void handle(String param){
    System.out.println(param);    
  }    

}

  2、不可变对象一定是线程安全的。

  提到不可变对象,总让人不知不觉的想到基本类型的包装类,比如Java当中的String就是典型的不可变对象。不可变对象的不可变性与无状态的对象非常相似,只是无状态对象通过不添加任何状态保持对象在运行时状态的不可变性,而不可变对象则通常通过final域来强制达到这一特性,不过要注意的是,如果final域指向的是可变对象,则该对象依然可能是可变的。

  比如一个List的包装类,如果提供了对List的操作,那么既然内部的List是final类型的,该对象依然是可变的,我们看下面的例子。

import java.util.ArrayList;
import java.util.List;

public class ListWrapper<E> {

    private final List<E> list;
    
    public ListWrapper(){
        list = new ArrayList<E>();
    }
    
    public boolean contains(E e){
        return list.contains(e);
    }
    
    public void add(E e){
        list.add(e);
    }
    
    public void remove(E e){
        list.remove(e);
    }
    
}

  这个类其实有时候是有用的,尽管它很简单,但是它可以弥补JDK1.5加入泛型的弊病,比如remove方法的参数是Object。但是很可惜,它唯一的域是final类型的,但却不是不可变的。因为我们提供了add和remove方法,这些方法依然可以改变这个类的状态,因为list的状态就是它的状态。倘若我们在构造函数中加入一些初始化的元素,并且去掉add和remove方法,那么尽管该类引用了可变的非线程安全的类,但它依然是不可变的,也就是说依然是线程安全的。

  3、除了以上两种对象,我们通常都需要使用加锁机制来保证对象的线程安全性。

  这一条基本上道出了大部分的情况,很多时候,我们无法将一个可能处于多线程环境的对象设计成以上两种,这时就需要我们进行合适的加锁机制来保证它的线程安全性。通常情况下,我们希望一个对象是无状态的或者不可变的,这可以大大降低程序的复杂性,请尽量这么做。

 

加锁机制(何时加锁)

 

  既然有时候我们必须使用锁机制来保证类的线程安全性,那么我们最关心的就是两件事,第一件是何时加锁,第二件是如何加锁

  关于何时加锁这个问题,我们主要关注以下几点来决定,这些内容都是并发的精髓。

  1、原子性

  原子性,我们通俗的理解就是,一个操作要么就做完,要么就没开始,不存在做了一部分的情况,那么这个操作就具有原子性。这个简单的理解其实有一个重大漏洞,那就是这个操作是针对什么层次来说的,这将直接影响我们的判断。比如下面这个被用的烂透了的例子,万年的自增。

//    i++;

  博客园的大神们不让LZ直接输入i++,因此这里加了个注释符号(这算不算一个bug,0.0)。i++这个操作,从编程语言的层次来讲,它是一个原子操作,因为它只有一句代码,如果你去调试这行代码,它一定无法执行一半或一部分。但是如果从汇编语言的层次来讲,它就不是一个原子操作,因为它有好几条指令(看过计算机原理系列的猿友应该非常清楚),既然有好几条指令,那么就意味着i++这个操作在汇编层次,可以存在做了一部分的情况。

  对于原子性的层次定义,一般应该以CPU提供的指令集为准,至少我们认为,一个指令是无法拆分的操作。从这个角度来看,我们Java当中大部分看似原子性的操作,其实都不是原子操作,比如刚才提到的自增、赋值操作等等。如果在并发环境中,一个操作无法保证其原子性,可能就需要进行加锁操作。

  1.1、竞态条件

  上面已经简单的提了一下原子性的概念,接下来,我们再来看一个和原子性密切相关的概念——竞态条件。竞态条件的含义是,操作的正确性要取决于多线程之间指令执行的顺序。

  看了上面的定义,大部分猿友估计会唏嘘不已,因为多线程之间指令执行的顺序完全是不定的。如果我们考虑一个多线程程序可能的指令执行顺序,或许会得到10种、100种甚至更多种可能,而我们的程序可能在其中几种情况下执行是正确的,也就是说,我们的程序正确的概率可能为1/10、1/100甚至1/1000000。

  惊呆了,这是中彩票的概率吧?

  我们可以这么去想,当你中了500万的彩票时,你的程序或许就能正确执行了。程序的正确性完全取决于“运气”,这就是典型的竞态条件。比如下面这个更典型的单例模式当中经常出现的方式。

public static SingletonObject getInstance(){
    if (instance == null) {
        instance = new SingletonObject();
    }
    return instance;
}

  这里就出现了竞态条件,因为instance是否为单例,取决于指令执行的顺序。举一个极端的例子,假设10个线程同时运行这个方法,如果这10个线程每一个都判断完instance是否为null之后挂起,那这10个线程在再次被唤醒时都将会去执行new的操作,我们假设每个线程的new和return操作都会一起执行完,然后才把CPU让给其它线程。最终的结果会是,这10个线程得到了10个不一样的实例。各位猿友可以执行一下下面这个简单的测试程序,它将开启100个线程同时执行getInstance方法。

import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SingletonObject {

    private static SingletonObject instance;
    
    private SingletonObject(){}
    
    public static SingletonObject getInstance(){
        if (instance == null) {
            instance = new SingletonObject();
        }
        return instance;
    }
    
    public static void main(String[] args) throws InterruptedException {
        int threadCounts = 100;
        int testCounts = 10000;
        for (int i = 0; i < testCounts; i++) {
            test(threadCounts);
        }
    }
    
    public static void test(int threadCounts) throws InterruptedException{
        ExecutorService executorService = Executors.newCachedThreadPool();
        final CountDownLatch startFlag = new CountDownLatch(1);
        final CountDownLatch counter = new CountDownLatch(threadCounts);
        final Set<String> instanceSet = Collections.synchronizedSet(new HashSet<String>());
        for (int i = 0; i < threadCounts; i++) {
            executorService.execute(new Runnable() {
                public void run() {
                    try {
                        startFlag.await();
                    } catch (InterruptedException e) {}
                    instanceSet.add(SingletonObject.getInstance().toString());
                    counter.countDown();
                }
            });
        }
        startFlag.countDown();
        counter.await();
     SingletonObject.instance = null;
if (instanceSet.size() > 1) { System.out.print("{"); for (String instance : instanceSet) { System.out.print("[" + instance + "]"); } System.out.println("}"); } executorService.shutdown(); } }

  以上的测试共执行1万次,这是为了加大出错几率。基本上,你总能看到以下这样的输出。

{[SingletonObject@16930e2][SingletonObject@7259da]}

  这说明在一次测试中,生成了两个SingletonObject对象(可能会有更多,LZ运行了一小会就见到一次14个的)。可以看出,并不是这10000次测试都会出错,相对来说,出错的概率还是非常小的。这正是竞态条件的发生形式,在一定的指令执行序列下,程序就会出错,比如单例模式实际上变成了非单例的情况。

  1.2、复合操作

  顾名思义,复合操作就是非原子性的操作,两者具有互斥性,也就是说,一个操作要么属于原子操作,要么属于复合操作。上面的if块就是一个典型的复合操作,根据某一个变量的值,决定下一步的行为。通常情况下,使用同步关键字(synchronized)可以使得复合操作变成原子操作,但我们往往更推荐使用现有的类库去实现原子性。

  比如一个并发的计数器,就可以写成如下形式。

import java.util.concurrent.atomic.AtomicInteger;

public class ConcurrentCounter {

    private final AtomicInteger count = new AtomicInteger(0);
    
    public int getCount(){
        return count.get();
    }
    
    public int increment(){
        return count.incrementAndGet();
    }
    
    public int decrement(){
        return count.decrementAndGet();
    }
    
}

  这里我们使用现有的线程安全类来实现一个并发计数器,这省去了我们很多工作,比如自增并返回、递减并返回这些复合操作(实际上AtomicInteger提供了很多常用的复合操作,并保证原子性)。这样做的好处是,不容易出错,性能可能更高(比如ConcurrentHashMap),分析起来更简单。实际上,我们包装了一个线程安全的类,使之成为了另外一个线程安全的类。

  2、可见性

  可见性这玩意实在是太奇葩了,以至于亮瞎了LZ的一双氪金人眼。为了把可见性写的更神秘一点,LZ先给出一个简单的例子。

public class Integer {

    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
    
}

  这个类是Java类库中Integer类的伪劣产品,各位猿友想象一下,它是一个线程安全的类吗?(JDK中的Integer是不可变对象,因此是线程安全的)

  乍一看好像是的,因为这个类太简单了,而且没有竞态条件(当前的行为不受之前状态的影响)。但是很抱歉,这个类依然不是线程安全的。原因就是因为它的可见性不能保证,因此在多线程环境下,如果一个线程设置了value的值为100,那么另外一个线程或许会看不到100这个值。

  为何会这样呢?

  我们依然回想一下计算机原理当中的内容,在计算机原理当中我们曾经无数次的接触过寄存器与存储器,在汇编级别的代码当中,我们会发现,很多变量的赋值是不会反应到存储器当中的,它们有时候一直存在于寄存器当中。这样一来,可见性就好解释了,有时候一个线程A去读取一个变量,这时候它会瞄准存储器的某一个位置进行读取操作,它或许会期待另外一个线程B去改变存储器的值,但事实往往是,另外一个线程B只是把值隐藏在了寄存器,而导致线程A永远看不到这个更新后的值。

  还有另外一种情况是,编译器会将现有的程序进行乱序重组,或许表面看起来,我们是先给一个变量赋值,然后又在另外一个线程去读取它,但事实可能是我们先去读取了这个变量,然后才进行的赋值。

  不管是哪种情况,一旦牵扯到可见性,就说明程序的行为是不可预见的。换句话说,我们的程序如果想要正确的运行,和中彩票是一个概念,需要一定的概率才能发生,这当然是我们不能容忍的。

  因此,我们必须保证一个对象的可见性,否则在共享一个对象时,就会非常的危险。对于上面这个简单的整数类,我们只要给get/set方法加上synchronized关键字,就可以保证它的可见性。这是由于synchronized关键字不仅保证了同步机制,更重要的是禁用了乱序重组以及保证了值对存储器的写入,这样就可以保证可见性。

  

加锁机制(如何加锁)

 

  上面主要回答了各位我们应该在何时加锁,看似很复杂,但其实更难的还是在如何加锁的问题上。因为如果不考虑简单性或者性能等一些问题,给一个类的全部方法加上synchronized关键字就可以确保这个类的线程安全性。但是很显然,这种做法很多时候是不可取的,除非你想收到上级的“夸奖”。

  如果一个多线程环境下的类无法做成无状态或者是不可变对象,那么我们就只能尝试去做一些同步机制,来保证它的线程安全性,或者说保证它可以正常工作。这个问题很难一概而论,不过在绝大多数情况下,我们秉持这样一个原则去进行同步,那就是总是用同一个锁去保护需要协变的状态

  这一句话显然无法概括所有加锁的情况,但是却是LZ个人感觉能解决大部分问题的方法。接下来LZ就举一个简单的例子,比如上面的区间类,它当中就有一些明显的协变状态(协变状态是LZ个人起的名字,意思是想指那些需要相互协助变化的状态)。我们接下来就尝试将上面的区间类变成线程安全的类。

public class Region {
    
    private int left;
    
    private int right;
    
    public Region() {
        super();
    }

    public Region(int left, int right) {
        super();
        if (left <= right) {
            this.left = left;
            this.right = right;
        }else {
            this.left = left;
            this.right = right;
        }
    }

    public synchronized void setLeft(int left) {
        if (left > right) {
            this.left = right;
        }else {
            this.left = left;
        }
    }

    public synchronized void setRight(int right) {
        if (right < left) {
            this.right = left;
        }else {
            this.right = right;
        }
    }
    
    public synchronized boolean in(int value){
        return value >= left && value <= right;
    }
    
    public String toString(){
        return "[" + left + "," + right + "]";
    }

}

  方法非常简单,我们只是简单的给三个方法加上了synchronized关键字,但不可否认的是,它现在已经是一个线程安全的类(我们对toString的显示要求不高,因此不进行同步)。这个类当中很显然left和right变量是一组协变状态,它们两个之间需要相互协助的变化,而不可以单独进行改变。

  其实在现实当中,这样的协变状态有很多。比如我们常用的ArrayList,它当中就有一个Object数组和一个size标识,这两个状态很明显是需要协变的,一旦object数组有所变化,size就要跟随着变化,这样的话在多线程当中使用时,就需要将二者使用同一个锁进行同步(一般情况下,我们会使用当前对象充当这个锁,即this关键字)。

  如果一个方法当中,并不全是协变状态,我们就可以进行局部同步(使用synchronized同步块),这样就可以减少性能的损失,但也要保证一定的简单性,否则的话,这段程序维护起来会非常头疼。

  接下来,我们看一个简单的例子,我们给区间类加一些输出语句,来显示同步块的使用。

public class Region {
    
    private int left;
    
    private int right;
    
    public Region() {
        super();
    }

    public Region(int left, int right) {
        super();
        if (left <= right) {
            this.left = left;
            this.right = right;
        }else {
            this.left = left;
            this.right = right;
        }
    }

    public void setLeft(int left) {
        System.out.println("before setLeft:" + toString());
        synchronized (this) {
            if (left > right) {
                this.left = right;
            }else {
                this.left = left;
            }
        }
        System.out.println("after setLeft:" + toString());
    }

    public void setRight(int right) {
        System.out.println("before setRight:" + toString());
        synchronized (this) {
            if (right < left) {
                this.right = left;
            }else {
                this.right = right;
            }
        }
        System.out.println("after setRight:" + toString());
    }
    
    public synchronized boolean in(int value){
        return value >= left && value <= right;
    }
    
    public String toString(){
        return "[" + left + "," + right + "]";
    }

}

  这里我们为了尽可能的保证程序的性能,所以使用了同步块,在进行输出语句的调用时,并不会将当前对象锁定。众所周知,JAVA在I/O方面的处理是比较慢的,因此在同步的语句当中,我们应当尽量的将I/O语句移出同步块(当然还包括其它的一些处理较慢的语句)。

  这里LZ再举一个非常常见的例子,就是对于循环一个列表的处理,以下这段代码节选自JDK1.6当中Observable类(观察者模式当中的被观察者父类)。

public void notifyObservers(Object arg) {
    /*
         * a temporary array buffer, used as a snapshot of the state of
         * current Observers.
         */
        Object[] arrLocal;

    synchronized (this) {
        /* We don't want the Observer doing callbacks into
         * arbitrary code while holding its own Monitor.
         * The code where we extract each Observable from 
         * the Vector and store the state of the Observer
         * needs synchronization, but notifying observers
         * does not (should not).  The worst result of any 
         * potential race-condition here is that:
         * 1) a newly-added Observer will miss a
         *   notification in progress
         * 2) a recently unregistered Observer will be
         *   wrongly notified when it doesn't care
         */
        if (!changed)
                return;
            arrLocal = obs.toArray();
            clearChanged();
        }

        for (int i = arrLocal.length-1; i>=0; i--)
            ((Observer)arrLocal[i]).update(this, arg);
    }

  可以看到,这个方法的任务是通知所有的观察者,也就是说,需要循环obs这个list列表,并挨个调用update方法。但是这里并没有直接循环obs这个列表,而是使用了一个临时变量arrLocal,并获取到obs的一个快照(snapshot)进行循环。这就是为了保证同步的情况下,尽量的提高性能,因为update方法当中可能会有一些很占用时间的操作,这样的话,如果我们直接对obs循环期间进行同步,那么就可能会导致被观察者被锁定相当长的一段时间。

 

总结

 

  并发算是编程当中的一个高级课题,所以难度可能会较高。但话说回来,只要你在做Java Web,就一定离不开并发。所以看似高级课题的并发,其实一直都与你日夜相伴。从某种意义上来讲,真正要入门web的前提,就是搞清楚并发的相关内容,因为在运维的过程中,往往代码中出现的bug都是非常简单的,而难的地方,就是一些并发所带来的偶然性问题,这就需要你对并发有一定深入的了解才能发现问题的所在。

  好了,本章内容就到此为止了,尽管LZ也是刚刚入门,但还是希望本文能给各位带来一些帮助。

版权声明:程序员胖胖胖虎阿 发表于 2022年10月31日 下午9:32。
转载请注明:没听说过这些,就不要说你懂并发了,two。 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...