Java并发编程(二):线程安全

1 概述

在多线程并发环境下,由于线程之间的执行顺序不可预测,所以如果多个线程对同一共享且可变的变量执行写操作,就可能导致该共享变量的状态发生错乱,这不是我们所期望的,甚至可能是造成系统崩溃的因素。我们一般把这种问题称为线程安全问题

那什么是线程安全呢?如果你到网上搜索线程安全关键字,可能会得到很多不同版本的解释,大多数解释都没有错,只是会让人听完摸不着头脑,对线程安全仍然是一头雾水。在《Java并发编程》一书中,作者给出了一种线程安全的定义,我个人认为是一种比较好理解的定义,即:

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。

这句话中有几个关键字:多线程、调度方式、交替执行、主调代码不需要额外的同步或者协同、正确的行为。

  • 多线程,指的就是多线程并发。
  • 调度方式,这里主要指的就是线程调度。
  • 交替执行,即线程的执行是交替的,隐含的信息就是线程的执行顺序是不可预测的。
  • 主调代码不需要额外的同步或者协同,这句话的意思不是指该类不需要同步或者协同,而是指的调用方不需要“额外”的同步或协同,注意他们之间的区别。
  • 正确的行为,即逻辑正确并且符合我们预期的行为。

我想,经过这番解释,上面的定义应该已经不难理解了。那有什么手段能保证线程安全呢,即不发生线程安全问题?要保证线程安全,主要有三大类方式,分别是:

  • 不在线程之间共享变量。
  • 将共享变量修改为不可变的变量。
  • 在访问共享变量的时候采取同步操作。

这里先不对这三类方式做过多介绍,下面会逐一详细的介绍并且给出相应的解决方案。

需要说明的是,不是任何问题都能套用这三类解决方式,即有些线程安全问题可能只能采用上面列出的第三种方式:在访问共享变量的时候采取同步操作,而不能采用另外两种。

2 线程安全问题

先来看一个示例:

public class Main {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable = () -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        };

        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);

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

        thread1.join();
        thread2.join();

        System.out.println(count);
    }
}

多运行几次,应该可以看到输出的值并不总是一样的,我们的预期输出值应该是100000,但实际上每次运行的结果都是小于等于100000的值,也就是说运气好的话会得到预期的结果100000,运气不好那就不好说了,反正不会大于100000就是,这里其实就出现了线程安全问题。现在来分析一下为什么会出现线程安全问题,只有将问题分析清楚了,才能对症下药,解决问题。

在代码里开启了两个线程,这两个线程每个线程会执行50000次循环,在循环体里对同一个共享变量count进行自增操作,从输出上看,结果并不符合预期,主要有两个原因,一是count是一个共享的可变变量,二是线程的执行顺序不可预测,可能会发生如下情况:

线程1获取到count的值,该值是1,但由于线程调度的原因,线程1此时不得不停下来,而线程2进入运行状态,并且获取count的值,显然该值仍然是1,然后执行+1操作,并将值写回内存,即此时内存中count的值是2。之后线程1再次得到运行机会,并且继续执行上次执行完毕的操作,即将获取的count值执行+1操作,但线程1并不知道线程2对count的修改,也就说线程1仍然认为count的值是1,所以+1操作后得到count值为2,然后写回内存。

在这个过程中,执行了两次+1操作,但结果却和执行了一次+1操作一样,这就好像是有一个操作莫名其妙丢失了一样!这页是为什么输出的结果值总是小于等于100000,而不可能大于100000的原因。

现在我们知道导致线程安全的原因了,那该如何解决这个问题呢?

3 使用同步机制解决线程安全问题

同步的手段有很多,可以用内置锁synchronized,显式锁Lock来开辟一个临界区,该临界区具有互斥访问的特性,即只允许拿到锁的线程进入临界区,当线程离开临界区的时候需要释放锁,以便其他线程获取锁。还可以使用原子变量来包装共享变量,使其具有原子性,从而实现线程安全。

