文章目录
线程与进程
进程(Process)
是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
(进程的理解:操作系统上的一块独立的区域,每个进程都是独立运行的,资源相互是不共享的。)
线程(Thread)
是操作系统能够进行运算调度的最小单位。一个进程能够有多条线程,线程与线程之间是能够资源共享的。一个进程中可以并发多个线程,每条线程并行执行不同的任务。
CPU时间分片
进程是资源分配单位,线程是CPU调度单位。如果线程数不多于CPU核心数,会把各个线程都分配一个核心。不需分片,而当线程数多于CPU核心数就会分片。
多个线程在操作时,如果系统只有一个CPU,CPU会把运行时间划分成若干个时间片,分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。
并发表示同时发生了多件事情,通过时间片切换,哪怕只有单一的核心,也可以实现“同时做多件事情”这个效果。
并发是什么
顺序:上个任务执行完,当前任务才能开始
并发(concurrent):不管上个任务是否执行完,当前任务都可以开始
串行:只有一个厕所,上厕所只能一个一个地排队
并行(parallel):有多个厕所,可以同时多个人上厕所
5个线程,每个线程能处理100万个任务。(5为并行度,100万为并发量)
资源共享是什么
反向思维:哪些资源是线程私有的?
进程地址空间划分:栈区、堆区、代码区(源代码编译后的机器指令存放区域)、数据区(全集变量、static变量存放)
线程运行的本质其实就是函数的执行。函数的执行总会有一个源头,这个源头就是所谓的入口函数,CPU从入口函数开始执行从而形成一个执行流,只不过我们人为的给执行流起一个名字,这个名字就叫线程。
线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。这些资源统称为线程上下文(thread context)。
所以说,共享的资源有:堆区、代码区、数据区
资源共享相关文章:线程间到底共享了哪些进程资源?看完这篇你就懂了~
并发编程
并发编程:为了程序运行更快,让多个线程分别完成不同任务。
但是多线程同时也带来了新的问题 - 并发问题(共享资源的竞争问题)
并发问题的源头
-
线程切换带来的原子性问题
-
缓存导致的可见性问题
-
编译优化带来的有序性问题
原子性问题
原子性:一个线程的操作是不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。
出现原子性问题的原因:Java是门高级语言里的一条语句往往需要多条CPU指令完成。CPU做任务切换可以发生在任何一条CPU指令执行完之后,而不是高级语言里的一条语句。
为什么线程操作会被打断
线程是CPU调度单位。在线程数超过CPU核心数的时候,CPU就会通过时间分片来将时间分给每个线程,例如50毫秒,线程执行50毫秒之后就会切换到另一个线程。在时间片内的线程拥有CPU的使用权,其他线程会挂起。这种切换可以称为任务切换(也可以叫线程切换)。
原子性问题例子
下面以i++为例:
//G.java
public class G {
private int i;
public void test(){
i++;
}
}
//javap后看看字节码
Compiled from "G.java"
public class com.llk.kt.G {
public com.llk.kt.G();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void test();
Code:
0: aload_0
1: dup
2: getfield #2 //获取对象字段的值
5: iconst_1 //1(int)值入栈
6: iadd //将栈顶两int类型数相加,结果入栈
7: putfield #2 //给对象字段赋值
10: return
}
/*
i++分为了三步
step1 将i内存加载到CPU的寄存器
step2 在寄存器中执行+1操作
step3 将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)
上边每一个步骤执行完,CPU都有可能切换任务。
例如:
线程A与线程B同时执行test()方法,线程A得到CPU的使用权后进行操作,执行完step1后任务切换到了线程B。
然后线程B执行,线程B比较幸运直接执行完了test(),并且成功将自增完后的值写回到内存中,i这时候变成了1。
任务切换回线程A,由于线程A在经过step1后已经i的值缓存到了寄存器,继续执行step2,直接用i的旧值0来做自增,完事后将结果写回到内存中。
i的预期值本应为2的,最后由于任务切换,导致了i的值出现了异常。
*/
可见性问题
可见性:指某个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。
出现可见性问题的原因:CPU为了解决内存io操作速度慢的问题,CPU自身会有高速缓存。多核CPU中,每个CPU都有自己独立的高速缓存。线程是CPU调度单位,所以线程拥有这高速缓存空间。线程在操作共享内存中的变量时,都会先将其拷贝一份到自己的缓存空间中。在并发环境下,无法保证CPU的缓存一致性,就会导致可见性问题的发生。
CPU的缓存一致性
缓存一致性
指保证存储在多个缓存中的共享资源数据相同的机制。
缓存不一致
是指相同数据在不同的缓存中呈现出不同的表现。缓存不一致的问题,在多核CPU的系统中,比较容易出现。
如果保证CPU的缓存一致性
缓存一致性协议有多,主流的是"嗅探(snooping)"协议,它的基本思想是:所有内存的传输都发生在一条共享的总线上,而所有的CPU都能看到这条总线。
缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。
CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的CPU去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个CPU一写内存,其它CPU马上知道这块内存在它们的缓存段中已失效。
MESI协议是当前最主流的缓存一致性协议。(感兴趣自行了解)
可见性问题例子
public class V {
private static boolean bool = false;
public static void b_test(){
new Thread(new Runnable() {//线程1
@Override
public void run() {
System.out.println("11111");
while (!bool){ }
System.out.println("22222");
}
}).start();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(new Runnable() { //线程2
@Override
public void run() {
System.out.println("33333");
bool = true;
System.out.println("44444");
}
}).start();
}
public static void main(String[] args) {
b_test();
}
/* 输出 ->
11111
33333
44444
线程2明明已经将bool设置为true了,为什么线程1没有结束循环呢?
因为线程有自己的缓存区域,会先把共享内存中的变量拷贝到自己的缓存区域中。所以这就导致了线程2刷新了共享内存中的bool值,但线程1依旧使用自己缓存的bool值,最终导致线程1一直无法退出。
*/
}
有序性问题
有序性:在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。
出现有序性问题的原因:编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,在单核CPU最终结果看起来没什么变化。
但是在多线程环境下(多核CPU),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。
有序性问题例子
public class V {
private static V instance;
private V(){}
public static V getInstance() {
if (instance==null){ //首次检查
synchronized (V.class){ //通过synchronized保证了代码块跟上下文的可见性、原子性、有序性
if (instance == null){ //二次检查
instance = new V();
}
}
}
return instance;
}
}
上面是一个经典的单例实现方式,双重检查锁(Double Check Lock)单例。但是这个单例并不完美,在多线程模式下getInstance()仍然可能出现问题,会由于指令重排出现有序性问题。
不是synchronized就能保证了有序性了吗,为什么还会出现有序性问题?synchronized只能保证受保护的代码块跟与上下文的有序性,而不能保证代码块内的有序性。
//原因出在:
instance = new V();
//这一行代码并不是原子操作,而是由三个操作完成的
//1、为instance开辟一块内存空间
//2、初始化对象
//3、instance指向刚分配的内存地址
//由于编译器优化,出现指令重排,变成了
//1、为instance开辟一块内存空间
//3、instance指向刚分配的内存地址
//2、初始化对象
//假设线程A执行到了“instance指向刚分配的内存地址”这一步,那么instance就不为空了。
//这个时候线程B正好执行getInstance()的首次检查,发现instance不为空直接返回了。
//但是这个instance对象可能并未初始化完成,如果我们这个时候访问instance的成员变量就可能触发空指针异常。
想要解决上边例子中出现的有序性问题,只需要给instance变量加上volatile关键字即可。
Java内存模型
JMM(Java Memory Model)也称Java内存模型
JMM是用来定义一个一致的、跨平台的内存模型,是缓存一致性协议,用来定义数据读写的规则。
JMM最核心的概念是Happens-Before。
Happens-Before规则
Happens-Before规则:前面一个操作的结果对后续操作是可见的
1. 顺序性规则
一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作
int a = 1; //1
int b = 2; //2
int c = a + b; //3
2. volatile变量规则
对一个volatile变量的写操作,Happens-Before于后续任意对这个volatile变量的读操作
volatile int i = 0;
//线程A执行
i = 10; //写操作
//线程执行
int b = i; //读操作
/*
线程A先执行,紧接着线程B执行
由于volatile修饰了i,保证了可见性线程B立马能够获取到i最新的值
*/
3. 传递性规则
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C
int i = 0;
volatile boolean b;
void set(){
i = 10;
b = true;
}
void get(){
if(b){
int g = i;
}
}
/*
线程A调用了set()后,紧接着线程B调用了get()。
i = 10 Happens-Before b = true ---> 顺序性规则
b写操作 Happens-Before b读操作 ---> volatile变量规则
最后根据传递性规则,i = 10 Happens-Before int g = i 这里的g获取到i值已经是最新的值10
*/
4. 管程中锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁
管程是一种通用的同步原语,synchronized是Java里对管程的实现。
int i = 5;
void set(){
synchronized (this) { //加锁
if (i < 10) {
i = 99;
}
} //解锁
}
/*
线程A、线程B同时调用了set(),线程A率先进入代码块,线程B进入等待
当线程A自动释放锁后,线程B进入了代码块。线程B读取的i值已经是最新的值99。
*/
5. 线程start()规则
主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作
换句话说:线程B.start()前的操作 Happens-Before 于线程B内的任意操作
int i = 0; //共享变量
Thread B = new Thread(()->{
System.out.println("i=" + i); //输出-> i=10
});
i = 10;
B.start();
/*
i = 10 是线程B启动前的操作,所以在B线程内i的值是最新值
*/
6. 线程join()规则
主线程A等待子线程B完成(主线程A调用子线程B的join()方法),当子线程B执行完成后(主线程A中join()方法返回),主线程能够看到子线程对共享变量的操作。
换句话说:线程B内任何操作 Happens-Before 于线程B.join()后的操作
int i = 0; //共享变量
Thread B = new Thread(()->{
i = 10;
}).start();
System.out.println("i=" + i); //输出-> i=0
B.join();
System.out.println("i=" + i); //输出-> i=10
/*
线程B中的 i = 10 操作,在B.join()后是可见的
*/