【八股文系列】Java并发

【线程和进程】

进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。

一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
进程是资源分配的最小单位,线程是CPU调度的最小单位

进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。

多个线程之间共享数据的方式

场景抽象:
对于卖票系统每个线程的核心执行的代码都相同(就是票数–)。
解决方法:
只需创建一个Runnable,这个Runnable里有那个共享数据。
public class Ticket implements Runnable{ private int ticket = 10 };

场景抽象:
每个线程执行的代码不同(比如上面的问题,对每个账户可以执行++操作和–操作),这时候需要用不同的Runnable对象,有如下两种方式来实现这些Runnable之间的数据共享
解决方案:
有两种方法来解决此类问题:
1.将共享数据封装成另外一个对象中封装成另外一个对象中,然后将这个对象逐一传递给各个Runnable对象,每个线程对共享数据的操作方法也分配到那个对象身上完成,这样容易实现针对数据进行各个操作的互斥和通信
2.将Runnable对象作为偶一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个Runnable对象调用外部类的这些方法。

【线程生命周期】

初始化状态(NEW)、可运行/运行状态(RUNNABLE)、阻塞状态
(BLOCKED)、无时限等待状态(WAITING)、有时限等待状态(TIMED_WAITING)、终止状态(TERMINATED)。
RUNNABLE与BLOCKED的状态转换
只有一种场景会触发这种转换,就是线程等待synchronized隐式锁。synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他的线程则需要等待。此时,等待的线程就会从RUNNABLE状态转换到BLOCKED状态。当等待的线程获得synchronized隐式
锁时,就又会从BLOCKED状态转换到RUNNABLE状态。
这里,需要大家注意:线程调用阻塞API时,在操作系统层面,线程会转换到休眠状态。但是在JVM中,Java线程的状态不会发生变化,也就是说,Java线程的状态仍然是RUNNABLE状态。JVM并不关心操作系统调度相关的状态,在JVM角度来看,等待CPU使用权(操作系统中的线程处于可执行状态)和等待IO操作(操作系统中的线程处于休眠状态)没有区别,都是在等待某个资源,所以,将其都归入了RUNNABLE状态。
RUNNABLE与WAITING状态转换
线程从RUNNABLE状态转换成WAITING状态总体上有三种场景。
场景一
获得synchronized隐式锁的线程,调用无参的Object.wait()方法。此时的线程会从RUNNABLE状态转换成WAITING状态。
场景二
调用无参数的Thread.join()方法。其中join()方法是一种线程的同步方法。例如,在threadA线程中调用threadB线程的join()方法,则threadA线程会等待threadB线程执行完。而threadA线程在等待threadB线程执行的过程中,其状态会从RUNNABLE转换到WAITING。当threadB执行完毕,threadA线程的状态则会从WAITING状态转换成RUNNABLE状态。
场景三
调用LockSupport.park()方法,当前线程会阻塞,线程的状态会从RUNNABLE转换成WAITING。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。

【死锁】预防

破坏互斥条件:无法破坏
破坏不可剥夺条件:tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。
破坏请求与保持条件,我们可以一次性申请所需要的所有资源
破坏循环等待条件,则可以通过对资源排序,按照一定的顺序来申请资源,然后按照顺序来锁定资源,可以有效的避免死锁。
例如,在我们的转账操作中,往往每个账户都会有一个唯一的id值,我们在锁定账户资源时,可以按照id值从小到大的顺序来申请账 户资源,并按照id从小到大的顺序来锁定账户,此时,程序就不会再进行循环等待了

【JUC】分类

Lock框架:ReentrantLock
Tools类:CountDownLatch CyclicBarrier
Collections并发集合: CopyOnWriteArrayList 读多写少 ,ConcurrentHashMap
Atomic:原子类

Executors:线程池
(1)CPU密集型任务,就需要尽量压榨CPU,参考值可以设置为CPU的数量加1。
(2)IO密集型任务,参考值可以设置为CPU数量乘以2
newFixedThreadPool重用固定数量线程的线程池​​
newWorkStealingPool(int parallelism)抢占式执行的线程池​​(JDK8的并行流 forkJoin)
newSingleThreadExecutor()单例的线程池​​
newCachedThreadPool()可缓存的线程池​​
newSingleThreadScheduledExecutor()单线程的可以执行延迟任务的线程池​​
newScheduledThreadPool(int corePoolSize)周期性的运行任务线程池​

rejectHandler:拒绝处理任务时的策略
直接抛出异常,这也是默认的策略。实现类为AbortPolicy。
用调用者所在的线程来执行任务。实现类为CallerRunsPolicy。
丢弃队列中最靠前的任务并执行当前任务。实现类为DiscardOldestPolicy。
直接丢弃当前任务。实现类为DiscardPolicy。

