突击并发编程JUC系列演示代码地址:
https://github.com/mtcarpenter/JavaTutorial
CAS(Compare And Swap)
指比较并交换。CAS
算法CAS(V, E, N)
包含 3 个参数,V 表示要更新的变量,E 表示预期的值,N 表示新值。在且仅在 V 值等于 E值时,才会将 V 值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,当前线程什么都不做。最后,CAS 返回当前 V 的真实值。Concurrent
包下所有类底层都是依靠CAS
操作来实现,而sun.misc.Unsafe
为我们提供了一系列的CAS
操作。
ABA
问题什么是 ABA 问题呢?多线程环境下。线程 1 从内存的V位置取出 A ,线程 2 也从内存中取出 A,并将 V 位置的数据首先修改为 B,接着又将 V 位置的数据修改为 A,线程 1 在进行CAS
操作时会发现在内存中仍然是 A,线程 1 操作成功。尽管从线程 1 的角度来说,CAS
操作是成功的,但在该过程中其实 V 位置的数据发生了变化,线程 1 没有感知到罢了,这在某些应用场景下可能出现过程数据不一致的问题。
可以版本号(version)来解决 ABA 问题的,在 atomic
包中提供了 AtomicStampedReference
这个类,它是专门用来解决 ABA 问题的。
直达链接: AtomicStampedReference ABA 案例链接
由于单次 CAS
不一定能执行成功,所以 CAS
往往是配合着循环来实现的,有的时候甚至是死循环,不停地进行重试,直到线程竞争不激烈的时候,才能修改成功。
CPU 资源也是一直在被消耗的,这会对性能产生很大的影响。所以这就要求我们,要根据实际情况来选择是否使用 CAS
,在高并发的场景下,通常 CAS
的效率是不高的。
不能灵活控制线程安全的范围。只能针对某一个,而不是多个共享变量的,不能针对多个共享变量同时进行 CAS
操作,因为这多个变量之间是独立的,简单的把原子操作组合到一起,并不具备原子性。
AbstractQueuedSynchronizer
抽象同步队列简称AQS
,它是实现同步器的基础组件,并发包中锁的底层就是使用AQS
实现的。AQS
定义了一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它,例如常用的Synchronized
、ReentrantLock
、ReentrantReadWriteLock
、Semaphore
、CountDownLatch
等。该框架下的锁会先尝试以CAS
乐观锁去获取锁,如果获取不到,则会转为悲观锁(如RetreenLock
)。
ReentrantLock
。Semaphore
和CountDownLatch
。Java
从 JDK1.5
开始提供了 java.util.concurrent.atomic
包,方便程序员在多线程环 境下,无锁的进行原子操作。在 Atomic
包里一共有 12 个类,四种原子更新方式,分别是原子更新基本类型
,原子更新数组
,原子更新引用
和原子更新字段
。在 JDK 1.8
之后又新增几个原子类。如下如:
针对思维导图知识点在前面的章节都进行了理论+实践的讲解,到达地址如下:
突击并发编程JUC系列-原子更新AtomicLong<br/>
突击并发编程JUC系列-数组类型AtomicLongArray<br/>
突击并发编程JUC系列-原子更新字段类AtomicStampedReference<br/>
突击并发编程JUC系列-JDK1.8 扩展类型 LongAdder
AtomicLong
的常用方法long getAndIncrement()
:以原子方式将当前值加1,注意,返回的是旧值。(i++)long incrementAndGet()
:以原子方式将当前值加1,注意,返回的是新值。(++i)long getAndDecrement()
:以原子方式将当前值减 1,注意,返回的是旧值 。(i--)long decrementAndGet()
:以原子方式将当前值减 1,注意,返回的是新值 。(--i)long addAndGet(int delta)
:以原子方式将输入的数值与实例中的值(AtomicLong
里的value
)相加,并返回结果synchronized
的使用范围要广泛得多。比如说 synchronized 既可以修饰一个方法,又可以修饰一段代码,相当于可以根据我们的需要,非常灵活地去控制它的应用范围synchronized
锁的粒度都要大于原子变量的粒度。synchronized
是一种典型的悲观锁,而原子类恰恰相反,它利用的是乐观锁。volatile
可见性问题有了更高效的 LongAdder
,那 AtomicLong
可否不使用了呢?是否凡是用到 AtomicLong
的地方,都可以用 LongAdder
替换掉呢?答案是不是的,这需要区分场景。
LongAdder
只提供了 add
、increment
等简单的方法,适合的是统计求和计数的场景,场景比较单一,而 AtomicLong
还具有 compareAndSet
等高级方法,可以应对除了加减之外的更复杂的需要 CAS
的场景。
结论:如果我们的场景仅仅是需要用到加和减操作的话,那么可以直接使用更高效的 LongAdder
,但如果我们需要利用 CAS
比如compareAndSet
等操作的话,就需要使用 AtomicLong
来完成。
直达链接:突击并发编程JUC系列-JDK1.8 扩展类型 LongAdder
CountDownLatch
基于线程计数器来实现并发访问控制,主要用于主线程等待其他子线程都执行完毕后执行相关操作。其使用过程为:在主线程中定义CountDownLatch
,并将线程计数器的初始值设置为子线程的个数,多个子线程并发执行,每个子线程在执行完毕后都会调用countDown
函数将计数器的值减1,直到线程计数器为0,表示所有的子线程任务都已执行完毕,此时在CountDownLatch
上等待的主线程将被唤醒并继续执行。
突击并发编程JUC系列-并发工具 CountDownLatch
CyclicBarrier
(循环屏障)是一个同步工具,可以实现让一组线程等待至某个状态之后再全部同时执行。在所有等待线程都被释放之后,CyclicBarrier
可以被重用。CyclicBarrier
的运行状态叫作Barrier
状态,在调用await
方法后,线程就处于Barrier
状态。
CyclicBarrier
中最重要的方法是await方法,它有两种实现。
public int await()
:挂起当前线程直到所有线程都为Barrier状态再同时执行后续的任务。public int await(long timeout, TimeUnit unit)
:设置一个超时时间,在超时时间过后,如果还有线程未达到Barrier
状态,则不再等待,让达到Barrier状态的线程继续执行后续的任务。突击并发编程JUC系列-并发工具 CyclicBarrier
Semaphore
指信号量,用于控制同时访问某些资源的线程个数,具体做法为通过调用acquire()
获取一个许可,如果没有许可,则等待,在许可使用完毕后通过release()
释放该许可,以便其他线程使用。
突击并发编程JUC系列-并发工具 Semaphore
相同点:都能阻塞一个或一组线程,直到某个预设的条件达成发生,再统一出发。
但是它们也有很多不同点,具体如下。
CyclicBarrier
要等固定数量的线程都到达了栅栏位置才能继续执行,而 CountDownLatch
只需等待数字倒数到 0,也就是说 CountDownLatch
作用于事件,但 CyclicBarrier
作用于线程;CountDownLatch
是在调用了 countDown
方法之后把数字倒数减 1,而 CyclicBarrier
是在某线程开始等待后把计数减 1。CountDownLatch
在倒数到 0 并且触发门闩打开后,就不能再次使用了,除非新建一个新的实例;而 CyclicBarrier
可以重复使用。CyclicBarrier
还可以随时调用 reset 方法进行重置,如果重置时有线程已经调用了 await 方法并开始等待,那么这些线程则会抛出 BrokenBarrierException
异常。CyclicBarrier
有执行动作 barrierAction
,而 CountDownLatch
没这个功能。CountDownLatch
和CyclicBarrier
都用于实现多线程之间的相互等待,但二者的关注点不同。CountDownLatch
主要用于主线程等待其他子线程任务均执行完毕后再执行接下来的业务逻辑单元,而CyclicBarrier
主要用于一组线程互相等待大家都达到某个状态后,再同时执行接下来的业务逻辑单元。此外,CountDownLatch
是不可以重用的,而CyclicBarrier
是可以重用的。Semaphore
和Java
中的锁功能类似,主要用于控制资源的并发访问。ReentrantLock
支持公平锁和非公平锁两种方式。公平锁指锁的分配和竞争机制是公平的,即遵循先到先得原则。非公平锁指JVM
遵循随机、就近原则分配锁的机制。ReentrantLock
通过在构造函数ReentrantLock(boolean fair)
中传递不同的参数来定义不同类型的锁,默认的实现是非公平锁。这是因为,非公平锁虽然放弃了锁的公平性,但是执行效率明显高于公平锁。如果系统没有特殊的要求,一般情况下建议使用非公平锁。
synchronized
可以给类,方法,代码块加锁,而 lock
只能给代码块加锁。synchronized
不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁,而 lock
需要手动自己加锁和释放锁,如果使用不当没有 unLock
去释放锁,就会造成死锁。lock
可以知道有没有成功获取锁,而 synchronized
无法办到。synchronized
和 Lock
都是用来保护资源线程安全的。synchronized
和 ReentrantLock
都拥有可重入的特点。不同点:
lock
需要配合finally
)ReentrantLock
可响应中断、可轮回,为处理锁提供了更多的灵活性ReentrantLock
通过Condition
可以绑定多个条件synchronized
锁不够灵活synchronized
是同步阻塞,采用的是悲观并发策略;Lock
是同步非阻塞,采用的是乐观并发策略。使用
Lock
也不使用 synchronized
。synchronized
关键字适合你的程序,这样可以减少编写代码的数量,减少出错的概率Lock
的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock
。void lock()
:获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回void lockInterruptibly() throws InterruptedException
:可中断地获取锁,和lock
方法地不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程boolean tryLock()
: 尝试非阻塞地获取锁,调用该方法后立刻返回,如果能够获取则返回 true 否则 返回falseboolean tryLock(long time, TimeUnit unit)
:超时地获取锁,当前线程在以下 3 种情况下会返回:void unlock()
: 释放锁Condition newCondition()
:获取锁等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的 wait()
方法,而调用后,当前线程将释放锁。tryLock
、lock
和lockInterruptibly
的区别如下。
tryLock
若有可用锁,则获取该锁并返回true,否则返回false,不会有延迟或等待;tryLock(long timeout, TimeUnit unit)
可以增加时间限制,如果超过了指定的时间还没获得锁,则返回 false。lock
若有可用锁,则获取该锁并返回true,否则会一直等待直到获取可用锁。lockInterruptibly
会抛出异常,lock
不会。突击并发编程JUC系列-ReentrantLock
要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)
ReentrantLock
适用于一般场合,ReadWriteLock
适用于读多写少的情况,合理使用可以进一步提高并发效率。
突击并发编程JUC系列-ReentrantReadWriteLock
ReentrantReadWriteLock
的实现选择了“不允许插队”的策略,这就大大减小了发生“饥饿”的概率。
插队策略
升降级策略:只能从写锁降级为读锁,不能从读锁升级为写锁。
tryLock(long timeout,TimeUnit unit)
的方法(ReentrantLock 、ReenttranReadWriteLock
)设置超时时间,超时可以退出防止死锁。 java.util.concurrent
并发类代替手写锁。Condition
类的 awiat
方法和 Object
类的 wait
方法等效Condition
类的 signal
方法和 Object
类的 notify
方法等效Condition
类的 signalAll
方法和 Object
类的 notifyAll
方法等效ReentrantLock
类可以唤醒指定条件的线程,而 object 的唤醒是随机的HashTable
使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;ConcurrentHashMap
JDK 1.7
中使用分段锁(ReentrantLock + Segment + HashEntry
),相当于把一个 HashMap
分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry
。JDK 1.8
中使用 CAS + synchronized + Node + 红黑树
。锁粒度:Node(首结点)(实现 Map.Entry
)。锁粒度降低了。JDK 1.7
中的ConcurrentHashMap
内部进行了 Segment
分段,Segment
继承了 ReentrantLock
,可以理解为一把锁,各个 Segment
之间都是相互独立上锁的,互不影响。
相比于之前的 Hashtable
每次操作都需要把整个对象锁住而言,大大提高了并发效率。因为它的锁与锁之间是独立的,而不是整个对象只有一把锁。
每个 Segment 的底层数据结构与 HashMap
类似,仍然是数组和链表组成的拉链法结构。默认有 0~15 共 16 个 Segment
,所以最多可以同时支持 16 个线程并发操作(操作分别分布在不同的 Segment
上)。16 这个默认值可以在初始化的时候设置为其他值,但是一旦确认初始化以后,是不可以扩容的。
图中的节点有三种类型:
HashMap
非常类似的拉链法结构,在每一个槽中会首先填入第一个节点,但是后续如果计算出相同的 Hash 值,就用链表的形式往后进行延伸。ConcurrentHashMap
中所没有的结构,在此之前我们可能也很少接触这样的数据结构链表长度大于某一个阈值(默认为 8),满足容量从链表的形式转化为红黑树的形式。
红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色,红黑树的本质是对二叉查找树 BST 的一种平衡策略,我们可以理解为是一种平衡二叉查找树,查找效率高,会自动平衡,防止极端不平衡从而影响查找效率的情况发生,红黑树每个节点要么是红色,要么是黑色,但根节点永远是黑色的。
Node[]
数组是否初始化,没有则进行初始化操作hash
定位数组的索引坐标,是否有 Node
节点,如果没有则使用 CAS
进行添加(链表的头节点),添加失败则进入下次循环。synchronized
锁住 f 元素(链表/红黑二叉树的头元素)Node
(链表结构)则执行链表的添加操作TreeNode
(树形结构)则执行树添加操作。突击并发编程JUC系列-并发容器ConcurrentHashMap
阻塞队列(BlockingQueue
)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
ArrayBlockingQueue
:一个由数组结构组成的有界阻塞队列。LinkedBlockingQueue
:一个由链表结构组成的有界阻塞队列。PriorityBlockingQueue
:一个支持优先级排序的无界阻塞队列。DelayQueue
:一个使用优先级队列实现的无界阻塞队列。SynchronousQueue
:一个不存储元素的阻塞队列。LinkedTransferQueue
:一个由链表结构组成的无界阻塞队列。LinkedBlockingDeque
:一个由链表结构组成的双向阻塞队列。突击并发编程JUC系列-阻塞队列 BlockingQueue
Java 中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。
当提交一个新任务到线程池时,线程池的处理流程如下:
ThreadPoolExecutor
执行execute()
方法的示意图 如下:
ThreadPoolExecutor
执行execute
方法分下面 4 种情况:
corePoolSize
,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。corePoolSize
,则将任务加入BlockingQueue
。BlockingQueue
(队列已满),则创建新的线程来处理任务(注意,执行这一步骤需要获取全局锁)。maximumPoolSize
,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()
方法。ThreadPoolExecutor
采取上述步骤的总体设计思路,是为了在执行execute()方法时,尽可能地避免获取全局锁(那将会是一个严重的可伸缩瓶颈)。在ThreadPoolExecutor
完成预热之后(当前运行的线程数大于等于corePoolSize
),几乎所有的execute()
方法调用都是执行步骤 2,而步骤2不需要获取全局锁。
NEW(初始)
,新建状态,线程被创建出来,但尚未启动时的线程状态;RUNNABLE(就绪状态)
,表示可以运行的线程状态,它可能正在运行,或者是在排队等待操作系统给它分配 CPU 资源;BLOCKED(阻塞)
,阻塞等待锁的线程状态,表示处于阻塞状态的线程正在等待监视器锁,比如等待执行 synchronized
代码块或者使用 synchronized
标记的方法;WAITING(等待)
,等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作,比如,一个线程调用了 Object.wait()
方法,那它就在等待另一个线程调用 Object.notify()
或 Object.notifyAll()
方法;TIMED_WAITING(超时等待)
,计时等待状态,和等待状态(WAITING)
类似,它只是多了超时时间,比如调用了有超时时间设置的方法 Object.wait(long timeout)
和 Thread.join(long timeout)
等这些方法时,它才会进入此状态;TERMINATED
,终止状态,表示线程已经执行完成。running
:这是最正常的状态,接受新的任务,处理等待队列中的任务。shutdown
:不接受新的任务提交,但是会继续处理等待队列中的任务。stop
:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。tidying
:所有的任务都销毁了,workcount
为 0,线程池的状态再转换 tidying 状态时,会执行钩子方法 terminated()
。terminated
: terminated()
方法结束后,线程池的状态就会变成这个。execute()
: 只能执行 Runable
类型的任务。submit()
可以执行 Runable
和 Callable
类型的任务。 Callable
类型的任务可以获取执行的返回值,而 Runnable
执行无返回值。
newSingleThreadExecutor()
: 他的特点是在于线程数目被限制位1:操作一个无界的工作队列,所以它保证了所有的任务的都是顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。newCachedThreadPool()
:它是一种用来处理大量短时间工作任务的线程,具有几个鲜明的特点,它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程,如果线程闲置的时间超过 60 秒,则被终止并移除缓存;长时间闲置时,这种线程池不会消耗什么资源,其内部使用 synchronousQueue
作为工作队列。newFixedThreadPool(int nThreads)
:重用指定数目 nThreads 的线程,其背后使用的无界的工作队列,任何时候最后有 nThreads 个工作线程活动的,这意味着 如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现,如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。newSingleThreadScheduledExecutor()
: 创建单线程池,返回ScheduleExecutorService
可以进行定时或周期性的工作强度。newScheduleThreadPool(int corePoolSize)
: 和 newSingleThreadSceduleExecutor()
类似,创建的ScheduledExecutorService
可以进行定时或周期的工作调度,区别在于单一工作线程还是工作线程。newWorkStrealingPool(int parallelism)
:这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool
利用 work-strealing
算法 并行的处理任务,不保证处理顺序。ThreadPollExecutor
: 是最原始的线程池创建,上面 1-3 创建方式 都是对ThreadPoolExecutor
的封装。上面 7 种创建方式中,前 6 种 通过Executors
工厂方法创建,ThreadPoolExecutor
手动创建。
下面介绍下 ThreadPoolExecutor
接收 7 个参数的构造方法
/** * 用给定的初始参数创建一个新的ThreadPoolExecutor。 */ public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量 int maximumPoolSize,//线程池的最大线程数 long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间 TimeUnit unit,//时间单位 BlockingQueue<Runnable> workQueue,//任务队列 ThreadFactory threadFactory,//线程工厂 RejectedExecutionHandler handler//拒绝策略 )
corePoolSize
: 核心线程数线程数定义了最小可以同时运行的线程数量。maximumPoolSize
: 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,信任就会被存放在队列中。keepAliveTime
:线程活动保持时间,当线程池中的线程数量大于 corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime
才会被回收销毁;unit
: keepAliveTime
参数的时间单位。threadFactory
: 任务队列,用于保存等待执行的任务的阻塞队列。可以选择以下几个阻塞队列。
ArrayBlockingQueue
:是一个基于数组结构的有界阻塞队列,此队列按 FIFO
(先进先出)原则对元素进行排序。LinkedBlockingQueue
:一个基于链表结构的阻塞队列,此队列按FIFO
排序元素,吞吐量通常要高于ArrayBlockingQueue
。静态工厂方法Executors.newFixedThreadPool()
使用了这个队列。SynchronousQueue
:一个不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于Linked-BlockingQueue
,静态工厂方法Executors.newCachedThreadPool
使用了这个队列。PriorityBlockingQueue
:一个具有优先级的无限阻塞队列。handler
:饱和策略(又称拒绝策略)。当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy
,表示无法处理新任务时抛出异常。在JDK 1.5
中 Java 线程池框架提供了以下4种策略。
AbortPolicy
:直接抛出异常。CallerRunsPolicy
:只用调用者所在线程来运行任务。DiscardOldestPolicy
:丢弃队列里最近的一个任务,并执行当前任务。DiscardPolicy
:不处理,丢弃掉欢迎关注公众号 山间木匠 , 我是小春哥,从事 Java 后端开发,会一点前端、通过持续输出系列技术文章以文会友,如果本文能为您提供帮助,欢迎大家关注、 点赞、分享支持,_我们下期再见!_