11.并发(Thinking in java学习11)
多线程编程
从根本上来看,所谓的多线程编程,不过是JVM或者说当前的计算机体系结构无法处理好多线程下资源竞争的情况而人为加上的一些处理方法。
多线程编程是为了更好的使用CPU的性能,人为设计出来的补偿机制。
并发三个特性:
- 原子性:原子性表示一步操作执行过程中不允许其他操作的出现,直到该操作的完成。
- 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改的值。
- 有序性:即程序执行的顺序按照代码的先后顺序执行。
进程和线程
- 进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。
- 线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位,一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。
线程状态
image.png线程实现方式
- Thread,实现类,start()方法将线程变为可运行状态,在运行态的时候调用定义的run()方法。不过一般不会有人用定义子类的方式定义一个线程
- Runnable, 接口,通常实现这个接口,然后作为构造参数新建一个线程实例
- Callable, Java 1.5, java.util.concurrent, 与runnable类似,call()方法可以返回线程运行的状态,并且可以抛出异常
- FutureTask,包装器,处于thread和callable的中间,它通过接受Callable来创建,同时实现了Future和Runnable接口,可以检查线程的状态。
Java语法中的多线程机制
- synchronized 关键字,对非null的object加上synchronize关键字,标记一个代码块,可以自动对某个对象加解锁。
- 可重入性,是指在某个线程得到某个对象的锁之后,不需要额外申请该对象的锁也可以进入关键代码块。
在JVM中,每个object都有一个header,保存object的一些信息,普通对象头的长度为两个字,数组对象头的长度为三个字(JVM内存字长等于虚拟机位数,32位虚拟机即32位一字,64位亦然),其中有两个bit位记录了对象的锁类型。
- 偏向锁
锁对象第一次被线程获取的时候,虚拟机把对象头的status设置为"01",偏向锁状态,当发生锁重入时,只需要检查MarkValue中的ThreadID是否与当前线程ID相同即可,相同即可直接重入。偏向锁的释放不需要做任何事情,这也就意味着加过偏向锁的MarkValue会一直保留偏向锁的状态,因此即便同一个线程持续不断地加锁解锁,也是没有开销的。
一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
偏向锁的释放:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。
- 轻量级锁
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
重量级锁
image.pngvolatile 关键字
volatile仅仅用来保证该变量对所有线程的可见性,但不保证原子性。
与锁相比,Volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件 —— 即变量真正独立于其他变量和自己以前的值 —— 在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。
为了优化性能,编译器和CPU可能对某些指令进行重排。java代码最终会被编译成汇编指令,而一条java语句可能对应多条汇编指令。为了优化性能,CPU和编译器会对这些指令重排,volatile的变量在进行操作只会在尾部添加一个内存屏障(Memory Barrier),lock addl $0x0,(%rsp)。
a) 确保一些特定操作执行的顺序;
b) 影响一些数据的可见性(可能是某些指令执行后的结果)。
编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。
例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。所以一旦你完成写入,任何访问这个变量的线程将会得到最新的值。而且在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
ReentrentLock
ReentrentLock和synchronized关键字类似,也是实现一种可重入的锁。ReentrentLock实现了Lock接口,内部定义了两个Sync类,FairSync和NonFairSync,分别实现公平锁和非公平锁。 该类又继承自AbstractQueuedSynchronizer类。
AbstractQueuedSynchronizer实际上在内部维护了一个列表形式的等待队列,每个node都记录了一个线程和等待的状态。
线程池Executor框架
Executor是一套线程池管理框架,接口里只有一个方法execute,执行Runnable任务。ExecutorService接口扩展了Executor,添加了线程生命周期的管理,提供任务终止、返回任务结果等方法。AbstractExecutorService实现了ExecutorService,提供例如submit方法的默认实现逻辑。
ThreadPoolExecutor最普通的构造函数:
-
corePoolSize是线程池的目标大小,也叫核心线程数。maximumPoolSize是线程池的最大上限,maximumPoolSize减去corePoolSize即是非核心线程数,或者叫空闲线程。keepAliveTime指明空闲线程的存活时间,超出存活时间的空闲线程就会被回收。
-
newFixedThreadPool的corePoolSize和maximumPoolSize都设置为传入的固定数量,keepAliveTim设置为0。线程池创建后,线程数量将会固定不变,适合需要线程很稳定的场合。
-
newSingleThreadExecutor:newSingleThreadExecutor是线程数量固定为1的newFixedThreadPool版本,保证池内的任务串行。
-
newCachedThreadPool:newCachedThreadPool生成一个会缓存的线程池,线程数量可以从0到Integer.MAX_VALUE,超时时间为1分钟。线程池用起来的效果是:如果有空闲线程,会复用线程;如果没有空闲线程,会新建线程;如果线程空闲超过1分钟,将会被回收。
-
newScheduledThreadPool:将会创建一个可定时执行任务的线程池。
等待队列
newCachedThreadPool的线程上限几乎等同于无限,但系统资源是有限的,任务的处理速度总有可能比不上任务的提交速度。因此,可以为ThreadPoolExecutor提供一个阻塞队列来保存因线程不足而等待的Runnable任务,这就是BlockingQueue。
JDK为BlockingQueue提供了几种实现方式,常用的有:
- ArrayBlockingQueue:数组结构的阻塞队列
- LinkedBlockingQueue:链表结构的阻塞队列
- PriorityBlockingQueue:有优先级的阻塞队列
- SynchronousQueue:不会存储元素的阻塞队列
newFixedThreadPool和newSingleThreadExecutor在默认情况下使用一个无界的LinkedBlockingQueue。
newCachedThreadPool使用的SynchronousQueue十分有趣,看名称是个队列,但它却不能存储元素。要将一个任务放进队列,必须有另一个线程去接收这个任务,一个进就有一个出,队列不会存储任何东西。因此,SynchronousQueue是一种移交机制,不能算是队列。
饱和策略
当有界的等待队列满了之后,就需要用到饱和策略去处理,ThreadPoolExecutor的饱和策略通过传入RejectedExecutionHandler来实现。如果没有为构造函数传入,将会使用默认的defaultHandler。AbortPolicy是默认的实现,直接抛出一个RejectedExecutionException异常,让调用者自己处理。
DiscardPolicy的rejectedExecution直接是空方法,什么也不干。如果队列满了,后续的任务都抛弃掉。
DiscardOldestPolicy会将等待队列里最旧的任务踢走,让新任务得以执行。
CallerRunsPolicy,它既不抛弃新任务,也不抛弃旧任务,而是直接在当前线程运行这个任务。
ThreadFactory
每当线程池需要创建一个新线程,都是通过线程工厂获取。如果不为ThreadPoolExecutor设定一个线程工厂,就会使用默认的defaultThreadFactory。这个默认的线程工厂,创建的线程是普通的非守护线程,如果需要定制,实现ThreadFactory后传给ThreadPoolExecutor即可。
死锁条件
死锁发生的条件,以下四个同时满足时就会发生死锁
- 互斥条件:任务使用的资源中至少有一个是不能共享的。
- 持有等待:至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源。
- 资源不能被任务抢占,任务必须把资源释放当作普通事件,不能抢占其他被占用的资源。
- 必须有循环等待,一个任务在等待其他任务所持有的资源,后者又在等待另一个资源,一直下去,直到有任务等待第一个任务的资源,形成闭环。
要防止死锁,只需破坏上面其中一个条件即可。在程序中最容易的是破坏第4个条件,不要形成循环等待。
乐观锁和悲观锁
乐观锁和悲观锁都是一种并发控制的方法。
- 乐观锁
它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的 那部分数据。
是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突(一般实现方式是通过加版本号然后进行版本号的对比方式实现);
特点:乐观锁是一种并发类型的锁,其本身不对数据进行加锁通而是通过业务实现锁的功能,不对数据进行加锁就意味着允许多个请求同时访问数据,同时也省掉了对数据加锁和解锁的过程,这种方式大大的提高了数据操作的性能;
- 悲观锁
悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的;
特点:可以完全保证数据的独占性和正确性,因为每次请求都会先对数据进行加锁, 然后进行数据操作,最后再解锁,而加锁释放锁的过程会造成消耗,所以性能不高;