Java复习笔记(4)——多线程与并发(2)

版权声明:本文为博主原创,未经博主允许不得转载。 https://blog.csdn.net/weixin_36904568/article/details/90743847

八:线程池原理

在这里插入图片描述
线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务。

如果线程数量超过了最大数量,则超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。

主要特点为:

  • 线程复用
  • 控制最大并发数
  • 管理线程

1. 线程复用

每一个 Thread 的类都有一个 start 方法。 当调用 start 启动线程时 Java 虚拟机会调用该类的 run 方法。 那么该类的 run() 方法中就是调用了 Runnable 对象的 run() 方法。 我们可以继承重写Thread 类,在其 start 方法中添加不断循环调用传递过来的 Runnable 对象。 这就是线程池的实现原理。

循环方法中不断获取 Runnable 是用 Queue 实现的,在获取下一个 Runnable 之前可以
是阻塞的。

2. 线程池的组成

  • 线程池管理器:用于创建并管理线程池
  • 工作线程:线程池中的线程
  • 任务接口:每个任务必须实现的接口,用于工作线程调度其运行
  • 任务队列:用于存放待处理的任务,提供一种缓冲机制
    在这里插入图片描述
    在这里插入图片描述
    线程池的构造函数:
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize, 
	long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue) {
		this(corePoolSize, maximumPoolSize, keepAliveTime, unit,
		 workQueue,Executors.defaultThreadFactory(), defaultHandler);
}
  1. corePoolSize:指定了线程池中的线程数量。当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePolSize;如果当前线程数为 corePolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的 prestarAlCoreThreads()方法,线程池会提前创建并启动所有核心线程
  2. maximumPoolSize:指定了线程池中的最大线程数量。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于 maximuPolSize
  3. keepAliveTime:当前线程池数量超过 corePoolSize 时,多余的空闲线程的存活时间,即多次时间内会被销毁。
  4. unit:keepAliveTime 的单位。
  5. workQueue:任务队列,被提交但尚未被执行的任务。
  6. threadFactory:线程工厂,用于创建线程,一般用默认的即可。
  7. handler:拒绝策略,当任务太多来不及处理,如何拒绝任务。

拒绝策略

线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时就需要拒绝策略机制合理的处理这个问题。

JDK 内置的拒绝策略如下:

  1. AbortPolicy : 直接抛出异常,阻止系统正常运行。
  2. CallerRunsPolicy : 只要线程池未关闭,则直接在调用者线程中运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
  3. DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
  4. DiscardPolicy : 该策略默默地丢弃无法处理的任务,不予任何处理。如果允许任务丢失,这是最好的一种方案。

以上内置拒绝策略均实现了 RejectedExecutionHandler 接口,若以上策略仍无法满足实际需要,完全可以自己扩展接口。

3. 线程池状态

在这里插入图片描述

4. 线程池的种类

newCachedThreadPool

初始化一个可以缓存线程的线程池,默认缓存 60s,线程池的线程数可达到Integr.MAX_VALUE,即 214783647,内部使用 SynchronusQue 作为阻塞队列

对于执行很多短期异步任务的程序而言,这些线程池通常可提高程序性能。调用 execute 将重用以前构造的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。因此,长时间保持空闲的线程池不会使用任何资源。

在没有任务执行时,当线程的空闲时间超过 kepAliveTime,会自动释放线程资源;

newFixedThreadPool

初始化一个指定线程数的线程池,其中 corePolSize = maxiPolSize,使用 LinkedBlockingQuen 作为阻塞队列

如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

即使当线程池没有可执行任务时,也不会释放线程

newScheduledThreadPool

创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

newSingleThreadExecutor

初始化只有一个线程的线程池,内部使用 LinkedBlockingQue 作为阻塞队列。
如果该线程异常结束,会重新创建一个新的线程继续执行任务,唯一的线程可以保证所提交任务的顺序执行

5. 工作过程

在这里插入图片描述

  • 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  • 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
    • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务
    • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列
    • 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    • 如果队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
  • 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  • 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

九:阻塞队列

在这里插入图片描述
在阻塞队列中,线程阻塞有这样的两种情况:

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。
    在这里插入图片描述
  • 当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。
    在这里插入图片描述

1. 主要方法

