1. 串行、并行与并发的概念
- 串行:用于表示多个操作“依次处理”
- 例如:十个操作任务要一个人完成,这个人要一个一个按顺序来完成
- 并行:用于表示多个操作“同时处理”
- 例如:十个操作任务分给两个人处理,这两个人就会同时来处理。
- 并发:表示:将一个操作分割成多个部分并且允许无序处理。
- 比如:十个操作分成相对独立两类,就开始顺序执行并发处理。如同有一碟咕噜肉和一盘红烧肉,我一口吃咕噜肉,下一口吃红烧肉。下下口我还可以随机选一个来吃,但是吃的过程是有顺序,总不会同时夹两个肉一起吃。
2.竞态
问题引入:对于同样的输入,程序的输出有时候是正确的,而有时候却是错误的
1)基本概念
竞态定义
- 竞态(Race Condition)是指计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving) 。
- 竞态不一定就导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能
竞态条件
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。
临界区
导致竞态条件发生的代码区称作临界区。
在临界区中使用适当的同步就可以避免竞态条件。
2)竞态的模式
read-modify-write(读-改-写)
细分操作:
- 读取一个共享变量的值(read)
- 根据该值做出计算(modify)
- 最后更新该共享变量的值(write)
check-then-act(检测而后行动)
- 读取某个共享变量的值(检测变量的值)
- 根据该变量的值决定下一步的动作是什么(赋值、修改值)
不会出现竞态的例子
解决竞态的方法
- 使用局部变量
- 使用synchronized变量
3.线程安全性
- 如果一个类能够导致竞态,那么它是非线程安全的;若一个类是线程安全的,那么它就不会导致竞态
- Java标准库中的一些类如:ArrayList、HashMap、StringBuilder等等,都是非线程安全的
线程安全性包括三方面:
- 原子性
- 可见性
- 有序性
1)原子性
- 原子(Atomic)的字面意思是不可分割的(Indivisible)
- 若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,并且可以消除该操作的导致竞态的可能性
- 原子操作是针对访问共享变量的操作而言的。
- 原子操作只有在多线程环境下才有意义
- 所谓不可分割:
- 其中一个含义指:访问(读写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么执行结束要么尚未发送,即其他线程不会 “看到” 该操作执行了部分的中间效果
- 例如ATM机取款:
- 用户账户==共享变量
- 用户的存取款 == 线程操作
- 当张三拥有10000余额,想来取款5000,但是突然发现取多2000,想要重新存回去;其中张三的线程操作包括有:取、存。线程执行的操作,其他任何线程都不会 “看到” 张三线程的操作。这就保证了线程操作的原子的不可分割性。否则就存在缺陷。
实现方式
Java有两种方式实现原子性:
- 锁(Lock)
- 锁具有排他性,能够保障一个共享变量在任意一个时刻只能够被一个线程访问
- CAS(Compare-and-Swap)指令
- 与锁实现方式实质上是相同的,差别在于锁是在软件层次实现。而CAS是在硬件(处理器和内存)层次实现,被当做 “ 硬件锁”
Java类型的原子操作
Java语言除了long/double以外,其余变量和引用型变量的写操作都是原子的。
因为Java中的long/double型变量占用64位(8字节)的存储空间,而32位的Java虚拟机对这种变量的写操作会分为两个步骤实现:
- 比如先写低32位,再写高32位
- 多个线程共享变量时,也可能先写高32位,后写低32位
2)可见性
可见性:指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。
num = 0
A 线程 修改 num = 1
此时,B线程是否对A线程修改共享变量的可见:
若可见,则B 读取 num=1; 否则 读取num =0
- 多线程程序在可见性访问存在的问题:可能某个线程读取到了旧数据,会导致程序出现意料之外的结果
实现方式
- volatile关键字
线程的启动与可见性
- Java语言规范(JLS, Java language Specification)保证,父线程在启动子线程之前对共享变量的更新,对于子线程来说是可见的
实例
public class ThreadStartVisibility extends Thread{
//线程间的共享变量
static int data = 0;
@Override
public void run(){
System.out.printf("2.子线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
System.out.printf("2.子线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
}
public static void main(String[] args) {
ThreadStartVisibility thread = new ThreadStartVisibility();
// 在子线程thread启动前更新变量data的值
data = 1;
thread.start();
System.out.printf("1.父线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
// 子线程启动后更新data的值
data =2 ;
System.out.printf("1.父线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
}
}
执行结果:
- main线程在启动子线程thread之前,将共享变量data的值更新为1,因此子线程thread读取到共享变量data一定是 1 。
- 结论:父线程在子线程启动前对共享变量的更新对子线程的可见性是有保证的
- main线程启动子线程后,再将data更新为2,子线程thread读取到共享变量data可能为1,也可能为2.
- 结论:父线程在子线程启动后再对共享变量的更新对子线程的可见性是没有保证的
// 将data=2 语句注释后的执行结果
1.父线程 :main,data:1.
2.子线程 :Thread-0,data:1.
2.子线程 :Thread-0,data:2.
1.父线程 :main,data:2.
// 没有将data = 2 语句注释后的执行结果
2.子线程 :Thread-0,data:1.
2.子线程 :Thread-0,data:1.
1.父线程 :main,data:1.
1.父线程 :main,data:2.
线程的终止与可见性
- 一个子线程终止后,该子线程对共享变量的更新,对于调用该子线程的join方法的父线程而言是可见的。
实例
public class ThreadJoinVisibility extends Thread{
static int data = 0;
public void run(){
//更新data的值
data = 1;
System.out.println("我是子线程,已结束");
}
public static void main(String[] args) throws InterruptedException {
System.out.printf("1.父线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
ThreadJoinVisibility thread = new ThreadJoinVisibility();
thread.start();
// 等待线程thread结束后,main线程才继续运行
// t.join()方法只会使主线程(或者说调用t.join()的线程)进入等待池并等待t线程执行完毕后才会被唤醒
thread.join();
System.out.printf("1.父线程 :%s.%n\n",Thread.currentThread().getName()+",data:"+data);
}
}
执行结果:
1.父线程 :main,data:0.
我是子线程,已结束
1.父线程 :main,data:1.
3)有序性
- 有序性 (Ordering):指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(Out of order)
- 乱序:指内存访问操作的顺序看起来是发送了变化。
重排序
- 顺序结构是结构化编程的一种基本结构,它表示我们希望某个操作必须先于另外一个操作得以执行。反映在代码上这些操作也总是有先后关系。
- 但是在多核处理器的环境下,这些操作执行顺序可能是没有保障的:编辑器可能改变两个操作的先后顺序
- 一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫做重排序(Reordering)
- 重排序是对内存访问有关的操作所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题。
4)三者的区别
原子性与可见性的联系和区别
- **原子性描述:一个线程对共享变量的更新。**从另外一个线程的角度来看,它来么完成了,要么尚未发送,而不是进行中的一种状态。
- 可见性描述:一个线程对共享变量的更新对于另外一个线程而言是否可见(或者说什么情况下可见)的问题。
- 从保障线程安全的角度看:可见性和原子性同时得以保障才能够确保一个线程能够 “ 正确” 地看到(而不是 “ 半成品”) 其他线程对共享变量所做的更新。
可见性与有序性的联系和区别
-
可见性是有序性的基础
- 可见性描述一个线程对共享变量的更新对于另外一个线程是否可见
- 有序性描述一个处理器上的线程对共享变量所做的更新,在其他处理器运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。
-
有序性影响可见性
- 由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。
4.上下文切换及其产生原因
1)基本概念
- 单处理器上能够以多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程。
- 例如:一个遥控四驱车一次只能被一个儿童玩:每个儿童玩一定的时间,时间到了,这个玩具必须交给另外一个儿童玩,一次规则各个儿童轮流玩。这里,每个儿童可以占用玩具进行玩耍的时间就被称为时间片(Time Slice)
- 单处理器上的多线程就是通过这种时间片分配的方式实现的
- 时间片决定了一个线程可以连续占用处理器运行的时间长度
- 当一个进程的一个线程时间片用完或被迫暂停,另外一个线程就可以选中占用处理器,这种一个线程被暂停,即被剥夺处理器的使用权。另外一个线程被选中开始运行的过程叫做线程的上下文切换。
- 一个线程被暂停运行被称为切出(Switch Out)
- 一个被选中开始运行被称为切入(Switch In)
- 切入和切出那一刻相应的线程所执行的任务进行到了什么程度(如计算的中间结果以及执行到了哪条指令),这个进度进行就被称为上下文(Context)
- 主要包括有:通用寄存器、程序计数器
- 切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。
- 切入时,操作系统需要从内存中加载(恢复)被选中线程的上下文,以在之前运行的基础上继续进展
2)分类及具体诱因
分类:
-
自发性上下文切换
- 线程由于其自身因素导致的切出
- sleep、wait、yield、join、park
-
非自发性上下文切换
-
由于线程调度器的原因被迫切出。
-
常见因素:
- 线程优先级更高需要被运行
- 时间片用完
- Java平台的角度看:虚拟机的垃圾回收(Garbage Collect)动作也可能导致非自发性上下文切换。因为GC在回收过程中需要暂停所有应用线程(Stop-the-world)才能完成其工作
-
3)开销和测量
包括:
- 直接开销
- 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销
- 线程调度器进行线程调度的开销
- 间接开销
- 处理器高速缓存重新加载的开销
- 上下文切换也可能导致整个一级高速缓存中的内容被冲刷(Flush)
- 从定量的角度来说,一次上下文切换的时间消耗是微妙级的
5.线程的活性故障
-
线程是为任务而生的。理想情况下我们希望线程一直处于Runnable状态。
-
事实并非如此:导致一个线程可能处于非Runnable状态的因素除了资源(上下文切换)限制之外,还有程序自身的错误和缺陷
常见的活性故障:
- 死锁(DeadLock)
- 锁死(Lockout)
- 活锁(Livelock)
- 饥饿(Starvation)
6. 资源争用与调度
- 我们往往需要在多个线程间共享同一个资源。
- 一次只能够被一个线程占用的资源被称为 排他性(Exclusive)资源
- 常见的排他性资源包括:处理器、数据库连接、文件等
其他线程视图访问该资源的现象就被称为资源争用
- 争用是在并发环境下产生的一种现象
- 同时试图访问同一线程的数量越多,争用的程度也越高,称为高争用和低争用,反之。
但高并发增加了争用了概率,但是高并发未必就意味着高争用。
我们想达到的理想目标是高并发、低争用
而多个线程共享同一个资源又会带来新的问题,即 资源的调度问题