JAVA多线程杂学2-2018年10月25日

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

什么是缓存一致性问题?该如何解决?

当程序在运行过程中,会将运算需要的数据从内存条复制一份到CPU 高速缓存当中,那么CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到内存条当中。

因此,当同一个变量被多条线程访问操作,由于不同线程运行在不同CPU中,因此不同线程的CPU高速缓存里的值可能不一样,这就导致先刷新到内存条的数据可能被后刷新的数据给覆盖,这就是缓存一致性问题。

为了解决缓存不一致性问题,通常来说有以下2 种解决方法:

①通过在总线加LOCK#锁的方式

②通过缓存一致性协议

通过在总线加LOCK#锁的方式

在早期的CPU 当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU 对其他部件访问(如内存),从而使得只能有一个CPU 能使用这个变量的内存。但是上由于在锁住总线期间,其他CPU 无法访问内存,导致效率低下,所以就出现了缓存一致性协议。

通过缓存一致性协议

该协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU 向内存写入数据时,如果发现操作的变量是共享变量,即在其他CPU 中也存在该变量的副本,会发出信号通知其他CPU 将该变量的缓存行置为无效状态,因此当其他CPU 需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

简述volatile 关键字

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile 修饰之后,那么就具备了两层语义:

保证了不同线程对这个变量进行读取时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(volatile 解决了线程间共享变量的可见性问题)

第一:使用volatile 关键字会强制将修改的值立即写入主存。

第二:使用volatile 关键字的话,当线程2进行修改时,会导致线程1 的工作内存中缓存变量stop 的缓存行无效(反映到硬件层的话,就是CPU 的L1或者L2 缓存中对应的缓存行无效);

第三:由于线程1 的工作内存中缓存变量stop 的缓存行无效,所以线程1再次读取变量stop 的值时会去主存读取。那么线程1读取到的就是最新的正确的值。

禁止进行指令重排序,阻止编译器对代码的优化

volatile 关键字禁止指令重排序有两层意思:

①当程序执行到volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;(指的是程序编写顺序)

②在进行指令优化时,不能把volatile 变量前面的语句放在其后面执行,也不能把volatile 变量后面的语句放到其前面执行。(指的是程序编译期不对该变量进行优化,与编写内容一致)

双重校验锁DCL(double checked locking)是使用volatile 的场景之一。

实现原理

为了实现volatile 的内存语义,加入volatile 关键字时,JAVA编译器在生成字节码时,会在指令序列中插入内存屏障,会多出一个lock 前缀指令。内存屏障是一组处理器指令,解决禁止指令重排序和内存可见性的问题。JAVA编译器和CPU 可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。处理器在进行重排序时是会考虑指令之间的数据依赖性。

内存屏障

内存屏障有2 个作用:①先于这个内存屏障的指令必须先执行,后于这个内存屏障的指令必须后执行。②使得内存可见性。所以,如果你的字段是volatile,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。

lock 前缀指令在多核处理器下会引发了两件事情:

①将当前处理器中这个变量所在缓存行的数据会写回到系统内存。这个写回内存的操作会引起在其他CPU 里缓存了该内存地址的数据无效。但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

②它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

内存屏障可以被分为以下几种类型:

①LoadLoad 屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2 及后续读取操作要读取的数据被访问前,保证Load1 要读取的数据被读取完毕。

②StoreStore 屏障:对于这样的语句Store1; StoreStore; Store2,在Store2 及后续写入操作执行前,保证Store1 的写入操作对其它处理器可见。

③LoadStore 屏障:对于这样的语句Load1; LoadStore; Store2,在Store2 及后续写入操作被刷出前,保证Load1 要读取的数据被读取完毕。

④StoreLoad 屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2 及后续所有读取操作执行前,保证Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

JAVA自带同步容器类以及它们的缺陷

在Java 中,同步容器主要包括2 类:

①Vector、HashTable。

②Collections 类中提供的静态工厂方法创建的类:Collections.synchronizedXXX()。

缺陷

②性能问题:在有多个线程进行访问时,如果多个线程都只是进行读取操作,那么每个时刻就只能有一个线程进行读取,其他线程便只能等待,这些线程必须竞争同一把锁。

②ConcurrentModificationException 异常:在对Vector 等容器进行迭代修改时,会报ConcurrentModificationException 异常。但是在并发容器中(如ConcurrentHashMap,CopyOnWriteArrayList 等)不会出现这个问题。

为什么说ConcurrentHashMap 是弱一致性的?以及为何多个线程并发修改ConcurrentHashMap时不会报ConcurrentModificationException?

①get方法:正是因为get 操作几乎所有时候都是一个无锁操作(get 中有一个readValueUnderLock 调用,不过这句执行到的几率极小),使得同一个Segment 实例上的put 和get 可以同时进行,这就是get 操作是弱一致的根本原因。(说白了就是虽然有readValueUnderLock调用,但执行到的几率极小,也就是一个无锁操作)