【synchronized】原理

synchronized是java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试CAS获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

实际上大部分时候我认为说到monitorenter就行了,但是为了更清楚的描述,还是再具体一点。

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。
当多个线程进入同步代码块时,首先进入entryList
有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
在这里插入图片描述

【synchronized】锁优化

从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。

自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。

自适应锁:自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。

锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。

锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。

偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置-XX:+UseBiasedLocking开启偏向锁。

轻量级锁:JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。

整个锁升级的过程非常复杂,我尽力去除一些无用的环节,简单来描述整个升级的机制。

简单点说,偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。

JDK15放弃了偏向锁

在过去,Java 应用通常使用的都是 HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全。

如果在单线程的情景下使用这些集合库就会有不必要的加锁操作,从而导致性能下降。

而偏向锁可以保证即使是使用了这些老的集合库,也不会产生很大的性能损耗,因为 JVM 知道访问临界区的线程始终是同一个,也就避免了加锁操作。

新的 Java 应用基本都已经使用了无锁的集合库,比如 HashMap、ArrayList 等,这些集合库在单线程场景下比老的集合库性能更好。
即使是在多线程场景下,Java 也提供了 ConcurrentHashMap、CopyOnWriteArrayList 等性能更好的线程安全的集合库。

综上,对于使用了新类库的 Java 应用来说,偏向锁带来的收益已不如过去那么明显,而且在当下多线程应用越来越普遍的情况下,偏向锁带来的锁升级操作反而会影响应用的性能。

【synchronized】两个线程同时访问的问题

1、两个方法都没有synchronized修饰,调用时都可进入:方法A和方法B都没有加synchronized关键字时,调用方法A的时候可进入方法B;
2、一个方法有synchronized修饰,另一个方法没有,调用时都可进入:方法A加synchronized关键字而方法B没有加时,调用方法A的时候可以进入方法B;
3、两个方法都加了synchronized修饰,一个方法执行完才能执行另一个:方法A和方法B都加了synchronized关键字时,调用方法A之后,必须等A执行完成才能进入方法B;
4、两个方法都加了synchronized修饰,其中一个方法加了wait()方法,调用时都可进入:方法A和方法B都加了synchronized关键字时,且方法A加了wait()方法时,调用方法A的时候可以进入方法B;
5、一个添加了synchronized修饰,一个添加了static修饰,调用时都可进入:方法A加了synchronized关键字,而方法B为static静态方法时,调用方法A的时候可进入方法B;
6、两个方法都是静态方法且还加了synchronized修饰,一个方法执行完才能执行另一个:方法A和方法B都是static静态方法,且都加了synchronized关键字,则调用方法A之后,需要等A执行完成才能进入方法B;
7、两个方法都是静态方法且还加了synchronized修饰,分别在不同线程调用不同的方法,还是需要一个方法执行完才能执行另一个:方法A和方法B都是static静态方法,且都加了synchronized关键字,创建不同的线程分别调用A和B,需要等A执行完成才能执行B(因为static方法是单实例的,A持有的是Class锁,Class锁可以对类的所有对象实例起作用)
总结:
同一个object中多个方法都加了synchronized关键字的时候,其中调用任意方法之后需等该方法执行完成才能调用其他方法,即同步的,阻塞的;
此结论同样适用于对于object中使用synchronized(this)同步代码块的场景;
synchronized锁定的都是当前对象!

【ReentrantLock】原理

相比于synchronized,ReentrantLock需要显式的获取锁和释放锁,相对现在基本都是用JDK7和JDK8的版本,ReentrantLock的效率和synchronized区别基本可以持平了。他们的主要区别有以下几点:
等待可中断,当持有锁的线程长时间不释放锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务。
公平锁:synchronized和ReentrantLock默认都是非公平锁,但是ReentrantLock可以通过构造函数传参改变。只不过使用公平锁的话会导致性能急剧下降。
绑定多个条件:ReentrantLock可以同时绑定多个Condition条件对象。

ReentrantLock基于AQS(抽象队列同步器)双向链表实现。AQS内部维护一个state状态位,尝试加锁的时候通过CAS(CompareAndSwap)修改值,如果成功设置为1,并且把当前线程ID赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把state重新置为0,同时当前线程ID置为空。(park/unpark)

