【多线程与并发】Java并发理论基础

多线程与并发问题

为什么出现多线程

了解计算机的都知道,CPU、内存、I/O设备的速度是有很大差异的,为了合理利用CPU的高性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序都做出的贡献,主要体现为:

  • CPU:增加了缓存,以均衡与内存的速度差异;(导致可见性问题)
  • 操作系统:增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;(导致原子性问题)
  • 编译程序:优化指令执行次序,使得缓存能够得到跟家合理的利用。(导致有序性问题)

多线程不安全示例

如果多线程对同一个共享数据进行访问而不采取同步操作,那么操作的结果可能不一致。

来看看示例代码:两个线程,共同读写一个全局变量count,每个线程执行10000次count++,count的最终结果会是20000吗?

public class ThreadUnsafeExample implements Runnable{
    private static ThreadUnsafeExample unsafeExample = new ThreadUnsafeExample();
    private static int count;

    public static void main(String[] args) {
        Thread thread1 = new Thread(unsafeExample);
        Thread thread2 = new Thread(unsafeExample);
        thread1.start();
        thread2.start();
        try {
            // 等待两个线程都运行结束后,再打印结果
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //期待结果是20000,但是结果会小于这个值
        System.out.println(count);
    }
    @Override
    public void run() {
        for (int k = 0; k < 10000; k++) {
            count++;
        }
    }
}

多次运行结果: count最终值会小于等于20000

并发问题根源 — 并发三要素

并发三要素:

  • 可见性
  • 原子性
  • 有序性

上述代码输出为什么不是20000?并发出现问题的根源是什么?

可见性:CPU缓存引起

可见性:当一个线程对共享变量修改,另一个线程能够立即看到。

举例说明:

int a = 0;
// 线程 A 执行
a++; 
// 线程 B 执行
System.out.print("a=" + a);

即使是在执行完A线程里的a++后再执行线程 B,线程 B 的输入结果也会有 2 个种情况,一个是 0 和1。

因为 a++ 在线程 A(CPU1)中做完了运算,并没有立刻更新到主内存当中,而线程B(CPU2)就去主内存当中读取并打印,此时打印的就是 0。

原子性:分时复用引起

原子性:一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。

举例说明:

int a = 1;
//线程A执行
i++;
//线程B执行
i++;

i++是非原子操作,需要三条CPU指令:

  • i从内存读取到CPU寄存器;
  • 在CPU寄存器中执行i + 1操作;
  • 将结果i写入内存

由于CPU分时复用(切换线程)的存在,线程A执行了第一条指令后,就切换到线程B执行,若线程B执行了三条指令后,在切换回线程A执行线程A的后续两条指令,将造成最后写到内存中的i值是2而不是3。

有序性:重排序引起

有序性:程序执行的顺序按照代码的先后顺序执行。

举例说明:

//线程A
int a = -1;
boolean flag = false;
a = 1; //语句1
flag = true;// 语句2

//线程B
int b = 0;
if(flag){
  b = a;
}

处理器为了拥有更好的运算效率,会自动优化、重排序指令,但会确保直接结果不变。

上面代码中,语句1 与语句2并没有数据的依赖,如果运行在单线程,把两句代码调换也不会出现问题。

但若运行在多线程中,当处理器把语句1和语句2进行重排序,那么可能会导致结果不一致。若线程A先执行了语句2,此时,语句1并未执行,那么线程B中b的值会和预期结果有差异。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现在处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:


java-jmm-3.png

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。

  • 对于编译器重排序:JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
  • 对于处理器重排序:JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,Intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性。

Java如何解决并发问题 — Java内存模型(JMM)

JMM是一种虚拟机规范,用于屏蔽掉各种硬件和操作系统的内存访问差异,已实现让Java程序再各种平台下都能达到一致的效果。关于JMM后续会详细解说,本篇文章我们先从下面两个维度来理解JMM:

  • 第一个理解维度:核心知识点
  • 第二个理解维度:可见性,有序性,原子性

第一个理解维度:核心知识点

JMM本质上可以理解为:Java内存模型规范了JVM如何提供按需禁用缓存编译优化的方法。

这些方法具体包括:

  • volatile、synchronized和final三个关键字
  • happens-before规则

关键字:volatile、synchronized和final

关于这三个关键字后面会有文章进行详细解说,这里就先不展开。

happens-before规则

可以用 volatile 和 synchronized 来保证有序性,除此之外,JVM 还规定了happens-before原则,happens-before 用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。它的定义如下:

如果一个操作A happens-before 另一个操作B,那么操作A的执行结果将对操作B可见。

举例说明:

private int value = 0;
//操作A
public void setValue(int value){
  value = 1;
}
//操作B
public int getValue(){
  return value;
}

假设 setValue 就是操作 A,getValue 就是操作 B。如果我们先后在两个线程中调用 A 和 B,那最后在 B 操作中返回的 value 值是多少,有以下两种情况:

  • 如果A happens-before B 不成立

    当线程调用操作 B(getValue)时,即使操作 A(setValue)已经在其他线程中被调用过,并且 value 也被成功设置为 1,但这个修改对于操作 B(getValue)仍然是不可见的。此时CPU缓存中的结果如果入内存中则value值返回 1,否则返回 0。

  • 如果A happens-before B 成立

    根据 happens-before 的定义,先行发生动作的结果,对后续发生动作是可见的。也就是说如果我们先在一个线程中调用了操作 A(setValue)方法,那么这个修改后的结果对后续的操作 B(getValue)始终可见。因此如果先调用 setValue 将 value 赋值为 1 后,后续在其他线程中调用 getValue 的值一定是 1。

那在 Java 中的两个操作如何就算符合 happens-before 规则了呢? JMM 中定义了以下几种情况是自动符合 happens-before 规则的:

  1. 单一线程原则(Single Thread Rule)
    在一个线程内,在程序前面的操作先行发生于后面的操作。

在单线程内部,如果一段代码的字节码顺序也隐式符合 happens-before 原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见,只是后续逻辑中不一定用到而已。比如以下代码:

int a = 10;  // 1
b = b + 1;   // 2

当代码执行到 2 处时,a = 10 这个结果已经是公之于众的,至于用没用到 a 这个结果则不一定。比如上面代码就没有用到 a = 10 的结果,说明 b 对 a 的结果没有依赖,这样就有可能发生指令重排。

但如果b对a的结果没有依赖,则不会发生指令重排优化:

int a = 10;  // 1
b = b + a;   // 2
  1. 管程锁定规则(Monitor Lock Rule)
    对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。通俗的说就是如果A线程先写了一个volatile变量,然后B线程去读这个变量,那么这个写操作一定是 happens-before 读操作的。

  2. volatile 变量规则(Volatile Variable Rule)
    对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。通俗的说就是如果A线程先写了一个volatile变量,然后B线程去读这个变量,那么这个写操作一定是 happens-before 读操作的。

  3. 线程启动规则(Thread Start Rule)
    Thread 对象的 start() 方法先行发生于此线程的每一个动作。假设线程 A 在执行过程中,通过执行 ThreadB.start() 来启动线程 B,那么线程A对共享变量的修改确保在线程B开始执行后对线程B的可见。

  4. 线程终结规则(Thread Join Rule)
    Thread 对象的结束先行发生于 join() 方法返回。假定线程 A 在执行的过程中,通过调用 ThreadB.join() 等待线程 B 终止,那么线程 B 在终止之前对共享变量的修改在线程 A 等待返回后可见。

  5. 线程中断规则(Thread Interruption Rule)
    对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
    这句话不是很好理解,举例说明一下:在线程A中中断线程B之前,将共享变量x的值修改为100,则当线程B检测到中断事件时,访问到的x变量的值为100。

//在线程A中将x变量的值初始化为0
    private int x = 0;

    public void execute(){
        //在线程A中初始化线程B
        Thread threadB = new Thread(()->{
            //线程B检测自己是否被中断
            if (Thread.currentThread().isInterrupted()){
                //如果线程B被中断,则此时X的值为100
                System.out.println(x);
            }
        });
        //在线程A中启动线程B
        threadB.start();
        //在线程A中将共享变量X的值修改为100
        x = 100;
        //在线程A中中断线程B
        threadB.interrupt();
    }
  1. 对象终结规则(Finalizer Rule)
    一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。

举例:

public class TestThread {

   public TestThread(){
       System.out.println("构造方法");
   }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("对象销毁");
    }

    public static void main(String[] args){
        new TestThread();
        System.gc();
    }
}

运行结果:

构造方法
对象销毁
  1. 传递性
    **如果操作 A happens-before 操作 B,而操作 B happens-before 操作 C,则操作 A 一定 happens-before 操作 C。 **

第二个理解维度:可见性,有序性,原子性

  • 可见性

在Java中,普通的共享变变量是不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  1. Java提供了volatile关键字来保证可见性。(当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。)
  2. 通过synchronized和Lock也能够保证可见性。(synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。)
  • 有序性
  1. 可以通过volatile关键字来保证一定的“有序性”(happens-before原则中的volatile 变量规则)。
  2. 还可以通过synchronized和Lock来保证有序性。(synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。)
  3. JMM是通过Happens-Before 规则来保证有序性的。
  • 原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。先来看下列操作哪些是原子性的:

x = 10; //语句1:直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x; //语句2:包含2个操作,操作1先去读取x的值,操作2再将x的值写入工作内存,故不是原子性操作
x++; //语句3:包含3个操作,操作1读取x的值,操作2进行加1操作,操作3写入新的值
x = x + 1; //语句4:同语句3

以上只有语句1的操作具备原子性。

也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

  1. 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作;
  2. 若要实现更大范围操作的原子性,可以通过ynchronized和Lock来实现。(由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然可以保证原子性。)

线程安全的实现方法

1. 同步互斥

synchronized 和 ReentrantLock。

后续文章会详细分析。

2. 非阻塞同步

阻塞同步(同步互斥)与非阻塞同步

互斥同步主要的问题就是线程阻塞和唤醒锁带来的性能问题,因此这种同步也称为阻塞同步。

互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。

CAS

乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。

CAS 全称是 Compare And Swap,译为比较和替换,是一种通过硬件实现并发安全的常用技术,底层通过利用 CPU 的 CAS 指令对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。它的实现过程主要有 3 个操作数:内存值 V,旧的预期值 E,要修改的新值 U,当且仅当预期值 E和内存值 V 相同时,才将内存值 V 修改为 U,否则什么都不做。

AtomicInteger

J.U.C 包里面的整数原子类 AtomicInteger,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 类的 CAS 操作。

以下代码使用了 AtomicInteger 执行了自增的操作。

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    cnt.incrementAndGet();
}

以下代码是 incrementAndGet() 的源码,它调用了 unsafe 的 getAndAddInt() 。

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。

可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

ABA问题

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

3. 无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

线程本地存储(ThreadLocal)

如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

可以使用ThreadLocal 类来实现线程本地存储功能。

后续会出ThreadLocal的详细分析。

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

推荐阅读更多精彩内容