Java并发编程-解决并发:多线程应用没那么难写

点赞功
• 阅读 2065

我们前面提到过,为了提高计算机的性能,大神们只能破坏程序的可见性、原子性、有序性,从而带来了并发问题。

这三者是编程领域的共同问题,所有编程语言都会遇到。Java 作为排名第一的编程语言,自然也有一套领先的技术方案—Java 内存模型

我们要写出可靠的程序,自然要对 Java 内存模型有所了解。

破除一个错误的观念

说起 Java 内存模型,你可能感到不明觉厉,然后立马放弃。

的确,网上的各种资料都特别深奥,像是多级缓存、流水线、执行单元等等,各种名词满天飞。这些东西虽然很酷,但都是计算机的底层知识,复杂程度远超你的想象。如果你硬要一头扎进去,不但增加了学习难度,也找不到实践价值,最后只能放弃。

然而,你不用管计算机的底层知识,工程师之间是一个分工合作的关系,你可以看下面这幅图。

Java并发编程-解决并发:多线程应用没那么难写

处理器工程师负责解决 CPU 体系结构的问题;编译器、JVM 工程师则利用内存屏障等技术,保证 Java 内存模型的正确性。

我们作为 Java 应用工程师,最重要的是了解 Java 内存模型,然后利用 Java 的语法和规则,写出可靠的多线程应用。

说了这么多,无非就是一点:我们可是站在食物链顶端的人,大可放下执念、恐惧等情绪,好好看下去。

什么是 Java 内存模型

在早期的编程语言中,并没有内存模型的概念。要保证程序的可见性、原子性、有序性,只能靠处理器自身的内存一致性模型

然而,问题来了。

不同的处理器差异很大。比如,一段 C 程序在一个处理器上运行正常,但在另一个处理器上却得出不一样的结果。

Java 的口号是“书写一次,到处执行”,但这显然是低估了事情的难度。当时,Java 的语言规范还有各种缺陷,在不同的处理器上没法保证运行正常。比如,在一些情况下,volatile 没法保证可见性。

在不同的平台下,怎么保证程序是正确的?

谁都没有底。随着运行 Java 的平台越来越多,这个问题也越发重要。在 2004 年,Java 推出了 5.0 版本。这是个大招,上面明确定义了 Java 内存模型,从此问题得到了解决。

Java 内存模型是一套复杂的规范。我们作为 Java 应用工程师,只需要利用其中的 happen-before规则,用好 volatilesynchronized 等关键词,就能写出可靠的多线程应用。

不过,说起来容易,但具体该怎么做呢?

我们在前面提到,一旦有多个线程操作同一个变量时,这些线程只顾做自己的事,完全不管对方在做什么,最后却错得一塌糊涂。那这样行不行?

我想想办法,让线程之间有心灵感应,一个线程做了些什么,另一个线程马上就能知道。

这种像是心灵感应的东西,Java 已经做到了,叫 happen-before如果一个操作先发生,另一个操作后发生,那么前一个操作的结果对后续操作可见。

简单来说,计算机可以用缓存,可以线程切换,可以编译优化,但计算机无论怎么折腾,Java 虚拟机一定会遵守happen-before规则,从而保证线程之间的 happen-before

这样一来,像是 volatilesynchronized 等等关键词,语义被大大增强。对 Java 应用程序员来说,解决方案就十分清楚了,用好关键词就行。

volatile 解决可见性、有序性

我们前面提过,缓存导致了可见性问题,编译优化导致了有序性问题。那解决方案显而易见,禁用缓存、禁用编译优化。

可这样一来,程序的性能又堪忧。比如,用户很少修改个人信息,不可能一秒钟修改几百次,这就没必要考虑并发问题了。

因此,合理的解决方案是按需禁用缓存和编译优化,我们要用到 volatile

在 Java 中,volatile 有两层意思:

  1. 禁用 CPU 缓存,直接读写内存的数据,保证线程的可见性;
  2. 禁用编译优化,保证程序的有序性;

先来看第一点,volatile 禁用 CPU 缓存,你看下面这段代码。

public class VolatileExample {

    // volatile 禁用 CPU 缓存
    private static int number;
    // private volatile static int number;
    private static boolean isStopReader = false;