②clear方法:因为没有全局的锁,在清除完一个segment 之后,正在清理下一个segment 的时候,已经清理的segment 可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap 中是可能存在数据的。因此,clear 方法是弱一致的。

③迭代器:在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException 异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。这就是ConcurrentHashMap 迭代器弱一致的表现。在这种迭代方式中,当iterator 被创建后,集合再发生改变就不再是抛出ConcurrentModificationException,取而代之的是在改变时new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据,这样iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变,更重要的,这保证了多个线程并发执行的连续性和扩展性,是性能提升的关键。

总结:ConcurrentHashMap 的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable 和同步的HashMap 一样了。

CopyOnWriteArrayList的实现原理

CopyOnWrite 容器即写时复制的容器,也就是当我们往一个容器添加元素的时候,不直接往当前容器添加而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器(改变引用的指向)。这样做的好处是我们可以对CopyOnWrite容器进行并发的读而不需要加锁,因为当前容器不会添加任何元素。所以CopyOnWrite 容器也是一种读写分离的思想,读和写在不同的容器上进行,注意,写的时候需要加锁。

add方法

以下代码是向CopyOnWriteArrayList 中add 方法的实现,可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy 出N 个副本出来。

在CopyOnWriteArrayList 里处理写操作(包括add、remove、set 等)是先将原始的数据通过Arrays.copyof()来生成一份新的数组,然后在新的数据对象上进行写,写完后再将原来的引用指向到当前这个数据对象,这样保证了每次写都是在新的对象上。然后读的时候就是在引用的当前对象上进行读(包括get,iterator 等),不存在加锁和阻塞。CopyOnWriteArrayList 中写操作需要大面积复制数组,所以性能肯定很差,但是读操作因为操作的对象和写操作不是同一个对象,读之间也不需要加锁,读和写之间的同步处理只是在写完后通过一个简单的“=”将引用指向新的数组对象上来,这个几乎不需要时间,这样读操作就很快很安全,适合在多线程里使用。

(说白了就是同时进行read和write的时候,两者互不干扰,只是最后通过“=”来更改下指向即可)

read方法

读的时候不需要加锁, 如果读的时候有线程正在向CopyOnWriteArrayList 添加数据,读还是会读到旧的数据(在原容器中进行读)。

缺点

内存占用问题和数据一致性问题。

①内存占用问题:因为CopyOnWrite 的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象。针对内存占用问题,可以

A通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10 进制的数字,可以考虑把它压缩成36 进制或64 进制。

B不使用CopyOnWrite 容器而使用其他的并发容器, 如ConcurrentHashMap。

②数据一致性问题:CopyOnWrite 容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的数据,马上能读到,请不要使用CopyOnWrite 容器!!

Java 中堆和栈有什么不同

栈是一块和线程紧密相关的内存区域。每个线程都有自己的栈内存,用于存储本地变量,方法参数和栈调用,一个线程中存储的变量对其它线程是不可见的。而堆是所有线程共享的一片公用内存区域。对象都在堆里创建,为了提升效率线程会从堆中弄一个缓存到自己的栈(这里指的是主存与CPU高速缓存),如果多个线程使用该变量就可能引发问题,这时volatile 变量就可以发挥作用了,它要求线程从主存中读取变量的值。

线程池工作原理

一个线程池管理了一组工作线程,同时它还包括了一个用于放置等待执行任务的任务队列(阻塞队列)。默认情况下,在创建了线程池后,线程池中的线程数为0。当任务提交给线程池之后的处理策略如下:

①如果此时线程池中的数量小于corePoolSize(核心池的大小),即使线程池中的线程都处于空闲状态,也要创建新的线程来处理被添加的任务(也就是每来一个任务,就要创建一个线程来执行任务)。

②如果此时线程池中的数量大于等于corePoolSize,但是缓冲队列workQueue 未满,那么任务被放入缓冲队列,则该任务会等待空闲线程将其取出去执行。

③如果此时线程池中的数量大于等于corePoolSize , 缓冲队列workQueue 满,并且线程池中的数量小于maximumPoolSize(线程池最大线程数),建新的线程来处理被添加的任务。

④如果此时线程池中的数量大于等于corePoolSize , 缓冲队列workQueue 满,并且线程池中的数量等于maximumPoolSize,那么通过RejectedExecutionHandler 所指定的策略(任务拒绝策略)来处理此任务。也就是处理任务的优先级为: 核心线程corePoolSize 、任务队列workQueue、最大线程maximumPoolSize,如果三者都满了,使用handler 处理被拒绝的任务。

