第一章 走进并行世界
1、临界区
表示共享资源或者共享数据
2、同步与异步
如果系统中存在临界资源(资源数量少于竞争资源的线程数量的资源),例如正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就必须进行同步存取(数据库操作中的排他锁就是最好的例子)。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。事实上,所谓的同步就是指阻塞式操作,而异步就是非阻塞式操作。
同步和异步常用来形容一次方法调用。
同步:等方法执行完成返回后再继续后续操作。
异步:一旦方法启动后就可以直接继续后续操作,不用等待方法完成
3、死锁、饥饿、活锁
死锁:多个线程互现持有对方手中的资源,多个线程一起进去阻塞状态
饥饿:高优先级线程插队,低优先级线程无法获得资源,但未来有可能得到解决
活锁:让路问题,线程谦让,主动释放所持有地资源,导致资源不断地在两个线程之间跳动而又无法进行下去
【死锁产生的4个条件?】
①互斥:各资源同一时刻只能被一个线程持有
②请求与保持:线程请求某个资源时会保持已持有的资源,不释放
③不剥夺:线程所持有的资源不会被剥夺,只能自己使用完后释放
④循环等待:各线程之间形成一种头尾相接、循环等待的关系
【如何解决死锁?】
①破坏互斥:破坏不了(临界区需要互斥访问)
②破坏请求与保持:一次性申请所有资源
③不剥夺:线程申请不到想要的资源时可以主动释放资源
④破坏循环等待:给资源排序,线程申请多个资源只能按照顺序申请
【面试题】Java中死锁的解决方案:
· 死锁出现的原因是因为各个线程均不肯释放手中的资源而直接进入阻塞状态,因此可以使用可重入锁ReentrantLock的中断响应和限时等待来解决
①中断响应:lock.lockInterruptibly(); 等待锁时可以被中断,中断响应逻辑中可以释放已持有的锁资源(破坏第3个条件)
②锁申请等待限时:lock.tryLock(时间),规定时间内等不到返回false,可以采取释放锁的操作
· 释放所持有的锁并不意味着操作失败,可以在外层设置循环,再重新尝试获取锁资源,直到成功(破坏第3个条件)
4、并发级别
阻塞、无饥饿、无障碍、无锁、无等待
· 无饥饿:公平锁
·无障碍:自由进出,遇到冲突回滚操作(不循环)。例如一致性标志法检测冲突
·无锁:自由进出,遇到冲突循环重试,最终总有能走出临界区的,例如CAS锁
·无等待:访问只有,修改加锁并检测冲突
5、加速比
· 加速比 = time优化前 / time优化后
· 不仅取决于CPU的核数,还取决于串行比例
6、JMM:原子性、可见性、有序性
· 原子性:
基本、引用数据类型的赋值和引用是原子性操作。
但在32位的机子中,long和double的引用和赋值则是可分割的。
· 有序性:(happen-before原则)
指令重排问题,为了减少中断流水线的次数,在汇编指令层面上。
指令重排的仅保证串行语义的一致,但没有义务保证多线程之间的语义也一直,因此多线程时不能保证有序性。
哪些指令不能重排:happen-before规则
第二章 Java并行程序基础
2.1 进程
· 进程是系统资源分配和调度的基本单位
· 进程是程序的实体
· 线程是轻量级进程,是程序执行的最小单位
· 使用多线程而不是多进程的原因?
线程间切换和调度的成本远小于进程
· 线程状态6种:new、runable、waiting、timed waiting、blocked、terminated
2.2 线程基本操作
· 不要用run方法来开启线程,他只会在当前线程中串行地执行run方法中的代码
· 创建线程三种方式
2.2.2 终止线程
· Thread.stop():太过暴力,会直接强制终止线程,并释放其持有的所有资源,包括锁,引发一致性问题
可以通过增加stopme标志和对应标志函数来实现安全的终止
2.2.3 线程中断(interrupt)
· 需要自己在run方法中增加中断处理逻辑
2.2.4 等待和通知(wait和notify)
· Object类中的方法
· 二者在使用前均需要获得目标对象的一个监视器,因此他必须被包含在synchronized语句中
· notify执行后仅唤醒在目标对象的等待队列中的线程,使其进入runable状态,但自己并不会立马释放该对象的监视器
· wait()和sleep()的区别:sleep()不会释放任何资源
2.2.5 挂起和继续执行(suspend和resume)
· suspend不会释放任何锁资源,直到调用resume
· 因此不推荐,已被弃用。若resume在suspend前执行(在不同线程中调用时会出现这种情况),则线程会一直处于挂起状态
· 可以利用wait()和notify()方法,在应用层面实现suspend()和resume()的功能(会释放锁资源)
2.2.6 等待线程结束(join)和谦让(yield)
· 线程1.join() :需先调用start方法,再用join(), 暂停当前线程,无限期等调用线程结束,也有含参方法,可设置等待时间。
其本质是让调用线程wait在线程对象实例上。
因此在应用程序中,尽量不要使用线程对象作为锁对象,避免出现错误
· yield() :让出当前cpu,进入runable状态,等待cpu重新调度,当然也可能还是他自己抢到cpu执行权
2.3 volatile与Java内存模型(JMM)
· 在虚拟机的Server模型下,由于系统优化的结果,临界区数据的修改并不一定发生能被其他线程发现
· volatile关键字:高速虚拟机,该变量是不稳定的,会在不同的线程中被修改,每次使用记得实时地去临界区看看最新的数据
· volatile保证修改可见性
2.4 分门别类管理:线程组
ThreadGroup:方便统一管理和进行一些统计
2.5 驻守后台:守护线程(Daemon)
· 是系统的守护者,负责为用户(工作)线程提供服务,例如垃圾回收线程、JIT线程就可以理解为守护线程。但一个java应用内只有守护线程时,虚拟机就会自然退出。
2.6 线程优先级
· 1-10,数值越大优先级越高
2.7 线程安全的概念与synchronized
· 作用是实现线程间的同步
· 同步代码块、同步方法、静态同步方法的锁对象
· synchronized可以保证多线程的有序性和可靠性
· synchronized既保证了可见性又保证了访问共享变量的原子性
· synchronized原理:
获得锁
清空线程变量副本
拷贝共享变量到线程变量副本
执行代码
将修改后的变量副本拷贝到共享数据区
释放锁
2.8 隐蔽的错误
· 并发下的ArrayList
数组越界问题:保存容器大小的变量被多线程不正常的访问
数据丢失问题:多个线程对容器同一个位置进行赋值
· 并发下的HashMap
jdk 1.7:多个线程操作容器,执行扩容操作的数据迁移函数transfer函数时,有可能造成数据丢失和链表循环的情况
jdk 1.8:解决了之前的问题。将头插改为尾插,并将数据迁移操作合并到了resize函数中,然而在多个线程进行put操作时存在数据覆盖的问题
· 加锁的注意事项
不要把不可变类型对象当作锁对象,例如String、Integer,因为当进行拼接或计算时,这类引用会重新指向新的对象,并发操作时就可能获取到不同的对象
面试题:
合适的线程数量是多少?CPU核心数和线程数的关系?
https://blog.csdn.net/qq_29860591/article/details/113618636
①CPU密集型任务:
首先,我们来看 CPU 密集型任务,比如加密、解密、压缩、计算等一系列需要大量耗费 CPU 资源的任务。对于这样的任务最佳的线程数为 CPU 核心数的 1~2 倍,如果设置过多的线程数,实际上并不会起到很好的效果。此时假设我们设置的线程数量是 CPU 核心数的 2 倍以上,因为计算任务非常重,会占用大量的 CPU 资源,所以这时 CPU 的每个核心工作基本都是满负荷的,而我们又设置了过多的线程,每个线程都想去利用 CPU 资源来执行自己的任务,这就会造成不必要的上下文切换,此时线程数的增多并没有让性能提升,反而由于线程数量过多会导致性能下降。针对这种情况,我们最好还要同时考虑在同一台机器上还有哪些其他会占用过多 CPU 资源的程序在运行,然后对资源使用做整体的平衡。
②耗时IO型任务:
第二种任务是耗时 IO 型,比如数据库、文件的读写,网络通信等任务,这种任务的特点是并不会特别消耗 CPU 资源,但是 IO 操作很耗时,总体会占用比较多的时间。对于这种任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在任务队列中等待的任务就会减少,可以更好地利用资源。
第3章 JDK并发包
3.1 多线程的团队协作:同步控制
3.1.1 synchronized 的功能拓展:重入锁ReentrantLock
· lock()、unlock()
· lockInterruptibly(): 获得锁,但优先响应中断
· tryLock(): 尝试获得,不等待,有返回值true、false
· tryLock(long time,TimeUnit unit): 尝试获取锁,一段时间后未成功返回false
· 特点:灵活性好,性能好
· 重入:同一个线程可以连续获取同一把锁而不会造成自己和自己死锁的情况,但解锁时也需要释放相同次数
· 中断响应:在等待锁的过程中可以被中断,可以根据需要取消对锁的请求。catch住中断异常来实现
· 锁申请等待限时间
· 公平锁:请求锁直接加入队列,按照时间先后顺序,new ReentrantLock(true),维持公平竞争是以牺牲系统性能为代价的
· 非公平锁:请求锁先抢,抢不到再进队列中排队
· 特点:
1. 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
2. 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
3. 重入锁的内部实现是基于CAS操作的。
4. 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信。
3.1.2 重入锁的好搭档:Condition条件
· ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
· Synchronized与wait、notify、notifyAll搭配使用 (用锁对象调用)
· ReentrantLock与await、signal、signalAll搭配使用 (用Condition对象调用,不需要获得锁也能调用)
3.1.3 允许多个线程同时访问:Semaphore信号量机制
· 指定了同时有多少个线程可以访问某一资源
· 申请:acquire()、tryAcquire(时间,单位)
· 释放:release()
3.1.4 ReadWriteLock读写锁
· https://zhuanlan.zhihu.com/p/91408261
· 机制:读-读不阻塞,读-写(同一个线程不算)和写-写(同一个线程可重入)会阻塞 P85
· ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock()
Lock readLock = readWriteLock.readLock();
Lock writeLock = readWriteLock.writeLock();
· 使用int类型的state变量来表示锁的状态,高16位表示读锁的数量,低16位表示写锁重入的次数。将0x0000FFFF与state进行与运算可以得到写锁的数量。state不等于0表示有锁被获取了。state的位数决定了读锁的最大数目和写锁的最大可重入次数。锁的状态通过CAS算法来修改
· 读写锁不支持锁升级(同一个线程,先获取读锁,再获取写锁),当支持锁降级(同一个线程先获取写锁,在获取读锁)
· 获取写锁:判断state是否为0,为0尝试获取锁写。
若state不为0,①读锁为0,判断是否当前线程持有写锁,若是,则可以重入,若不是则失败②写锁为0,则为读锁,失败。不断尝试获取锁。
· 获取读锁:判断写锁是否为0,为0则判断是否需要排队,不为0则看写锁是否为当前线程持有。若是,则可以锁降级,接着获取写锁。
3.1.5 倒计时器:CountDownLatch
· 该工具通常用来控制线程等待,它可以让某一个线程在某处等待直到倒计时结束(其他指定的线程执行结束)。
3.1.6 循环栅栏:CyclicBarrier
· 也是实现线程间的计数等待,计数器可以反复使用。。。
3.1.7 线程阻塞工具类:LockSupport
· 是一个非常方便的线程阻塞工具,可以在线程类任意位置让线程阻塞。
· 比Thread.suspend()、Thread.resume()好用,要更为安全。
· park()阻塞进入waiting状态、unpark()唤醒。因为采用了类似semaphore机制,park执行后会检查唤醒许可,如果许可可用,park()会立即返回,因此即使unpark发生在前也不会导致线程一直阻塞。
· 用法:
LockSupport.park() 阻塞当前线程
LockSupport.unpark(t1) 唤醒t1线程
3.2 线程复用:线程池
3.2.1 什么是线程池
· 为什么要有线程池?大量线程的创建与销毁非常消耗CPU与内存资源,因此可以让创建的线程进行复用。
· 创建线程变成从线程池获取空闲线程,关闭现场变成了向池子归还线程
面试题:创建和销毁线程的开销具体体现在哪里?
Java线程的线程栈所占用的内存是在Java堆外的,所以是不受Java程序控制的,只受系统资源限制,默认一个线程的线程栈大小是1M。但是如果执行每个任务都去创建一个线程的话,1024个线程就占了1个G的内存,如果系统比较大的话,一下子系统资源就不够用了,最后程序有可能会发生崩溃。
此外,对于操作系统来说,创建一个线程的代价是十分昂贵的,需要给他分配内存、列入调度,同时在线程切换的时候还要执行内存换页,清空CPU缓存,切换回来的时候还要重新从内存中读取信息,破坏了数据的局部性原则。
3.2.2 Java中的Executor框架
· 线程池工厂Executors提供的线程池:
newFixedThreadPool( n ):
newSingleThreadExecutor( ):
newCachedThreadExecutor( ): core=0,max=1,阻塞队列为空
newSingleThreadScheduledExecutor( ): 可以计划任务,线程池大小为1
newScheduledThreadPool( ): 可以指定线程数目
· 计划任务:newScheduledThreadPool( )
可以根据时间需要对线程进行调度,如FixRate(固定频率)和FixDelay(固定间隔)
· Executors的内部实现
调用了ThreadPoolExecutor的构造方法
七个参数:(核心线程的数目、线程池最大线程数、空闲线程的存活时间、空闲线程的存活时间单位、阻塞队列、线程工厂、拒绝策略)
· BlockingQueue的常见种类:
直接提交的队列:SynchronousQueue,没有容量,新任务直接交给线程执行
有界的任务队列:ArrayBlockingQueue
无界的任务队列:LinkedBlockingQueue
优先任务队列:PriorityBlockingQueue
· 拒绝策略(ThreadPoolExeccutor的内部类)
AbortPolicy:
DiscardPolicy:
DiscardOldestPolicy:
CallerRunsPolicy:
· ThreadPoolExecutor的任务调度逻辑
3.2.5 自定义线程创建:ThreadFactory
· 自定义线程池可以让我们更加自由地设置池子中所有线程的状态
· 用匿名内部类重写ThreadFactory中的newThread方法
3.2.6 扩展线程池
· 用匿名内部类重写ThreadPoolExecutor类中的beforeExecute()、afterExecute()、terminated()方法,默认为空。
可以实现对线程池状态的跟踪,输出一些有用的调试信息,以帮助系统进行故障诊断
3.2.8 在线程池中寻找异常堆栈
· 线程池很可能会吃掉程序抛出的异常,因此可以用一些方法来打印这些异常信息
3.2.9 分而治之:Fork/Join框架
3.3 JDK的并发容器
· ConcurrentHashMap
线程安全的hashmap,jdk1.7、jdk1.8
· CopyOnWriteArrayList
写入时复制。在读多写少的场合,性能远远好于Vector。在访问的时候加锁,拷贝出来一个副本,先操作这个副本,再把现有的数据替换为这个副本。这样就可以保证写操作不会影响读了。
优点:CopyOnWriteArrayList 并发安全且性能比 Vector 好。Vector 是增删改查方法都加了synchronized 来保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而 CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于 Vector。
缺点:1.数据一致性问题。这种实现只是保证数据的最终一致性,不能保证数据的实时一致性;在添加到拷贝数据而还没进行替换的时候,读到的仍然是旧数据。2.内存占用问题。在进行写操作的时候,内存里会同时驻扎两个对象的内存,如果对象比较大,频繁地进行替换会消耗内存,从而引发 Java 的 GC 问题,这个时候,我们应该考虑其他的容器,例如 ConcurrentHashMap。
与读写锁的区别:
读写锁:分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥。总之,读的时候上读锁,写的时候也上写锁!
· ConcurrentLinkedQueue
高效的并发队列
· BlockingQueue
一个接口。表示阻塞队列,非常适合用于作为数据共享的通道
· ConcurrentSkipListMap
跳表实现map,使用跳表的数据结构进行快速查找。
第4章 锁的优化及注意事项
· 对于多线程而言,除了需要处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。因此,在单核CPU上,采用并行算法的效率一般要低于原始的串行算法(IO密集型任务除外)。
4.1 有助于提高“锁”性能的几点建议
· 减小锁的持有时间
只在必要的时候进行同步
· 锁粗化
如果有线程对同一个锁不停地进行请求、同步和释放(如循环等),其本身也会消耗系统宝贵地资源,反而不利于性 能地优化,因此可以把所有的锁操作整合成对锁地一次请求。例如两次请求锁之间有执行时间即短的操作,则可以将两个同步操作合并成一个。
· 减小锁粒度(分割数据结构)
例如ConcurrentHashMap:
优点:锁分段增大了并发吞吐量
缺点:在获取全局信息如size()时,需要获取所有段的锁才能统计,性能较差。
· 读写分离锁来替换独占锁(对系统功能点进行分割)
ReadWriteLock读写锁
· 锁分离技术
例如LinkedBlockingQueue,take操作和put操作发生在不同侧,因此可以使用两把不同的锁来削弱锁竞争的可能性
4.2 JVM对锁优化做出的努力
· 偏向锁
当第一个线程获取该锁的时候,那么锁进入偏向模式,在mark word中标记当前线程id和偏向锁标记。当访问完成同步块后也不会释放锁。当下次有线程申请锁的时候,在mark word中检查偏向锁的持有者,如果还是原先持有者则直接进入。若是新的锁竞争者,则收回之前的偏向锁,并将锁升级成轻量级锁。
· 轻量级锁
将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。尝试用自旋+CAS来获取锁对象,线程不会被挂起,避免了阻塞和唤醒线程的开销。若迟迟获取不到锁,则当前线程锁请求会升级为重量级锁,避免傻等。
· 自旋锁
假设在不久的将来,线程就可以获得这把锁,因此让当前线程自旋(会牺牲一定cpu资源),若在几次循环后成功获得锁则进入临界区。当自旋次数达到一定次数时,锁就会升级为重量级锁。
· 锁消除
JVM在JIT编译期间会进行分析,找到不可能存在共享资源竞争的锁,然后消除该锁。例如局部变量是在线程栈上分配的,属于线程私有,因此不会被竞争,可以进行锁消除。
锁消除中有一项关键技术为逃逸分析,可以观察某一变量是否会逃出某个作用域。
【补充】synchronized的实现原理及锁升级的过程:
https://blog.csdn.net/weixin_43217515/article/details/88365016
偏向锁 --- 升级/膨胀---> 轻量级锁 ---> 重量级锁
4.3 人手一支笔 ThreadLocal
· 让每个线程拥有自己的局部变量
· static ThreadLocal
ThreadLocal为每一个线程都产生一个E对象实例
· ThreadLocal.ThreadLocalMap内部类,维护一个类哈希表结构,Thread对象为Key,value为E对象实例
· 在线程方法中通过tl.get()和tl.set(e)来获取和创建(重置)对象实例
· E实例对象只有在线程销毁后才会被回收,而线程池中的线程又是循环利用的,因此可能会引发内存泄露。故可以在线程run方法中手动给该对象set(this,null),这样就可以被垃圾回收器发现,从而加速回收
4.4 无锁
4.4.1 CAS
· CAS(待更新变量的内存地址,预期值,修改值)
· 常与自旋操作搭配使用,硬件层面已原子化的CAS指令
4.4.2 无锁的线程安全整数:AtomicInteger
· 实现原理为CAS操作
· 存在ABA问题
4.4.3 Java中的指针:Unsafe类
4.4.4 无锁的对象引用:AtomicReference
· 可以对普通对象进行原子操作
· 同样存在ABA问题
· 常用get()和getAndSet(V)方法
4.4.5 带有时间戳的对象引用:AtomicStampedReference
· 内部类Pair对象保存引用和时间戳
· 用int类型的时间戳来保存变量版本,CAS操作时同时检查时间戳的变化
4.4.6 数组也能无锁:AtomicIntegerArray
4.4.7 普通变量也能享受原子操作:AtomicIntegerFieldUpdater
· 可以在不改动(极少改动)原有代码的基础上,利用反射机制让普通变量也能享受原子操作