    // 客服系统
    public static void init() {
        for (int i = 0; i < 5; i++) {
            // 发送上线通知
            number = i;
            System.out.println(number + " 号客服已上线");

            // 分配电话线路
            try {
                Thread.sleep(1000 * 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        // 客服上线完毕
        isStopReader = true;
    }

    // 录音系统
    public static void read() {
        // 已录音的客服
        Set<Integer> workingSet = new HashSet<>();

        // 启动录音设备
        while (isStopReader == false) {
            if (workingSet.add(number)) {
                System.out.println("客服:" + number + " 号,进行电话录音");
            }
        }
    }

    public static void main(String[] args) throws Exception {
        // 客服上班
        Thread th1 = new Thread(
                () -> init()
        );
        // 启动录音系统
        Thread th2 = new Thread(
                () -> read()
        );

        // 启动两个线程
        th1.start();
        th2.start();

        // 等待两个线程执行结束
        th1.join();
        th2.join();
    }
}

原始结果:
1 号客服已上线
客服:1 号,进行电话录音
2 号客服已上线
3 号客服已上线
=========死循环

修改结果:number 加了 volatile 修饰符
1 号客服已上线
客服:1 号,进行电话录音
2 号客服已上线
客服:2 号,进行电话录音
3 号客服已上线
客服:3 号,进行电话录音

每隔一秒,就有一位客服小姐姐上线。在这期间,录音系统会一直循环等待,对上线客服的线路进行录音。等到客服系统执行完 isStopReader = true 后,录音系统就进入休眠。

然而,录音系统只对 1 号客服进行了录音,随后就进入了死循环。

这是因为客服系统运行在 CPU-1 上,录音系统运行在 CPU-2 上。录音系统把 number = 1 放到了自己的 CPU 缓存中。所以,客服系统再怎么修改 number 的值,录音系统也完全不知道。

这就是 CPU 缓存带了的可见性问题。要想解决这个问题,你只要加上 volatile 关键词。

在 CPU-1 中,线程一执行了 number = 2,就立刻写到内存,并通知线程二;线程二收到了通知,就把 CPU-2 的执行结果丢掉,重新读取内存的数据。

Java并发编程-解决并发:多线程应用没那么难写

再来看第二点,volatile 禁用编译优化。编译优化会带来一些意想不到的问题,我们来看一个经典案例—利用双重检查创建单例对象,你看下面这段代码。

public class IdGen {

    // volatile 禁用编译优化
    // private static volatile IdGen instance;
    private static IdGen instance;

    static IdGen getInstance() {
        if (instance == null) {
            synchronized (IdGen.class) {
                if (instance == null) {
                    instance = new IdGen();
                }
            }
        }
        return instance;
    }

}

你留意第 11 行代码 instance = new IdGen(),这有可能造成空指针异常。

Java并发编程-解决并发:多线程应用没那么难写

这是由编译优化造成的错误,要解决问题也很容易,加上 volatile 关键词,禁用掉编译优化就行。

看到这儿,对于程序的可见性、有序性问题,相信你已经有了解决方案:在需要的时候,用 volatile 这个关键词,禁用掉 CPU 缓存和编译优化。

互斥锁解决原子性问题

你已经知道,线程切换造成了原子性问题。但你可能不知道,在三个问题中,原子性问题是最复杂的一个。

好在这个问题已经有了解决思路,你只要保证:在同一时刻,一个资源只能由一个线程操作。程序的原子性就能得到保障,这个条件,我们称之为互斥

互斥有多种实现方式,最直接的就是禁用线程切换。

在单核 CPU 时代,这是行得通的。因为单核 CPU 在同一时刻,只能执行一个线程。这时候,你只要禁用掉线程切换,线程就能一直执行到结束为止,原子性问题就这样解决了。

然而,新问题来了。

首先,线程切换是为了提高计算机的性能,你如果禁用掉线程切换,性能自然也会大大下降。

其次,控制 CPU 的线程切换非常复杂,没几个人能拍胸脯保证搞定。这个工作交给广大的应用开发者,肯定不合适呀。

最后,现在是多核 CPU 时代,禁用线程切换根本没用。

在多核 CPU 中,最少也有两个以上的线程在同时执行。比如,一个线程在 CPU-1 上,另一个线程在 CPU-2 上,可你只能保证线程能连续执行,不能保证同一时刻只有一个线程执行,那最后的结果肯定会错漏百出。

因此,Java 用的是另一种互斥方案—互斥锁,简称:锁。锁是一种通用的技术方案,各种编程语言都有实现。

在 Java 中,synchronized 关键字就是锁的一种实现。它可以用来修饰方法、也可以用来修饰代码块。你看下面这段代码:

public class Counter {

    long count = 0L;

    public synchronized void addOne() {
        count++;
    }
}

我们前面提过,count++ 会被拆成 3 个 CPU 指令,一旦发生线程切换,不一定被正确执行。

然而,加了 synchronized 关键字后,线程执行 addOne() 方法前得先加锁,但锁只有一个。如果一个线程抢先加锁,其它线程就得等着,直到第一个线程解锁后,才能再抢着加锁。

在这个过程中,CPU 可以做线程切换,但其它线程准备执行 addOne() 方法时,如果发现锁还没释放,那就只能在外面等着。

这样一来,不管 CPU 是单核还是多核,只要用对了锁,程序的原子性都能得到保障。而且,由于没有禁止线程切换,计算机的性能不受什么影响。

当然,并发编程是高阶技能,原子性问题又是最复杂的一个,我后面会仔细讲清楚:锁究竟是怎么一回事。拭目以待吧~

写在最后

可见性、原子性、有序性,这三者是编程领域的共同问题,Java 也有一套业界领先的技术方案—Java 内存模型。

Java 内存模型是一套复杂的规范,但我们作为 Java 应用工程师,只需要利用其中的 happen-before规则,用好 volatilesynchronized 等关键词,就能写出可靠的多线程应用。

其中,volatile 可以解决可见性、有序性问题;互斥锁可以解决原子性问题。在 Java 中,synchronized 就是互斥锁的一种实现。

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
7个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
3年前
Java 深入理解volatile关键字
我们知道Java中volatile实现了修饰变量的原子性以及可见性,并且为了实现多线程环境下的线程安全,禁止了指令重排。首先我们先来了解一下happensbefore原则、asifserial语义以及数据依赖性,引用自《Java并发编程的艺术》happensbefore简介从JDK5开始,Java使用新的JSR133内存模型
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Python进阶者 Python进阶者
1年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这
点赞功
点赞功
Lv1
有趣的人生就要一路撒野狂奔。
文章
3
粉丝
0
获赞
0