(1)添加数据

  • add(E paramE):将指定元素插入此队列中(如果立即可行且不会违反容量限制),成功时返回 true
    • 如果当前没有可用的空间,则抛出 IllegalStateException
    • 如果该元素是 NULL,则会抛出 NullPointerException 异常
  • offer(E paramE):将指定元素插入此队列中(如果立即可行且不会违反容量限制),成功时返回 true
    • 如果当前没有可用的空间,则返回 false。
  • offer(E o, long timeout, TimeUnit unit):可以设定等待的时间
    • 如果在指定的时间内,还不能往队列中加入元素,则返回false。
  • put(E paramE) throws InterruptedException: 将指定元素插入此队列中
    • 没有可用的空间时阻塞线程

(2)获取数据

  • remove(E e):取走 BlockingQueue 里指定对象,操作成功返回true
    • 如果该元素是 NULL,则返回null
    • 如果没有该元素,返回false
  • poll():取走 BlockingQueue 里排在首位的对象
    • 若不能立即取出,返回null
  • poll(long timeout, TimeUnit unit):从 BlockingQueue 取出一个队首的对象,如果在
    指定时间内,队列一旦有数据可取,则立即返回队列中的数据。
    • 直到时间超时还没有数据可取,返回null。
  • take():取走 BlockingQueue 里排在首位的对象
    • 若 BlockingQueue 为空,阻塞线程,直到 BlockingQueue 有新的数据被加入
  • drainTo():一次性从 BlockingQueue 获取所有可用的数据对象(还可以指定获取数据的个数),通过该方法,可以提升获取数据效率;不需要多次分批加锁或释放锁。

2. Java 中的阻塞队列

  • ArrayBlockingQueue :由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue :由链表结构组成的有界阻塞队列。
  • PriorityBlockingQueue :支持优先级排序的无界阻塞队列。
  • DelayQueue:使用优先级队列实现的无界阻塞队列。
  • SynchronousQueue:不存储元素的阻塞队列。
  • LinkedTransferQueue:由链表结构组成的无界阻塞队列。
  • LinkedBlockingDeque:由链表结构组成的双向阻塞队列

ArrayBlockingQueue(公平、非公平)

用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序,默认情况下不保证访问者公平的访问队列。

所谓公平访问队列是指阻塞的所有生产者线程或消费者线程,当队列可用时,可以按照阻塞的先后顺序访问队列,即先阻塞的生产者线程,可以先往队列里插入元素,先阻塞的消费者线程,可以先从队列里获取元素。通常情况下为了保证公平性会降低吞吐量。

保证并发的安全性是基于 ReentrantLock 和 Conditon 实现的。其中有两个重要的成
员变量 putindex 和 takeindex,putindex 就是指向数组中上一个添加完元素的位置的
下一个地方,其中有一点特别注意的就是当index=数组的长度减一的时候,意味着数组已经到了满了,那么需要将 putindex 置位 0,原因是数组在被消费的也就是取出操作的时候,是从数组的开始位置取得,所以最开始的位置容易是空的,所以把要添加的位置位 0;takeindex 也是一样的,当 takeindex 到了数组的长度减一的时候,也需要
将 takeindex 置为 0。

操作
添加
  • add 方法:调用了 offer 方法,add 方法数组满了则抛出异常
  • offer 方法:用 ReentrantLock 加锁,首先判断数组是否满了,数组满了则返回 false,数组不满的话直接入队,也就是将 putindex 索引处的值置为新要加入的数,如果加入以后发现 putindex+ = 数组的长度,那么说明后面的全部已经填满了,因此 putindex 置为 0,因为前面的可能出队的过程空出来了,所以变为 0,最后一步就是执行notEmpty.signal 去唤醒消费的执行了 take 的线程,只有可能是执行了 take 的方法的线程会导致线程的挂起操作。
  • put 方法:首先是 ReentrantLock 加锁,然后判断是否满了,队列满了,则执行 notFul.awit()操作挂起,等待 notFul.signal()唤醒。没满,则直接进行入队,入队和 ofer 操作一样,也就是将 putindex 索引处的值置为新要加入的数,如果加入以后发现 putindex+ = 数组的长度,那么说明后面的全部已经填满了,因此 putindex 置为 0,因为前面的可能出队的过程空出来了,所以变为 0,最后一步就是执行 notEmpty()去唤醒消费的执行了 take 的线程
