多线程详解(2)——不得不知的几个概念

多线程系列文章:

多线程详解(1)——线程基本概念

0. 简介

在多线程中可能会出现很多预想不到的现象,要理解这些现象的产生的原因,就一定要理解以下讲解的几个概念。

1. Java 线程内存模型

Java 内存模型主要定义变量的访问规则,这里的变量只是指实例变量,静态变量,并不包括局部变量,因为局部变量是线程私有的,并不存在共享。在这个模型有以下几个主要的元素:

  • 线程
  • 共享变量
  • 工作内存
  • 主内存

这几个元素之间还有几个要注意的地方:

作用处 说明
线程本身 每条线程都有自己的工作内存,工作内存当中会有共享变量的副本。
线程操作共享变量 线程只能对自己工作内存的当中的共享变量副本进行操作,不能直接操作主内存的共享变量。
不同线程间操作共享变量 不同线程之间无法直接操作对方的工作内存的变量,只能通过主线程来协助完成。

以下就是这几个元素之间的关系图:

Java 内存模型

1.1 内存间的操作

Java 定义了 8 种操作来操作变量,这 8 种操作定义如下:

操作 作用处 说明
lock(锁定) 主内存变量 把一个变量标识成一条线程独占的状态
unlock(解锁) 主内存变量 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取) 主内存变量 把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
load(载入) 工作内存变量 把 read 操作得到的变量放入到工作内存的变量副本中
use(使用) 工作内存变量 将工作内存中的一个变量的值传递给执行引擎
assign(赋值) 工作内存变量 将执行引擎接收到的值赋给工作内存的变量
store(存储) 工作内存变量 把工作内存中一个变量的值传给主内存中,以便给随后的 write 操作使用
write(写入) 主内存变量 把 store 操作从工作内存中得到的变量的值放入主内存变量中

1.1.1 内存操作的规则

Java 内存模型操作还必须满足如下规则:

操作方法 规则
read 和 load 这两个方法必须以组合的方式出现,不允许一个变量从主内存读取了但工作内存不接受情况出现
store 和 write 这两个方法必须以组合的方式出现,不允许从工作内存发起了存储操作但主内存不接受的情况出现
assign 工作内存的变量如果没有经过 assign 操作,不允许将此变量同步到主内存中
load 和 use 在 use 操作之前,必须经过 load 操作
assign 和 store 在 store 操作之前,必须经过 assign 操作
lock 和 unlock 1. unlock 操作只能作用于被 lock 操作锁定的变量
2. 一个变量被执行了多少次 lock 操作就要执行多少次 unlock 才能解锁
lock 1. 一个变量只能在同一时刻被一条线程进行 lock 操作
2. 执行 lock 操作后,工作内存的变量的值会被清空,需要重新执行 load 或 assign 操作初始化变量的值
unlock 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中

这些操作不用记下来,只要用到的时候再回来查看一下就好。

2. 多线程中几个重要的概念

了解完 Java 的内存模型后,还需要继续理解以下几个可以帮助理解多线程现象的重要概念。

2.1 同步和异步

同步和异步的都是形容一次方法的调用。它们的概念如下:

  • 同步:调用者必须要等到调用的方法返回后才会继续后续的行为。

  • 异步:调用者调用后,不必等调用方法返回就可以继续后续的行为。

下面两个图就可以清晰表明同步和异步的区别:

同步
异步

2.2 并发和并行

并发和并行是形容多个任务时的状态,它们的概念如下:

  • 并发:多个任务交替运行。

  • 并行:多个任务同时运行。

其实这两个概念的的区别就是一个是交替,另一个是同时。其实如果只有一个 CPU 的话,系统是不可能并行执行任务,只能并发,因为 CPU 每次只能执行一条指令。所以如果要实现并行,就需要多个 CPU。为了加深这两个概念的理解,可以看下面两个图:

并发
并行

2.3 原子性

原子就是指化学反应当中不可分割的微粒。所以原子性概念如下:

