Java技术栈总结:多线程篇
一、线程基础
1、线程与进程
程序由 指令和数据 组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行一个进程之内可以分为一到多个线程。
【对比】
- 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务;
- 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间;
- 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)。
2、并行与并发
并发(concurrent)是同一时间应对(dealing with)多件事情的能力;
并行(parallel)是同一时间动手做(doing)多件事情的能力。
在多核CPU下
- 并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU;
- 并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程。
3、线程状态
【线程状态】:新建、运行、无限期等待、限期等待、阻塞、结束。
(1)新建(New):创建后尚未启动的线程所处的状态;
(2)运行(Runable):包括了操作系统线程状态中的 Running 和 Ready,即线程可能正在执行,也可能在等待 CPU 给它分配执行的时间。
注:有的地方将这里拆分为了 Runable 和 Running,当执行了 Start() 方法后,线程状态为 Runnable, CPU调度即分配了时间片后状态为 Running;
(3)无限期等待(Waiting):线程不会被分配 CPU 执行时间,需要等待被其他线程显式地唤醒。
让线程进入该状态的方法:
- 没有设置 Timeout 参数的 Object.wait() 方法;
- 没有设置 Timeout 参数的 Thread.join() 方法;
- LockSupport.park() 方法。
(4)限期等待(Timed Waiting):线程不会被分配 CPU 执行时间,但不需要等待被其他线程显示地唤醒。
让线程进入该状态的方法:
- Thread.sleep() 方法;
- 设置了 Timeout 参数的 Object.wait() 方法;
- 设置了 Timeout 参数的 Thread.join() 方法;
- LockSupport.parkNanos() 方法;
- LockSupport.parkUntil() 方法。
(5)阻塞(Blocked):与等待状态的区别:阻塞状态在等待着获取到一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;而等待状态则是在等待一段时间,或者唤醒动作的发生。
(6)结束(Terminated):已经终止线程的线程状态,线程已经结束执行。
Q:线程的状态是如何转换的?
A:
(1) 创建线程对象是 新建状态(2) 调用了 start() 方法转变为 可执行状态(3) 线程获取到了 CPU 的执行权,执行结束是 终止状态(4) 在可执行状态的过程中,如果没有获取 CPU 的执行权,可能会切换其他状态:① 如果没有获取锁( synchronized 或 lock )进入 阻塞状态 ,获得锁再切换为可执行状态;② 如果线程调用了 wait() 方法进入 等待状态 ,其他线程调用 notify() 唤醒后可切换为可执行状态;③ 如果线程调用了 sleep(50) 方法,进入 计时等待状态 ,到时间后可切换为可执行状态;
4、创建线程的方式
四种方式:继承Thread类、实现Runnable接口、实现Callable接口、线程池创建线程。
(1)继承Thread类
需要重写 run() 方法,对应为具体需要执行的业务逻辑。通过实例调用 start() 方法启动与执行。
(2)实现Runnable接口
重写 run() 方法。通过Thread类带有 Runnable 传参的构造方法创建Thread对象,调用 start() 方法启动与执行。
(3)实现Callable接口
重写 Callable接口 下的 call() 方法,返回的对象需要借助 FutureTask 类对象承接,同样需要 Thead 类带有 FutureTask 传参的构造方法创建Thead对象,调用 start() 方法启动。
(4)线程池创建线程
详见:文章:线程安全“线程池”部分。
Q:Runnable 与 Callable的区别?
A:
(1)Runnable 接口run方法没有返回值;
(2)Callable接口call方法有返回值,是个泛型,和 Future、FutureTask 配合可以用来获取异步执行的结果;
(3)Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续向上抛。
Q:线程的 run方法 和 start 方法的区别?
A:
start方法: 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run方法: 封装了要被线程执行的代码,可以被调用多次。不会启动新的线程。
5、notify() 和 notifyAll() 的区别
- notifyAll:唤醒所有 wait 的线程
- notify:随机唤醒一个 wait 线程
6、wait 和 sleep 方法的异同
共同点:wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态。
不同点:
(1)方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有。
(2)醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
(3)* 锁特性不同
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(放弃 cpu,其他线程也用不了)。
7、停止线程的方法
- 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
- 使用stop方法强行终止(不推荐,方法已作废)
- 使用interrupt方法中断线程
- 打断阻塞的线程( sleep,wait,join )的线程,线程会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
8、线程通信
三种方式:<共享内存、消息传递(等待-通知)、管道流>。
(1)共享内存:线程之间共享程序的公共状态,线程之间通过读写内存中的公共状态来隐式通信。例如,使用volatile保证内存的可见性。
(2)消息传递:线程之间没有公共的状态,线程之间必须通过明确的发送消息来显式地进行通信。例如,wait/notify/notifyAll等待通知的方式和join方式。
(3)管道流:管道输入/输出流或者网络输入输出流的不同之处在于,它主要用于线程之间的数据传输,传输的媒介为管道。管道通信就是使用java.io.PipedInputStream 和 java.io.PipedOutputStream 进行通信。
9、守护线程与用户线程
Java中的线程分为两类,分别为“daemon线程(守护线程)”和“user线程(用户线程)”。
在JVM启动时,就会调用 main 函数,main函数所在的线程就是一个用户线程,同时JVM还启动了很多守护线程,比如垃圾回收线程。
区别:当最后一个非守护线程结束时,JVM会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束不影响JVM退出。只要没有结束的用户线程,正常情况下JVM就不会退出。
二、线程并发安全
当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果的问题。
解决方式:1、加锁;2、ThreadLocal、3、CAS(compare and swap)
注:如果多个线程都只是读取共享资源,而不去修改,是不存在线程安全问题的。只有当至少一个线程修改共享资源时,才会存在线程安全问题。
1、常见锁类型
(1)乐观锁与悲观锁
乐观锁:认为数据在一般情况下不会造成冲突。所以,在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据是否冲突进行检测。乐观锁直到提交时才锁定,所以不会产生死锁。
悲观锁:对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改。所以,在处理数据前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。
(2)公平锁与非公平锁
根据线程获取锁的抢占机制进行的划分。
公平锁:表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是说最早请求锁的线程将最早获取到锁。
非公平锁锁:在运行时闯入,先来不一定先得。
ReentrantLock提供的公平与非公平锁的实现:
- 公平锁:ReentrantLock pairLock = new ReentrantLock(true);
- 非公平锁:ReentrantLock pairLock = new ReentrantLock(false)。如果构造函数不传递参数,默认为公平锁。
(3)独占锁与共享锁
根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
独占锁:任何时候只能有一个线程得到锁。独占锁是一种悲观锁。
共享锁:可以同时由多个线程共同持有。共享锁是一种乐观锁,允许多个线程同时进行读操作。
(4)可重入锁
如果一个线程再次获取它自己已经获取的锁时不被阻塞,我们就称该锁为可重入锁。即,只要该线程获取了该锁,那么可以多次(有限次)进入被该锁锁住的代码。
可重入锁在锁的内部维护了一个线程标识,用来标识该锁目前被哪个线程占用,然后关联一个计数器。开始时,计数器的值为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变为1,这时其他线程再来获取该锁时就会发现锁的所有者不是自己而被阻塞挂起。
当获取了该锁的线程再次获取该锁时会发现锁的持有者是自己,就会把计数器 +1 ,当释放锁后计数器值 -1(每次+1或者-1)。当计数器值为0时,锁里面的线程标识就被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
注:synchronized内部锁是可重入锁。
(5)自旋锁
在JAVA中线程与操作系统中的线程一一对应,所以当一个线程获取锁失败后,会被切换到内核状态而被挂起。当线程获取到锁时,又需要将其切换到内核状态而唤醒该线程。从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
自旋锁则是,当线程在获取锁时,如果发现锁已经被其他线程占用,它并不马上阻塞自己,而是在不放弃CPU使用权的情况下,多次进行尝试获取(默认10次),很有可能在后面的尝试中,原来占用锁的线程已经释放了锁。如果,尝试指定的次数后仍未获取到锁,则当前的线程才会被阻塞挂起。
自旋锁使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间被白白浪费掉。
2、线程死锁
死锁:是指两个或者两个以上的线程在执行的过程中,因争夺资源而造成的互相等待的现象,在无外作用的情况下,这些线程会一直等待而无法继续执行下去。
(1)死锁的条件
互斥、请求并持有对象、不可剥夺、环路等待。
【互斥条件】线程对已经获得的资源进行排他性使用,即该资源同时只有一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有该资源的线程释放该资源。
【请求并持有对象】指的是一个线程已经持有了至少一种资源,但是又提出了新的资源请求,而新的资源已经被其他线程占用,所以当前线程会被阻塞,阻塞的同时并不释放自己的资源。
【不可剥夺】指的是线程获取到的资源在自己使用完之前不能被其他资源抢占,只有自己使用完毕后才又自己释放该资源。
【环路等待】在发生死锁时,必然存在一个线程一资源的环形链,即线程集合{T0、T1、T2…、Tn}中的T0 正在等待一个T1占用的资源,T1正在等待一个T2占用的资源,…,Tn正在等待一个T0占用的资源。
(2)避免死锁
想要避免死锁必须破坏掉造成死锁的至少一个条件。
目前可以破坏的条件只有“请求并持有”与“环路等待”。
造成死锁的原因和申请资源的顺序有很大的关系,使用资源申请的有序性原则就可以避免死锁。
3、synchronized
注:修饰代码块。
synchronized块是Java提供的一种原子性内置锁,JAVA中的每个对象都可以把它当作一个同步锁来使用。(这些JAVA内置的使用者看不到的锁被称为内部锁,也叫做监视器锁。)
线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。
(1)排他性
拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步代码块内调用了该内置锁资源的wait系列方法时,释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
(2)内存语义
进入synchronized块的语义:是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时,就不会从线程的工作内存中获取,而是直接从主内存中获取。
退出synchronized块的语义:是把在synchronized块内对共享变量的修改刷新到主内存中。
(3)修饰对象的三种情况
- 普通同步方法:锁的是当前实例对象;
- 静态同步方法:锁的是当前类的Class对象;
- 方法块:锁的是 synchronized 括号里配置的对象。
(4)锁的结构
在HotSpot虚拟机中,对象在虚拟机中可以划分为3部分:对象头、实例数据、对齐填充。
其中,对象头又可以分为:标记字段(Mark Word)、类型指针(Class Pointer)。
对象头
(1)类型指针
Class Pointer 存储了类对象信息的指针,虚拟机通过这个指针确定当前对象时哪个类的实例。
(2)标记字段
Mark down 存储了对象的 HashCode、GC信息、锁信息。
实例数据:存储对象里面变量的数据。
对齐填充:作为对齐使用,在64为服务版本中,对象在内存所占的空间需要能被8字节整除。如果对象头及实例数据部分不能整除,需要该部分来补齐。
(5)实现原理
通过 monitorenter 和 monitorexit 指令实现。
被 synchronized 修饰的方法(代码块),其对应的字节码在同步代码块的起始位置会被插入 monitorenter 指令,在结束位置会被插入 monitorexit 指令。
synchronized 利用 monitor 来实现加锁解锁。monitor 是一种同步机制,内置于每个java对象中,monitor依赖于操作系统的 Mutex Lock 实现。
每个对象都拥有一个monitor锁,当一个monitor被持有后,它将处于锁定状态。一个对象的monitor是唯一的,相当于唯一的许可证。拿到许可证的线程才可以执行,执行完成释放对象的monitor后,才可以被其他的线程获取。每个对象自身维护着一个被加锁的计数器,当计数器不为0时,只有获得锁的线程才能再次获得锁。
Q: Synchronized 关键字的底层原理?A:① Synchronized【 对象锁 】 采用互斥的方式让同一时刻至多只有一个线程能持有 【 对象锁 】② 它的底层由 monitor 实现的, monitor 是 jvm 级别的对象( C++ 实现),线程获得锁需要使用对象(锁)关联 monitor③ 在 monitor 内部有三个属性,分别是 owner 、 entrylist 、 waitset④ 其中 owner 是关联的获得锁的线程,并且只能关联一个线程; entrylist 关联的是处于阻塞状态的线程; waitset 关联的是处于 Waiting 状态的线程。
(6)synchronized锁的升级过程
在“Java SE 1.6”版本中,为了优化锁的获取与释放的性能,引入了“偏向锁”和“轻量级锁”。
锁的状态一共有四种,级别从低到高:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
锁可以升级,不能降级。
1、偏向锁:多数情况下,锁不存在竞争,由一个线程多次获得。在对象头和栈帧中记录存储锁偏向的线程ID,之后该线程进入同步块时,先判断对象头的 Mark Word 里是否存储着指向当前线程的偏向锁,如果存在则直接获取锁。
2、轻量级锁:当其他线程尝试竞争偏向锁时,锁升级为轻量级锁。线程执行同步块之前,JVM会在当前线程的栈帧中创建用于存储锁记录的空间,并将开头的 Mark Word 替换为指向锁记录的指针。
如果成功,则当前线程获得锁;如果失败,通过自旋尝试。
3、重量级锁:如果一个线程执行同步块的时间很长,其他等待锁的线程尝试自旋的次数到达10次,则升级为重量级锁。(等待的次数可以通过 -XX:PreBlockSpin 设置)
synchronized和ReentrantLock 的区别
- 1)修饰对象:synchronized可以修饰普通方法、静态方法、代码块;ReentrantLock只能用在代码块上;
- 2)获取和释放的方式:synchronized会自动加锁和释放锁,ReentrantLock需要手动释放锁;
- 3)锁类型:synchronized属于非公平锁,ReentrantLock可以是公平锁也可以是非公平锁(默认);
- 4)响应中断:ReentrantLock可以使用lockInterruptibly获取锁并响应中断,synchronized不能响应中断;
- 5)底层实现:synchronized底层通过监视器(Monitor)实现,ReentrantLock通过AQS程序级别的API实现。
volatile和synchronized的区别
<并发的三个特性:可见性、有序性、原子性>
- synchronized是独占锁,同时只能有一个线程调用其代码块中的方法,其他线程调用会被阻塞,同时会存在线程上下文切换和线程重新调度的开销。
- volatile是非阻塞算法,不会造成线程上下文的开销。
- volatile虽然提供了(变量)可见性保证,但是并不保证操作的原子性。
4、Volatile
注:修饰变量(类的成员变量、类的静态成员变量)
<保证线程间的可见性 && 禁止指令重排序>
(1)内存语义
volatile 的内存语义和 synchronized 有相似之处:
线程写入volatile变量值时:等价于线程退出synchronized同步块,就是把写入工作内存的变量同步到主内存。
线程读取volatile变量值时:相当于进入同步synchronized同步块,就是先清空本地内存变量值,再从主内存中获取最新值。
(2)保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。
(3)禁止指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
5、AQS抽象同步队列
AbstractQueuedSynchronizer抽象同步队列,简称AQS。是构建锁或其他同步组件的基础框架。AQS是一个FIFO(先进先出)的双向队列,队列元素类型为 Node。AQS通过head和tail记录队首与队尾元素。(它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列))。
(1)常见的实现类
- ReentrantLock 阻塞式锁
- Semaphore 信号量
- CountDownLatch 倒计时锁
(2)实现原理
1)队列中的Node节点(内部类:static final class Node)
static final Node SHARED = new Node(); // 标记该线程是获取共享资源时被阻塞挂起
static final Node EXCLUSIVE = null; // 标记该线程是获取独占资源时被阻塞挂起
volatile int waitStatus; // 记录当前线程的等待状态
static final int CANCELLED = 1; // 被取消;
static final int SIGNAL = -1; // 需要被唤醒;
static final int CONDITION = -2; // 在条件队列里等待;
static final int PROPAGATE = -3; // 释放共享资源时需要通知其他节点。
volatile Node prev; // 前驱节点
volatile Node next; // 后继节点
volatile Thread thread; // 存放进入AQS队列里面的线程;
Node nextWaiter;
2)AQS状态信息state
单一状态。
对于 AQS 来说,线程同步的关键就是对状态 state 进行操作。
根据 state 是否属于同一个线程,操作 state 的方式分为“独占方式”和“共享方式”。
- 独占方式:获取的资源与线程是绑定的,只能有一个线程获取,获取后会标记获取到资源的线程。例如,ReentrantLock。
- 共享方式:资源与具体的线程不相关。当多个线程去请求资源时,通过CAS方式竞争获取资源,当一个线程获取到了资源后,另一个线程再次去获取时,如果当前资源还能满足它的需要,则当前线程只需要使用CAS方式进行获取即可。例如, Semaphore信号量。
Q:什么是AQS?
A:
(1)是多线程中的队列同步器。是一种锁机制,它是做为一个基础框架使用的,像ReentrantLock、Semaphore都是基于AQS实现的
(2)AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程
(3)在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是0(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源
(4)在对state修改的时候使用的cas操作,保证多个线程修改的情况下原子性
与Synchronized的区别:
Synchronized | AQS |
---|---|
关键字,c++ 语言实现 | java 语言实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
6、ReentrantLock
7、ThreadLocal
ThreadLocal<T> localVariable = new ThreadLocal<>();
(1)初始化与使用
* 创建一个ThreadLocal变量,访问这个变量的每个线程都会有这个变量的一个本地副本。
* 每个线程Thread内部有两个ThreadLocalMap类型的变量,其中ThreadLocal对应的本地变量保存在threadLocals中(threadLocals是一个定制化的HashMap)。
(2)方法
① void set(T value)
② T get()
③ void remove()
(3)不支持继承
同一个ThreadLocal变量在父线程中被设置值后,在子线程中是获取不到的;
如果需要继承,可使用子类InheritableThreadLocal。
(4)内存泄漏
一个线程在调用ThreadLocal的set方法设置变量时,当前线程的ThreadLocalMap里会存放一个记录。如果一个线程一直存在,且没有调用ThreadLocal的remove方法,并且还有其他地方有对ThreadLocal的引用,则当前线程的threadLocals变量就会存在对ThreadLocal变量和value对象的引用,不会释放,就会造成内存泄漏。
ThreadLocalMap里面的key是ThreadLocal对象的弱引用,会在gc的时候被回收,但是value不会,这种情况就会出现ThreadLocalMap里存在key为null但是value不为null的entry项。
8、线程池
(1)核心参数
<核心线程数、最大线程数、空闲线程存活时间、时间单位、任务队列、拒绝策略、线程工厂>。
- 获取CPU核心数的方法:Runtime.getRuntime().availableProcessor() 方法来获取(可能不准确,作为参考)
- 核心线程数设定,IO密集型 -- 2N (或2N+1),CPU密集型 -- N+1;其中N为CPU核心数;
(2)submit 和 execute 方法
submit 和 execute 的区别:
- 接收的参数不同;
- execute只能提交一个Runnable对象;
- submit有三种提交方式:Callable接口对象、Runnable对象、带result传参及Runnable对象;
- 返回不同:submit有返回值,execute无返回值;
- submit方便进行异常处理(通过返回Future对象的get方法获取结果/异常)。
(3)执行流程
(4)Excutors
ExecutorsnewCachedThreadPool,可缓存线程池。
newFixedThreadPool,定长,
newScheduledThreadPool,定长,定时任务。
newSingleThreadExecutor,单线程。
(5)并发队列
名称 | 是否有界 | 是否阻塞 | 底层结构 | CAS/锁 | 常用方法 |
ConcurrentLinkedQueue | 无界 | 非阻塞 | 单向链表 | CAS |
|
LinkedBlockingQueue | 有界 默认为:Integer.MAX_VALUE | 阻塞 | 单向链表 | ReentrantLock:
|
|
ArrayBlockingQueue | 有界 必须指定大小 | 阻塞 | 数组 | ReentrantLock |
|
PriorityBlockingQueue | 无界 初始默认大小为11,64前每次加2扩容,后续1.5倍; 最大:Integer.MAX_VALUE-8 | 阻塞 | 数组:queue; 平衡二叉树 | ReentrantLock |
|
DelayQueue | 无界 | 阻塞 | PriorityQueue | ReentrantLock |
|
参考:
《深入理解java虚拟机》P383
《Java并发编程之美》P122
原文地址:https://blog.csdn.net/qq_27378621/article/details/140425311
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!