这里简单说一下原子性,在计算机底层有很多指令,有一类指令由多条指令组合而成,并且这些指令是不可分割的,即要么全部执行,要么全部不执行,这种指令就称作原子指令,也可以把这类指令称作具有原子性的指令。

3.1 使用内置锁synchronized

synchronized有四种方式(其实严格来说应该算是两种,细分成四种而已):

  • 作用在实例方法上。
  • 作用在静态方法上。
  • 作用在代码块上,使用实例变量作为锁。
  • 作用在代码块上,使用静态变量作为锁。

下面逐一介绍这四种方式。

3.1.1 作用在实例方法上

此时synchronized锁住的是调用该实例方法的对象,即如果有多个线程使用同一个对象多次调用该方法,那么这些调用之间的关系是互斥的,同一时刻只能有一个线程的调用能执行该方法。如下所示:

public class SynchronizedTest {

    public synchronized void instanceMethod() {
        for (int i = 0; i < 10; i++) {
            print("instance method  : " + i);
        }
    }

    private void print(String message) {
        System.out.println(message);
    }

    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();
        ExecutorService service = Executors.newFixedThreadPool(4);
        service.execute(test::instanceMethod);
        service.execute(test::instanceMethod);
    }
}

代码中创建了一个对象实例test,然后使用两个线程调用test.instanceMethod()方法,发现输出总是两次从0到9的遍历,即如下所示:

instance method  : 0
instance method  : 1
instance method  : 2
instance method  : 3
instance method  : 4
instance method  : 5
instance method  : 6
instance method  : 7
instance method  : 8
instance method  : 9
instance method  : 0
instance method  : 1
instance method  : 2
instance method  : 3
instance method  : 4
instance method  : 5
instance method  : 6
instance method  : 7
instance method  : 8
instance method  : 9

但如果没有synchronized修饰,则可能出现这样的输出(多试几次):

instance method  : 0
instance method  : 1
instance method  : 0
instance method  : 2
instance method  : 1
instance method  : 2
instance method  : 3
instance method  : 4
instance method  : 5
instance method  : 6
instance method  : 7
instance method  : 3
instance method  : 4
instance method  : 5
instance method  : 8
instance method  : 9
instance method  : 6
instance method  : 7
instance method  : 8
instance method  : 9

即两个调用的执行时交替执行的。这说明synchronized确实起到了同步互斥的作用。假设现在我们再创建一个对象,然后两次调用instanceMethod时使用不同的对象,结果会怎样?如下所示:

public static void main(String[] args) {
    SynchronizedTest test1 = new SynchronizedTest();
    SynchronizedTest test2 = new SynchronizedTest();
    ExecutorService service = Executors.newFixedThreadPool(4);
    service.execute(test1::instanceMethod);
    service.execute(test2::instanceMethod);
}
//其他代码和之前一致

从输出可以看出,此时两个方法是交替执行的,没有发生互斥,这验证了最开始的那一段描述,即如果有多个线程使用同一个对象多次调用该方法,那么这些调用之间的关系是互斥的,同一时刻只能有一个线程的调用能执行该方法。那synchronized作用在静态方法上会是怎样的一个表现呢?

3.1.2 作用在静态方法上

public class SynchronizedTest {

    public static synchronized  void staticMethod() {
        for (int i = 0; i < 10; i++) {
            print("instance method  : " + i);
        }
    }


    private static void print(String message) {
        System.out.println(message);
    }

    public static void main(String[] args) {
        SynchronizedTest test1 = new SynchronizedTest();
        SynchronizedTest test2 = new SynchronizedTest();
        ExecutorService service = Executors.newFixedThreadPool(4);
        service.execute(() -> {
            test1.staticMethod();
        });

        service.execute(() -> {
            test2.staticMethod();
        });
    }
}

从输出结果可以看到,此时两次调用时互斥的,即使调用的对象不同。这表明当synchronized关键字修饰静态方法的时候,不会受到实例对象的影响,也就是说此时synchronized锁住的是整个类,而不是某一个特定的实例,所以无论调用该方法的实例对象是否一致,都不会影响到方法执行的互斥性。