⑤特别注意,在corePoolSize 和maximumPoolSize 之间的线程数会被自动释放。当线程池中线程数量大于corePoolSize 时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize。这样,线程池可以动态的调整池中的线程数。

使用线程池优点

①通过重复利用已创建的线程,减少在创建和销毁线程上所花的时间以及系统资源的开销。

②提高响应速度。当任务到达时,任务可以不需要等到线程创建就可以立即执行。

③提高线程的可管理性。使用线程池可以对线程进行统一的分配和监控。

④如果不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存。

JAVA对线程池的支持

①Executor 接口:Executor 是一个顶层接口,在它里面只声明了一个方法execute(Runnable),返回值为void,参数为Runnable 类型,从字面意思可以理解,就是用来执行传进去的任务的

②Executors 类:它主要用来创建线程池。

③ExecutorService 接口:ExecutorService 接口继承了Executor 接口,并声明了一些方法:submit、invokeAll、invokeAny 以及shutDown 等;

④AbstractExecutorService 抽象类:抽象类AbstractExecutorService 实现了ExecutorService 接口,基本实现了ExecutorService 中声明的所有方法;

⑤ThreadPoolExecutor 类:ThreadPoolExecutor 继承了类AbstractExecutorService。

同步辅助类

CountDownLatch

它相当于一个计数器。用一个给定的数值初始化CountDownLatch,之后计数器就从这个值开始倒计数,直到计数值达到零。

CountDownLatch是通过“共享锁”实现的。在创建CountDownLatch 时,会传递一个int 类型参数,该参数是“锁计数器”的初始状态,表示该“共享锁”最多能被count 个线程同时获取, 这个值只能被设置一次, 而且CountDownLatch 没有提供任何机制去重新设置这个计数值。主线程必须在启动其他线程后立即调用await()方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。当某线程调用该CountDownLatch 对象的await()方法时,该线程会等待“共享锁”可用时,才能获取“共享锁”进而继续运行。而“共享锁”可用的条件,就是“锁计数器”的值为0!而“锁计数器”的初始值为count,每当一个线程调用该CountDownLatch 对象的countDown()方法时,才将“锁计数器”-1;通过这种方式,必须有count 个线程调用countDown()之后,“锁计数器”才为0,而前面提到的等待线程才能继续运行!

await()函数的作用是让线程阻塞等待其他线程,直到CountDownLatch 的计数值变为0,才继续执行之后的操作。

countDown()函数:这个函数用来将CountDownLatch 的计数值减1,如果计数达到0,则释放所有等待的线程。

它的应用场景:一个任务,它需要等待其他的一些任务都执行完毕之后它才能继续执行。比如:开5 个多线程去下载,当5 个线程都执行完了才算下载成功。

CyclicBarrier

这个类是一个可以重复利用的屏障类。它允许一组线程相互等待,直到全部到达某个公共屏障点,然后所有的这组线程再同步往后执行。

await()函数每被调用一次,计数便会减少1(CyclicBarrier 设置了初始值),并阻塞住当前线程。当计数减至0 时,阻塞解除,所有在此CyclicBarrier 上面阻塞的线程开始运行。

(就是当其他线程执行到await方法后处于堵塞状态,然后让某线程执行,当某线程全部执行完后,被await所堵塞的其他线程继续执行)

CountDownLatch 和CyclicBarrier 的区别?

①CountDownLatch 的作用是允许1 个线程等待其他线程执行完成之后它才执行;而CyclicBarrier 则是允许N 个线程相互等待到某个公共屏障点,然后这一组线程再同时执行。

②CountDownLatch 的计数器的值无法被重置,这个初始值只能被设置一次,是不能够重用的;CyclicBarrier 是可以重用的。

Semaphore

可以控制某个资源可被同时访问的个数,通过构造函数设定一定数量的许可,通过acquire() 获取一个许可,如果没有就等待,而release() 释放一个许可。

阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:在队列为空时,获取元素的线程会等待队列变为非空。当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

ArrayBlockingQueue

ArrayBlockingQueue是一个由数组支持的有界缓存的阻塞队列。ArrayBlockingQueue 内部还保存着两个整形变量,分别标识着队列的头部和尾部在数组中的位置。这个类是线程安全的。生产者和消费者共用一把锁。

LinkedBlockingQueue

LinkedBlockingQueue基于链表的阻塞队列,内部维持着一个数据缓冲队列(该队列由链表构成)。LinkedBlockingQueue 之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

ArrayBlockingQueue 和LinkedBlockingQueue 的区别

①队列大小的初始化方式不同:ArrayBlockingQueue 是有界的,必须指定队列的大小;LinkedBlockingQueue 是分情况的,指定队列的大小时,就是有界的;不指定队列的大小时,默认是Integer.MAX_VALUE,看成无界队列,但当生产速度大于消费速度时候,有可能会内存溢出。

