Java线程(1):Java 并发学习总览
JAVA并发涵盖一下知识块:
基础知识、并发理论(JMM)、并发关键字、Lock体系、并发容器、线程池(Executor体系)、原子操作类、并发工具、并发实践。如下图所示
基础知识:
包含并发编程的优缺点、线程的状态和一些基本操作
并发理论(JMM):
包含JMM内存模型、重排序、happens-before规则
并发关键字:
包含synchronized、volatile、final、三大性质
Lock体系:
包含Lock与synchronized的比较、AQS、AQS源码解析、ReentrantLock、ReentrantReadWriteLock、Condition机制、LockSupport
并发容器:
包含concurrentHashMap、CopyOnWriteArrayList、ThreadLocal、BlockingQueue、ConcurrentLinkedQueue
线程池(Executor体系):
包含ThreadPoolExecutor、ScheduledThreadPoolExecutor、FutureTask
原子操作类:
包含实现原理、原子更新基本类型、原子更新数组类型、原子更新引用类型、原子更新字段类型
并发工具:
包含倒计时器CountDownLatch、循环栅栏CyclicBarrier、CountDownLatch和CyclicBarrier的比较、资源访问控制Semaphore、数据交换Exchanger
并发实践:
包含生产者-消费者问题
前言
使用并发的一个重要原因是提高执行效率。由于I/O
等情况阻塞,单个任务并不能充分利用CPU
时间。所以在单处理器的机器上也应该使用并发。
为了实现并发,操作系统层面提供了多进程。但是进程的数量和开销都有限制,并且多个进程之间的数据共享比较麻烦。另一种比较轻量的并发实现是使用线程,一个进程可以包含多个线程。线程在进程中没有数量限制, 数据共享相对简单。线程的支持跟语言是有关系的。Java
语言中支持多线程。
Java
中的多线程是抢占式的。这意味着一个任务随时可能中断并切换到其它任务。所以我们需要在代码中足够的谨慎,防范好这种切换带来的副作用。
基础
Java 1.5
之后,不再推荐直接使用Thread
对象作为任务的入口。推荐使用Executor
管理Thread
对象。Executor
是线程与任务之间的的一个中间层,它屏蔽了线程的生命周期,不再需要显式的管理线程。并且ThreadPoolExecutor
实现了此接口,我们可以通过它来利用线程池的优点。
线程池涉及到的类有:Executor
, ExecutorService
, ThreadExecutorPool
, Executors
, Executors.newFixedThreadPool()
, Executors.newCachedThreadPool()
, Executors.newSingleThreadExecutor()
, Executors.newScheduledThreadPool()
。
1. Executor
只有一个方法,execute(Runnable command)
来提交一个任务
2. ExecutorService
提供了管理异步任务的方法,也可以产生一个Future
对象来跟踪一个异步任务。
主要的方法如下:
submit
可以提交一个任务shutdown
可以拒绝接受新任务shutdownNow
可以拒绝新任务并向正在执行的任务发出中断信号invokeXXX
批量执行任务
3. ThreadPoolExecutor
线程池的具体实现类。线程池的好处在于提高效率,能避免频繁申请/回收线程带来的开销。
它的使用方法复杂一些,构造线程池的可选参数有:
corePoolSize
:int
工作的Worker
的数量。maximumPoolSize
:int
线程池中持有的Worker
的最大数量keepAliveTime
:long
当超过Workder
的数量corePoolSize
的时候,如果没有新的任务提交,超过corePoolSize
的Worker
的最长等待时间。超过这个时间之后,一部分Worker
将被回收。unit
:TimeUnit
keepAliveTime
的单位workQueue
:BlockingQueue
缓存任务的队列,这个队列只缓存提交的Runnable
任务。threadFactory
:ThreadFactory
产生线程的”工厂”handler
:RejectedExecutionHandler
当一个任务被提交的时候,如果所有Worker
都在工作并且超过了缓存队列的容量的时候。会交给这个Handler
处理。Java
中提供了几种默认的实现,AbortPolicy
,CallerRunsPolicy
,DiscardOldestPolicy
,DiscardPolicy
。 这里的Worker
可以理解为一个线程。
这里之前想不通,觉得线程不可能重新利用绑定新任务。看了下源码发现原来确实不是重新绑定任务。每一个Worker
的核心部分只是一个循环,不断从缓存队列中取任务执行。这样达到了重用的效果。
1
|
final void runWorker(Worker w) {
|
4. Executors
类提供了几种默认线程池的实现方式。
newCachedThreadPool()
工作线程的数量没有上限(Integer
的最大值), 有需要就创建新线程。newFixedThreadPool()
预先一次分配固定数量的线程,之后不再需要创建新线程。newSingleThreadExecutor()
只有一个线程的线程池。如果提交了多个任务,那么这些任务将排队,每个任务都在上一个任务执行完之后执行。所有任务都是按照它们的提交顺序执行的。
sleep(long)
当前线程 中止一段时间。它不会释放锁。Java1.5
之后提供了更加灵活的版本。
TimeUnit
可以指定睡眠的时间单位。
- 优先级 绝大多数情况下我们都应该使用默认的优先级。不同的虚拟机中对应的优先级级别的总数,一般用三个就可以了
MAX_PRIORITY
,NORM_PRIORITY
,MIN_PRIORITY
。 - 让步
Thread.yield()
建议相同优先级的其它线程先运行,但是不保证一定运行其它线程。 - 后台线程 一个进程中的所有非后台线程都终止的时候整个进程也就终止,同时杀死所有后台线程。与优先级没有什么关系。
- join() 线程
A
持有线程T
,当在线程T
调用T.join()
之后,A
会阻塞,直到T
的任务结束。可以加一个超时参数,这样在超时之后线程A
可以放弃等待继续执行任务。 - 捕获异常 不能跨线程捕获异常。比如说不能在
main
线程中添加try-catch
块来捕获其它线程中抛出的异常。每一个Thread
对象都可以设置一个UncaughtExceptionHandler
对象来处理本线程中抛出的异常。线程池中可以通过参数ThreadFactory
来为每一个线程设置一个UncaughtExceptionHandler
对象。
访问共享资源
在处理并发的时候,将变量设置为private
非常的重要,这可以防止其它线程直接访问变量。
synchronized
修饰方法在不加参数情况下,使用对象本身作为锁。静态方法使用Class
对象作为锁。同一个任务可以多次获得对象锁。
- 显式锁
Lock
,相比synchronized
更加灵活。但是需要的代码更多,编写出错的可能性也更高。只有在解决特殊问题或者提高效率的时候才用它。- 原子性 原子操作就是永远不会被线程切换中断的操作。很多看似原子的操作都是非原子的,比如说
long
,double
是由两个byte
表示的,它们的所有操作都是非原子的。所以,涉及到并发异常的地方都加上同步吧。除非你对虚拟机十分的了解。- volatile 这个关键字的作用在于防止多线程环境下读取变量的脏数据。这个关键字在c语言中也有,作用是相同的。
- 原子类
AtomicXXX
类,它们能够保证对数据的操作是满足原子性的。这些类可以用来优化多线程的执行效率,减少锁的使用。然而,使用难度还是比较高的。- 临界区
synchronized
关键字的用法。不是修饰整个方法,而是修饰一个代码块。它的作用在于尽量利用并发的效率,减少同步控制的区域。- ThreadLocal 这个概念与同步的概念不同。它是给每一个线程都创建一个变量的副本,并保持副本之间相互独立,互不干扰。所以各个线程操作自己的副本,不会产生冲突。
终结任务
这里我讲一下自己当前的理解。
一个线程不是可以随便中断的。即使我们给线程设置了中断状态,它也还是可以获得CPU
时间片的。只有因为sleep()
方法而阻塞的线程可以立即收到InterruptedException
异常,所以在sleep()
中断任务的情况下可以直接使用try-catch
跳出任务。其它情况下,均需要通过判断线程状态来判断是否需要跳出任务(Thread.interrupted()
方法)。
synchronized
关键字修饰的代码不会在收到中断信号后立即中断。ReentrantLock
锁控制的同步代码可以通过InterruptException
中断。
Thread.interrupted
方法调用一次之后会立即清空中断状态。可以自己用变量保存状态。
线程协作
wait/notifyAll
wait/notifyAll
是Object
类中的方法。调用wait/notifyAll
方法的对象是互斥对象。因为Java
中所有的Object
都可以做互斥量(synchronized
关键字的参数),所以wait/notify
方法是在Object
类中的。
wait
与sleep
不同在于sleep
方法是Thread
类中的方法,调用它的时候不会释放锁;wait
方法是Object
类中的方法,调用它的时候会释放锁。
调用wait
方法之前,当前线程必须持有这段逻辑的锁。否则会抛出异常,不能继续执行。
wait
方法可以将当前线程放入等待集合中,并释放当前线程持有的锁。此后,该线程不会接收到CPU
的调度,并进入休眠状态。有四种情况肯能打破这种状态:
- 有其它线程在此互斥对象上调用了
notify
方法,并且刚好选中了这个线程被唤醒;- 有其它线程在此互斥对象上调用了
notifyAll
方法;- 其它线程向此线程发出了中断信号;
- 等待时间超过了参数设置的时间;
线程一旦被唤醒之后,它会像正常线程一样等待之前持有的锁。直到恢复到wait
方法调用之前的状态。
还有一种不常见的情况,
spurious wakeup
(虚假唤醒)。就是在没有notify
,notifyAll
,interrupt
的时候线程自动醒来。查了一些资料并没有弄清楚是为什么。不过为了防止这种现象,我们要在wait的条件上加一层循环。
当一个线程调用wait
方法之后,其它线程调用该线程的interrupt
方法。该线程会唤醒,并尝试恢复之前的状态。当状态恢复之后,该线程会抛出一个异常。
notify
唤醒一个等待此对象的线程。notifyAll
唤醒所有等待此对象的线程。
错失的信号
当两个线程使用notify/wait
或者notifyAll/wait
进行协作的时候,不恰当的使用它们可能会导致一些信号丢失。例子:
1
|
T1:
|
信号丢失是这样发生的:
当
T2
执行到Point1
的时候,线程调度器将工作线程从T2
切换到T1
。T1
完成T2
条件的设置工作之后,线程调度器将工作线程从T1
切换回T2
。虽然T2
线程等待的条件已经满足,但还是会被挂起。
解决的方法比较简单:
1
|
T2:
|
将竞争条件放到
while
循环的外面即可。在进入while
循环之后,在没有调用wait
方法释放锁之前,将不会进入到T1
线程造成信号丢失。
notify/notifyAll
前面已经提过这两个方法的区别。notify
是随机唤醒一个等待此锁的线程,notifyAll
是唤醒所有等待此锁的线程。- Condition 他是
concurrent
类库中显式的挂起/唤醒任务的工具。它是真正的锁Lock
对象产生的一个对象。其实用法跟wait/notify
是一致的。await()
挂起任务,signalAll()
唤醒任务。 - 生产者消费者队列
Java
中提供了一种非常简便的容器,BlockingQueue
。已经帮你写好了阻塞式的队列。除了BlockingQueue
,使用PipedWriter
/PipedReader
也可以方便的在线程之间传递数据。