3.1.3 作用在代码块上,使用实例变量作为锁和使用静态变量作为锁

synchronized还可以作用在代码块上,这时候synchronized表现的行为就取决于括号后面的东西了!如果是实例变量,那么synchronized的表现和”作用在实例方法上“基本一致,即多线程同一对象调用多次方法会发生互斥,但如果是不同对象调用,那么就不会发生互斥。如果是静态变量,那么就和“作用在静态方法”上的表现基本一致。如下所示:

    public void method() {
        //作用在静态变量上
//        synchronized (staticField) {
//            for (int i = 0; i < 10; i++) {
//                print("instance method  : " + i);
//            }
//        }
//
        //作用在实例变量上
        synchronized (instanceField) {
            for (int i = 0; i < 10; i++) {
                print("instance method  : " + i);
            }
        }
    }

各位自行运行一下吧,不多说了。

3.2 解决问题

回到我们最开始的那个计数的问题,现在如果要采用synchronized关键字的方法,该如何修改代码呢?其实非常简单,至少有两种实现方式,分别是:

private static void increment(int delta) {
    synchronized (Main.class) {
        //Main.class是class对象,是静态的
        count += delta;
    }
}

private synchronized static void increment(int delta) {
    count += delta;
}

此时,再运行程序,发现无论运行多少次,都能得到正确的结果100000,这说明经过改造后,我们已经消除了线程安全问题。

3.3 原子类

对于该问题来说,其实还有一种更加简便且性能更好的解决方案,即采用原子类保障共享变量,使其具备原子性。JDK1.5提供了大量的Atomic为前缀的原子类,例如AtomicInteger,AtomicLong,AtomicDouble等等,这些类提供了丰富的API供我们使用,这些API都是原子操作,即不可分割的操作,对于我们的计数问题,尤其合适。其底层大量使用Unsafe里的CAS(比较并交换)操作,CAS是一种无锁的同步操作,性能在多数情况下会优于有锁的同步。

下面修改一下原来的有线程安全问题的代码,使用AtomicInteger来包装count变量,如下所示:

private static AtomicInteger count = new AtomicInteger(0);

private static void increment(int delta) {
    count.getAndAdd(delta);
}

//其他代码和之前完全一样,不需要修改

多运行几次,发现结果都是一样的,即100000,这表明这种解决方案对该问题是可行的。

4 不在线程之间共享变量来解决线程安全问题

这种方式其实有一个更加“专业”一点的名字:线程封闭。其实这种解决方案不算是解决问题,而是躲避问题!通过将变量限制在线程内部来避免其他线程访问变量,从而避免线程安全问题。常见的线程封闭有三种:Ad-hoc封闭,栈封闭和ThreadLocal封闭。

4.1 Ad-hoc封闭

这种方式完全是靠实现者自己实现的,非常脆弱,因为没有任何语言特性能将对象封闭到目标线程上。值得一提的是,Ad-hoc是拉丁文,意思是特定的、特别的等等意思,有时候这些个老外总是喜欢用这种花里胡哨的词语,让人摸不着头脑。

这种方式我也知之甚少,不敢多说了。

4.2 栈封闭

每个线程都有自己独立的栈,其他线程无法直接访问,基于这个特点,如果我们把一些有可能发生线程安全问题的对象“关”在栈内部,那么对该对象的访问肯定就是线程安全的了,是吧?一般我们都把这种变量叫做“局部变量”,即在方法内部声明的变量,每个方法其实都对应着一个栈帧,所以方法里声明的变量不会被其他线程的访问。

public class Snippet {
    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;
    
        // animals被封闭在方法中,不要使它们逸出!
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a))
                candidate = a;
            else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }
}

这是《Java并发编程》一书中的例子,从例子中可以看到animals在方法内部声明,而且没有“逸出”,所以该变量仅该线程内部可见,所以该变量肯定是线程安全的。