删除
  • poll 方法:首先加锁 ReentrantLock ,然后判断队列是否为空,不为空,则将 putindex 出的值用副本 copy,然后置位 null,然后去执行唤醒 notFull()操作,也就是唤醒调用了 put 操作的线程,唤醒操作并不一定总是发生。
  • take 方法:先加锁ReentrantLock ,然后如果队列空则 notEmpty.awit()方法,不为空,则执行和 poll 一样的出队操作

LinkedBlockingQueue(两个独立锁提高并发)

基于链表的阻塞队列,同 ArrayListBlockingQueue 类似,此队列按照先进先出(FIFO)的原则对元素进行排序。

而 LinkedBlockingQueue 之所以能够高效的处理并发数据,是因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

LinkedBlockingQueue 会默认一个类似无限大小的容量(Integer.MAX_VALUE)

操作
添加
  • add 方法:调用了 offer 方法,add 方法数组满了则抛出异常
  • offer 方法:putlock 锁,然后不满则加入,同时获取一个 c 值,c 值代表本次队列增加前的队列的数目,然后判断如果不满则继续去唤醒 notFull.signal,去唤醒添加线程去添加(添加过程是直接 last 节点指向下一个,简单的节点后增加一个节点,然后 last指向最后一个节点),上述过程结束,然后去判断 c=0(如果添加之前队列长度是 0 那么说明可能有挂起的消费线程,需要从队列取元素,但队列长度为 0 没有元素;判断c>0 没有意义,因为添加之前队列不为空,说明不存在挂起的消费线程) ,c 如果等于 0 那么去唤醒阻塞的 notEmpty 上的条件等待线程。
  • put 方法:就是满了则挂起,不满则执行,同时添加完一个后,发现没满继续去唤醒挂起的添加线程
删除
  • poll 方法:获取 takelcok 然后不为空则出队一个元素,也就是链表的删除头结点操作,如果队列不为空,那么继续去唤醒被挂起的消费线程(消费线程就是执行了队列的 take 操作的线程),直到没有消费线程或者队列为空,结束,然后如果 c(也是队列消费一个头节点的元素后,没消费之前队列的长度),如果 c 的长度已经是队列的长度,则去唤醒被挂起的执行了put 方法的线程,然后释放 takelock 锁
  • take 方法:为空则挂起,不为空一直消费,唤起消费线程一直消费,直到条件不满足,那么去尝试判断 c 的值,c 是队列长度减一,那么去唤醒执行了 put 方法的被挂起的线程。