原子性:在 Java 中就是指一些不可分割的操作。

比如刚刚介绍的内存操作全部都属于原子性操作。以下再举个例子帮助大家理解:

x = 1;
y = x;

以上两句代码哪个是原子性操作哪个不是?
x = 1 是,因为线程中是直接将数值 1 写入到工作内存中。
y = x 不是,因为这里包含了两个操作:

  1. 读取了 x 的值(因为 x 是变量)
  2. 将 x 的值写入到工作内存中

2.4 可见性

可见性:指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

这里举个例子来讲解这个可见性的重要性,代码如下:

public class ThreadTest {
    
    
    private static boolean plus = true;
    private static int a;
    
    static class VisibilityThread1 extends Thread {
            
        
        public VisibilityThread1(String name) {
            setName(name);
        }
        
        @Override
        public void run() {
            while(true) {
                if(plus) {
                    a++;
                    plus = false;
                    System.out.println(getName() + " a = " + a + " plus = " + plus);
                }
            }
        }
        
    }

    static class VisibilityThread2 extends Thread {
        
        public VisibilityThread2(String name) {
            setName(name);
        }
        
        @Override
        public void run() {
            while(true) {
                if(!plus) {
                    a--;
                    plus = true;
                    System.out.println(getName() + " a = " + a + " plus = " + plus);
                }
            }

        }
        
    }
    
    
    public static void main(String[] args) {
        
        VisibilityThread1 visibilityThread1 = new VisibilityThread1("线程1");
        VisibilityThread2 visibilityThread2 = new VisibilityThread2("线程2");
        
        visibilityThread1.start();
        visibilityThread2.start();
        
    }
    
    

}

这段代码的期待输出的结果应该是以下这两句循环输出:

线程1 a = 1 plus = false
线程2 a = 0 plus = true

但是你会发现会出现如下的结果:

线程1 a = 0 plus = true
线程2 a = 1 plus = false

出现这个错误的结果是因为两条线程同时都在修改共享变量 a 和 plus。一个线程在修改共享变量时,其他线程并不知道这个共享变量被修改了,所以多线程开发中一定要关注可见性。

2.5 重排序

重排序:编译器和处理器为了优化程序性能而对指令重新排序的一种手段。
在讲解这个概念之前要先铺垫一个概念:数据依赖性。

2.5.1 数据依赖性

如果两个操作同时操作一个变量,其中一个操作还包括写的操作,那么这两个操作之间就存在数据依赖性了。这些组合操作看下表:

名称 说明 代码示例
写后读 写一个变量后,再读取这个变量 a = 1;
b = a;
写后写 写一个变量后,再写入这个变量 a = 1;
a = 2;
读后写 读取一个变量后,再写入这个变量 b = a;
a = 2;

上表这三种情况如果重排序的话就会改变程序的结果了。所以编译器和处理器并不会对这些有数据依赖性的操作进行重排序的。
注意,这里所说的数据依赖性只是在单线程的才会出现,如果多线程的话,编译器和处理器并不会有数据依赖性。

2.5.2 多线程中的重排序

这里使用简化的代码来讲解,代码如下:

int a = 0;
boolean flag = false;

// 线程1
VisibilityThread1 {
  a = 3; // 1
  flag = true; // 2
}

// 线程2
VisibilityThread2 {
  if(flag) { // 3
    a= a * 3; // 4
  }
}

这里操作 1,2 和 操作 3,4 并不存在数据依赖性,所以编译器和处理器有可能会对这些操作组合进行重排序。程序的执行的其中一种情况如下图:

重排序

因为线程 2 中的操作 5 和 6 存在控制依赖的关系,这会影响程序执行的速度,所以编译器和处理器就会猜测执行的方式来提升速度,以上的情况就是采用了这种方式,线程 2 提前读取了 a 的值,并计算出 a * 3 的值并把这个值临时保存到重排序缓冲的硬件缓存中,等待 flag 的值变为 true 后,再把存储后的值写入 a 中。但是这就会出现我们并不想要的结果了,这种情况下,a 可能还是为 1。