ReentrantLock独有的功能
ReentrantLock可指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
提供了一个Condition类,可以分组唤醒需要唤醒的线程。而synchronized只能随机唤醒一个线程,或者唤醒全部的线程
提供能够中断等待锁的线程的机制,lock.lockInterruptibly()。ReentrantLock实现是一种自旋锁,通过循环调用CAS操作来实
现加锁,性能上比较好是因为避免了使线程进入内核态的阻塞状态。
synchronized能做的事情ReentrantLock都能做,而ReentrantLock有些能做的事情,synchronized不能做。
在性能上,ReentrantLock不会比synchronized差。
synchronized的优势
不用手动释放锁,JVM自动处理,如果出现异常,JVM也会自动释放锁。
JVM用synchronized进行管理锁定请求和释放时,JVM在生成线程转储时能够锁定信息,这些对调试非常有价值,因为它们能
标识死锁或者其他异常行为的来源。而ReentrantLock只是普通的类,JVM不知道具体哪个线程拥有lock对象。
synchronized可以在所有JVM版本中工作,ReentrantLock在某些1.5之前版本的JVM中可能不支持。

【CountDownLatch/CyclicBarrier】原理

CountDownLatch是利用AbstractQueuedSynchronizer AQS的state来做计数器功能,当初始化CountDownLatch时,会将state值进行初始化,让调用CountDownLatch的awit时,会判断state计数器是否已经变为0,如果没有变为0则挂起当前线程,并加入到AQS的阻塞队列中,如果有线程调用了CountDownLatch的countDown时,这时的操作是将state计数器进行减少1,每当减少操作时都会唤醒阻塞队列中的线程,线程会判断此时state计数器是否已经都执行完了,如果还没有执行完则继续挂起当前线程,直到state计数器清零或线程被中断为止(park/unpark)

CountDownLatch的计数器只能使用一次,而CyclicBarrier的计数器可以使用reset()方法重置,可以使用多次,所以CyclicBarrier能够处理更为复杂的场景;

CyclicBarrier 原理是ReentrantLock 和 Condition 的组合使用,还提供了一些其他有用的方法,比如getNumberWaiting()方法可以获得CyclicBarrier阻塞的线程数量,isBroken()方法用来了解阻塞的线程是否被中断;
CountDownLatch允许一个或多个线程等待一组事件的产生,而CyclicBarrier用于等待其他线程运行到栅栏位置。
CyclicBarrier 内部维护了一个计数器,每个线程调用 await() 方法时计数器会加1,同时线程会被阻塞。当计数器的值达到了 CyclicBarrier 构造函数中传入的参与线程数量时,所有被阻塞的线程都会被唤醒继续执行。
CyclicBarrier 还支持一个可选的 Runnable 参数,在所有线程都到达 barrier 后,系统会自动执行该 Runnable 任务。并且,CyclicBarrier 可以重用,即当所有线程到达时,计数器会被重置,可以继续使用。

【ConcurrentHashmap】原理

从JDK7版本的ReentrantLock+Segment+HashEntry,到JDK8版本synchronized+CAS+Node+红黑树
1.7分段锁
从结构上说,1.7版本的ConcurrentHashMap采用分段锁机制,里面包含一个Segment数组,Segment继承与ReentrantLock,Segment则包含HashEntry的数组,HashEntry本身就是一个链表的结构,具有保存key、value的能力能指向下一个节点的指针。
实际上就是相当于每个Segment都是一个HashMap,默认的Segment长度是16,也就是支持16个线程的并发写,Segment之间相互不会受到影响。
put的流程:
其实发现整个流程和HashMap非常类似,只不过是先定位到具体的Segment,然后通过ReentrantLock去操作而已,后面的流程我就简化了,因为和HashMap基本上是一样的。
计算hash,定位到segment,segment如果是空就先初始化
使用ReentrantLock加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
遍历HashEntry,就是和HashMap一样,数组中key和hash一样就直接替换,不存在就再插入链表,链表同样
get也很简单,key通过hash定位到segment,再遍历链表定位到具体的元素上,需要注意的是value是volatile的,所以get是不需要加锁的。

1.8抛弃分段锁,转为用CAS+synchronized来实现,同样HashEntry改为Node,也加入了红黑树的实现。put的流程:
首先计算hash,遍历node数组,如果node是空的话,就通过CAS+自旋的方式初始化
如果当前数组位置是空则直接通过CAS自旋写入数据
如果hash==MOVED,说明需要扩容,执行扩容
如果都不满足,就使用synchronized写入数据,写入数据同样判断链表、红黑树,链表写入和HashMap的方式一样,key hash一样就覆盖,反之就尾插法,链表长度超过8就转换成红黑树

【happen-before】 规则

在JDK1.5版本中的Java内存模型中引入
虽然指令重排提高了并发的性能,但是Java虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:
单线程每个操作,happen-before于该线程中任意后续操作
volatile写happen-before与后续对这个变量的读
synchronized解锁happen-before后续对这个锁的加锁
final变量的写happen-before于final域对象的读,happen-before后续对final变量的读
传递性规则,A先于B,B先于C,那么A一定先于C发生

猜你喜欢

转载自blog.csdn.net/qq798280904/article/details/130822448