LinkedBlockingQueue与ArrayBlockingQueue
  • 队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而 LinkedBlockingQueue可以是有界的也可以是无界的(Integr.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。
  • 数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而 LinkedBlockingQueue采用的则是以 Node 节点作为连接对象的链表。
  • 由于 ArrayBlockingQueue 采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而 LinkedBlockingQueue则会生成一个额外的 Node 对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于 GC 可能存在较大影响。
  • 两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个 ReenterLock 锁,而 LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是 putLock,移除采用的则是 takeLock,这样能大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

PriorityBlockingQueue(compareTo 排序实现优先)

是一个支持优先级的无界队列。默认情况下元素采取自然顺序升序排列。

可以自定义实现compareTo()方法来指定元素进行排序规则,或者在初始化时,指定构造参数Comparator 来对元素进行排序。需要注意的是不能保证同优先级元素的顺序。

DelayQueue(缓存失效、定时任务 )

是一个支持延时获取元素的无界阻塞队列,队列使用 PriorityQueue 来实现。

队列中的元素必须实现 Delayed 接口,在创建元素时可以指定多久才能从队列中获取当前元素。

运用场景:

  • 缓存系统的设计:可以用 DelayQueue 保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从 DelayQueue 中获取元素时,表示缓存有效期到了。
  • 定时任务调度:使用 DelayQueue 保存当天将会执行的任务和执行时间,一旦从
    DelayQueue 中获取到任务就开始执行, TimerQueue 就是使用 DelayQueue 实现的。

SynchronousQueue(不存储数据、可用于传递数据)

是一个不存储元素的阻塞队列。每一个 put 操作必须等待一个 take 操作,否则不能继续添加元素。

SynchronousQueue负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景(在一个线程中使用的数据,传递给另一个线程)使用

SynchronousQueue 的 吞 吐 量 高 于 LinkedBlockingQueue 和ArrayBlockingQueue。

LinkedTransferQueue(快速传输数据)

是一个由链表结构组成的无界阻塞 TransferQueue 队列

相对于其他阻塞队列,LinkedTransferQueue 多了 tryTransfer 和 transfer 方法:

  • transfer 方法:
    • 如果当前有消费者正在等待接收元素(消费者使用 take()方法或带时间限制的poll()方法时),transfer 方法可以把生产者传入的元素立刻 transfer(传输)给消费者。
    • 如果没有消费者在等待接收元素,transfer 方法会将元素存放在队列的 tail 节点,并等到该元素被消费者消费了才返回
  • tryTransfer 方法:用来试探下生产者传入的元素是否能直接传给消费者
    • 如果有消费者正在等待接收元素,则把生产者传入的元素立刻传输给消费者
    • 如果没有消费者等待接收元素,则立即返回 false
  • tryTransfer(E e, long timeout, TimeUnit unit)方法:试图把生产者传入的元素直接传给消费者
    • 如果有消费者正在等待接收元素,则把生产者传入的元素立刻传输给消费者
    • 没有消费者消费该元素,则等待指定的时间再返回,如果超时还没消费元素,则返回 false,如果在超时时间内消费了元素,则返回 true。

LinkedBlockingDeque(双向队列)

是一个由链表结构组成的双向阻塞队列,可以从队列的两端插入和移出元素。双端队列因为多了一个操作队列的入口,在多线程同时入队时,也就减少了一半的竞争。

相比其他的阻塞队列,LinkedBlockingDeque 多了 addFirst,addLast,offerFirst,offerLast,peekFirst,peekLast 等方法,表示插入,获取或移除双端队列的第一个或最后一个元素。另外,插入方法 add = addLast,移除方法 remove = removeFirst,take = takeFirst

在初始化 LinkedBlockingDeque 时可以设置容量防止其过渡膨胀,双向阻塞队列可以运用在“工作窃取”模式中。

十:线程的工具类

1. CountDownLatch(线程计数器 )

CountDownLatch 类位于 java.util.concurrent 包下,利用它可以实现类似计数器的功能

比如有一个任务 A,它要等待其他 2 个任务执行完毕之后才能执行,此时就可以利用 CountDownLatch来实现这种功能了

public static void main(String[] args) {
	final CountDownLatch latch = new CountDownLatch(2);
	new Thread(){
		public void run() {
			System.out.println("子线程"+Thread.currentThread().getName()+"正在执行");
			Thread.sleep(3000);
			System.out.println("子线程"+Thread.currentThread().getName()+"执行完毕");
			latch.countDown();
		};
	}.start();
	
	new Thread(){
	 public void run() {
		System.out.println("子线程"+Thread.currentThread().getName()+"正在执行"); 
		Thread.sleep(3000);
		System.out.println("子线程"+Thread.currentThread().getName()+"执行完毕"); 
		latch.countDown();
		};
	}.start();
	
	System.out.println("等待 2 个子线程执行完毕...");
	latch.await();
	System.out.println("2 个子线程已经执行完毕");
	System.out.println("继续执行主线程");
}

2. CyclicBarrier(回环栅栏,等待至barrier状态再全部同时执行)

通过它可以实现让一组线程等待至某个状态之后再全部同时执行

叫做回环,是因为当所有等待线程都被释放以后,CyclicBarrier 可以被重用,这个状态就叫做barrier,当调用 await()方法之后,线程就处于 barrier 了。

CyclicBarrier 中最重要的方法就是 await 方法,它有 2 个重载版本:

  1. public int await():用来挂起当前线程,直至所有线程都到达 barrier 状态再同时执行后续任务;
  2. public int await(long timeout, TimeUnit unit):让这些线程等待至一定的时间,如果还有线程没有到达 barrier 状态就直接让到达 barrier 的线程执行后续任务
public static void main(String[] args) {
	int N = 4;
	CyclicBarrier barrier = new CyclicBarrier(N);
	for(int i=0;i<N;i++)
		new Writer(barrier).start(); 
 }
 
static class Writer extends Thread{
 
	private CyclicBarrier cyclicBarrier;
 
	public Writer(CyclicBarrier cyclicBarrier) { 
		this.cyclicBarrier = cyclicBarrier;
	}
 
	@Override 
	public void run() {
		try {
 			Thread.sleep(5000); //以睡眠来模拟线程需要预定写入数据操作
			System.out.println("线程"+Thread.currentThread().getName()+"写入数据完毕,等待其他线程写入完毕"); 
			cyclicBarrier.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}catch(BrokenBarrierException e){
			e.printStackTrace();
		} 			
		System.out.println("所有线程写入完毕,继续处理其他任务,比如数据操作");
	}
}

CountDownLatch 和 CyclicBarrier

  • 都能够实现线程之间的等待
  • CountDownLatch 一般用于某个线程 A 等待若干个其他线程执行完任务之后,它才执行
  • CyclicBarrier 一般用于一组线程互相等待至某个状态,然后这一组线程再同时
    执行
  • CountDownLatch 是不能够重用的
  • CyclicBarrier 是可以重用的

3. Semaphore(信号量,控制同时访问的线程个数)

Semaphore 翻译成字面意思为 信号量,Semaphore 可以控制同时访问的线程个数,通过acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

Semaphore 类中比较重要的几个方法:

  1. public void acquire(): 用来获取一个许可,若无许可能够获得,则会一直等待,直到获得许可。
  2. public void acquire(int permits):获取 permits 个许可
  3. public void release() { } :释放许可。在释放许可之前,必须先获获得许可
  4. public void release(int permits) { }:释放 permits 个许可

上面 4 个方法都会被阻塞,如果想立即得到执行结果,可以使用下面几个方法

  1. public boolean tryAcquire():尝试获取一个许可,若获取成功,则立即返回 true,若获取失败,则立即返回 false
  2. public boolean tryAcquire(long timeout, TimeUnit unit):尝试获取一个许可,若在指定的时间内获取成功,则立即返回 true,否则则立即返回 false
  3. public boolean tryAcquire(int permits):尝试获取 permits 个许可,若获取成功,则立即返回 true,若获取失败,则立即返回 false
  4. public boolean tryAcquire(int permits, long timeout, TimeUnit unit): 尝试获取 permits
    个许可,若在指定的时间内获取成功,则立即返回 true,否则则立即返回 false
  5. 可以通过 availablePermits()方法得到可用的许可数目。

Semaphore 其实和锁有点类似,它一般用于控制对某组资源的访问权限。

public static void main(String[] args) {
	int N = 8; //工人数
	Semaphore semaphore = new Semaphore(5); //机器数目
	for(int i=0;i<N;i++)
		new Worker(i,semaphore).start();
}

static class Worker extends Thread{
	private int num;
	private Semaphore semaphore;
	public Worker(int num,Semaphore semaphore){
		this.num = num;
		this.semaphore = semaphore;
	}
	@Override
	public void run() {
		try {
			semaphore.acquire();
			System.out.println("工人"+this.num+"占用一个机器在生产...");
			Thread.sleep(2000);
			System.out.println("工人"+this.num+"释放出机器");
			semaphore.release();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
}

十一:volatile 关键字的作用(变量可见性、禁止重排序)

Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。

volatile 变量具备两种特性,volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。

  • 变量可见性:保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。
  • 禁止指令重排序:volatile的内存屏障就是禁止指令重排,JMM从编译器和处理器层面限制了指令重排,JMM的 屏障插入策略是保守策略
  • 单操作原子性:对 volatile 变量的单次读/写操作是可以保证原子性的,如 long 和 double 类型变量。但是并不能保证 i++这种操作的原子性,因为本质上 i++是读、写两次操作。

可见性原理

  • 对非 volatile 变量进行读写时,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中
  • 声明变量是 volatile 时,JVM会向处理器发送一条Lock前缀的指令,在多核处理器下当前CPU cache的数据会写回系统内存,同时其他CPU cache里实现缓存一致性协议,该内存地址的数据无效。也就是说,读变量都从内存中读,跳过 CPU cache 这一步
    在这里插入图片描述

禁止指令重排序原理

在这里插入图片描述

volatile 和 sychronized

在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。但是volatile 不能完全取代 Synchronized 的位置,只有在一些特殊的场景下,才能适用 volatile。

volatile 适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。

  • 对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值
    (boolean flag = true)
  • 该变量没有包含在具有其他变量的不变式中,也就是说,不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。
  • 当要访问的变量已经在synchronized同步块中,或者为常量时,无需使用volatile
  • volatile屏蔽了代码优化,代码执行效率低
  • volatile不如synchronized同步块安全

十二:如何在两个线程之间共享数据

Java 里面进行多线程通信的主要方式就是共享内存的方式,共享内存主要的关注点有两个:可见性、有序性、原子性。

Java 内存模型(JMM)解决了可见性和有序性的问题,而锁解决了原子性的问题,理想情况下我们希望做到“同步”和“互斥”。有以下常规实现方法:

将数据抽象成一个类,把对数据的操作作为这个类的方法

只需要在方法上加”synchronized“即可

public class MyData {
	private int j=0;
	public synchronized void add(){
		j++;
		System.out.println("线程"+Thread.currentThread().getName()+"j 为:"+j);
	}
	public int getData(){
		return j;
	}
}

public class AddRunnable implements Runnable{ 	
	MyData data;
	public AddRunnable(MyData data){
		this.data= data;
	}
	public void run() {
		data.add();
	}
}
public static void main(String[] args) {
	MyData data = new MyData();
	Runnable add = new AddRunnable(data); 
	new Thread(add).start();
}

Runnable 对象作为一个类的内部类

将 Runnable 对象作为一个类的内部类,共享数据作为这个类的成员变量,每个线程对共享数据的操作方法也封装在外部类,以便实现对数据的各个操作的同步和互斥,作为内部类的各个 Runnable 对象调用外部类的这些方法。

public class MyData {
	private int j=0;
	public synchronized void add(){
		j++;
		System.out.println("线程"+Thread.currentThread().getName()+"j 为:"+j);
	}
	public int getData(){
		return j;
	}
}
public class TestThread {
	public static void main(String[] args) {
		final MyData data = new MyData();
		new Thread(new Runnable(){
			public void run() {
				data.add();
			}
		}).start();
	}
}

十三:ThreadLocal(线程本地存储)

线程本地变量或线程本地存储,作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度

ThreadLocalMap(线程的一个属性)

在这里插入图片描述
每个线程中都有一个自己的 ThreadLocalMap 类对象,可以将线程自己的对象保持到其中,线程可以正确的访问到自己的对象。

将一个共用的 ThreadLocal 静态实例作为 key,将不同对象的引用保存到不同线程的
ThreadLocalMap 中,然后在线程执行的各处通过这个静态 ThreadLocal 实例的 get()方法取得自己线程保存的那个对象,避免了将这个对象作为参数传递的麻烦。

ThreadLocalMap 其实就是线程里面的一个属性,它在 Thread 类中定义

ThreadLocal.ThreadLocalMap threadLocals = null;

最常见的 ThreadLocal 使用场景为 用来解决 数据库连接、Session 管理等

private static final ThreadLocal threadSession = new ThreadLocal(); 
public static Session getSession() throws InfrastructureException { 
	Session s = (Session) threadSession.get(); 
	try { 
		if (s == null) { 
			s = getSessionFactory().openSession(); 
			threadSession.set(s); 
		} 
	} catch (HibernateException ex) { 
		throw new InfrastructureException(ex);
	} 
	return s; 
}	

十四:ConcurrentHashMap 并发

1. 减小锁粒度

减小锁粒度是指缩小锁定对象的范围,从而减小锁冲突的可能性,从而提高系统的并发能力。减小锁粒度是一种削弱多线程锁竞争的有效手段

对于 HashMap 而言,最重要的两个方法是 get 与 set 方法,如果我们对整个 HashMap 加锁,可以得到线程安全的对象,但是加锁粒度太大。

2. 分段锁

ConcurrentHashMap的内部细分了若干个小的 HashMap,称之为段(Segment),段的大小也被称为 ConcurrentHashMap 的并发度。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段

如果需要在 ConcurrentHashMap 中添加一个新的表项,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该表项应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行 put操作,只要被加入的表项不存放在同一个段中,则线程间可以做到真正的并行。

3. 组成

在这里插入图片描述
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment 是一种可重入锁 ReentrantLock,扮演锁的角色;HashEntry 则用于存储键值对数据。

  • 一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap类似,是一种数组和链表结构
  • 一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素, 每个 Segment 守护一个 HashEntry 数组里的元素。当对 HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁。

十五:CAS(比较并交换、乐观锁机制、自旋锁)

1. 概念

CAS(Compare And Swap/Set)比较并交换,包含 3 个参数CAS(V,E,N)。

  • V 表示要更新的变量(内存值)
  • E 表示预期值(旧的)
  • N 表示新值

当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。

CAS 操作是抱着乐观的态度进行的(乐观锁),它总是认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。

2. 原理

参考博客:深入浅出CAS
CAS 中 有 Unsafe 类 中 的 compareAndSwapInt 方 法 ,这是一个本地方法,该方法的实现位于 unsafe.cpp:

  • 先想办法拿到变量 value 在内存中的地址。
  • 通过 Atomic:mpxchg 实现比较替换,其中参数 x 是即将更新的值,参数 e 是原内存的值。
  • 其中 Atomic:mpxchg 中对此指令加 lock 前缀,保证了内存屏障效果,保证了 CAS 同时具有 volatie 读和 volatie 写的内存语义。

在这里插入图片描述

3. 原子包 java.util.concurrent.atomic(锁自旋)

JDK1.5 的原子包:java.util.concurrent.atomic 这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择一个另一个线程进入
在这里插入图片描述

compareAndSet 利用 JNI 来完成CPU 指令的操作:在这里插入图片描述
相对于对于 synchronized 这种阻塞算法,CAS 是非阻塞算法的一种常见实现。由于一般 CPU 切换时间比 CPU 指令集操作更加长, 所以 J.U.C 在性能上有了很大的提升。

4. ABA 问题

CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。

比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。

部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题,乐观锁每次在执行数据的修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行+1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题,因为版本号只会增加不会减少。

十六:AQS(抽象的队列同步器)

AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch

1. 原理

参考博客:ReentrantLock实现原理深入探究
AQS维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进入此队列)。
在这里插入图片描述
由于AQS是基于FIFO队列的实现,因此必然存在一个个节点,Node就是一个节点,Node的结构:
在这里插入图片描述

2. 资源共享方式

  • Exclusive 独占资源:只有一个线程能执行,如 ReentrantLock
  • Share 共享资源:多个线程可同时执行,如 Semaphore/CountDownLatch

AQS 只是一个框架,不同的自定义同步器争用共享资源的方式也不同,自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。

实现方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它
  • tryAcquire(int):独占方式,尝试获取资源,成功则返回 true,失败则返回 false。
  • tryRelease(int):独占方式,尝试释放资源,成功则返回 true,失败则返回 false。
  • tryAcquireShared(int):共享方式,尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式,尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回 false。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现 tryAcquire-tryRelease和tryAcquireShared-tryReleaseShared 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 ReentrantReadWriteLock

3. 同步器的实现是 ABS 核心(state 资源状态计数)

以 ReentrantLock 为例,state 初始化为 0,表示未锁定状态。A 线程lock()时,会调用 tryAcquire()独占该锁并将 state+1。此后,其他线程再 tryAcquire()时就会失败,直到 A 线程 unlock()到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放
锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到零态的。

以 CountDownLatch 以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()一次,state会 CAS 减 1。等到所有子线程都执行完后(即 state=0),会 unpark()主调用线程,然后主调用线程就会从 await()函数返回,继续后续动作。

十七:Java 中用到的线程调度

在这里插入图片描述

1. 抢占式调度

抢占式调度指的是每条线程执行的时间、线程的切换都由系统控制。系统在某种运行机制下,可能每条线程都分同样的执行时间片,也可能是某些线程执行的时间片较长,甚至某些线程得不到执行的时间片。

特点:

  • 在这种机制下,一个线程的堵塞不会导致整个进程堵塞
  • 饥饿现象

2. 协同式调度

协同式调度指某一线程执行完后主动通知系统切换到另一线程上执行。线程的执行时间由线程本身控制,线程切换可以预知,不存在多线程同步问题,

弱点:

  • 线程都能够得到执行
  • 如果一个线程编写有问题,运行到一半就一直堵塞,那么可能导致整个系统崩溃

3. JVM 的线程调度实现(抢占式调度)

java 使用的线程调使用抢占式调度,Java 中线程会按优先级分配 CPU 时间片运行,且优先级越高越优先执行,但优先级高并不代表能独自占用执行时间片,可能是优先级高得到越多的执行时间片,反之,优先级低的分到的执行时间少但不会分配不到执行时间。

4. 线程让出 CPU 的情况

  1. 当前运行线程主动放弃 CPU,JVM 暂时放弃 CPU 操作(基于时间片轮转调度的 JVM 操作系统不会让线程永久放弃 CPU)或者说放弃本次时间片的执行权,例如调用 yield()方法
  2. 当前运行线程因为某些原因进入阻塞状态,例如阻塞在 I/O 上
  3. 当前运行线程结束,即运行完 run()方法里面的任务

猜你喜欢

转载自blog.csdn.net/weixin_36904568/article/details/90743847