2.6 有序性

如果理解了重排序后,有序性这个概念其实也是很容易理解的。
有序性:是指程序的运行顺序与编写代码的顺序一致。

3. 线程安全

理解了上述的概念之后,再来讲解线程安全的概念可能会更容易理解。

3.1 定义

线程安全就是指某个方法在多线程环境被调用的时候,能够正确处理多个线程之间的共享变量,使程序功能能够正确执行。
这里举个经典的线程安全的案例——多窗口卖票。假设有 30 张票,现在有两个窗口同时卖这 30 张票。这里的票就是共享变量,而窗口就是线程。这里的代码逻辑大概可以分为这几步:

  1. 两条线程不停循环卖票,每次卖出一张,总票数就减去一张。
  2. 如果发现总票数为 0,停止循环。

代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {
            
            if(ticketNum <= 0) {
                break;
            }
            
            System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
        }
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

代码打印结果如下:

窗口1 卖出第  30 张票,剩余的票数:28
窗口2 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  28 张票,剩余的票数:27
窗口2 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口2 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口2 卖出第  23 张票,剩余的票数:22
窗口2 卖出第  21 张票,剩余的票数:20
窗口1 卖出第  22 张票,剩余的票数:21
窗口2 卖出第  20 张票,剩余的票数:19
窗口1 卖出第  19 张票,剩余的票数:18
窗口1 卖出第  17 张票,剩余的票数:16
窗口1 卖出第  16 张票,剩余的票数:15
窗口1 卖出第  15 张票,剩余的票数:14
窗口1 卖出第  14 张票,剩余的票数:13
窗口1 卖出第  13 张票,剩余的票数:12
窗口1 卖出第  12 张票,剩余的票数:11
窗口1 卖出第  11 张票,剩余的票数:10
窗口1 卖出第  10 张票,剩余的票数:9
窗口1 卖出第  9 张票,剩余的票数:8
窗口1 卖出第  8 张票,剩余的票数:7
窗口1 卖出第  7 张票,剩余的票数:6
窗口1 卖出第  6 张票,剩余的票数:5
窗口1 卖出第  5 张票,剩余的票数:4
窗口1 卖出第  4 张票,剩余的票数:3
窗口1 卖出第  3 张票,剩余的票数:2
窗口1 卖出第  2 张票,剩余的票数:1
窗口1 卖出第  1 张票,剩余的票数:0
窗口2 卖出第  18 张票,剩余的票数:17

从以上的打印结果就可以看到,窗口1和窗口2同时都卖出第 30 张票,这和我们所期待的并不相符,这个就是线程不安全了。

4. synchronized 修饰符

那上述卖票的案例怎么才可以有线程安全性呢?其中一个办法就是用synchronized 来解决。

4.1 synchronized 代码块

4.1.1 语法格式

synchronized(obj) {
    // 同步代码块
}

4.1.2 使用 synchronized 代码块

synchronized 括号的 obj 是同步监视器,Java 允许任何对象作为同步监视器,这里使用 SellTicketDemo 实例来作为同步监视器。代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {
            synchronized(this) {
                if(ticketNum <= 0) {
                    break;
                }
                
                System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
            }
        }
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

打印结果如下:

