本篇主要基于Java面试题之并发篇(一)继续梳理java中关于并发相关的高频面试题。本篇的面试题基于网络整理,和自己编辑。在不断的完善补充哦。
2、synchronized
?的原理是什么?
synchronized
是 Java 内置的关键字,它提供了一种独占的加锁方式。
synchronized
的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。然而,synchronized
?也有一定的局限性。当线程尝试获取锁的时候,如果获取不到锁会一直阻塞。如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待。
synchronized
?的实例方法后,其它线程是否可进入此对象的其它方法?
- 如果其他方法没有?
synchronized
?的话,其他线程是可以进入的。- 所以要开放一个线程安全的对象时,得保证每个方法都是线程安全的。
同步块是更好的选择,因为它不会锁住整个对象(当然你也可以让它锁住整个对象)。同步方法会锁住整个对象,哪怕这个类中有多个不相关联的同步块,这通常会导致他们停止执行并需要等待获得这个对象上的锁。
同步块更要符合开放调用的原则,只在需要锁住的代码块锁住相应的对象,这样从侧面来说也可以避免死锁。
监视器和锁在 Java 虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。
代码如下:
public class SpinLock { private AtomicReference<Thread> sign =new AtomicReference<>(); public void lock() { // <1> Thread current = Thread.currentThread(); while(!sign .compareAndSet(null, current)) { // <1.1> } } public void unlock () { // <2> Thread current = Thread.currentThread(); sign .compareAndSet(current, null); } } |
<1>
?处,#lock()
?方法,如果获得不到锁,就会“死循环”,直到或得到锁为止。考虑到“死循环”会持续占用 CPU ,可能导致其它线程无法获得到 CPU 执行,可以在?<1.1>
?处增加?Thread.yiead()
?代码段,出让下 CPU 。<2>
?处,#unlock()
?方法,释放锁。6.1、volatile
?有什么用?volatile
?保证内存可见性和禁止指令重排。
同时,
volatile
?可以提供部分原子性。
简单来说,volatile
?用于多线程环境下的单次操作(单次读或者单次写)。
6.2、volatile
?变量和 atomic 变量有什么不同?
volatile
?变量,可以确保先行关系,即写操作会发生在后续的读操作之前,但它并不能保证原子性。例如用?volatile
?修饰?count
?变量,那么?count++
?操作就不是原子性的。- AtomicInteger 类提供的 atomic 方法,可以让这种操作具有原子性。例如?
#getAndIncrement()
?方法,会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。
volatile
?数组吗?Java 中可以创建?
volatile
?类型数组,不过只是一个指向数组的引用,而不是整个数组。如果改变引用指向的数组,将会受到?volatile
?的保护,但是如果多个线程同时改变数组的元素,volatile
?标示符就不能起到之前的保护作用了。?
同理,对于 Java POJO 类,使用?
volatile
?修饰,只能保证这个引用的可见性,不能保证其内部的属性。
6.4、volatile
?能使得一个非原子操作变成原子操作吗?一个典型的例子是在类中有一个?
long
?类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为?volatile
?。为什么?因为 Java 中读取?long
?类型变量不是原子的,需要分成两步,如果一个线程正在修改该?long
?变量的值,另一个线程可能只能看到该值的一半(前 32 位)。但是对一个?volatile
?型的?long
?或?double
?变量的读写是原子。
6.5、volatile
?类型变量提供什么保证?volatile
?主要有两方面的作用:
- 避免指令重排
- 可见性保证
例如,JVM 或者 JIT 为了获得更好的性能会对语句重排序,但是?volatile
?类型变量即使在没有同步块的情况下赋值也不会与其他语句重排序。
volatile
?提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。- 某些情况下,
volatile
?还能提供原子性,如读 64 位数据类型,像?long
?和?double
?都不是原子的(低 32 位和高 32 位),但?volatile
?类型的?double
?和?long
?就是原子的。不过需要在 64 位的 JVM 虚拟机上。
6.6、volatile
?和?synchronized
?的区别?
volatile
?本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized
?则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。volatile
?仅能使用在变量级别。synchronized
?则可以使用在变量、方法、和类级别的。volatile
?仅能实现变量的修改可见性,不能保证原子性。而synchronized
?则可以保证变量的修改可见性和原子性。volatile
?不会造成线程的阻塞。synchronized
?可能会造成线程的阻塞。volatile
?标记的变量不会被编译器优化。synchronized
标记的变量可以被编译器优化。
另外,会有面试官会问?
volatile
?能否取代?synchronized
?呢?答案肯定是不能,虽然说?volatile
?被称之为轻量级锁,但是和?synchronized
?是有本质上的区别,原因就是上面的几点落。
volatile
?替换?synchronized
??
- 只需要保证共享资源的可见性的时候可以使用?
volatile
?替代,synchronized
?保证可操作的原子性一致性和可见性。volatile
?适用于新值不依赖于旧值的情形。- 1 写 N 读。
- 不与其他变量构成不变性条件时候使用?
volatile
?。
死锁,是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
- 互斥条件:所谓互斥就是进程在某一时间内独占资源。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
- 撤消陷于死锁的全部进程。
- 逐个撤消陷于死锁的进程,直到死锁不存在。
- 从陷于死锁的进程中逐个强迫放弃所占用的资源,直至死锁消失。
- 从另外一些进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态。
活锁,任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。
活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
实际上,死锁就是悲观锁可能产生的结果,而活锁是乐观锁可能产生的结果。
悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
- 再比如 Java 里面的同步原语?
synchronized
?关键字的实现也是悲观锁。
乐观锁,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。
像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。
例如,version 字段(比较跟上一次的版本号,如果一样则更新,如果失败则要重复读-比较-写的操作)
在 Java 中?java.util.concurrent.atomic
?包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
- 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
- Java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
java.util.concurrent.locks.AbstractQueuedSynchronizer
?抽象类,简称 AQS ,是一个用于构建锁和同步容器的同步器。事实上concurrent
?包内许多类都是基于 AQS 构建。例如 ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,等。AQS 解决了在实现同步容器时设计的大量细节问题。AQS 使用一个 FIFO 的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态?
waitStatus
?。
java.util.concurrent.locks.Lock
?接口,比?synchronized
?提供更具拓展行的锁操作。它允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。它的优势有:
- 可以使锁更公平。
- 可以使线程在等待锁的时候响应中断。
- 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间。
- 可以在不同的范围,以不同的顺序获取和释放锁。
举例来说明锁的可重入性。代码如下:
public class UnReentrant{ Lock lock = new Lock(); public void outer() { lock.lock(); inner(); lock.unlock(); } public void inner() { lock.lock(); //do something lock.unlock(); } } |
#outer()
?方法中调用了?#inner()
?方法,#outer()
?方法先锁住了?lock
?,这样?#inner()
?就不能再获取?lock
?。- 其实调用?
#outer()
?方法的线程已经获取了?lock
?锁,但是不能在?#inner()
?方法中重复利用已经获取的锁资源,这种锁即称之为不可重入。- 可重入就意味着:线程可以进入任何一个它已经拥有的锁所同步着的代码块。
synchronized
、ReentrantLock 都是可重入的锁,可重入锁相对来说简化了并发编程的开发。
简单来说,ReenTrantLock 的实现是一种自旋锁,通过循环调用 CAS 操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。
- 都实现了多线程同步和内存可见性语义。
- 都是可重入锁。
????????1、同步实现机制不同
synchronized
?通过 Java 对象头锁标记和 Monitor 对象实现同步。ReentrantLock 通过CAS、AQS(AbstractQueuedSynchronizer)和 LockSupport(用于阻塞和解除阻塞)实现同步。
?
????????2、可见性实现机制不同
synchronized
?依赖 JVM 内存模型保证包含共享变量的多线程内存可见性。ReentrantLock 通过 ASQ 的?
volatile state
?保证包含共享变量的多线程内存可见性。
????????3、使用方式不同
synchronized
?可以修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、代码块(显示指定锁对象)。ReentrantLock 显示调用 tryLock 和 lock 方法,需要在?
finally
?块中释放锁。
????????4、功能丰富程度不同
synchronized
?不可设置等待时间、不可被中断(interrupted)。ReentrantLock 提供有限时间等候锁(设置过期时间)、可中断锁(lockInterruptibly)、condition(提供 await、condition(提供 await、signal 等方法)等丰富功能
????????5、锁类型不同
synchronized
?只支持非公平锁。ReentrantLock 提供公平锁和非公平锁实现。当然,在大部分情况下,非公平锁是高效的选择。
在?
synchronized
?优化以前,它的性能是比 ReenTrantLock 差很多的,但是自从?synchronized
?引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用?synchronized
?。并且,实际代码实战中,可能的优化场景是,通过读写分离,进一步性能的提升,所以使用 ReentrantReadWriteLock 。
ReadWriteLock ,读写锁是,用来提升并发程序性能的锁分离技术的 Lock 实现类。可以用于 “多读少写” 的场景,读写锁支持多个读操作并发执行,写操作只能由一个线程来操作。
ReadWriteLock 对向数据结构相对不频繁地写入,但是有多个任务要经常读取这个数据结构的这类情况进行了优化。ReadWriteLock 使得你可以同时有多个读取者,只要它们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直至这个写锁被释放为止。
ReadWriteLock 对程序性能的提高主要受制于如下几个因素:
- 数据被读取的频率与被修改的频率相比较的结果。
- 读取和写入的时间
- 有多少线程竞争
- 是否在多处理机器上运行
在没有 Lock 之前,我们使用?
synchronized
?来控制同步,配合 Object 的?#wait()
、#notify()
?等一系列方法可以实现等待 / 通知模式。在 Java SE 5 后,Java 提供了 Lock 接口,相对于?synchronized
?而言,Lock 提供了条件 Condition ,对线程的等待、唤醒操作更加详细和灵活。
LockSupport 是 JDK 中比较底层的类,用来创建锁和其他同步工具类的基本线程阻塞。
- Java 锁和同步器框架的核心 AQS(AbstractQueuedSynchronizer),就是通过调用?
LockSupport#park()
和?LockSupport#unpark()
?方法,来实现线程的阻塞和唤醒的。- LockSupport 很类似于二元信号量(只有 1 个许可证可供使用),如果这个许可还没有被占用,当前线程获取许可并继续执行;如果许可已经被占用,当前线程阻塞,等待获取许可。
Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各层硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的关系如下图:
?
线程、主内存、工作内存
线程之间的通信方式,目前有共享内存和消息传递两种。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。典型的共享内存通信方式,就是通过共享对象进行通信。
例如上图线程 A 与 线程 B 之间如果要通信的话,那么就必须经历下面两个步骤:
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。在 Java 中典型的消息传递方式,就是?#wait()
?和?#notify()
?,或者 BlockingQueue 。
在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果。
- 存在数据依赖关系的不允许重排序
需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。
内存屏障,又称内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
对主存的一次访问一般花费硬件的数百次时钟周期。处理器通过缓存(caching)能够从数量级上降低内存延迟的成本这些缓存为了性能重新排列待定内存操作的顺序。也就是说,程序的读写操作不一定会按照它要求处理器的顺序执行。当数据是不可变的,同时/或者数据限制在线程范围内,这些优化是无害的。如果把这些优化与对称多处理(symmetric multi-processing)和共享可变状态(shared mutable state)结合,那么就是一场噩梦。
?
当基于共享可变状态的内存操作被重新排序时,程序可能行为不定。一个线程写入的数据可能被其他线程可见,原因是数据写入的顺序不一致。适当的放置内存屏障,通过强制处理器顺序执行待定的内存操作来避免这个问题。
何为同步容器?可以简单地理解为通过?synchronized
来实现同步的容器,如果有多个线程调用同步容器的方法,它们将会串行执行。
- 比如 Vector,Hashtable,以及?
Collections#synchronizedSet()
,Collections#synchronizedList()
?等方法返回的容器。- 可以通过查看 Vector,Hashtable 等这些同步容器的实现代码,可以看到这些容器实现线程安全的方式就是将它们的状态封装起来,并在需要同步的方法上加上关键字?
synchronized
?。
并发容器,使用了与同步容器完全不同的加锁策略来提供更高的并发性和伸缩性。
- 例如在 ConcurrentHashMap 中采用了一种粒度更细的加锁机制,可以称为分段锁。在这种锁机制下,允许任意数量的读线程并发地访问 map ,并且执行读操作的线程和写操作的线程也可以并发的访问 map ,同时允许一定数量的写操作线程并发地修改 map ,所以它可以在并发环境下实现更高的吞吐量。
- 再例如,CopyOnWriteArrayList 。
一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map 。
使用分段锁来保证在多线程下的性能。ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将 hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。【注意,这块是 JDK7 的实现。在 JDK8 中,具体的实现已经改变】
另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException 异常,取而代之的是在改变时?
new
?新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。
在 JDK8 前,ConcurrentHashMap 把实际 map 划分成若干部分来实现它的可扩展性和线程安全。这种划分是使用并发度获得的,它是 ConcurrentHashMap 类构造函数的一个可选参数,默认值为 16 ,这样在多线程情况下就能避免争用。
在 JDK8 后,它摒弃了 Segment(锁段)的概念,而是启用了一种全新的方式实现,利用 CAS 算法。同时加入了更多的辅助变量来提高并发度,具体内容还是查看源码吧。
- HashEntry 中的?
key
、hash
、next
?均为?final
?型,只能表头插入、删除结点。
- HashEntry 类的?
value
?域被声明为?volatile
?型。- 不允许用?
null
?作为键和值,当读线程读到某个 HashEntry 的?value
?域的值为?null
?时,便知道产生了冲突——发生了重排序现象(put 方法设置新?value
?对象的字节码指令重排序),需要加锁后重新读入这个?value
?值。volatile
?变量?count
?协调读写线程之间的内存可见性,写操作后修改?count
?,读操作先读?count
,根据 happen-before 传递性原则写操作的修改读操作能够看到。
- Node 的?
val
?和?next
?均为?volatile
?型。#tabAt(..,)
?和?#casTabAt(...)
?对应的 Unsafe 操作实现了?volatile
?语义。
CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException 异常。在 CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。
- 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 ygc 或者 fgc 。
- 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然 CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。
CopyOnWriteArrayList 透露的思想:
- 读写分离,读和写分开
- 最终一致性
- 使用另外开辟空间的思路,来解决并发冲突
CopyOnWriteArrayList 适用于读操作远远多于写操作的场景。例如,缓存。
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:
- 在队列为空时,获取元素的线程会等待队列变为非空。
- 当队列满时,存储元素的线程会等待队列可用。
阻塞队列常用于生产者和消费者的场景:
- 生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程
- 阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。
BlockingQueue 接口,是 Queue 的子接口,它的主要用途并不是作为容器,而是作为线程同步的的工具,因此他具有一个很明显的特性:
- 当生产者线程试图向 BlockingQueue 放入元素时,如果队列已满,则线程被阻塞。
- 当消费者线程试图从中取出一个元素时,如果队列为空,则该线程会被阻塞。
- 正是因为它所具有这个特性,所以在程序中多个线程交替向BlockingQueue中 放入元素,取出元素,它可以很好的控制线程之间的通信。
阻塞队列使用最经典的场景,就是 Socket 客户端数据的读取和解析:
- 读取数据的线程不断将数据放入队列。
- 然后,解析线程不断从队列取数据解析。
JDK7 提供了 7 个阻塞队列。分别是:
Java5 之前实现同步存取时,可以使用普通的一个集合,然后在使用线程的协作和线程同步可以实现生产者,消费者模式,主要的技术就是用好 wait、notify、notifyAll、
sychronized
?这些关键字。而在 Java5 之后,可以使用阻塞队列来实现,此方式大大简少了代码量,使得多线程编程更加容易,安全方面也有保障。
ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
此队列按照先进先出(FIFO)的原则对元素进行排序,但是默认情况下不保证线程公平的访问队列,即如果队列满了,那么被阻塞在外面的线程对队列访问的顺序是不能保证线程公平(即先阻塞,先插入)的。
LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
此队列按照先出先进的原则对元素进行排序
PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
DelayQueue:支持延时获取元素的无界阻塞队列,即可以指定多久才能从队列中获取当前元素。
SynchronousQueue:一个不存储元素的阻塞队列。
每一个 put 必须等待一个 take 操作,否则不能继续添加元素。并且他支持公平访问队列。
LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
相对于其他阻塞队列,多了 tryTransfer 和 transfer 方法。
- transfer 方法:如果当前有消费者正在等待接收元素(take 或者待时间限制的 poll 方法),transfer 可以把生产者传入的元素立刻传给消费者。如果没有消费者等待接收元素,则将元素放在队列的 tail 节点,并等到该元素被消费者消费了才返回。
- tryTransfer 方法:用来试探生产者传入的元素能否直接传给消费者。如果没有消费者在等待,则返回 false 。和上述方法的区别是该方法无论消费者是否接收,方法立即返回。而 transfer 方法是必须等到消费者消费了才返回。
LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
优势在于多线程入队时,减少一半的竞争。??
方法处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
插入方法 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
移除方法 | remove() | poll() | take() | poll(time, unit) |
检查方法 | element() | peek() | 不可用 | 不可用 |
Queue | 阻塞与否 | 是否有界 | 线程安全保障 | 适用场景 | 注意事项 |
---|---|---|---|---|---|
ArrayBlockingQueue | 阻塞 | 有界 | 一把全局锁 | 生产消费模型,平衡两边处理速度 | 用于存储队列元素的存储空间是预先分配的,使用过程中内存开销较小(无须动态申请存储空间) |
LinkedBlockingQueue | 阻塞 | 可配置 | 存取采用 2 把锁 | 生产消费模型,平衡两边处理速度 | 无界的时候注意内存溢出问题,用于存储队列元素的存储空间是在其使用过程中动态分配的,因此它可能会增加 JVM 垃圾回收的负担。 |
JDK 的 Timer 和 DelayQueue 插入和删除操作的平均时间复杂度为?
O(nlog(n))
?,而基于时间轮可以将插入和删除操作的时间复杂度都降为?O(1)
?。
在 Java 多线程应用中,队列的使用率很高,多数生产消费模型的首选数据结构就是队列(先进先出)。
Java 提供的线程安全的 Queue 可以分为
1、阻塞队列,典型例子是 LinkedBlockingQueue 。
适用阻塞队列的好处:多线程操作共同的队列时不需要额外的同步,另外就是队列会自动平衡负载,即那边(生产与消费两边)处理快了就会被阻塞掉,从而减少两边的处理速度差距。
2、非阻塞队列,典型例子是 ConcurrentLinkedQueue 。
当许多线程共享访问一个公共集合时,
ConcurrentLinkedQueue
?是一个恰当的选择。
具体的选择,如下:
- 单生产者,单消费者
- 多生产者,单消费者
- 单生产者,多消费者
- 多生产者,多消费者
原子操作(Atomic Operation),意为”不可被中断的一个或一系列操作”。
- 处理器使用基于对缓存加锁或总线加锁的方式,来实现多处理器之间的原子操作。
- 在 Java 中,可以通过锁和循环 CAS 的方式来实现原子操作。CAS操作 —— Compare & Set ,或是 Compare & Swap ,现在几乎所有的 CPU 指令都支持 CAS 的原子操作。
原子操作是指一个不受其他操作影响的操作任务单元。原子操作是在多线程环境下避免数据不一致必须的手段。
int++
?并不是一个原子操作,所以当一个线程读取它的值并加 1 时,另外一个线程有可能会读到之前的值,这就会引发错误。- 为了解决这个问题,必须保证增加操作是原子的,在 JDK5 之前我们可以使用同步技术来做到这一点。到 JDK5 后,
java.util.concurrent.atomic
?包提供了?int
?和?long
?类型的原子包装类,它们可以自动的保证对于他们的操作是原子的并且不需要使用同步。
java.util.concurrent
?这个包里面提供了一组原子类。其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,具有排他性,即当某个线程进入方法,执行其中的指令时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由 JVM 从等待队列中选择一个另一个线程进入,这只是一种逻辑上的理解。
- 原子类:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference 。
- 原子数组:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray 。
- 原子属性更新器:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater 。
- 解决 ABA 问题的原子类:AtomicMarkableReference(通过引入一个
boolean
?来反映中间有没有变过),AtomicStampedReference(通过引入一个?int
?来累加来反映中间有没有变过)。
比如说一个线程 one 从内存位置 V 中取出 A ,这时候另一个线程 two 也从内存中取出 A ,并且 two 进行了一些操作变成了 B ,然后 two 又将 V 位置的数据变成 A ,这时候线程 one 进行 CAS 操作发现内存中仍然是 A ,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。
从 Java5 开始 JDK 的?atomic
包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。
对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于?
synchronized
?。
当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。
本篇文章梳理了java并发相关高频的面试题,希望对你有帮助哦。
JAVA面试题传送门:
?
?