线程概念
说起线程概念就要和进程一起去对比,
所谓进程就是某个程序的一次运行过程,是属于操作系统级别的,而且执行程序时将会把程序载入内存中,Linux操作系统原理一书中举例–程序若是磁带,那么进程就是把磁带放进播放器中播放的这一个过程
那么线程呢:与进程相似,是更小的执行单位,一个进程执行过程会产生多个线程,这些线程会共享同一块内存空间和资源,因此切换线程不像切换进程一样负担过重,由于进程之间不是共享内存空间和资源所以切换进程需要切换上下文。也有人称线程为轻量级进程,毕竟有很多相似。
图片来源:https://segmentfault.com/a/1190000018198573
- 一个进程可以包含一个或多个线程。(其实你经常听到“多线程”,没有听过“多进程”嘛)
- 进程存在堆和方法区
- 线程存在程序计数器和栈
- 堆占最大内存,其为创建时分配的,是多线程共享的,主要存放new创建的对象
- 方法区也是多线程共享的,主要存放类、常量、静态变量
- CPU的基本执行单位是线程(注意!不是进程)
- 由此,线程需要一个程序计数器记录当前线程要执行的指令地址
- 当CPU的时间片用完,让出后记录当前执行地址,下次继续执行(时间片轮询)
- 只有执行Java代码时计数器记录的才是下一条指令的地址,执行native方法,则记录的是undefined地址
- 线程中的栈,只要存储线程局部变量、调用栈帧
多线程概念
多线程,多线程,就是几乎同时执行多个线程呗,这里用词注意,是“几乎”“同时”,毕竟cpu是高速切换线程,而不是同时执行多个线程,除非有多个处理器。
注意点
- 在Java 平台中,一个线程就是一个对象,对象的创建离不开内存空间的分配。创建
一个线程与创建其他类型的Java 对象所不同的是, Java 虚拟机会为每个线程分配调用栈
(Call Stack) 所需的内存空间。调用栈用于跟踪Java 代码(方法)间的调用关系以及Java
代码对本地代码(Native Code, 通常是C 代码)的调用。 - Java 平台中的任意一段代码(比如一个方法)总是由确定的线程负责执行的,这个线程就相应地被称为这段代码的执行线程。同一段代码可以被多个线程执行
- 任意一段代码都可以通过调用Thread.currentThread()来获取这段代码的执行线程,这个线程就被称为当前线程。由千同一段代码可以被多个线程执行,因此当前线程是相对的,即概念层次上的当前线程(即Thread.currentThread() 的返回值),在代码实际运行的时候可能对应着不同的线程(对象)。这就好比大家都自称“本人“(当前线程),“本人”这个词由张三来说就是指张三(线程X), 而由李四来说则指李四(线程Y) 。
- 为什么不直接用run而是start方法 run方法永远是运行在当前线程(即run方法的调用方代码的执行线程)之中而不是运行在其自身线程之中,从而违背了创建线程的初衷,即运行在自身线程
public class Run {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("2.Welcome! i am " + Thread.currentThread().getName());
}
});
thread.run();
thread.start();
System.out.println("1.Welcome! i am " + Thread.currentThread().getName());
}
}
运行结果:
2.Welcome! i am main//run方法
1.Welcome! i am main
2.Welcome! i am Thread-0//start方法
线程安全
原子性:Atomic,字面意思是不可分割,一般是涉及到共享变量的访问的操作,比如生活中的转账操作,要么成功,余额数字改变,要么失败,余额数字不改变。
原子性的理解是访问一个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生。
“原子操作+原子操作”所得到的复合操作并非原子操作。
实现方式:
- Lock,锁具有排他性可保障,一个共享变扯在任意一个时刻只能够被一个线程访问。
- 利用处理器提供的专门的CAS(Compare-and-Swap)指令,实质上和Lock一样,一个在软件层次,一个在处理器和内存这一硬件层次
可见性:在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式: 可见性(Visibility) 。这是程序涉及的问题
另一方面还可能与计算机存储系统有关。程序中的变量可能会被分配到寄存器(Register) 而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。
另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。
这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache) 子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器(Store Buffer) 中,还没有到达该处理器的高速缓存中,更不用说到主内存中了。而一个处理器的写缓冲器中的内容无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变批的更新。即便一个处理器上运行的线程对共享变量的更新结果被写入该处理器的高速缓存,由于该处理器将这个变量更新的结果通知给其他处理韶的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列(Invalidate
Queue) 中,而没有直接根据更新通知的内容更新其高速缓存的相应内容,这就导致了其他处理器上运行的其他线程后续再读取相应共享变最时,从相应处理器的高速缓存中读取到的变量值是一个过时的值。
不过可以通过处理器之间的缓存一致性协议去读取其他处理器的高速缓存中的数据,该过程称为缓存同步,
Java平台通过volatile关键字实现
原子性和可见性的联系与区别
原子性只能保证processor2读取到的a的值是1或者2,但不能保证该值是1还是2,如果a类型是long或double,a值有可能是任何值
可见性描述的是一个线程对一个共享变量的更新对于另一个线程是否可见,可见性保证的是t3时刻processor2读取到的共享变量的值为2.
因此两个性质都需要考虑到才得以保障线程安全。
有序性:有序性(Ordering) 指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(Out of order)。
实质上就是如何保证感知顺序与源代码顺序一致
重排序:编译器可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。重排性对单线程无影响,对多线程可能有影响,
有序性的保障也可以理解为从逻辑上部分禁止重排序。当然,这并不意味着从物理上禁止重排序而使得处理器完全依照源代码顺序执行指令,因为那样性能太低!因此,禁止重排序,都是指逻辑上的部分禁止重排序。
锁锁锁
理解锁:我们知道线程安全问题的产生前提是多个线程并发对某个共享变量,共享资源的访问造成的。因此可以想一些方法将线程并发化为线程串行。这就是锁的设计的思想。
一些名称:一个线程在访间共享数据前必须申请相应的锁(许可证),线程的这个动作被称为锁的获得(Acquire)。一个线程获得某个锁(持有许可证),我们就称该线程为相应锁的持有线程(线程持有许可证),一个锁一次只能被一个线程持有。
锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为临界区(CriticalSection)
Java虚拟机对锁的实现划分为内部锁–synchronized,显式锁–java.concurrent.locks.Lock接口的实现类
问题:
- 那么线程持有该锁对共享变量的改变,关于可见性如何保证呢?–》在Java平台,锁的获得隐含着刷新处理器缓存这一个动作
- 原子性如何保证呢? --》通过互斥保证原子性,所谓互斥就是一个锁一次只能被一个线程持有,这样就保证临界区代码一次只能有一个线程执行。
了解一下的锁需要先补一下基础知识
CAS(Compare and swap)
有时候对于一个共享变量的自增,如果用synchronized将该代码块包裹起来开销会比较大,且用volatile无法保障自增操作的原子性。
CAS是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个参数 —— 内存位置(V)、预期原值(E)和新值(N)。如果内存地址里面的值和预期原值是一样的,那么就将内存里面的值更新成N。如果不同说明已经有其他线程做了更改,当前线程就什么也不做,最后CAS会返回当前V的值。CAS的问题:CAS算法需要在某一时刻提取出内存数据,然后下一时刻进行比较和替换,而这个时间间隔有可能其他线程将原来的值改变,然后再改回原来的值,而当前线程不会察觉到,某些应用场景可能会出现数据不一致的问题。
lock前缀
在 Intel 处理器中,有两种方式保证处理器的某个核心独占某片内存区域。第一种方式是通过锁定总线,让某个核心独占使用总线,但这样代价太大。总线被锁定后,其他核心就不能访问内存了,可能会导致其他核心短时内停止工作。第二种方式是锁定缓存,若某处内存数据被缓存在处理器缓存中。处理器发出的 LOCK# 信号不会锁定总线,而是锁定缓存行对应的内存区域。其他处理器在这片内存区域锁定期间,无法对这片内存区域进行相关操作。
偏向锁
字面意思是偏向第一个访问锁的线程
大多数情况下锁不仅不存在多线程竞争,且锁总是由同一线程获得
偏向锁目的是消除这个线程锁重入(CAS)的开销
如果运行过程中遇到其他线程抢占锁,持有偏向锁的线程会被挂起,升级到轻量级锁
轻量级锁
死锁
死锁是线程的一种常见活性故障。如果两个或者更多的线程因相互等待对方而被永远
暂停(线程的生命周期状态为BLOCKED 或者WAITING), 那么我们就称这些线程产生
了死锁(Deadlock) 。
死锁产生的必要条件:
- 资源互斥:涉及的资源必须是独占的,即每个资源一次只能被一个线程占用
- 不可剥夺:资源只能够被持有的线程主动释放
- 占有和等待:涉及的线程当前至少持有一个资源并申请其他资源,而这些资源又恰好被其他线程占有
- 循环等待资源
这些都是必要条件,就是只要产生了死锁,那么上面这些条件一定同时成立,但是反过来上述条件同时成立不一定产生死锁。
规避死锁
既然有上面有了必要条件,那就想办法把某个条件破坏掉。
- 粗锁法,“占用并等待资源”,采用一个粒度较粗的锁替代原先多个粒度较细的锁,这样涉及的线程就只需请求同一个锁就行,排队就行。
缺点:降低了并发性导致资源浪费 - 锁排序法:相关线程使用全局统一的顺序申请锁。对资源进行编号,使用对象的身份hashCode,规定只有先拿到资源id较小的才能拿到资源id较大的资源
- 使用ReentrantLock.tryLock(long,TimeUnit)申请锁。允许我们为锁申请这个操作指定一个超时时间。在超时时间内,如果相应的锁申请成功,那么该方法返回true; 如果在tryLock(long,TimeUnit)执行的那一刻相应的锁正被其他线程持有,那么该方法会使当前线程暂停, 直到这个锁被申请成功(此时该方法返回true) 或者等待时间超过指定的超时时间(此时该方法返回false) 。因此,使用tryLock(long, Time Unit)来申请锁可以避免一个线程无限制地等待另外一个线程持有的资源,从而最终能够消除死锁产生的必要条件中的“占用并等待资源” 。
死锁的恢复
如果使用的是内部锁或者使用的是显式锁而锁的申请是通过Lock.lock()调用实现的,那么造成的死锁故障无法恢复,只能重启jvm
如果锁的申请是通过Lock.lockInterruptibly调用实现,可依赖线程的 中断机制,
利用一个工作者线程专门用于死锁检测和恢复。使其抛出InterruptedException。
关键字
Synchronized原理
任何一个对象都有一个与之相关联的锁。这种锁称为监视器monitor或者内部锁。这种锁是排他锁(即这种锁一次只能被一个线程持有),可以保证原子性、可见性、有序性。
sychronized就是通过内部锁关键字实现的,其中锁的获取和释放分别是monitorenter和monitorexit指令
加上该关键字的代码段,生成的字节码文件会多出这两条指令,还有ACC_SYNCHRONIZED标志位
方法被调用时,调用指令就会检查是否有该标志位,如果有先获取monitor,再执行方法,方法执行完释放monitor
monitor是一个对象,
用法 | 锁句柄 |
---|---|
synchronized实例方法 | 该实例对象 |
synchronized静态方法 | 静态方法所在类类对象 |
synchronized代码块 | 一个对象的引用,通常填this表当前对象 |
作为锁句柄的变量通常用final修饰,因为变量值一旦改变了,会导致执行同一个同步块的多个线程实际上使用不同的锁。
自java1.6之后synchronized在实现上分为偏向锁、轻量级锁、重量级锁,偏向锁默认开启
①
volatile
字面意思“易挥发”,“不稳定”
保证可见性和有序性
至于原子性只能保证被其修饰的变量的写操作,没有锁的排他性
其次volatile不会引起线程上下文切换,这也是为什么称为轻量级锁
可见性原理:在给volatile变量赋值时,java字节码指令会在赋值操作前加lock指令,lock指令会使cpu执行以下事件:
- 将当前处理器的缓存行的数据立即写回系统内存
- 将其他cpu里缓存了该内存地址的数据置为无效
为什么要这样子做呢?
一般来说没有volatile修饰的变量处理器操作完是写入缓存的,而不是直接写入内存,这就造成了其他处理器读到的变量值不是最新值,
若用了volatile修饰的变量,jvm会向处理器发送一条lock指令,将其在缓存行的数据立即写回主内存
这时其他处理器缓存行的数据仍然不是最新值,但是可以利用缓存一致性协议,其他处理器就通过嗅探在总线上传播的数据来检查自己的缓存的数据是否已经过期
当发现过期,则将自己缓存中的缓存行数据设置为无效
如果要再次修改该变量值,则是从主内存读取数据到自己的缓存行,重新加入缓存
有序性原理
通过禁止指令重排序来实现。
禁止指令重排序通过加内存屏障来实现。
- 在每个volatile写操作的前面插入一个StoreStore屏障,防止写volatile与后面的写操作重排序。
- 在每个volatile写操作的后面插入一个StoreLoad屏障,防止写volatile与后面的读操作重排序。
- 在每个volatile读操作的后面插入一个LoadLoad屏障,防止读volatile与后面的读操作重排序。
- 在每个volatile读操作的后面插入一个LoadStore屏障,防止读volatile与后面的写操作重排序。
部分原子性原理
为什么说是部分原子性:因为volatile仅仅保障对其修饰的变量的写操作或读操作本身的原子性,对于read-modify-write操作并不是原子性操作,比如共享变量i=i+1,这个就是读-改-写,执行过程有可能其他线程已经修改了i。如果变量i是局部变量则该赋值操作是原子操作。
因此要保障涉及到共享变量的赋值操作,可以额外加锁
另一个例子:
对于数组,volatile关键字只能对数组引用本身的操作起作用,无法对其数组元素的操作起作用
对数组的操作 |
---|
读取数组元素 |
写数组元素 |
读取数组引用 |
int i = array1[0];语句①
array1[1] = 1;语句②
volatile int[] array2 = array1;语句③
语句①可分为
- 读取数组引用(由上表可知这是一个volatile变量操作,可以保障线程读取得到的数组引用本身的相对新值)
- 接着读取第0个元素(在指定的数组引用基础上计算偏移量来读取数组元素,不能保障当前线程能够读取到的值是相对新值)
语句②:类似地,只能保障读取到的array1这个数组引用是相对新值,不能保障读取到的值
语句③:现在只是将array1的数组引用写入另一个数组,相当于更新array2的引用,可以保障xxxx1
并发集合
并发集合对象自身就支持对其进行线程安全的遍历操作。应用代码对并发集合对象进行
遍历的时候无须加锁(内部采用CAS方式)就可以实现遍历操作的线程安全。
并发集合遍历有2种方式:
- 快照:就是在Iterator实例被创建时的那一刻待遍历对象内部结构的一个只读副本(对象),对某一个并发集合进行遍历操作的每一个线程都会得到各自的一份快照。
- 准实时:是指遍历操作不是针对待遍历对象的副本进行的,但又不借助锁来保障线程安全,从而使得遍历操作可以与更新操作并发进行。并且,遍历过程中其他线程对被遍历对象的内部结构的更新(比如删除了一个元素)可能会(也可能不会)被反映出来。这种遍历方式所返回的Iterator 实例可以支持remove 方法。由于Iterator 是被设计用来一次只被一个线程使用的,因此如果有多个线程需要进行遍历操作,那么这些线程之间是不适宜共享同一个Iterator 实例的!
线程同步集合采用CAS操作或对锁的使用进行优化比如使用粒度小的锁,这样一个线程进行操作某个数据段时另一个线程还可以对另一个数据段进行操作。
不过若是并发线程数量上升到一定程度,锁争用程度越来越高,线程上下文切换开销越来越大,导致程序吞吐率下降。