那这种解决方案是否使用我们的计数问题呢?(即最开始提出的那个问题)答案是:可以!但会稍微有些麻烦,思路是在每个线程的执行方法里创建一个局部变量count,当循环完毕后,将count作为返回值返回(这不算是逸出,基本类型不存在逸出的问题,因为返回的只是一份拷贝),然后再将两个线程的返回结果合并即可,如下所示:

public class Main {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        Callable<Integer> callable = () -> {
            int count = 0;
            for (int i = 0; i < 50000; i++) {
                count++;
            }
            return count;
        };

        FutureTask<Integer> task1 = new FutureTask<>(callable);
        FutureTask<Integer> task2 = new FutureTask<>(callable);

        Thread thread1 = new Thread(task1);
        Thread thread2 = new Thread(task2);

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

        thread1.join();
        thread2.join();

        int res = task1.get() + task2.get();
        System.out.println(res);
    }
}

无论运行多少次都会得到正确的答案100000,这说明这种方案是可行的。

4.3 ThreadLocal封闭

即利用ThreadLocal类来封装共享变量,每个线程只能拿到和自己有关的那份变量的拷贝。这样说可能有些不太好理解,不过当知道ThreadLocal的底层原理之后,就非常简单了。

下面是ThreadLocal的get方法源码:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}


ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

先获取当前线程实例,然后获取该实例的threadLocals字段值,该字段的类型ThreadLocalMap,该Map的键是ThreadLocal实例,值就是那个我们要包装的共享变量的值。ThreadLocal就是利用这个机制实现的对象封闭,借此来实现线程安全。同样,计数那个问题也能使用ThreadLocal来解决,而且比起栈封闭的更加简便。这里就不再贴代码了,各位可以自行编码实现。

5 将共享变量修改为不可变的变量

不可变对象具有天然的线程安全特性。换句话说,不可变对象即使被多个线程共享,也肯定是线程安全的,因为即使被多个线程共享,其值也不会被改变,而并发的读是完全没有问题的,不存在线程安全问题,而且不可变对象的性能较普通的对象好,JVM对不可变对象有一些特殊的处理,所以我认为将“共享变量修改成不可变的变量”这种方式是比较好的解决线程安全问题的方案。

关于如何构造一个不可变类,我在Java虚拟机(二):Java内存模型一文中有比较详细的介绍,在此不再赘述。

6 小结

本文先介绍了什么是线程安全,解决线程安全的三大类方式,接着就是围绕这三种解决方案,详细介绍了每种方案都有哪些常用的手段。需要注意的是,这三种解决方案并不都完全适用任何线程安全问题,最好的方法还是“先诊断,后下药”,由于线程安全问题往往都比较复杂,而且难以复现,所以诊断可能会有些困难,不过可以借助一下工具,例如JDK自带的jconsole,jstack,jstate等以及高级的类似Visual VM等工具来帮助发现问题,定位问题。

7 参考资料

《Java并发编程》

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容

  • 本文是我自己在秋招复习时的读书笔记,整理的知识点,也是为了防止忘记,尊重劳动成果,转载注明出处哦!如果你也喜欢,那...
    波波波先森阅读 11,232评论 4 56
  • 进程和线程 进程 所有运行中的任务通常对应一个进程,当一个程序进入内存运行时,即变成一个进程.进程是处于运行过程中...
    胜浩_ae28阅读 5,084评论 0 23
  • Java多线程学习 [-] 一扩展javalangThread类 二实现javalangRunnable接口 三T...
    影驰阅读 2,948评论 1 18
  • 本文主要讲了java中多线程的使用方法、线程同步、线程数据传递、线程状态及相应的一些线程函数用法、概述等。 首先讲...
    李欣阳阅读 2,438评论 1 15
  • 自从「笑人征文」活动开始后,我就感觉这个世界,除了与“假期”无关之外,其余的都是“假”的。 假男票,假学历,假货…...
    杨喜爱阅读 1,406评论 44 103