窗口1 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  29 张票,剩余的票数:28
窗口1 卖出第  28 张票,剩余的票数:27
窗口1 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口1 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口1 卖出第  23 张票,剩余的票数:22
窗口1 卖出第  22 张票,剩余的票数:21
窗口1 卖出第  21 张票,剩余的票数:20
窗口2 卖出第  20 张票,剩余的票数:19
窗口2 卖出第  19 张票,剩余的票数:18
窗口2 卖出第  18 张票,剩余的票数:17
窗口2 卖出第  17 张票,剩余的票数:16
窗口2 卖出第  16 张票,剩余的票数:15
窗口2 卖出第  15 张票,剩余的票数:14
窗口2 卖出第  14 张票,剩余的票数:13
窗口2 卖出第  13 张票,剩余的票数:12
窗口2 卖出第  12 张票,剩余的票数:11
窗口2 卖出第  11 张票,剩余的票数:10
窗口2 卖出第  10 张票,剩余的票数:9
窗口2 卖出第  9 张票,剩余的票数:8
窗口2 卖出第  8 张票,剩余的票数:7
窗口2 卖出第  7 张票,剩余的票数:6
窗口2 卖出第  6 张票,剩余的票数:5
窗口2 卖出第  5 张票,剩余的票数:4
窗口2 卖出第  4 张票,剩余的票数:3
窗口2 卖出第  3 张票,剩余的票数:2
窗口2 卖出第  2 张票,剩余的票数:1
窗口2 卖出第  1 张票,剩余的票数:0

可以看到现在的结果就是正确的了。

4.2 synchronized 方法

4.2.1 语法格式

[修饰符] synchronized [返回值] [方法名](形参...) {
        
}

4.2.2 使用 synchronized 方法

使用同步方法非常简单,直接用 synchronized 修饰多线程操作的方法即可,代码如下:

public class SellTicketDemo implements Runnable {

    private int ticketNum = 30;
    
    @Override
    public void run() {
        while(true) {

            sellTicket();
            
        }
    }
    
    public synchronized void sellTicket() {
        if(ticketNum <= 0) {
            return;
        }
        
        System.out.println(Thread.currentThread().getName() +" 卖出第  " + ticketNum + " 张票,剩余的票数:" + --ticketNum);
    }
    
    public static void main(String[] args) {
        
        SellTicketDemo sellTicketDemo = new SellTicketDemo();
        
        Thread thread1 = new Thread(sellTicketDemo,"窗口1");
        Thread thread2 = new Thread(sellTicketDemo,"窗口2");
        
        thread1.start();
        thread2.start();
        
    }

}

打印如下:

窗口1 卖出第  30 张票,剩余的票数:29
窗口1 卖出第  29 张票,剩余的票数:28
窗口1 卖出第  28 张票,剩余的票数:27
窗口1 卖出第  27 张票,剩余的票数:26
窗口1 卖出第  26 张票,剩余的票数:25
窗口1 卖出第  25 张票,剩余的票数:24
窗口1 卖出第  24 张票,剩余的票数:23
窗口1 卖出第  23 张票,剩余的票数:22
窗口1 卖出第  22 张票,剩余的票数:21
窗口1 卖出第  21 张票,剩余的票数:20
窗口1 卖出第  20 张票,剩余的票数:19
窗口2 卖出第  19 张票,剩余的票数:18
窗口2 卖出第  18 张票,剩余的票数:17
窗口2 卖出第  17 张票,剩余的票数:16
窗口2 卖出第  16 张票,剩余的票数:15
窗口2 卖出第  15 张票,剩余的票数:14
窗口2 卖出第  14 张票,剩余的票数:13
窗口2 卖出第  13 张票,剩余的票数:12
窗口2 卖出第  12 张票,剩余的票数:11
窗口2 卖出第  11 张票,剩余的票数:10
窗口2 卖出第  10 张票,剩余的票数:9
窗口2 卖出第  9 张票,剩余的票数:8
窗口2 卖出第  8 张票,剩余的票数:7
窗口2 卖出第  7 张票,剩余的票数:6
窗口2 卖出第  6 张票,剩余的票数:5
窗口2 卖出第  5 张票,剩余的票数:4
窗口2 卖出第  4 张票,剩余的票数:3
窗口2 卖出第  3 张票,剩余的票数:2
窗口2 卖出第  2 张票,剩余的票数:1
窗口2 卖出第  1 张票,剩余的票数:0

参考文章和书籍:
java并发之原子性、可见性、有序性
Java内存访问重排序的研究
Java并发编程的艺术
Java并发编程实战
实战Java高并发程序设计
深入理解Java虚拟机

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

推荐阅读更多精彩内容