②队列中锁的实现不同:ArrayBlockingQueue 实现的队列中的锁是没有分离的,即生产和消费用的是同一个锁;进行put 和take 操作,共用同一个锁对象。也即是说,put 和take 无法并行执行!LinkedBlockingQueue 实现的队列中的锁是分离的,即生产用的是putLock,消费是takeLock。也就是说,生成端和消费端各自独立拥有一把锁,避免了读(take)写(put)时互相竞争锁的情况,可并行执行。

③在生产或消费时操作不同:ArrayBlockingQueue 基于数组,在插入或删除元素时,是直接将枚举对象插入或移除的,不会产生或销毁任何额外的对象实例;LinkedBlockingQueue 基于链表,在插入或删除元素时,需要把枚举对象转换为Node<E>进行插入或移除,会生成一个额外的Node 对象,这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC 的影响还是存在一定的区别,会影响性能。

PriorityBlockingQueuePriorityQueue区别

此阻塞队列为基于数组的无界阻塞队列。它会按照元素的优先级对元素进行排序,按照优先级顺序出队,每次出队的元素都是优先级最高的元素。注意,不会阻塞生产者,但会阻塞消费者。PriorityBlockingQueue 里面存储的对象必须是实现Comparable 接口,队列通过这个接口的compare 方法确定对象的priority。队列的元素并不是全部按优先级排序的,但是队头的优先级肯定是最高的。每取一个头元素时候,都会对剩余的元素做一次调整,这样就能保证每次队头的元素都是优先级最高的元素。

DelayQueue

DelayQueue 是一个无界阻塞队列,用于放置实现了Delayed 接口的对象,只有在延迟期满时才能从中提取元素。该队列的头部是延迟期满后保存时间最长的Delayed 元素。这个队列里面所存储的对象都带有一个时间参数,采用take 获取数据的时候,如果时间没有到,取不出来任何数据。而加入数据的时候,是不会阻塞的(不会阻塞生产者,但会阻塞消费者)。DelayQueue 内部使用PriorityQueue 实现的。DelayQueue 是一个使用PriorityQueue实现的BlockingQueue,优先队列的比较基准值是时间。本质上即:DelayQueue =BlockingQueue +PriorityQueue + Delayed。

SynchronousQueue

同步队列是一个不存储元素的队列,它的size()方法总是返回0。每个线程的插入操作必须等待另一个线程的移除操作,同样任何一个线程的移除操作都必须等待另一个线程的插入操作。可以认为SynchronousQueue 是一个缓存值为1 的阻塞队列。

Java 中实现多线程的四种方式

继承Thread 类创建线程类

通过Runnable 接口创建线程类

通过Callable 和Future 创建线程

通过线程池创建线程

Java Runnable Callable 有什么不同?

①Callable 定义的方法是call()而Runnable 定义的方法是run()。

②Callable 的call 方法可以有返回值而Runnable 的run 方法不能有返回值。

③Callable 的call 方法可抛出异常而Runnable 的run 方法不能抛出异常。

一个类是否可以同时继承Thread 和实现Runnable接口?

可以。从Thread 类中继承了run()方法,这个run()方法可以被当作对Runnable 接口的实现。

如果不用锁机制如何实现共享数据访问。(不要用锁,不要用sychronized 块或者方法,也不要直接使用jdk 提供的线程安全的数据结构,需要自己实现一个类来保证多个线程同时读写这个类中的共享数据是线程安全的,怎么办?)

无锁化编程的常用方法:硬件CPU 同步原语CAS(Compare a

nd Swap),如无锁栈,无锁队列(ConcurrentLinkedQueue)等等。现在几乎所有的CPU 指令都支持CAS 的原子操作,X86 下对应的是CMPXCHG 汇编指令,处理器执行CMPXCHG 指令是一个原子性操作。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。

CAS 实现了区别于sychronized 同步锁的一种乐观锁,当多个线程尝试使用CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS 有3 个操作数,内存值V,旧的预期值A,要修改后的新值B。当且仅当预期值A 和内存值V 相同时,将内存值V 修改为B,否则什么都不做。其实CAS 也算是有锁操作,只不过是由CPU 来触发,比synchronized 性能好的多。CAS 的关键点在于,系统在硬件层面保证了比较并交换操作的原子性,处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。CAS 是非阻塞算法的一种常见实现。

Atomic 包提供了一系列原子类。这些类可以保证多线程环境下,当某个线程在执行atomic 的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM 从等待队列中选择一个线程执行。Atomic 类在软件层面上是非阻塞的,它的原子性其实是在硬件层面上借助相关的指令来保证的。

 

猜你喜欢

转载自blog.csdn.net/attack_breast/article/details/83385165