Java并发(一)
1. 线程基础知识
1.1 线程与进程的区别
- Ⅰ 拥有资源:进程是资源分配的基本单位,但是线程不拥有资源,线程可以访问隶属进程的资源。
- Ⅲ 系统开销:由于创建或撤销进程时,系统都要为之分配或回收资源,如内存空间、I/O 设备等,所付出的开销远大于创建或撤销线程时的开销。类似地,在进行进程切换时,涉及当前执行进程 CPU 环境的保存及新调度进程 CPU 环境的设置,而线程切换时只需保存和设置少量寄存器内容,开销很小。
- Ⅱ 调度:线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,从一个进程中的线程切换到另一个进程中的线程时,会引起进程切换。
- Ⅳ 通信方面:线程间可以通过直接读写同一进程中的数据进行通信,但是进程通信需要借助 IPC。
1.2 并发编程的优缺点
优点:
- 多核的CPU的背景下,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,计算速度和性能得到提升。
- 面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。
缺点: - 由于线程之间调度是要付出性能代价的,需要频繁的上下文切换
- 线程安全问题
2. 线程状态
2.1 新建(New)
创建后尚未启动。
2.2 可运行(Runnable)
可能正在运行(自旋或真正在运行线程),也可能正在等待 CPU 时间片。包含了操作系统线程状态中的 Running 和 Ready。处在就绪队列中(等待获取cpu时间片)。
2.3 阻塞(Blocking)
在同步队列中等待获取一个排它锁,如果其线程释放了锁就会结束此状态。处在同步队列中。
2.4 无限期等待(Waiting)
等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。处在等待队列中。
可以由下列实现:
- Object.wait()
- Thread.join()
- LockSupport.park()
2.5 限期等待(Timed Waiting)
无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。处在等待队列中。
可以由下列实现:
- Timeout 参数的 Object.wait()
- Timeout 参数的 Thread.join()
- Thread.sleep()
- LockSupport.parkNanos()
- LockSupport.parkUntil()
2.6 死亡(Terminated)
可以是线程结束任务之后自己结束,或者产生了异常而结束。
3. 基础线程机制
3.1 使用线程
- 实现 Runnable 接口
- 实现 Callable 接口:FutureTask封装Callable对象和Future
- 继承 Thread 类
- 调用Thread类的start方法:一个线程结束后仍然会继续存在,线程之间的切换顺序是由线程调度机制确定的,而线程调动机制是不确定性的
实现接口 VS 继承 Thread:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
3.2 线程状态的基本操作(Thread)
3.2.1 线程的中断
概念:中断可以理解为线程的一个标志位,它表示了一个运行中的线程是否被其他线程进行了中断操作。
- Thread.interrupt():对一个线程执行中断,即修改该线程的中断标志为中断
- Thread.isInterruptted():判断当前线程中断标志,是否被中断,不会清除标志位
- Thread.interruptted():判断当前线程是否被中断,会清除标志位
- InterruptException:通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于限期等待或者无限期等待状态,那么就会抛出 InterruptedException(不会响应线程处于阻塞、IO输入输出状态)
3.2.2 Thread.join()
在线程中调用另一个线程的 join() 方法,会将当前线程挂起,不占用cpu,而不是忙等待,直到目标线程结束。
3.2.3 Thread.sleep()
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理,可以自定义uncaughtexception。
Thread.sleep() VS Object.wait()
- sleep()方法是Thread的静态方法,而wait是Object实例方法
- wait()方法必须要在同步方法或者同步块中调用,也就是必须已经获得对象锁。而sleep()方法没有这个限制可以在任何地方种使用。另外,wait()方法会释放占有的对象锁,使得该线程进入等待池中,等待下一次获取资源。而sleep()方法只是会让出CPU并不会释放掉对象锁;
- sleep()方法在休眠时间达到后如果再次获得CPU时间片就会继续执行,而wait()方法必须等待Object.notift/Object.notifyAll通知后,才会离开等待池,且再次获得CPU时间片才会继续执行。
3.2.4 Thread.yield()
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
3.3 守护线程
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
3.4 Executor
3.4.1 定义
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
3.4.2 线程池
线程池用法
- CachedThreadPool:一个任务创建一个线程;
FixedThreadPool:所有任务只能使用固定大小的线程;(程序开始就会分配好所有线程数)
SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。
线程池好处
- 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,所以需要管理
- 通过复用已存在的线程和降低线程关闭的次数来尽可能降低系统性能损耗
- 通过复用线程,省去创建线程的过程,因此整体上提升了系统的响应速度;
线程池原理
- 先判断线程池中核心线程池所有的线程是否都在执行任务,如果不是则新创建一个线程执行刚提交的任务;如果是则进入第2步
- 判断当前阻塞队列是否已满,如果未满则将提交的任务放置在阻塞队列中;否则进入第3步;
- 判断线程池中所有的线程是否都在执行任务,如果没有,则创建一个新的线程来执行任务;否则,则交给饱和策略(其中DiscardOldestPolicy:丢弃掉阻塞队列中存放时间最久的任务,执行当前任务)进行处理
线程池的关闭
- shutdownNow首先将线程池的状态设置为STOP,然后尝试停止所有的正在执行和未执行任务的线程,并返回等待执行任务的列表;
- shutdown只是将线程池的状态设置为SHUTDOWN状态,然后中断所有没有正在执行任务的线程
3.4.3 ScheduledThreadPoolExecutor
线程池之ScheduledThreadPoolExecutor
定义
用来给定延时后执行异步任务或者周期性执行任务
类图大致关系
- ScheduledThreadPoolExecutor继承了ThreadPoolExecutor,也就是说ScheduledThreadPoolExecutor拥有execute()和submit()提交异步任务的基础功能
- ScheduledThreadPoolExecutor类实现了ScheduledExecutorService,该接口定义了ScheduledThreadPoolExecutor能够延时执行任务和周期执行任务的功能
- 在ScheduledThreadPoolExecutor实现方法中实际上是封装成ScheduledFutureTask,其功能是根据当前任务是否具有周期性,对异步任务进行进一步封装。如果不是周期性任务(调用schedule方法)则直接通过run()执行,若是周期性任务,则需要在每一次执行完后,重设下一次执行的时间,然后将下一次任务继续放入到阻塞队列中。
- DelayedWorkQueue是基于堆的数据结构,按照时间顺序将每个任务进行排序,将待执行时间越近的任务放在在队列的队头位置,以便于最先进行执行。
整体执行大致过程
对于周期性任务,先将任务移入到阻塞队列中,然后通过addWork方法新建了Work类,并通过runWorker方法启动线程,并不断的从阻塞对列中获取异步任务执行交给Worker执行,直至阻塞队列中无法取到任务为止。
3.4.4 FutureTask
用法
它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候结果才能取回,如果运算尚未完成get方法将会阻塞。
原理
FutureTask 实现了 RunnableFuture 接口,该接口继承自 Runnable 和 Future 接口,这使得 FutureTask 既可以当做一个任务执行,也可以有返回值。
应用场景
FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。
4. 共享受限资源
4.1 synchronized
4.1.1 定义
这是JVM实现的,是对共享资源的互斥同步的实现。又称内置锁、对象锁、监视器锁、互斥锁。
- 同步一个方法:它只作用于同一个对象(括号中所写),如果调用两个对象上的同步代码块,就不会进行同步。
- 同步代码块:与上一致
- 同步一个类:作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
- 同步一个静态方法:与上一致
4.1.2 原理
对象锁原理
synchronized先天具有重入性,所有实例对象都会自动拥有一个锁,并且所有syn方法都共享一个锁,当一个线程在该对象上调用任何syn方法的时候,会获取此对象的锁,其它线程再调用将会被阻塞(进入同步队列);一个任务可以多次获得一个对象上的锁,即该任务调用了同一个对象上的多个syn方法,JVM维护一个计数器,当调用了加一,退出了减一,为零时释放锁
锁获取与释放
锁在获取后会将从主存中读取数据,而不是工作内存;锁在释放后会立即将cpu操作后的数据写回主存(可见性)
4.2 Lock
4.2.1 AbstractQueuedSynchronizer(AQS)
深入理解AbstractQueuedSynchronizer(AQS)
定义
在同步组件的实现中,AQS是核心部分,同步组件的实现者通过使用AQS提供的模板方法实现同步组件语义,AQS则实现了对同步状态的管理,以及对阻塞线程进行排队,等待通知等等一些底层的实现处理。
同步队列:
当共享资源被某个线程占有,其他请求该资源的线程将会阻塞,从而进入同步队列。同步队列是一个双向队列,AQS通过持有头尾指针管理同步队列,其节点的数据结构,即AQS的静态内部类Node,节点的等待状态等信息。
独占锁:
- 独占锁的获取(acquire方法)
- 线程获取锁失败,线程被封装成Node进行入队操作,核心方法在于addWaiter()和enq(),同时enq()完成对同步队列的头结点初始化工作以及CAS操作失败的重试;之后如果当前节点的前驱节点是头结点并且成功获得同步状态时,节点出队即该节点引用的线程获得锁。
- 独占锁的释放(release()方法)
- 释放锁的时候会唤醒后继节点;
- 可中断式获取锁(acquireInterruptibly方法)
- 超时等待式获取锁(tryAcquireNanos()方法)
共享锁
- 共享锁的获取(acquireShared()方法)
- 共享锁的释放(releaseShared()方法)
- 可中断(acquireSharedInterruptibly()方法)
- 超时等待(tryAcquireSharedNanos()方法)
4.2.2 ReentrantLock
重入性
ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁,支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。
公平锁与非公平锁
公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO。非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁。
二者区别
公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
4.2.3 ReentrantReadWriteLock
定义
读写锁允许同一时刻被多个读线程访问,但是在写线程访问时,所有的读线程和其他的写线程都会被阻塞。读是共享锁,写是独占锁。
应用场景
读多写少
4.3 二者比较
- 锁的实现:synchronized 是 JVM 实现的,而 ReentrantLock 是JUC包 实现的。
- 性能:新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
- 粒度:ReentrantLock有更多细粒度的控制力
- 非公平锁与公平锁:synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
- 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待。
ReentrantLock 可中断,而 synchronized 不行(标志位判定)。
5. 线程之间的协作
5.1 Object的wait() notify() notifyAll()
定义
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
使用 wait() 挂起期间,线程会释放锁,这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
5.2 Condition的await() signal() signalAll()
详解Condition的await和signal等待/通知机制
定义
Condition与Lock配合完成等待通知机制
原理
Condition
AQS内部维护了一个同步队列,如果是独占式锁的话,所有获取锁失败的线程的尾插入到同步队列,同样的,condition内部也是使用同样的方式,内部维护了一个等待队列,是一个带头结点的链式单向队列,所有调用condition.await方法的线程会加入到等待队列中,并且线程状态转换为等待状态。
await实现原理
当调用condition.await()方法后会使得当前获取lock的线程进入到等待队列,如果该线程能够从await()方法返回的话一定是该线程获取了与condition相关联的lock。
signal/signalAll实现原理
调用condition的signal或者signalAll方法可以将等待队列中等待时间最长的节点移动到同步队列中;Object的notify则是根据操作系统来决定的。
5.3 二者比较
- 前者是java底层级别的,后者是语言级别的,具有更高的可控制性和扩展性
- Condition能够支持多个等待队列(new 多个Condition对象),而Object方式只能支持一个
- Condition能够支持超时时间的设置,而Object不支持
- Condition能够支持不响应中断,而通过使用Object方式不支持
6. J.U.C
6.1 并发工具
6.1.1 CountDownLatch
应用场景
用来控制一个线程等待多个线程。
原理
每个在该对象下调用await方法的线程都会阻塞,维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。
6.1.2 CyclicBarrier
应用场景
用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
原理
线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。
CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。
6.1.3 Semaphore
应用场景
Semaphore可以理解为信号量,用于控制资源能够被并发访问的线程数量(多个访问共享资源的线程要么在运行要么在就绪等待cpu时间片,超过一定数量的线程则被阻塞),以保证多个线程能够合理的使用特定资源。Semaphore用来做特殊资源的并发访问控制是相当合适的,如果有业务场景需要进行流量控制,可以优先考虑Semaphore。
原理
acquire用来获取信号,即获取资源使用权,release用来释放信号,如果资源使用权达到上限个数,那么其它线程请求的话则会被阻塞
6.1.4 Exchanger
应用场景
Exchanger是一个用于线程间协作的工具类,提供了一个交换的同步点,在这个同步点两个线程能够交换数据。
原理
具体交换数据是通过exchange方法来实现的,在同一个对象下,如果一个线程先执行exchange方法,那么它会同步等待另一个线程也执行exchange方法,这个时候两个线程就都达到了同步点,两个线程就可以交换数据。
6.1.5 ForkJoin
应用场景
Fork/Join框架是Java7提供了的一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务(典型的是递归),最终汇总每个小任务结果后得到大任务结果的框架
原理
ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数,将大任务放Pool中的一个双端队列中去。
ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。
6.2 并发集合
6.2.1 BlockingQueue
生产者与消费者模式
错失的信号
:保证wait在notify之前调用,可以用标志法;循环wait
:防止其它线程已经获取了资源,而本线程还未知;同步锁在循环外
:保证不会出现在循环内的时候,线程调度器切换到其它线程。
FIFO队列
ArrayBlockingQueue:由数组实现的有界阻塞队列,会指定其大小,一旦创建,容量不能改变
LinkedBlockingQueue:用链表实现的有界阻塞队列,会指定其大小,如果未指定,容量等于Integer.MAX_VALUE
优先队列
PriorityBlockingQueue:支持优先级的无界阻塞队列,默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现compareTo()方法来指定元素排序规则,或者初始化时通过构造器参数Comparator来指定排序规则。
6.2.2 CopyOnWriteArrayList
定义
CopyOnWrite容器也是一种读写分离的思想,延时更新的策略是通过在写的时候针对的是不同的数据容器来实现的,放弃数据实时性以及多增加了空间使用来达到数据的最终一致性。
原理
当我们往一个容器添加元素的时候,需要加锁,不然会出现多个并发复制的数组,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用(保证可见性)指向新的容器;对CopyOnWrite容器进行并发的读的时候,不需要加锁,因为当前容器不会添加任何元素。
6.2.3 ConcurrentLinkedQueue
定义
ConcurrentLinkedQueue通过无锁CAS操作实现并发,做到了更高的并发量,是个高性能的队列
大致原理
可知入队出队函数都是操作volatile变量:head,tail。所以要保证队列线程安全只需要保证对这两个Node操作的可见性和原子性,由于volatile本身保证可见性,所以只需要看下多线程下如果保证对着两个变量操作的原子性。
对于offer操作是在tail后面添加元素,也就是调用tail.casNext方法,而这个方法是使用的CAS操作,只有一个线程会成功,然后失败的线程会循环一下,重新获取tail,然后执行casNext方法。对于poll也是这样的。
6.2.4 ConcurrentHashMap
JDK6,7中的ConcurrentHashmap主要使用Segment来实现减小锁粒度,分割成若干个Segment,在put的时候需要ReentrantLock锁住Segment,get时候不加锁,使用volatile来保证可见性。ConcurrentHashMap 在执行 size 操作时先尝试不加锁,如果连续两次不加锁操作得到的结果一致,那么可以认为这个结果是正确的。如果尝试的次数超过 3 次,就需要对每个 Segment 加锁。
JDK8不采用segment而采用node,锁住node来实现减小锁粒度;使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁;采用synchronized而不是ReentrantLock;链表过长时会转换为红黑树。