对《Java虚拟机并发编程》这本书真的是相见恨晚。以前对并发编程只是懂个皮毛,这本书让我对并发编程有了一个全新的认识。所以把书上的知识点做下笔记,以便以后复习使用。
并发与并行
仔细说来,并发和并行是两个不同的概念。但随着多核处理器的普及,并发程序的不同的线程往往会被编译器分配到不同处理器核心上,所以说并发编程与并行对于上层程序员来说是一样的。
并发的风险
饥饿
当一个线程等待某个需要运行时间很长或者永远无法完成的时间发发生,那么这个线程就会陷入饥饿状态。通常饥饿也会被叫做活锁。
解决饥饿的方法是设计一个等待超时策略,让线程等待有限的时间
死锁
两个或多个线程互相等待对方释放所占用的资源或执行的某些动作。
竞争条件
竞争条件指两个线程竞争使用相同的资源或者数据。
造成竞争条件的主要原因是:Just-In-Time(JIT)编译器优化以及Java内存模型。
内存栅栏
内存栅栏(Memory Barrier)指从本地缓存(或寄存机)或工作内存到主存之间的拷贝动作。在程序运行过程中,所有的变更会先在寄存器或本地cache中完成,然后才会被拷贝到主存以跨越内存栅栏。这种跨越顺序称为happens-before。
写操作必须happens-before读操作,即写操作需要在所有读线程跨越内存栅栏之前完成自己的跨越动作,其所作的变更才能对其他线程可见。
Java并发API中由很多操作都隐含有跨越内存栅栏的含义:volatile、sychronized、Thread中的start()和interrupt()、ExecutorService中的函数以及向CountDown这样的同步工具。
volidate
关键字volidate的作用是告知JIT编译器不要对标记变量执行任何可能影响其访顺序的优化,而且对该变量的读写访问都需要忽略本地cache并直接对内存进行操作。但会使每次变更访问都要跨越内存栅栏并最终导致程序性能下降。此外在多个字段被多个线程并发访问得场景下,由于针对每个volidate字段的访问都是各自独立处理的,并且无法将这些访问统一协调成一次访问,所以volatile关键字无法保证整体操作的原子性。
解决方法是屏蔽对变量的直接访问,并将所有访问都引导为通过同步的getter和setter方法。
并发策略
确定线程数
程序所需的线程的总数:
线程数=CPU可用核心数/(1-阻塞系数)
其中阻塞系数取值在0和1之间。计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。
确定任务数
实践证明,在解决问题的过程中使处理器一直处于忙碌状态比将负载均摊到每个子任务要轻松的多。从处理器的角度来看,只要保证“只要还有待完成的任务,就不可能有空闲的处理器核心”就行了。因此,与其斤斤计较如何将负载平摊到每个子任务上,不如将任务拆分的比线程数多,以是处理器一直不停的工作。
设计方法
共享可变性(shared mutability)
创建的变量允许所有线程在可控的模型下修改。编程虽然简单,但会导致同步问题。必须保证代码在合适的时间穿越内存栅栏,并是变量具有良好的可见性。
隔离可变性(isolated mutability)
变量是可变的,但在任意时刻只有不超过一个线程可以看到该变量。
纯粹不变性(pure mutability)
所有事物都是不允许更改的。
不可变链表
通过把新节点插入链表的头,来是新旧链表共存。
持久化的Tries
通过高分支因子和前缀树的特定来组织状态的存储。
可扩展性和安全性
用ExecutorService管理线程
每个ExecutorService都代表一个线程池,其作用是将线程的创建与执行过程分离开来,而不是将线程的生命周期管理和任务的执行过程绑在一起。用线程池来管理大量线程,增加线程的重用。
可以按需配置线程池的类型,单线程的、带缓存的、基于优先级的、按预定时间调度/周期调度的、固定大小的。
线程协作
使用Callable接口和ExecutorService的invokeAll()或submit()函数来获取线程任务的返回值
线程池诱发的死锁(Pool Induced Deadlock):先创建的线程由于等待其他线程的结果而阻塞,但仍占用线程池,而由于线程池大小限制,无法创建新的线程来产生结果,造成了死锁。
使用CounDownLatch实现线程的协作
数据交换
想要在线程之间互发多组数据,可以用BlockingQueue接口。如果队列里没有空间,则插入操作被阻塞;如果队列里没有可用数据,则删除操作被阻塞。
如果想使插入和删除操作一一对应,可以使用SynchronousQueue类。
如果希望数据可以根据某种优先级在队列中上下浮动,则可以使用PriorityBlockQueue操作
如果只是想要一个简单的阻塞队列,可以选择链表实现LinkedBlockingQueue或者ArrayBlockingQueue
Fork-Join API
ForkJoinPool类根据可用的处理器数量和任务需求动态对线程进行管理。Fork-join使用了work-stealing策略,即线程在完成自己的任务之后,发现其他线程还有任务没有完成,就主动帮助其他线程。不但提升了API的性能,而且还有助于提高线程利用率。
在Fork-join API中,活动任务(Active task)所创建的子任务都是由创建主任务所不同的另一套函数来负责调度。通常一个应用程序中会使用一个fork-join池来调度任务,而且由于该线程池使用了守护线程,所以无需执行关闭操作。
ForkJoinTask有两个子类:RecursiveAction(执行不需要返回值的任务)和RecursiveTask(用于执行需要返回值得任务)
Fork-join API非常适合解决那些可以递归分解至小到足以顺序运行的问题。通过使用ForkJoinPool管理线程,多个较小的分解任务可以被同时执行。
可扩展集合类
同步集合类(synchronized collections)实现了线程安全的特性,而并发集合类(Concurrent collections)则保证了线程安全的同时,也兼顾了并发访问得性能。
同步集合类在执行遍历的时候会持有一个互斥锁,即悲观锁(pessimistic lock)。这种做法虽然增加了线程安全性,但同时也将显著降低并发访问性能。
并发集合类允许交叉读写,吞吐量比同步版本好。这是由于并发集合类并不是只通过一个互斥锁来进行同步管理的,而是将整个集合数据分成若干bulk,并允许更新操作和读操作在多个块上同时进行。在使用并发集合类时,读操作并非总能看到最新的数据值,这是因为如果读操作在某一个bulk的更新期间发生,则这个读操作不会为了等待整个更新操作完成而被阻塞,这就意味着只能看到集合数据的部分变化。
Synchronized和Lock
sychronized关键字可以显式获取对象的monitor/锁,并通过“先获取monitor并在代码块结尾处释放掉”的方式来帮助线程穿越内存栅栏。无法设置sychronized关键字在获取锁时等待的时间,可能会造成活锁。
Lock接口保证了对其方法的调用或跨越内存栅栏的同时,获得了更强的控制能力。lock()、unlock()、tryLock()
共享可变性
共享可变性不等于public
如果一个变量可以被多个线程读写,则该变量是可访问的且共享的。另一方面,如果一个变量仅能被一个线程访问,则称该变量是隔离的且非共享的。必须保证所有被被当做参数传递给其他函数的那些变量都是线程安全的。因为需要假定调用的参数将会从多个线程里访问这些参数。因此向函数传递一个非线程安全的变量将会导致意想不到的结果。同时对于函数的返回值也存在这个问题。