并发编程
实现线程的方式
从代码层面上看,有继承Thread类、实现Runnable接口方式,还有Callable接口、线程池、定时器方式,虽然在代码层面的写法有很多,但是本质上都是对实现Runnable接口方式,继承Thread类方式的封装
从本质上看,Thread类源码中的实现是,如果是传入实现Runnable接口,其实是在Thread类的run方法中执行Runnable接口的run方法;如果是继承Thread类方式,其实是将run方法进行重写;所以本质上就是一种方式,就是新建线程通过Thread类,只不过一般我们分为两种
一般情况下会优先选择Runnable接口,因为接口可以避免单继承的局限性,同时也能将多线程与执行任务进行解耦,其次创建线程的操作本身性能消耗就很大,继承Thread类实现的线程,只能新建的独立的线程去执行,而实现Runable接口实现的线程,可通过线程池之类的工具来降低创建线程的性能损耗,因为线程池只能放入Runable或Callable接口实现类
在使用上也要注意,调用start方法,是请求JVM在空闲时启动新线程,所以调用顺序不是线程的执行顺序;线程启动后处于就绪状态,如果再次启动会抛出非法线程状态异常
线程停止
使用interrupt方法来请求中断,而不是使用stop方法或volatile标记的方式,因为stop方法会导致突然停止,可能会造成数据不安全,volatile标记会导致线程在阻塞情况下无法停止
真正想停止线程,需要停止方、被停止方相互配合,就是在接收到中断信号的时候进行处理,并且在处理过程中也要注意
- Java的一些阻塞方式无法响应中断,比如[synchronized锁、lock方法](# 锁的分类),可以使用lockInterruptibly方法加锁后响应中断
- 还有一些方法,在响应中断后会将中断标记位清除,比如[sleep方法](# wait/notify、sleep的区别),可以在catch块中直接return返回来解决
线程的生命周期
操作系统进程的生命周期分为新建、就绪、运行、阻塞、终止,而Java中线程的声明周期不是这样的,从创建Thread类开始线程处于新建状态,使用start方法启动线程后处于可运行状态,可运行状态包括操作系统进程中的就绪和运行两种状态,若线程因为某种原因处于阻塞状态,Java将操作系统的阻塞状态细分为被阻塞状态、等待状态、计时等待状态,没获得锁就处于被阻塞状态、调用不计时等待方法wait、join会进入等待状态、调用计时等待方法会进入计时等待状态,线程执行结束或调用stop方法都会进入终止状态
wait/notify、sleep的区别
- wait/notify、sleep都会让线程进入阻塞状态,也可以响应中断,但是wait/notify会释放锁,而sleep不会释放锁
- 线程等待和唤醒的方法,必须在有synchronized保护的代码块或方法中执行,并且必须使用保护代码块的锁对象进行方法调用,也就是必须先拥有monitor锁,否则会抛出非法monitor锁状态异常;之所以这样设计是因为,如果不是在同步代码块中的调用,由于线程的随机性可能会产生死锁,比如说两个线程,一个是要唤醒,另一个是要等待,只有先等待才能被唤醒,反之就会陷入永久等待,所以这个就需要安全的进行控制
- 线程等待和唤醒方法是定义在Object对象上的final native方法,调用等待或唤醒方法的必须是同一个对象,否则不是同一把锁导致无法唤醒,之所以这样设计是因为,这些方法是锁级别的操作,而锁是属于某个对象的,绑定在对象头中,而不是线程中,如果将锁定义在线程中,每个线程就只能有一把锁;并且也不要使用线程对象的等待方法,这是因为在每个线程执行结束后,都会自动调用notifyAll()方法
多线程缺点
当可运行[线程数超过CPU数量时就会发生线程调度](# 线程池),导致频繁挂起和恢复上下文切换的性能损耗,也导致CPU缓存失效需要重新缓存,如果频繁抢锁、IO导致频繁阻塞,就会发生密集型上下文切换
由于访问共享资源,同时写或修改未能同步,依赖操作的顺序所导致执行结果不一样,就是线程安全问题,最常见的线程安全问题,多个线程同时for (int i = 0; i < 10000; i++) a++;
,a的结果不是多个线程执行a++的次数
如果对象发布到了不该发布的地方,就会导致对象逸出,也会造成线程安全问题,比较常见的溢出现象有
- 方法返回私有对象,返回该私有对象的副本可解决此类线程安全问题
- 对象未初始化完就提供外界使用,使用[工厂模式](# 工厂模式)可解决此类线程安全问题
- 构造方法没执行完就将this赋值或新建线程
- 观察者模式中,事件的注册慢于事件的触发
除此之外还会造成一些活跃性问题,死锁、活锁、饥饿
线程池
因为线程的创建、销毁性能开销大,同时线程也会占用内存,所以需要通过线程池来复用线程,从而提高程序的效率、减少内存使用量
线程池的主要参数有核心线程数、最大线程数,线程池中线程数可以超过核心线程数,最多不能超过最大线程数;还有保持存活时间,超过核心线程数的线程在指定保活时间内没有执行任务就会被回收;还有任务队列,当核心线程都在执行任务,多余的任务会放进任务队列,任务队列满了后才会创建多余线程;还有拒绝处理器,当关闭新任务提交,或任务队列满,同时也达到最大线程数,就会拒绝,拒绝策略有抛出异常、默默丢弃、丢弃队列中老任务放入新任务、让提交任务线程自己执行提交任务;还有线程工厂,用来创建线程池中的线程,默认线程工厂创建出来的线程,就在一个线程组中,都是非守护线程,优先级相同
线程池的线程有自己的添加策略,线程数小于核心线程数,就算其他工作线程处于空闲状态,也会创建新线程来执行任务,如果线程数处于核心线程数和最大线程数之间,就会将新任务放入队列,任务队列已经满了,线程数也没超过最大线程数,就会创建一个额外的新线程,如果队列已满,也达到最大线程数,那么就拒绝任务
线程池的原理就是将线程和任务进行解耦,启动几个线程,无限循环的从阻塞队列获取任务,并调用该任务的run方法进行执行,使用阻塞队列的好处是,如果任务队列没有任务,线程就会进入阻塞状态
ThreadLocal
ThreadLocal主要用于线程单例、存储参数副本,无需加锁达到线程安全的目的,简单说ThreadLocal就是一种以空间换时间的做法
实现原理是,在每个Thread类中都有一个ThreadLocalMap的成员变量,以键值形式存储着该线程ThreadLocal
对象,而且解决哈希冲突的方式是线性探测法,key是[弱引用](# 四种引用类型),所以key不会发生内存泄漏,value是直接赋值的,所以是强引用,线程终止线程对象会被回收,ThreadLocalMap也会被回收,如果线程无法终止,比如线程池,会导致弱引用key回收,强引用value无法回收,就可能出现内存泄漏,所以使用完ThreadLocal后就应该调用remove()方法,从而防止内存泄漏
volatile、synchronized、ReentrantLock的区别
volatile
volatile只能作用于属性字段上,volatile是一种轻量级的同步机制,因为并不会有加锁解锁操作相对应的也无法做到,像synchronized那样原子性,volatile只是禁止指令重排和保证可见性
- 读取一个volatile变量之前,会先将缓存失效,这样就一定能读到最新的值
- 修改一个volatile变量也会立即刷新到主内存中
volatile相对于synchronized更轻量,如果一个共享变量始终只被各个线程赋值,而没有其他操作,那么就可以使用volatile来代替synchronized、以及原子变量,因为赋值本身就具有原子性,而volatile又保证了[可见性](# Java内存模型),所以就保证了线程安全
ReentrantLock
ReentrantLock和synchronized都是可重入锁,synchronized是依赖于JVM实现的,异常时自动释放锁,ReentrantLock是JDK API层面实现的,异常时不会自动释放锁,ReentrantLock还有一些高级功能,比如等待可中断lockInterruptibly、尝试获取锁tryLock、也可指定是否公平锁,默认是非公平的,而synchronized只能是非公平锁
锁的分类
锁的分类是从不同角度去看
- 等锁期间可以被中断就是可中断锁,不可以就是不可中断锁
- 线程锁住同步资源就是悲观锁,不锁住就是乐观锁:乐观锁在更新数据时,会对比当前数据是否被更改过,如果没有才更新,否则更新失败
- 同一个线程可以重复获取同一把锁就是可重入锁 ,不可以就是不可重入锁:可重入锁获取锁时会将该锁的获取次数加一,释放时减一,只有减到零才是真正的释放
- 等锁过程中线程未进入阻塞状态,而是不停的尝试获取锁,就是自旋锁,进入阻塞状态就是非自旋锁:如果有多个CPU,足够让线程并行执行,并且加锁时间短,就可以让请求锁的线程不进入阻塞状态,而是一直自旋,这个线程就可以不阻塞的获取锁,从而避免切换线程的开销,但是如果锁占用时间很长,那么自旋线程只会白白浪费CPU资源
- 多线程竞争时不允许插队就是公平锁,允许插队非公平锁:Java中的锁默认就是非公平锁,同一个线程在第二次获取锁时会直接获取,因为这样可以避免唤醒线程带来的空档期,从而提高效率
- 多线程可以共享同一把锁就是共享锁,不可以就是排他锁:要么多读,要么一写,Java中的ReentrantReadWriteLock有自己特殊的插队策略,因为如果允许只读锁插队,虽然可提高程序执行效率,但是可能会造成写锁线程饥饿
- 写锁随时可插队,因为读锁可以共存,写锁不能共存,就不容易插队,就不容易造成饥饿现象
- 读锁只能在等待队列头节点,是想要获取读锁的线程才能插队,因为可以减少空档期的时间浪费
- 写锁可以降级为读锁,读锁不可以升级为写锁,这样可以避免升级造成的死锁,因为必须等所有的读锁都释放,才能升级成写锁,若多个线程都想升级成写锁,都不释放自己的读锁,就会造成死锁
CAS
CAS比较并交换,是实现线程安全的算法,同时也是一个CPU指令,用来做哪些,不能被打断的数据交换操作,是乐观锁、原子类、并发集合的底层原理
基本思路是记录要修改的原先值,在当前线程要修改时,再对比之前的记录,如果已经修改过了,就放弃本次修改,返回原先值,避免多个线程进行修改时导致出错
虽然CAS没有加锁的操作,效率很高,但是CAS存在
- ABA问题:如果被修改过的值,最后和原先值相等,就有可能出现错误
- 自旋时间过长问题:如果竞争非常激烈,导致锁一直获取不到,就会导致长时间自旋,白白消耗CPU
由于CAS需要利用到CPU的特殊指令,所以Java使用的Unsafe工具包来直接操作内存,来实现硬件级别的原子操作
JVM锁优化
- JVM会优先进行锁粗化、锁消除,如果存在[逃逸](# 多线程缺点),就不能轻易将锁消除,因为无法知道外部是如何使用这些资源的
- 再尝试偏向锁,锁会偏向于当前已经占有锁的线程
- 再尝试轻量级锁,类似乐观锁的方式
- 再尝试自旋锁
- 最后尝试普通锁,使用OS互斥量在OS层挂起
同步工具
- CountDownLatch倒数门闩,一部分任务countDown,一部分任务await,倒数结束同后await任务被同时唤醒,同一个任务中可倒数多次,CountDownLatch不能重置 ,倒数结束后就废弃掉了
- CyclicBarrier循环栅栏,让线程进入阻塞状态,当阻塞线程达到一定数量后,同时执行
- Semaphore信号量,只有拿到信号量的线程才能执行,没拿到的会被阻塞,所以为了防止程序无法终止,用多少信号量,就应该归还多少
AQS
AQS(AbstarctQueuedSynchronizer)抽象的队列同步器是一个用于构建锁、同步工具的框架,AQS解决了在实现同步容器时大量的细节问题
在AQS中维护了一个state,使用volatile修饰的变量,为了保证线程安全需要使用AQS中提供的方法来操作,这些方法都依赖与原子类
- 在Semaphore中就表示剩余信号量的数量
- 在CountDownLatch中就表示倒数到几了
- 在ReentrantLock中就表示锁的重入次数
还维护了一个控制线程进行抢锁和配合的、先进先出双向队列,队列是一个双向链表形式,头节点就是已经拿到锁的线程,多个线程竞争同一把锁,失败的线程会被AQS放回该队列并阻塞;锁被释放后AQS从队列中唤醒线程来抢锁
Comments NOTHING