Java并发思考
该篇作为本文集的导读目录,将现有9篇关于Java并发的文章的核心内容整理并列出。这9篇文章大体上将Java并发的体系简述了一遍,对我个人是一次知识上的梳理,对大家也希望是一种阅读上的一种帮助。
祝各位阅读愉快。
1.《多线程安全性:每个人都在谈,但是不是每个人都谈地清》
多线程安全性的定义可能众说纷纭,但是其最核心的一点就是正确性,也就是程序的行为结果和预期一致。
当多个线程访问某个类时,不管运行环境采用何种线程调度算法或者这些线程如何交替执行,且不需要在主程序中添加任何额外的协同机制,这个类都能表现出正确的行为,那么这个类就是线程安全的。
要编写多线程安全的代码,最关键的一点就是需要对于共享的和可变的状态进行访问控制. 多线程安全要求在一个原子性操作中更新所有相关状态的变量。每个共享可变的变量,都应该只有一个锁来保护。如果由多个变量协同完成操作,则这些变量应该由同一个锁来保护。
2.《对象共享:Java并发环境中的烦心事》
并发的意义在于多线程协作完成某项任务,而线程的协作就不可避免地需要共享数据。
多线程安全不光要求实现了原子性,还要求实现内存可见性(Memory Visibility)。也就是在同步的过程中,不仅要防止某个线程正在使用的状态被另一个线程修改,还要保证一个线程修改了对象状态之后,其他线程能获得更新之后的状态。
在没有同步机制的情况下,在多线程的环境中,每个进程单独使用保存在自己的线程环境中的变量拷贝。正因如此,当多线程共享一个可变状态时,该状态就会有多份拷贝,当一个线程环境中的变量拷贝被修改了,并不会立刻就去更新其他线程中的变量拷贝。
加锁机制可以确保可见性、原子性和不可重排序性,但是Volatile变量只能确保可见性和不可重排序性。
3.《从Java内存模型角度理解安全初始化》
JVM只会在运行结果和严格串行执行结果相同的情况下进行如上的优化操作,如对代码的执行顺序重新排序。
为了进一步提高效率,多核处理器已经广泛被使用。在多核理器架构中,每个处理器都拥有自己的缓存,并且会定期地与主内存进行协调。这样的架构就需要解决缓存一致性(Cache Coherence)的问题。但是这些框架中只提供了最小保证,即允许不同处理器在任意时刻从同一存储位置上看到不同的值。
正因此存在上面所述的硬件能力和线程安全需求的差异,才导致需要在代码中使用同步机制来保证多线程安全。
Java内存模式为我们屏蔽了各个框架在内存模型上的差异。想要保证执行操作B的线程看到执行操作A的结果,而无论两个操作是否在同一线程,则操作A和操作B之间必须满足Happens-Before关系,否者JVM将可以对他们的执行顺序任意安排。
静态初始化或静态代码块因为由JVM的机制保护,不需要额外的同步机制,就可以保证其一定在调用类的方法(包括构造器)之前执行完毕。该特性和JVM的延迟加载机制结合,形成了一种完备的延迟初始化技术-延迟初始化占位类模式。
4.《从任务到线程:Java结构化并发应用程序》
并发设计的本质,就是要把程序的逻辑分解为多个任务,这些任务独立而又协作的完成程序的功能。而其中最关键的地方就是如何将逻辑上的任务分配到实际的线程中去执行。换而言之,任务是目的,而线程是载体,线程的实现要以任务为目标。
java.util.concurrent提供了Executor框架来帮助我们管理线程资源,规划线程的执行。Executor的本质就是管理和调度线程池。使用线程池任务池的优势在于:
- 通过复用现有线程而不是创建新的线程,降低创建线程时的开销;
- 复用现有线程,可以直接执行任务,避免因创建线程而让任务等待,提高响应速度。
Executor可以创建的线程池共有四种:
- newFixedThreadPool;
- newCachedThreadPool;
- newScheduledThreadPool;
- newSingleThreadExecutor。
Java1.5开始提供了Executor的扩展接口ExecutorService,其提供了两种方法关闭方法:
- shutdown: 平缓的关闭过程,即不再接受新的任务,等到已提交的任务执行完毕后关闭进程池;
- shutdownNow: 立刻关闭所有任务,无论是否再执行;
Java中设计了另一种接口Callable来作Runnable的升级版。Callable支持任务有返回值,并支持异常的抛出。
Future类表示任务生命周期状态,提供方法查询任务状态外,还提供get方法获得任务的返回值,如果任务没有执行完就会被拥塞。
当遇到一次性提交一组任务的情况,这个时候可以使用CompletionService,CompletionService可以理解为Executor和BlockingQueue的组合:当一组任务被提交后,CompletionService将按照任务完成的顺序将任务的Future对象放入队列中。
5.《关闭线程的正确方法:“优雅”的中断》
Java中没有提供安全的机制来终止线程。虽然有Thread.stop/suspend等方法,但是这些方法存在缺陷,不能保证线程中共享数据的一致性,所以应该避免直接调用。更为妥当的方式是使用Java中提供了中断机制,来让多线程之间相互协作,由一个进程来安全地终止另一个进程。
中断是取消线程的最合理的方式。
调用Interrupt方法并不是意味着要立刻停止目标线程,而只是传递请求中断的消息。所以对于中断操作的正确理解为:正在运行的线程收到中断请求之后,在下一个合适的时刻中断自己。
Future用来管理任务的生命周期,自然也可以来取消任务,调用Future.cancel方法就是用中断请求结束任务并退出.
一些的方法的拥塞是不能响应中断请求的,这类操作以I/O操作居多,但是可以让其抛出类似的异常,来停止任务。
6.《驾驭Java线程池:定制与扩展》
ThreadPoolExecutor提供了Executor的基本实现,除了提供*四种常见的方法来获得特定配置的进程池,还可以进行各种定制,以获得灵活稳定的线程池。
以下是ThreadPoolExecutor的构造函数
public ThreadPoolExecutor(
int corePoolSize,//基本大小
int maximumPoolSize, //最大大小
long keepAliveTime, //线程保活时间
TimeUnit unit, //保活时间单位
BlockingQueue<Runnable> workQueue,//任务队列
ThreadFactory threadFactory,//任务工厂
RejectedExecutionHandler handler) {...}//饱和策略
ThreadPoolExecutor使用拥塞队列BlockingQueue来保存等待的任务,任务队列共分为三种:无界队列,有解队列和同步队列。
ThreadPoolExecutor通过参数RejectedExecutionHandler来设定饱和策略,JDK中提供的实现共有四种:
- 中止策略(Abort Policy):默认的策略,队列满时,会抛出异常RejectedExecutionException,调用者在捕获异常之后自行判断如何处理该任务;
- 抛弃策略(Discard Policy):队列满时,进程池抛弃新任务,并不通知调用者;
- 抛弃最久策略(Discard-oldest Policy):队列满时,进程池将抛弃队列中被提交最久的任务;
- 调用者运行策略(Caller-Runs Policy):该策略不会抛弃任务,也不会抛出异常,而是将任务退还给调用者,也就是说当队列满时,新任务将在调用ThreadPoolExecutor的线程中执行。
当线程池需要创建新的线程时,就会通过线程工厂来创建Thread对象。默认情况下,线程池的线程工厂会创建简单的新线程,如果需要用户可以为线程池定制线程工厂。
ThreadPoolExecutor提供了可扩展的方法:
- beforeExecute: 在任务被执行之前被调用;
- afterExecute: 无论任务执行成功和还是抛出异常,都在返回后执行;如果任务执行中出现Error或是beforeExecute抛出异常,则afterExecutor不会被执行。
- terminated: 进程池完成之后被调用,可以用于释放进程池在生命周期内分配的各种资源和日志等工作。
7.《探秘Java并发模块:容器与工具类》
同步容器类的代表就是Vector和HashTable,这是早期JDK中提供的类。此外Collections.synchronizedXXX等工厂方法也可以把普通的容器(如HashMap)封装成同步容器。这些同步容器类的共同点就是:使用同步(Synchronized)方法来封装容器的操作方法,以保证容器多线程安全,但这样也使得容器的每次操作都会对整个容器上锁,所以同一时刻只能有一个线程访问容器。
同步容器类不能保证容器复合操作的原子性,使用其迭代器时也不能保证多线程安全。
从Java 5开始,JDK中提供了并发容器类来改进同步容器类的不足。Java 5 中提供了ConcurrentHashMap来代替同步的HashMap,提供了CopyOnWriteArrayList来代替同步都是List。
在ConcurrentHashMap分段锁来保护容器中的元素。如果访问的元素不是由同一个锁来保护,则允许并发被访问。这样做虽然增加了维护和管理的开销,但是提高并发性。不过,ConcurrentHashMap中也存在对整个容器加锁的情况,比如容器要扩容,需要重新计算所有元素的散列值, 就需要获得全部的分段锁。
CopyOnWriteArrayList用于代替同步的List,其为“写时复制(Copy-on-Write)”容器,本质为事实不可变对象,一旦需要修改,就会创建一个新的容器副本并发布。容器的迭代器会保留一个指向底层基础数组的引用,这个数组是不变的,且其当前位置位于迭代器的起始位置。
Java 5 还新增了两种容器类型:Queue和BlockingQueue:
- 队列Queue,其实现有ConcurrentLinkedQueue(并发的先进先出队列)和PriorityQueue(非并发的优先级队列);Queue上的操作不会被拥塞,如果队列为空 ,会立刻返回null,如果队列已满,则会立刻返回失败;
- 拥塞队列BlockingQueue,是Queue的一种扩展,其上的操作是可拥塞的:如果队列为空,则获取元素的操作将被拥塞直到队列中有可用元素,同理如果队列已满,则放入元素的操作也会被用塞到队列有可用的空间。
Java中还提供了同步工具类,这些同步工具类可以根据自身的状态来协调线程的控制流,上面提到的拥塞队列就是一种同步工具类,除此之外还有闭锁(Latch),信号量(Semaphore)和栅栏(Barrier)等
8.《Java高级上锁机制:显式锁 ReentrantLock》
ReentrantLock,它和同步(Synchronized)方法的内置锁不同,这是一种显式锁。显式锁作为一种高级的上锁工作, 是同步方法的一种补充和扩展,用来实现同步代码块无法完成的功能,比如提供响应中断的获得锁操作,提供支持超时的获得锁操作等等。
public interface Lock {
void lock(); //获取锁
void lockInterruptibly() throws InterruptedException; //可中断的获取锁操作
boolean tryLock(); //尝试获取锁,不会被拥塞,如果失败立刻返回
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //在一定时间内尝试获得锁,如果超时则失败
void unlock(); // 释放锁
Condition newCondition();
}
显式锁需要在手动调用lock方法来获得锁,并在使用后在finally代码块中调用unlock方法释放锁,以保证无论操作是否成功都能释放掉锁。
ReentrantLock的构造函数中提供两种锁的类型:
- 公平锁:线程将按照它们请求锁的顺序来获得锁;
- 非公平锁:允许插队,如果一个线程请求非公平锁的那个时刻,锁的状态正好为可用,则该线程将跳过所有等待中的线程获得该锁。
非公平锁在线程间竞争锁资源激烈的情况下,性能更高,是显式锁所使用默认模式。
无论是显式锁还是内置锁,都是互斥锁,也就是同一时刻只能有一个线程得到锁。互斥锁是保守的加锁策略,可以避免“写-写”冲突、“写-读”冲突”和"读-读"冲突。但是有时候不需要这么严格 ,同时多个任务读取数据是被允许,这有助于提升效率,不需要避免“读-读”操作。为此,Java 5.0 中出现了读-写锁ReadWriteLock。
建议只有在一些内置锁无法满足的情况下,再将显式锁ReentrantLock作为高级工具使用,比如要使用轮询锁、定时锁、可中断锁或者是公平锁。除此之外,还应该优先使用synchronized方法。
9.《尝试Java加锁新思路:原子变量和非阻塞同步算法》
无论是内置锁还是显式锁,都是一种独占锁,也是悲观锁。所谓悲观锁,就是以悲观的角度出发,认为如果不上锁,一定会有其他线程修改数据,破坏一致性,影响多线程安全,所以必须通过加锁让线程独占资源。
与悲观锁相对,还有更高效的方法——乐观锁,这种锁需要借助冲突检查机制来判断在更新的过程中是否存在来气其他线程的干扰,如果没有干扰,则操作成功,如果存在干扰则操作失败,并且可以重试或采取其他策略。
大部分处理器框架是通过实现比较并交换(Compare and Swap,CAS)指令来实现乐观锁。CAS指令包含三个操作数:需要读写的内存位置V,进行比较的值A和拟写入新值B。当且仅当V处的值等于A时,才说明V处的值没有被修改过,指令才会使用原子方式更新其为B值,否者将不会执行任何操作。无论操作是否执行, CAS都会返回V处原有的值。
CAS的方法在性能上有很大优势:在竞争程度不是很大的情况下,基于CAS的操作,在性能上远远超过基于锁的方法;在没有竞争的情况下,CAS的性能更高。
但是CAS的缺点是:将竞争的问题交给调用者来处理,但是悲观锁自身就能处理竞争。
Java中也引入CAS。对于int、long和对象的引用,Java都支持CAS操作,也就是原子变量类,JVM会把对于原子变量类的操作编译为底层硬件提供的最有效的方法:如果硬件支持CAS,则编译为CAS指令,如果不支持,则编译为上锁的操作。常见的原子变量有AtomicInteger、AtomicLong、AtomicBoolean和AtomicReference。
原子变量可以被视为一种更好volatile变量。但是原子变量没有定义hashCode和equals方法,所以每个实例都是不同的,不适合作为散列容器的key。
在中低程度的竞争之下,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够有效地避免竞争。
如果某种算法中,一个线程的失败或者挂起不会导致其他线程也失败和挂起,这该种算法是非阻塞的算法。
如果在算法中仅仅使用CAS用于协调线程间的操作,并且能够正确的实现,那么该算法既是一种无阻塞算法,也是一种无锁算法。在非拥塞算法中,不会出现死锁的优先级反转的问题。
创建非阻塞算法的关键在于将原子修改的范围缩小到单个变量上,同时保证数据一致性。非阻塞算法的特点:某项操作的完成具有不确定性,如不成功必须重新执行。