学习网址:
线程基本知识_Java多线程-IT乾坤技术博客线程基本知识
https://www.itqiankun.com/article/duoxianchengjichuzhishi
1 线程基本知识
1.1 并行与并发
概念
并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
并发不是并行。并行是让不同的代码片段同时在不同的物理处理器上执行。并行的关键是同时做很多事情,而并发是指同时管理很多事情,这些事情可能只做了一半就被暂停去做别的事情了。
在很多情况下,并发的效果比并行好,因为操作系统和硬件的总资源一般很少,但能支持系统同时做很多事情。
1.2 进程、线程
进程
进程的本质是一个正在执行的程序,程序运行时系统会创建一个进程,并且给每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰。同时,在 CPU 对进程做时间片的切换时,保证进程切换过程中仍然要从进程切换之前运行的位置出开始执行。所以进程通常还会包括程序计数器、堆栈指针。
注意点:
- 这种并发其实最终还是一个cpu再执行,只不过依赖时间片不停切换执行的。对于单核 CPU来说,在任意一个时刻只会有一个进程在被CPU调度。
- 每个进程分配独立的内存地址空间保证每个进程地址不会相互干扰。
线程
操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间
为什么需要线程?
1.减少进程创建和销毁和切换的资源消耗
- 从资源上来讲,线程是一种非常”节俭”的多任务操作方式。在linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种”昂贵”的多任务工作方式。
- 从切换效率上来讲,运行于一个进程中的多个线程,它们之间使用相同的地址空间,而且线程间彼此切换所需时间也远远小于进程间切换所需要的时间。据统计,一个进程的开销大约是一个线程开销的30倍左右。
- 从通信机制上来讲,线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进城下的线程之间贡献数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。
2.进程里并行需求的提出
就比如一个进程里面有多个同时任务,就比如我们在wps里面编辑内容任务,wps的把编写的内容保存临时文件任务,这里的两个任务就是一个并行的需求,如果使用进程来处理,那么就是串行执行这两个任务,这样就会容易造成卡顿,给用户带来不好的体验
进程和线程的区别
1.拥有资源
进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
2.调度
线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。我们常见意义上的并发一般是指多进程之间或者多线程之间。
3.系统开销
由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
4.通信方面
线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
1.3 多线程里面的原子性,可见性,有序性
原子性
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
在java代码里面,只有对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
注意上面说的只有,这里我们通过下面几个例子来讲解java里面的原子性:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中,所以是有原子性的。
但是语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的语句3和语句4,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值,所以也没有原子性。
可见性
可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
多线程里面没有满足可见性就会出现的问题
比如下面的代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
然后此时线程2执行 j = i,它会先去主存读取 i 的值并加载到CPU2的缓存当中,注意此时内存当中 i 的值还是0,那么就会使得 j 的值为0,而不是10.
这就是没有满足可见性,因为此时线程1对变量 i 修改了之后,线程2没有立即看到线程1修改的值。
怎么解决可见性问题?
- 对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
- 同时通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
什么叫做不能保证有序性?
比如下面的代码,下面的代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,比如下面的代码,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
有序性里面虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?
这里就要讲到happens-before的八大原则:
- 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。
- 锁的happen-before原则:同一个锁的unlock操作happen-before此锁的lock操作。
- volatile的happen-before原则:对一个volatile变量的写操作happen-before对此变量的任意操作(当然也包括写操作了)。
- happen-before的传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
- 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
- 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
- 线程终结的happen-before原则:线程中的所有操作都happen-before线程的终止检测。
- 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。
比如其中的单线程happen-before原则:在同一个线程中,书写在前面的操作happen-before后面的操作。什么意思呢?
比如下面的代码,下面的代码语句1肯定在语句3之前执行,语句1肯定在语句4之前执行,为什么会这样呢,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a * a; //语句4
但是上面的规则仅仅适用于单线程,在多线程里面可没有这样的规则
多线程里面不能保证有序性会出现哪些问题
比如下面的代码:
//线程1:
context = loadContext(); //语句1 进行加载资源
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context); // 根据加载的资源做一些逻辑
比如下面的代码,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行 doSomethingwithconfig(context)(),而此时 loadContext() 加载资源(比如mysql链接初始化)并没有被加载完成呢,你没有加载完资源就依赖资源去做某些事,这肯定会导致程序出错。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。解决方法之一就是给下面的inited变量添加volatile关键字,加上volatile关键字之后,happens-before的八大规则之一的volatile规则就会起作用:对一个volatile变量的写操作happen-before对此变量的任意操作。什么意思呢?
就是说线程1对 inited 变量有写入了操作,那么即使线程2获取了 inited 变量,此时也会让线程2里面的 inited 变量失效,然后重新从主内存里面获取inited变量值
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
当一个变量定义为 volatile 之后,将具备两种特性:
1.保证此变量对所有的线程的可见性,这里的“可见性”,如本文开头所述,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。
2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。