在多线程、多处理器甚至是分布式环境的编程时代,并发是一个不可回避的问题,很多程序员一碰到并发二字头皮就发麻,也包括我。既然并发问题摆在面前 一个到无法回避的坎,倒不如拥抱它,把它搞清楚,决心花一定的时间从操作系统底层原理到Java的基础编程再到分布式环境等几个方面深入探索并发问题。先 就从原理开始吧。
并发产生的原因
虽然从直观效果上,处理器是并行处理多项任务,但本质上一个处理器在某个时间点只能处理一个任务,属于串行执行。在单处理器的情况下,并发问题源于 多道程序设计系统的一个基本特性:进程的相对执行速度不可预测,它取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略。在分布式环境 下,并发产生的可能性就更大了,只要大家有依赖的共享资源,就有并发问题的出现,因为互相调用次序更加没法控制。
并发带来的问题
• 全局资源的共享充满了危险。不同任务对同一个共享资源的读写顺序非常关键
• 操作系统很难对分配资源进行最优化管理。挂起的线程占有了其他活动线程需要的资源
• 定位错误非常困难。这种问题来源和触发的不确定性,导致定位问题非常困难
• 限制分布式系统横向扩展能力
进程的交互
进程的交互方式决定了并发问题产生的上下文,解决并发问题也需根据进程交互方式的不同而不同对待。一般进程交互分为以下三种:
1)进程间相互独立
这种情况下虽然进程间没有数据共享,所做事情也互不联系,但它们存在竞争关系。计算机中有些临界资源比如I/O设备、存储器、CPU时间和时钟等等 都需要通过竞争得到,你占用的时候就得保证别人没法占用,因此首先得解决这种互斥的需求。另外,要处理好这种临界资源的调度策略,处理不当就有可能发生死 锁和饥饿
2)进程间通过共享合作
这种情况下进程间虽然执行的过程是相互独立的,互不知道对方的执行情况,但互相之间有共享的数据。因此除了有以上互斥需求和死锁饥饿的可能,另外还会有数据一致性的问题。当多个进程非原子性操作同一个数据时候,互相之间操作时序不当就有可能造成数据不一致
3)进程间通过通信合作
这种情况下进程间通过消息互相通信,知晓各自的执行情况,不共享任何资源,因此就可以避免互斥和数据不一致问题,但仍然存在死锁和饥饿的问题
并发问题的解决办法
操作系统解决并发问题一般通过互斥,为了提供互斥的支持,需要满足以下需求:
• 一次只允许一个进程进入临界区
• 一个非临界区停止的进程必须不干涉其他进程
• 不允许出现一个需要访问临界区的进程被无限延迟
• 一个进程驻留在临界区中的时间必须是有限的
• 临界区空闲时,任何需要进入临界区的进程必须能够立即进入
满足互斥的解决方案:
1)硬件支持
• 中断禁用
中断禁用简单说来就是在某一进程在临界区执行过程中禁用中断,不允许其他进程通过中断打断其执行。虽然这种方式可以保证互斥,但代价非常高,处理器被限制于只能交替执行程序,效率降低。另外不适用于多处理器环境。
• 专用机器指令
从硬件的角度提供一些机器指令,用于保证多个动作的原子性,通过适用这些具有原子性的指令来控制临界区的访问。比如提供符合以下逻辑的原子性指令:
1. boolean testset(int i){
2. if(i==0){
3. i=1;
4. return true;
5. }else{
6. return false;
7. }
8. }
在控制临界区的时候可以通过忙等待来保证只有一个进程停留在临界区,伪代码如下所示:
1. int bolt;
2. void onlyOneThread(){
3. while(!testset(bolt)){
4. /*等待*/
5. }
6. /*临界区*/
7. bolt=0;
8. }
专用机器指令的优点是可以不限制处理器数量,也不限制临界区的数量,但它的问题是使用了忙等待,消耗处理器时间。并且也存在饥饿和死锁的问题
2)信号量
其原理是多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一个位置停止,直到它收到一个特定的信号,再重新被唤起工作。这种方式最大优点 就是解决了忙等待的问题。其核心和机器指令类似,通过提供原子性信号量控制方法,一般情况下提供等待和唤起两种操作原语,以较为简单的二元信号量原语为 例,两种方法的伪代码如下:
1. void wait(semaphore s){
2. if(s.value==1){
3. s.value=0;
4. }else{
5. /*停止此线程,并把线程放入s的线程等待队列(s.queue)里*/
6. }
7. }
8. void signal(semaphore s){
9. if(s.queue.size()==0){
10. s.value=1;
11. }else{
12. /*从s的线程等待队列(s.queue)里拿出一个线程,使其激活执行*/
13. }
14. }
两个方法的实现关键在于其原子性,当然也可以借助专用机器指令的方法来保障其原子性,毕竟这两种方法的执行不长,使用忙等待也问题不大。
再看互斥的问题,若使用信号量,则其具体实现如以下伪代码所示:
1. void onlyOneThread(){
2. wait(s);
3. /*临界区*/
4. signal(s);
5. }
3)管程
信号量虽然解决了性能问题,但使得信号量的控制逻辑遍布在程序里,控制逻辑复杂以后很难整体上控制所有信号量。而管程的思路和面向对象类似,通过一个管程的概念把互斥和资源保护的细节封装在管程的内部,外部程序只需对管程的正确使用就能保证避免并发问题,管程的特点如下:
• 共享数据变量只能被管程的过程访问
• 一个进程通过调用管程的一个过程进入管程
• 只能有一个进程在管程中执行,其他进程被挂起,等待进入管程
4)消息传递
消息传递是通过消息通信的方式进程之间相互配合,满足互斥需求。这种方式最大好处就是可以运用与分布式环境。说到消息,抽象地看有两种操作方式:send和receive。从同步方式上看分为阻塞和非阻塞两种,其组合起来有以下 情况:
• 阻塞send,阻塞receive。发送进程和接收进程都被阻塞,直到信息交付,同步性最好
• 非阻塞send,阻塞receive。最为自然的一对组合
• 非阻塞send,非阻塞receive。
那么通过实现以上send和receive原语操作,就可达到互斥的目的,以下面伪代码为例,其中receive为阻塞的,send为非阻塞的:
1. void onlyOneThread(){
2. receive(box,msg);
3. /*临界区*/
4. send(box,msg);
5. }
小结
以上是从操作系统的底层来看待并发问题,平常的开发过程一般不需要了解,但透过其原理,我们可以发掘一些解决并发问题的思路。只有真正了解并发产生 的原因和操作系统采取的办法,我们才能理解在更高一个层次(比如高级语言编程)为什么有那些控制和措施,为什么对一些代码要做并发控制。
在写Java程序的时候,何时需要进行并发控制,关键在于判断这段程序或这个类是否是线程安全的。
当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步,这个类的行为仍然是正确的,那么称这个类是线程安全的。我们设计类就是要在有潜在并发问题存在情况下,设计线程安全的类。线程安全的类可以通过以下手段来满足:
• 不跨线程共享变量
• 使状态变量为不可变的
• 在任何访问状态变量的时候使用同步。
• 每个共享的可变变量都需要由唯一一个确定的锁保护。
满足线程安全的一些思路
1)从源头避免并发问题
很多开发者一想到有并发的可能就通过底层技术来解决问题,其实往往可以通过上层的架构设计和业务分析来避免并发场景。比如我们需要用多线程或分布式 集群来计算一堆客户的相关统计值,由于客户的统计值是共享数据,因此会有并发潜在可能。但从业务上我们可以分析出客户与客户之间数据是不共享的,因此可以 设计一个规则来保证一个客户的计算工作和数据访问只会被一个线程或一台工作机完成,而不是把一个客户的计算工作分配给多个线程去完成。这种规则很容易设 计。当你从源头就避免了并发问题的可能,下面的工作就完全可以不用担心线程安全问题。
2)无状态就是线程安全
多线程编程或者分布式编程最忌讳有状态,一有状态就不但限制了其横向扩展能力,也是产生并发问题的起源。当你设计的类是无状态的,那么它永远都是线程安全的。因此在设计阶段需要考虑如何用无状态的类来满足你的业务需求
3)分清原子性操作和复合操作
所谓原子性,是说一个操作不会被其他线程打断,能保证其从开始到结束独享资源连续执行完这一操作。如果所有程序块都是原子性的,那么就不存在任何并 发问题。而很多看上去像是原子性的操作正式并发问题高灾区。比如所熟知的计数器(count++)和check-then-act,这些都是很容易被忽视 的,例如大家所常用的惰性初始化模式,以下代码就不是线程安全的:
1. @NotThreadSafe
2. public class LazyInitRace {
3. private ExpensiveObject instance = null;
4. public ExpensiveObject getInstance() {
5. if (instance == null)
6. instance = new ExpensiveObject();
7. return instance;
8. }
9. }
这段代码具体问题在于没有认识到if(instance==null)和instance = new ExpensiveObject();是两条语句,放在一起就不是原子性的,就有可能当一个线程执行完if(instance==null)后会被中断, 另一个线程也去执行if(instance==null),这次两个线程都会执行后面的instance = new ExpensiveObject();这也是这个程序所不希望发生的。
虽然check-then-act从表面上看很简单,但却普遍存在与我们日常的开发中,特别是在数据库存取这一块。比如我们需要在数据库里存一个客 户的统计值,当统计值不存在时初始化,当存在时就去更新。如果不把这组逻辑设计为原子性的就很有可能产生出两条这个客户的统计值。
在单机环境下处理这个问题还算容易,通过锁或者同步来把这组复合操作变为原子操作,但在分布式环境下就不适用了。一般情况下是通过在数据库端做文章,比如通过唯一性索引或者悲观锁来保障其数据一致性。当然任何方案都是有代价的,这就需要具体情况下来权衡。
另外,java1.5以后提供了一套提供原子性操作的类,有兴趣的可以研究一下它是如何在软件层面保证原子性的。
4)锁的合理使用
大家都知道可以用锁来解决并发问题,但在具体使用上还有很多讲究,比如:
• 每个共享的可变变量都需要由一个个确定的锁保护。
• 一旦使用了锁,就意味着这段代码的执行就丧失了操作系统多道程序的特性,会在一定程度上影响性能
• 锁不能解决在分布式环境共享变量的并发问题
很多程序员对一个共享变量初始化要注意可见性和安全发布(安全地构建一个对象,并其他线程能正确访问)等问题不是很理解,认为Java是一个屏蔽内 存细节的平台,连对象回收都不需要关心,因此谈到可见性和安全发布大多不知所云。其实关键在于对Java存储模型,可见性和安全发布的问题是起源于 Java的存储结构。
Java存储模型原理
有很多书和文章都讲解过Java存储模型,其中一个图很清晰地说明了其存储结构:
由上图 jvm系统中存在一个主内存(Main Memory或Java Heap Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的。 每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
这个存储模型很像我们常用的缓存与数据库的关系,因此由此可以推断JVM如此设计应该是为了提升性能,提高多线程的并发能力,并减少线程之间的影响。
Java存储模型潜在的问题
一谈到缓存, 我们立马想到会有缓存不一致性问题,就是说当有缓存与数据库不一致的时候,就需要有相应的机制去同步数据。同理,Java存储模型也有这个问题,当一个线 程在自己工作内存里初始化一个变量,当还没来得及同步到主存里时,如果有其他线程来访问它,就会出现不可预知的问题。另外,JVM在底层设计上,对与那些 没有同步到主存里的变量,可能会以不一样的操作顺序来执行指令,举个实际的例子:
1. public class PossibleReordering {
2. static int x = 0, y = 0;
3. static int a = 0, b = 0;
4. public static void main(String[] args)
5. throws InterruptedException {
6. Thread one = new Thread(new Runnable() {
7. public void run() {
8. a = 1;
9. x = b;
10. }
11. });
12. Thread other = new Thread(new Runnable() {
13. public void run() {
14. b = 1;
15. y = a;
16. }
17. });
18. one.start(); other.start();
19. one.join(); other.join();
20. System.out.println("( "+ x + "," + y + ")");
21. }
22. }
由于,变量x,y,a,b没有安全发布,导致会不以规定的操作顺序来执行这次四次赋值操作,有可能出现以下顺序:
出现这个问题也可以理解,因为既然这些对象不可见,也就是说本应该隔离在各个线程的工作区内,那么对于有些无关顺序的指令,打乱顺序执行在JVM看来也是可行的。
因此,总结起来,会有以下两种潜在问题:
• 缓存不一致性
• 重排序执行
解决Java存储模型潜在的问题
为了能让开发人员安全正确地在Java存储模型上编程,JVM提供了一个happens-before原则,有人整理得非常好,我摘抄如下:
• 在程序顺序中, 线程中的每一个操作, 发生在当前操作后面将要出现的每一个操作之前.
• 对象监视器的解锁发生在等待获取对象锁的线程之前.
• 对volitile关键字修饰的变量写入操作, 发生在对该变量的读取之前.
• 对一个线程的 Thread.start() 调用 发生在启动的线程中的所有操作之前.
• 线程中的所有操作 发生在从这个线程的 Thread.join()成功返回的所有其他线程之前.
有了原则还不够,Java提供了以下工具和方法来保证变量的可见性和安全发布:
• 使用 synchronized来同步变量初始化。此方式会立马把工作内存中的变量同步到主内存中
• 使用 volatile关键字来标示变量。此方式会直接把变量存在主存中而不是工作内存中
• final变量。常量内也是存于主存中
另外,一定要明确只有共享变量才会有以上那些问题,如果变量只是这个线程自己使用,就不用担心那么多问题了
搞清楚Java存储模型后,再来看共享对象可见性和安全发布的问题就较为容易了
共享对象的可见性
当对象在从工作内存同步到主内存之前,那么它就是不可见的。若有其他线程在存取不可见对象就会引发可见性问题,看下面一个例子:
1. public class NoVisibility {
2. private static boolean ready;
3. private static int number;
4. private static class ReaderThread extends Thread {
5. public void run() {
6. while (!ready)
7. Thread.yield();
8. System.out.println(number);
9. }
10. }
11. public static void main(String[] args) {
12. new ReaderThread().start();
13. number = 42;
14. ready = true;
15. }
16. }
按照正常逻辑,应该会输出42,但其实际结果会非常奇怪,可能会永远没有 输出(因为ready为false),可能会输出0(因为重排序问题导致ready=true先执行)。再举一个更为常见的例子,大家都喜欢用只有set 和get方法的pojo来设计领域模型,如下所示:
1. @NotThreadSafe
2. public class MutableInteger {
3. private int value;
4. public int get() { return value; }
5. public void set(int value) { this.value = value; }
6. }
但是,当有多个线程同时来存取某一个对象时,可能就会有类似的可见性问题。
为了保证变量的可见性,一般可以用锁、 synchronized关键字、 volatile关键字或直接设置为final
共享变量发布
共享变量发布和我们常说的发布程序类似,就是说让本属于内部的一个变量变为一个可以被外部访问的变量。发布方式分为以下几种:
• 将对象引用存储到公共静态域
• 初始化一个可以被外部访问的对象
• 将对象引用存储到一个集合里
安全发布和保证可见性的方法类似,就是要同步发布动作,并使发布后的对象可见。
线程安全
其实当我们把这些变量封闭在本线程内访问,就可以从根本上避免以上问题,现实中存在很多例子通过线程封闭来安全使用本不是线程安全的对象,比如:
• swing的可视化组件和数据模型对象并不是线程安全的,它通过将它们限制到swing的事件分发线程中,实现线程安全
• JDBC Connection对象没有要求为线程安全,但JDBC的存取模式决定了一个Connection只会同时被一个线程使用
• ThreadLocal把变量限制在本线程中共享
基于线程安全的一些原则来编程当然可以避免并发问题,但不是所有人都能写出高质量的线程安全的代码,并且如果代码里到处都是线程安全的控制也极大地 影响了代码可读性和可维护性。因此,Java平台为了解决这个问题,提供了很多线程安全的类和并发工具,通过这些类和工具就能更简便地写线程安全的代码。 归纳一下有以下几种:
• 同步容器类
• 并发容器类
• 生产者和消费者模式
• 阻塞和可中断方法
• Synchronizer
这些类和方法的使用都可以从JDK DOC查到,但在具体使用中还是有很多问题需要注意
同步容器类
同步容器类就是一些经过同步处理了的容器类,比如List有Vector,Map有Hashtable,查看其源码发现其保证线程安全的方式就是把每个对外暴露的存取方法用synchronized关键字同步化,这样做我们立马会想到有以下问题:
1)性能有问题
同步化了所有存取方法,就表明所有对这个容器对象的操作将会串行,这样做来得倒是干净,但性能的代价也是很可观的
2)复合操作问题
同步容器类只是同步了单一操作,如果客户端是一组复合操作,它就没法同步了,依然需要客户端做额外同步,比如以下代码:
1. public static Object getLast(Vector list) {
2. int lastIndex = list.size() - 1;
3. return list.get(lastIndex);
4. }
5. public static void deleteLast(Vector list) {
6. int lastIndex = list.size() - 1;
7. list.remove(lastIndex);
8. }
getLast和deleteLast都是复合操作,由先前对原子性的分析可以判断,这依然存在线程安全问题,有可能会抛出ArrayIndexOutOfBoundsException的异常,错误产生的逻辑如下所示:
解决办法就是通过对这些复合操作加锁
3)迭代器并发问题
Java Collection进行迭代的标准时使用Iterator,无论是使用老的方式迭代循环,还是Java 5提供for-each新方式,都需要对迭代的整个过程加锁,不然就会有Concurrentmodificationexception异常抛出。
此外有些迭代也是隐含的,比如容器类的toString方法,或containsAll, removeAll, retainAll等方法都会隐含地对容器进行迭代
并发容器类
正是由于同步容器类有以上问题,导致这些类成了鸡肋,于是Java 5推出了并发容器类,Map对应的有ConcurrentHashMap,List对应的有CopyOnWriteArrayList。与同步容器类相比,它有以下特性:
• 更加细化的锁机制。同步容器直接把容器对象做为锁,这样就把所有操作串行化,其实这是没必要的,过于悲观,而并发容器采用更细粒度的锁机制,保证一些不会发生并发问题的操作进行并行执行
• 附加了一些原子性的复合操作。比如putIfAbsent方法
• 迭代器的弱一致性。它在迭代过程中不再抛出Concurrentmodificationexception异常,而是弱一致性。在并发高的情况下,有可能size和isEmpty方法不准确,但真正在并发环境下这些方法也没什么作用。
• CopyOnWriteArrayList采用写入时复制的方式避开并发问题。这其实是通过冗余和不可变性来解决并发问题,在性能上会有比较大的代价,但如果写入的操作远远小于迭代和读操作,那么性能就差别不大了
生产者和消费者模式
大学时学习操作系统多会为生产者和消费者模式而头痛,也是每次考试肯定会涉及到的,而Java知道大家很憷这个模式的并发复杂性,于是乎提供了阻塞 队列(BlockingQueue)来满足这个模式的需求。阻塞队列说起来很简单,就是当队满的时候写线程会等待,直到队列不满的时候;当队空的时候读线 程会等待,直到队不空的时候。实现这种模式的方法很多,其区别也就在于谁的消耗更低和等待的策略更优。以LinkedBlockingQueue的具体实 现为例,它的put源码如下:
1. public void put(E e) throws InterruptedException {
2. if (e == null) throw new NullPointerException();
3. int c = -1;
4. final ReentrantLock putLock = this.putLock;
5. final AtomicInteger count = this.count;
6. putLock.lockInterruptibly();
7. try {
8. try {
9. while (count.get() == capacity)
10. notFull.await();
11. } catch (InterruptedException ie) {
12. notFull.signal(); // propagate to a non-interrupted thread
13. throw ie;
14. }
15. insert(e);
16. c = count.getAndIncrement();
17. if (c + 1 < capacity)
18. notFull.signal();
19. } finally {
20. putLock.unlock();
21. }
22. if (c == 0)
23. signalNotEmpty();
24. }
撇开其锁的具体实现,其流程就是我们在操作系统课上学习到的标准生产者模式,看来那些枯燥的理论还是有用武之地的。其中,最核心的还是Java的锁实现,有兴趣的朋友可以再进一步深究一下
阻塞和可中断方法
由LinkedBlockingQueue的put方法可知,它是通过线程的阻塞和中断阻塞来实现等待的。当调用一个会抛出InterruptedException的方法时,就成为了一个阻塞的方法,要为响应中断做好准备。处理中断可有以下方法:
• 传递InterruptedException。把捕获的InterruptedException再往上抛,使其调用者感知到,当然在抛之前需要完成你自己应该做的清理工作,LinkedBlockingQueue的put方法就是采取这种方式
• 中断其线程。在不能抛出异常的情况下,可以直接调用Thread.interrupt()将其中断。
Synchronizer
Synchronizer不是一个类,而是一种满足一个种规则的类的统称。它有以下特性:
• 它是一个对象
• 封装状态,而这些状态决定着线程执行到某一点是通过还是被迫等待
• 提供操作状态的方法
其实BlockingQueue就是一种Synchronizer。Java还提供了其他几种Synchronizer
1)CountDownLatch
CountDownLatch是一种闭锁,它通过内部一个计数器count来标示状态,当count>0时,所有调用其await方法的线程都需等待,当通过其countDown方法将count降为0时所有等待的线程将会被唤起。使用实例如下所示:
1. public class TestHarness {
2. public long timeTasks(int nThreads, final Runnable task)
3. throws InterruptedException {
4. final CountDownLatch startGate = new CountDownLatch(1);
5. final CountDownLatch endGate = new CountDownLatch(nThreads);
6. for (int i = 0; i < nThreads; i++) {
7. Thread t = new Thread() {
8. public void run() {
9. try {
10. startGate.await();
11. try {
12. task.run();
13. } finally {
14. endGate.countDown();
15. }
16. } catch (InterruptedException ignored) { }
17. }
18. };
19. t.start();
20. }
21. long start = System.nanoTime();
22. startGate.countDown();
23. endGate.await();
24. long end = System.nanoTime();
25. return end-start;
26. }
27. }
2)Semaphore
Semaphore类实际上就是操作系统中谈到的信号量的一种实现,其原理就不再累述,可见探索并发编程------操作系统篇
具体使用就是通过其acquire和release方法来完成,如以下示例:
1. public class BoundedHashSet<T> {
2. private final Set<T> set;
3. private final Semaphore sem;
4. public BoundedHashSet(int bound) {
5. this.set = Collections.synchronizedSet(new HashSet<T>());
6. sem = new Semaphore(bound);
7. }
8. public boolean add(T o) throws InterruptedException {
9. sem.acquire();
10. boolean wasAdded = false;
11. try {
12. wasAdded = set.add(o);
13. return wasAdded;
14. }
15. finally {
16. if (!wasAdded)
17. sem.release();
18. }
19. }
20. public boolean remove(Object o) {
21. boolean wasRemoved = set.remove(o);
22. if (wasRemoved)
23. sem.release();
24. return wasRemoved;
25. }
26. }
3)关卡
关卡和闭锁类似,也是阻塞一组线程,直到某件事情发生,而不同在于关卡是等到符合某种条件的所有线程都达到关卡点。具体使用上可以用CyclicBarrier来应用关卡
以上是Java提供的一些并发工具,既然是工具就有它所适用的场景,因此需要知道它的特性,这样才能在具体场景下选择最合适的工具。
很多开发者谈到Java多线程开发,仅仅停留在new Thread(...).start()或直接使用Executor框架这个层面,对于线程的管理和控制却不够深入,通过读《Java并发编程实践》了解到了很多不为我知但又非常重要的细节,今日整理如下。
不应用线程池的缺点
有些开发者图省事,遇到需要多线程处理的地方,直接new Thread(...).start(),对于一般场景是没问题的,但如果是在并发请求很高的情况下,就会有些隐患:
• 新建线程的开销。线程虽然比进程要轻量许多,但对于JVM来说,新建一个线程的代价还是挺大的,决不同于新建一个对象
• 资源消耗量。没有一个池来限制线程的数量,会导致线程的数量直接取决于应用的并发量,这样有潜在的线程数据巨大的可能,那么资源消耗量将是巨大的
• 稳定性。当线程数量超过系统资源所能承受的程度,稳定性就会成问题
制定执行策略
在每个需要多线程处理的地方,不管并发量有多大,需要考虑线程的执行策略
• 任务以什么顺序执行
• 可以有多少个任何并发执行
• 可以有多少个任务进入等待执行队列
• 系统过载的时候,应该放弃哪些任务?如何通知到应用程序?
• 一个任务的执行前后应该做什么处理
线程池的类型
不管是通过Executors创建线程池,还是通过Spring来管理,都得清楚知道有哪几种线程池:
• FixedThreadPool:定长线程池,提交任务时创建线程,直到池的最大容量,如果有线程非预期结束,会补充新线程
• CachedThreadPool:可变线程池,它犹如一个弹簧,如果没有任务需求时,它回收空闲线程,如果需求增加,则按需增加线程,不对池的大小做限制
• SingleThreadExecutor:单线程。处理不过来的任务会进入FIFO队列等待执行
• SecheduledThreadPool:周期性线程池。支持执行周期性线程任务
其实,这些不同类型的线程池都是通过构建一个ThreadPoolExecutor来完成的,所不同的是 corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue,threadFactory这 么几个参数。具体可以参见JDK DOC。
线程池饱和策略
由以上线程池类型可知,除了CachedThreadPool其他线程池都有饱和的可能,当饱和以后就需要相应的策略处理请求线程的任 务,ThreadPoolExecutor采取的方式通过队列来存储这些任务,当然会根据池类型不同选择不同的队列,比如FixedThreadPool 和SingleThreadExecutor默认采用的是无限长度的LinkedBlockingQueue。但从系统可控性讲,最好的做法是使用定长的 ArrayBlockingQueue或有限的LinkedBlockingQueue,并且当达到上限时通过 ThreadPoolExecutor.setRejectedExecutionHandler方法设置一个拒绝任务的策略,JDK提供了 AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy几种策略,具体差异可 见JDK DOC
线程无依赖性
多线程任务设计上尽量使得各任务是独立无依赖的,所谓依赖性可两个方面:
• 线程之间的依赖性。如果线程有依赖可能会造成死锁或饥饿
• 调用者与线程的依赖性。调用者得监视线程的完成情况,影响可并发量
当然,在有些业务里确实需要一定的依赖性,比如调用者需要得到线程完成后结果,传统的Thread是不便完成的,因为run方法无返回值,只能通过 一些共享的变量来传递结果,但在Executor框架里可以通过Future和Callable实现需要有返回值的任务,当然线程的异步性导致需要有相应 机制来保证调用者能等待任务完成,关于Future和Callable的用法见下面的实例就一目了然了:
1. public class FutureRenderer {
2. private final ExecutorService executor = ...;
3. void renderPage(CharSequence source) {
4. final List<ImageInfo> imageInfos = scanForImageInfo(source);
5. Callable<List<ImageData>> task =
6. new Callable<List<ImageData>>() {
7. public List<ImageData> call() {
8. List<ImageData> result
9. = new ArrayList<ImageData>();
10. for (ImageInfo imageInfo : imageInfos)
11. result.add(imageInfo.downloadImage());
12. return result;
13. }
14. };
15. Future<List<ImageData>> future = executor.submit(task);
16. renderText(source);
17. try {
18. List<ImageData> imageData = future.get();
19. for (ImageData data : imageData)
20. renderImage(data);
21. } catch (InterruptedException e) {
22. // Re-assert the thread's interrupted status
23. Thread.currentThread().interrupt();
24. // We don't need the result, so cancel the task too
25. future.cancel(true);
26. } catch (ExecutionException e) {
27. throw launderThrowable(e.getCause());
28. }
29. }
30. }
以上代码关键在于List<ImageData> imageData = future.get();如果Callable类型的任务没有执行完时,调用者会阻塞等待。不过这样的方式还是得谨慎使用,很容易造成不良设计。另外对于这种需要等待的场景,就需要设置一个最大容忍时间timeout,设置方法可以在 future.get()加上timeout参数,或是再调用ExecutorService.invokeAll 加上timeout参数
线程的取消与关闭
一般的情况下是让线程运行完成后自行关闭,但有些时候也会中途取消或关闭线程,比如以下情况:
• 调用者强制取消。比如一个长时间运行的任务,用户点击"cancel"按钮强行取消
• 限时任务
• 发生不可处理的任务
• 整个应用程序或服务的关闭
因此需要有相应的取消或关闭的方法和策略来控制线程,一般有以下方法:
1)通过变量标识来控制
这种方式比较老土,但使用得非常广泛,主要缺点是对有阻塞的操作控制不好,代码示例如下所示:
1. public class PrimeGenerator implements Runnable {
2. @GuardedBy("this")
3. private final List<BigInteger> primes
4. = new ArrayList<BigInteger>();
5. private volatile boolean cancelled;
6. public void run() {
7. BigInteger p = BigInteger.ONE;
8. while (!cancelled ) {
9. p = p.nextProbablePrime();
10. synchronized (this) {
11. primes.add(p);
12. }
13. }
14. }
15. public void cancel() { cancelled = true; }
16. public synchronized List<BigInteger> get() {
17. return new ArrayList<BigInteger>(primes);
18. }
19. }
2)中断
中断通常是实现取消最明智的选择,但线程自身需要支持中断处理,并且要处理好中断策略,一般响应中断的方式有两种:
• 处理完中断清理后继续传递中断异常(InterruptedException)
• 调用interrupt方法,使得上层能感知到中断异常
3) 取消不可中断阻塞
存在一些不可中断的阻塞,比如:
• java.io和java.nio中同步读写IO
• Selector的异步IO
• 获取锁
对于这些线程的取消,则需要特定情况特定对待,比如对于socket阻塞,如果要安全取消,则需要调用socket.close()
4)JVM的关闭
如果有任务需要在JVM关闭之前做一些清理工作,而不是被JVM强硬关闭掉,可以使用 JVM的钩子技术,其实JVM钩子也只是个很普通的技术,也就是用个map把一些需要JVM关闭前启动的任务保存下来,在JVM关闭过程中的某个环节来并 发启动这些任务线程。具体使用示例如下:
1. public void start() {
2. Runtime.getRuntime().addShutdownHook(new Thread() {
3. public void run() {
4. try { LogService.this.stop(); }
5. catch (InterruptedException ignored) {}
6. }
7. });
8. }
大家使用多线程无非是为了提高性能,但如果多线程使用不当,不但性能提升不明显,而且会使得资源消耗更大。下面列举一下可能会造成多线程性能问题的点:
• 死锁
• 过多串行化
• 过多锁竞争
• 切换上下文
• 内存同步
下面分别解析以上性能隐患
死锁
关于死锁,我们在学习操作系统的时候就知道它产生的原因和危害,这里就不从原理上去累述了,可以从下面的代码和图示重温一下死锁产生的原因:
1. public class LeftRightDeadlock {
2. private final Object left = new Object();
3. private final Object right = new Object();
4. public void leftRight() {
5. synchronized (left) {
6. synchronized (right) {
7. doSomething();
8. }
9. }
10. }
11. public void rightLeft() {
12. synchronized (right) {
13. synchronized (left) {
14. doSomethingElse();
15. }
16. }
17. }
18. }
预防和处理死锁的方法:
1)尽量不要在释放锁之前竞争其他锁
一般可以通过细化同步方法来实现,只在真正需要保护共享资源的地方去拿锁,并尽快释放锁,这样可以有效降低在同步方法里调用其他同步方法的情况
2)顺序索取锁资源
如果实在无法避免嵌套索取锁资源,则需要制定一个索取锁资源的策略,先规划好有哪些锁,然后各个线程按照一个顺序去索取,不要出现上面那个例子中不同顺序,这样就会有潜在的死锁问题
3)尝试定时锁
Java 5提供了更灵活的锁工具,可以显式地索取和释放锁。那么在索取锁的时候可以设定一个超时时间,如果超过这个时间还没索取到锁,则不会继续堵塞而是放弃此次任务,示例代码如下:
1. public boolean trySendOnSharedLine(String message,
2. long timeout, TimeUnit unit)
3. throws InterruptedException {
4. long nanosToLock = unit.toNanos(timeout)
5. - estimatedNanosToSend(message);
6. if (!lock.tryLock(nanosToLock, NANOSECONDS))
7. return false;
8. try {
9. return sendOnSharedLine(message);
10. } finally {
11. lock.unlock();
12. }
13. }
这样可以有效打破死锁条件。
4)检查死锁
JVM采用thread dump的方式来识别死锁的方式,可以通过操作系统的命令来向JVM发送thread dump的信号,这样可以查询哪些线程死锁。
过多串行化
用多线程实际上就是想并行地做事情,但这些事情由于某些依赖性必须串行工作,导致很多环节得串行化,这实际上很局限系统的可扩展性,就算加CPU加线程,但性能却没有线性增长。有个Amdahl定理可以说明这个问题:
其中,F是串行化比例,N是处理器数量,由上可知,只有尽可能减少串行化,才能最大化地提高可扩展能力。降低串行化的关键就是降低锁竞争,当很多并行任务挂在锁的获取上,就是串行化的表现
过多锁竞争
过多锁竞争的危害是不言而喻的,那么看看有哪些办法来降低锁竞争
1)缩小锁的范围
前面也谈到这一点,尽量缩小锁保护的范围,快进快出,因此尽量不要直接在方法上使用synchronized关键字,而只是在真正需要线程安全保护的地方使用
2)减小锁的粒度
Java 5提供了显式锁后,可以更为灵活的来保护共享变量。synchronized关键字(用在方法上)是默认把整个对象作为锁,实际上很多时候没有必要用这么 大一个锁,这会导致这个类所有synchronized都得串行执行。可以根据真正需要保护的共享变量作为锁,也可以使用更为精细的策略,目的就是要在真 正需要串行的时候串行,举一个例子:
1. public class StripedMap {
2. // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
3. private static final int N_LOCKS = 16;
4. private final Node[] buckets;
5. private final Object[] locks;
6. private static class Node { ... }
7. public StripedMap(int numBuckets) {
8. buckets = new Node[numBuckets];
9. locks = new Object[N_LOCKS];
10. for (int i = 0; i < N_LOCKS; i++)
11. locks[i] = new Object();
12. }
13. private final int hash(Object key) {
14. return Math.abs(key.hashCode() % buckets.length);
15. }
16. public Object get(Object key) {
17. int hash = hash(key);
18. synchronized (locks[hash % N_LOCKS]) {
19. for (Node m = buckets[hash]; m != null; m = m.next)
20. if (m.key.equals(key))
21. return m.value;
22. }
23. return null;
24. }
25. public void clear() {
26. for (int i = 0; i < buckets.length; i++) {
27. synchronized (locks[i % N_LOCKS]) {
28. buckets[i] = null;
29. }
30. }
31. }
32. ...
33. }
上面这个例子是通过hash算法来把存取的值所对应的hash值来作为锁,这样就只需要对hash值相同的对象存取串行化,而不是像HashTable那样对任何对象任何操作都串行化。
3)减少共享资源的依赖
共享资源是竞争锁的源头,在多线程开发中尽量减少对共享资源的依赖,比如对象池的技术应该慎重考虑,新的JVM对新建对象以做了足够的优化,性能非常好,如果用对象池不但不能提高多少性能,反而会因为锁竞争导致降低线程的可并发性。
4)使用读写分离锁来替换独占锁
Java 5提供了一个读写分离锁(ReadWriteLock)来实现读-读并发,读-写串行,写-写串行的特性。这种方式更进一步提高了可并发性,因为有些场景大部分是读操作,因此没必要串行工作。关于ReadWriteLock的具体使用可以参加一下示例:
1. public class ReadWriteMap<K,V> {
2. private final Map<K,V> map;
3. private final ReadWriteLock lock = new ReentrantReadWriteLock();
4. private final Lock r = lock.readLock();
5. private final Lock w = lock.writeLock();
6. public ReadWriteMap(Map<K,V> map) {
7. this.map = map;
8. }
9. public V put(K key, V value) {
10. w.lock();
11. try {
12. return map.put(key, value);
13. } finally {
14. w.unlock();
15. }
16. }
17. // Do the same for remove(), putAll(), clear()
18. public V get(Object key) {
19. r.lock();
20. try {
21. return map.get(key);
22. } finally {
23. r.unlock();
24. }
25. }
26. // Do the same for other read-only Map methods
27. }
切换上下文
线程比较多的时候,操作系统切换线程上下文的性能消耗是不能忽略的,在构建高性能web之路------web服务器长连接 可以看出在进程切换上的代价,当然线程会更轻量一些,不过道理是类似的
内存同步
当使用到synchronized、volatile或Lock的时候,都会为了保证可见性导致更多的内存同步,这就无法享受到JMM结构带来了性能优化。
探索并发编程(七)------分布式环境中并发问题 收藏
在分布式环境中,处理并发问题就没办法通过操作系统和JVM的工具来解决,那么在分布式环境中,可以采取一下策略和方式来处理:
• 避免并发
• 时间戳
• 串行化
• 数据库
• 行锁
• 统一触发途径
避免并发
在分布式环境中,如果存在并发问题,那么很难通过技术去解决,或者解决的代价很大,所以我们首先要想想是不是可以通过某些策略和业务设计来避免并 发。比如通过合理的时间调度,避开共享资源的存取冲突。另外,在并行任务设计上可以通过适当的策略,保证任务与任务之间不存在共享资源,比如在以前博文中 提到的例子,我们需要用多线程或分布式集群来计算一堆客户的相关统计值,由于客户的统计值是共享数据,因此会有并发潜在可能。但从业务上我们可以分析出客 户与客户之间 数据是不共享的,因此可以设计一个规则来保证一个客户的计算工作和数据访问只会被一个线程或一台工作机完成,而不是把一个客户的计算工作分配给多个线程去 完成。这种规则很容易设计,例如可以采用hash算法。
时间戳
分布式环境中并发是没法保证时序的,无论是通过远程接口的同步调用或异步消息,因此很容易造成某些对时序性有要求的业务在高并发时产生错误。比如系 统A需要把某个值的变更同步到系统B,由于通知的时序问题会导致一个过期的值覆盖了有效值。对于这个问题,常用的办法就是采用时间戳的方式,每次系统A发 送变更给系统B的时候需要带上一个能标示时序的时间戳,系统B接到通知后会拿时间戳与存在的时间戳比较,只有当通知的时间戳大于存在的时间戳,才做更新。 这种方式比较简单,但关键在于调用方一般要保证时间戳的时序有效性。
串行化
有的时候可以通过串行化可能产生并发问题操作,牺牲性能和扩展性,来满足对数据一致性的要求。比如分布式消息系统就没法保证消息的有序性,但可以通 过变分布式消息系统为单一系统就可以保证消息的有序性了。另外,当接收方没法处理调用有序性,可以通过一个队列先把调用信息缓存起来,然后再串行地处理这 些调用。
数据库
分布式环境中的共享资源不能通过Java里同步方法或加锁来保证线程安全,但数据库是分布式各服务器的共享点,可以通过数据库的高可靠一致性机制来 满足需求。比如,可以通过唯一性索引来解决并发过程中重复数据的生产或重复任务的执行;另外有些更新计算操作也尽量通过sql来完成,因为在程序段计算好 后再去更新就有可能发生脏复写问题,但通过一条sql来完成计算和更新就可以通过数据库的锁机制来保证update操作的一致性。
行锁
有的事务比较复杂,无法通过一条sql解决问题,并且有存在并发问题,这时就需要通过行锁来解决,一般行锁可以通过以下方式来实现:
• 对于Oracle数据库,可以采用select ... for update方式。这种方式会有潜在的危险,就是如果没有commit就会造成这行数据被锁住,其他有涉及到这行数据的任务都会被挂起,应该谨慎使用
• 在表里添加一个标示锁的字段,每次操作前,先通过update这个锁字段来完成类似竞争锁的操作,操作完成后在update锁字段复位,标示已归还锁。这种方式比较安全,不好的地方在于这些update锁字段的操作就是额外的性能消耗
统一触发途径
当一个数据可能会被多个触发点或多个业务涉及到,就有并发问题产生的隐患,因此可以通过前期架构和业务设计,尽量统一触发途径,触发途径少了一是减少并发的可能,也有利于对于并发问题的分析和判断。
并发产生的原因
虽然从直观效果上,处理器是并行处理多项任务,但本质上一个处理器在某个时间点只能处理一个任务,属于串行执行。在单处理器的情况下,并发问题源于 多道程序设计系统的一个基本特性:进程的相对执行速度不可预测,它取决于其他进程的活动、操作系统处理中断的方式以及操作系统的调度策略。在分布式环境 下,并发产生的可能性就更大了,只要大家有依赖的共享资源,就有并发问题的出现,因为互相调用次序更加没法控制。
并发带来的问题
• 全局资源的共享充满了危险。不同任务对同一个共享资源的读写顺序非常关键
• 操作系统很难对分配资源进行最优化管理。挂起的线程占有了其他活动线程需要的资源
• 定位错误非常困难。这种问题来源和触发的不确定性,导致定位问题非常困难
• 限制分布式系统横向扩展能力
进程的交互
进程的交互方式决定了并发问题产生的上下文,解决并发问题也需根据进程交互方式的不同而不同对待。一般进程交互分为以下三种:
1)进程间相互独立
这种情况下虽然进程间没有数据共享,所做事情也互不联系,但它们存在竞争关系。计算机中有些临界资源比如I/O设备、存储器、CPU时间和时钟等等 都需要通过竞争得到,你占用的时候就得保证别人没法占用,因此首先得解决这种互斥的需求。另外,要处理好这种临界资源的调度策略,处理不当就有可能发生死 锁和饥饿
2)进程间通过共享合作
这种情况下进程间虽然执行的过程是相互独立的,互不知道对方的执行情况,但互相之间有共享的数据。因此除了有以上互斥需求和死锁饥饿的可能,另外还会有数据一致性的问题。当多个进程非原子性操作同一个数据时候,互相之间操作时序不当就有可能造成数据不一致
3)进程间通过通信合作
这种情况下进程间通过消息互相通信,知晓各自的执行情况,不共享任何资源,因此就可以避免互斥和数据不一致问题,但仍然存在死锁和饥饿的问题
并发问题的解决办法
操作系统解决并发问题一般通过互斥,为了提供互斥的支持,需要满足以下需求:
• 一次只允许一个进程进入临界区
• 一个非临界区停止的进程必须不干涉其他进程
• 不允许出现一个需要访问临界区的进程被无限延迟
• 一个进程驻留在临界区中的时间必须是有限的
• 临界区空闲时,任何需要进入临界区的进程必须能够立即进入
满足互斥的解决方案:
1)硬件支持
• 中断禁用
中断禁用简单说来就是在某一进程在临界区执行过程中禁用中断,不允许其他进程通过中断打断其执行。虽然这种方式可以保证互斥,但代价非常高,处理器被限制于只能交替执行程序,效率降低。另外不适用于多处理器环境。
• 专用机器指令
从硬件的角度提供一些机器指令,用于保证多个动作的原子性,通过适用这些具有原子性的指令来控制临界区的访问。比如提供符合以下逻辑的原子性指令:
1. boolean testset(int i){
2. if(i==0){
3. i=1;
4. return true;
5. }else{
6. return false;
7. }
8. }
在控制临界区的时候可以通过忙等待来保证只有一个进程停留在临界区,伪代码如下所示:
1. int bolt;
2. void onlyOneThread(){
3. while(!testset(bolt)){
4. /*等待*/
5. }
6. /*临界区*/
7. bolt=0;
8. }
专用机器指令的优点是可以不限制处理器数量,也不限制临界区的数量,但它的问题是使用了忙等待,消耗处理器时间。并且也存在饥饿和死锁的问题
2)信号量
其原理是多个进程可以通过简单的信号进行合作,一个进程可以被迫在某一个位置停止,直到它收到一个特定的信号,再重新被唤起工作。这种方式最大优点 就是解决了忙等待的问题。其核心和机器指令类似,通过提供原子性信号量控制方法,一般情况下提供等待和唤起两种操作原语,以较为简单的二元信号量原语为 例,两种方法的伪代码如下:
1. void wait(semaphore s){
2. if(s.value==1){
3. s.value=0;
4. }else{
5. /*停止此线程,并把线程放入s的线程等待队列(s.queue)里*/
6. }
7. }
8. void signal(semaphore s){
9. if(s.queue.size()==0){
10. s.value=1;
11. }else{
12. /*从s的线程等待队列(s.queue)里拿出一个线程,使其激活执行*/
13. }
14. }
两个方法的实现关键在于其原子性,当然也可以借助专用机器指令的方法来保障其原子性,毕竟这两种方法的执行不长,使用忙等待也问题不大。
再看互斥的问题,若使用信号量,则其具体实现如以下伪代码所示:
1. void onlyOneThread(){
2. wait(s);
3. /*临界区*/
4. signal(s);
5. }
3)管程
信号量虽然解决了性能问题,但使得信号量的控制逻辑遍布在程序里,控制逻辑复杂以后很难整体上控制所有信号量。而管程的思路和面向对象类似,通过一个管程的概念把互斥和资源保护的细节封装在管程的内部,外部程序只需对管程的正确使用就能保证避免并发问题,管程的特点如下:
• 共享数据变量只能被管程的过程访问
• 一个进程通过调用管程的一个过程进入管程
• 只能有一个进程在管程中执行,其他进程被挂起,等待进入管程
4)消息传递
消息传递是通过消息通信的方式进程之间相互配合,满足互斥需求。这种方式最大好处就是可以运用与分布式环境。说到消息,抽象地看有两种操作方式:send和receive。从同步方式上看分为阻塞和非阻塞两种,其组合起来有以下 情况:
• 阻塞send,阻塞receive。发送进程和接收进程都被阻塞,直到信息交付,同步性最好
• 非阻塞send,阻塞receive。最为自然的一对组合
• 非阻塞send,非阻塞receive。
那么通过实现以上send和receive原语操作,就可达到互斥的目的,以下面伪代码为例,其中receive为阻塞的,send为非阻塞的:
1. void onlyOneThread(){
2. receive(box,msg);
3. /*临界区*/
4. send(box,msg);
5. }
小结
以上是从操作系统的底层来看待并发问题,平常的开发过程一般不需要了解,但透过其原理,我们可以发掘一些解决并发问题的思路。只有真正了解并发产生 的原因和操作系统采取的办法,我们才能理解在更高一个层次(比如高级语言编程)为什么有那些控制和措施,为什么对一些代码要做并发控制。
在写Java程序的时候,何时需要进行并发控制,关键在于判断这段程序或这个类是否是线程安全的。
当多个线程访问一个类时,如果不用考虑这些线程在运行时环境下的调度和交替执行,并且不需要额外的同步,这个类的行为仍然是正确的,那么称这个类是线程安全的。我们设计类就是要在有潜在并发问题存在情况下,设计线程安全的类。线程安全的类可以通过以下手段来满足:
• 不跨线程共享变量
• 使状态变量为不可变的
• 在任何访问状态变量的时候使用同步。
• 每个共享的可变变量都需要由唯一一个确定的锁保护。
满足线程安全的一些思路
1)从源头避免并发问题
很多开发者一想到有并发的可能就通过底层技术来解决问题,其实往往可以通过上层的架构设计和业务分析来避免并发场景。比如我们需要用多线程或分布式 集群来计算一堆客户的相关统计值,由于客户的统计值是共享数据,因此会有并发潜在可能。但从业务上我们可以分析出客户与客户之间数据是不共享的,因此可以 设计一个规则来保证一个客户的计算工作和数据访问只会被一个线程或一台工作机完成,而不是把一个客户的计算工作分配给多个线程去完成。这种规则很容易设 计。当你从源头就避免了并发问题的可能,下面的工作就完全可以不用担心线程安全问题。
2)无状态就是线程安全
多线程编程或者分布式编程最忌讳有状态,一有状态就不但限制了其横向扩展能力,也是产生并发问题的起源。当你设计的类是无状态的,那么它永远都是线程安全的。因此在设计阶段需要考虑如何用无状态的类来满足你的业务需求
3)分清原子性操作和复合操作
所谓原子性,是说一个操作不会被其他线程打断,能保证其从开始到结束独享资源连续执行完这一操作。如果所有程序块都是原子性的,那么就不存在任何并 发问题。而很多看上去像是原子性的操作正式并发问题高灾区。比如所熟知的计数器(count++)和check-then-act,这些都是很容易被忽视 的,例如大家所常用的惰性初始化模式,以下代码就不是线程安全的:
1. @NotThreadSafe
2. public class LazyInitRace {
3. private ExpensiveObject instance = null;
4. public ExpensiveObject getInstance() {
5. if (instance == null)
6. instance = new ExpensiveObject();
7. return instance;
8. }
9. }
这段代码具体问题在于没有认识到if(instance==null)和instance = new ExpensiveObject();是两条语句,放在一起就不是原子性的,就有可能当一个线程执行完if(instance==null)后会被中断, 另一个线程也去执行if(instance==null),这次两个线程都会执行后面的instance = new ExpensiveObject();这也是这个程序所不希望发生的。
虽然check-then-act从表面上看很简单,但却普遍存在与我们日常的开发中,特别是在数据库存取这一块。比如我们需要在数据库里存一个客 户的统计值,当统计值不存在时初始化,当存在时就去更新。如果不把这组逻辑设计为原子性的就很有可能产生出两条这个客户的统计值。
在单机环境下处理这个问题还算容易,通过锁或者同步来把这组复合操作变为原子操作,但在分布式环境下就不适用了。一般情况下是通过在数据库端做文章,比如通过唯一性索引或者悲观锁来保障其数据一致性。当然任何方案都是有代价的,这就需要具体情况下来权衡。
另外,java1.5以后提供了一套提供原子性操作的类,有兴趣的可以研究一下它是如何在软件层面保证原子性的。
4)锁的合理使用
大家都知道可以用锁来解决并发问题,但在具体使用上还有很多讲究,比如:
• 每个共享的可变变量都需要由一个个确定的锁保护。
• 一旦使用了锁,就意味着这段代码的执行就丧失了操作系统多道程序的特性,会在一定程度上影响性能
• 锁不能解决在分布式环境共享变量的并发问题
很多程序员对一个共享变量初始化要注意可见性和安全发布(安全地构建一个对象,并其他线程能正确访问)等问题不是很理解,认为Java是一个屏蔽内 存细节的平台,连对象回收都不需要关心,因此谈到可见性和安全发布大多不知所云。其实关键在于对Java存储模型,可见性和安全发布的问题是起源于 Java的存储结构。
Java存储模型原理
有很多书和文章都讲解过Java存储模型,其中一个图很清晰地说明了其存储结构:
由上图 jvm系统中存在一个主内存(Main Memory或Java Heap Memory),Java中所有变量都储存在主存中,对于所有线程都是共享的。 每条线程都有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作都是在工作内存中进行,线程之间无法相互直接访问,变量传递均需要通过主存完成。
这个存储模型很像我们常用的缓存与数据库的关系,因此由此可以推断JVM如此设计应该是为了提升性能,提高多线程的并发能力,并减少线程之间的影响。
Java存储模型潜在的问题
一谈到缓存, 我们立马想到会有缓存不一致性问题,就是说当有缓存与数据库不一致的时候,就需要有相应的机制去同步数据。同理,Java存储模型也有这个问题,当一个线 程在自己工作内存里初始化一个变量,当还没来得及同步到主存里时,如果有其他线程来访问它,就会出现不可预知的问题。另外,JVM在底层设计上,对与那些 没有同步到主存里的变量,可能会以不一样的操作顺序来执行指令,举个实际的例子:
1. public class PossibleReordering {
2. static int x = 0, y = 0;
3. static int a = 0, b = 0;
4. public static void main(String[] args)
5. throws InterruptedException {
6. Thread one = new Thread(new Runnable() {
7. public void run() {
8. a = 1;
9. x = b;
10. }
11. });
12. Thread other = new Thread(new Runnable() {
13. public void run() {
14. b = 1;
15. y = a;
16. }
17. });
18. one.start(); other.start();
19. one.join(); other.join();
20. System.out.println("( "+ x + "," + y + ")");
21. }
22. }
由于,变量x,y,a,b没有安全发布,导致会不以规定的操作顺序来执行这次四次赋值操作,有可能出现以下顺序:
出现这个问题也可以理解,因为既然这些对象不可见,也就是说本应该隔离在各个线程的工作区内,那么对于有些无关顺序的指令,打乱顺序执行在JVM看来也是可行的。
因此,总结起来,会有以下两种潜在问题:
• 缓存不一致性
• 重排序执行
解决Java存储模型潜在的问题
为了能让开发人员安全正确地在Java存储模型上编程,JVM提供了一个happens-before原则,有人整理得非常好,我摘抄如下:
• 在程序顺序中, 线程中的每一个操作, 发生在当前操作后面将要出现的每一个操作之前.
• 对象监视器的解锁发生在等待获取对象锁的线程之前.
• 对volitile关键字修饰的变量写入操作, 发生在对该变量的读取之前.
• 对一个线程的 Thread.start() 调用 发生在启动的线程中的所有操作之前.
• 线程中的所有操作 发生在从这个线程的 Thread.join()成功返回的所有其他线程之前.
有了原则还不够,Java提供了以下工具和方法来保证变量的可见性和安全发布:
• 使用 synchronized来同步变量初始化。此方式会立马把工作内存中的变量同步到主内存中
• 使用 volatile关键字来标示变量。此方式会直接把变量存在主存中而不是工作内存中
• final变量。常量内也是存于主存中
另外,一定要明确只有共享变量才会有以上那些问题,如果变量只是这个线程自己使用,就不用担心那么多问题了
搞清楚Java存储模型后,再来看共享对象可见性和安全发布的问题就较为容易了
共享对象的可见性
当对象在从工作内存同步到主内存之前,那么它就是不可见的。若有其他线程在存取不可见对象就会引发可见性问题,看下面一个例子:
1. public class NoVisibility {
2. private static boolean ready;
3. private static int number;
4. private static class ReaderThread extends Thread {
5. public void run() {
6. while (!ready)
7. Thread.yield();
8. System.out.println(number);
9. }
10. }
11. public static void main(String[] args) {
12. new ReaderThread().start();
13. number = 42;
14. ready = true;
15. }
16. }
按照正常逻辑,应该会输出42,但其实际结果会非常奇怪,可能会永远没有 输出(因为ready为false),可能会输出0(因为重排序问题导致ready=true先执行)。再举一个更为常见的例子,大家都喜欢用只有set 和get方法的pojo来设计领域模型,如下所示:
1. @NotThreadSafe
2. public class MutableInteger {
3. private int value;
4. public int get() { return value; }
5. public void set(int value) { this.value = value; }
6. }
但是,当有多个线程同时来存取某一个对象时,可能就会有类似的可见性问题。
为了保证变量的可见性,一般可以用锁、 synchronized关键字、 volatile关键字或直接设置为final
共享变量发布
共享变量发布和我们常说的发布程序类似,就是说让本属于内部的一个变量变为一个可以被外部访问的变量。发布方式分为以下几种:
• 将对象引用存储到公共静态域
• 初始化一个可以被外部访问的对象
• 将对象引用存储到一个集合里
安全发布和保证可见性的方法类似,就是要同步发布动作,并使发布后的对象可见。
线程安全
其实当我们把这些变量封闭在本线程内访问,就可以从根本上避免以上问题,现实中存在很多例子通过线程封闭来安全使用本不是线程安全的对象,比如:
• swing的可视化组件和数据模型对象并不是线程安全的,它通过将它们限制到swing的事件分发线程中,实现线程安全
• JDBC Connection对象没有要求为线程安全,但JDBC的存取模式决定了一个Connection只会同时被一个线程使用
• ThreadLocal把变量限制在本线程中共享
基于线程安全的一些原则来编程当然可以避免并发问题,但不是所有人都能写出高质量的线程安全的代码,并且如果代码里到处都是线程安全的控制也极大地 影响了代码可读性和可维护性。因此,Java平台为了解决这个问题,提供了很多线程安全的类和并发工具,通过这些类和工具就能更简便地写线程安全的代码。 归纳一下有以下几种:
• 同步容器类
• 并发容器类
• 生产者和消费者模式
• 阻塞和可中断方法
• Synchronizer
这些类和方法的使用都可以从JDK DOC查到,但在具体使用中还是有很多问题需要注意
同步容器类
同步容器类就是一些经过同步处理了的容器类,比如List有Vector,Map有Hashtable,查看其源码发现其保证线程安全的方式就是把每个对外暴露的存取方法用synchronized关键字同步化,这样做我们立马会想到有以下问题:
1)性能有问题
同步化了所有存取方法,就表明所有对这个容器对象的操作将会串行,这样做来得倒是干净,但性能的代价也是很可观的
2)复合操作问题
同步容器类只是同步了单一操作,如果客户端是一组复合操作,它就没法同步了,依然需要客户端做额外同步,比如以下代码:
1. public static Object getLast(Vector list) {
2. int lastIndex = list.size() - 1;
3. return list.get(lastIndex);
4. }
5. public static void deleteLast(Vector list) {
6. int lastIndex = list.size() - 1;
7. list.remove(lastIndex);
8. }
getLast和deleteLast都是复合操作,由先前对原子性的分析可以判断,这依然存在线程安全问题,有可能会抛出ArrayIndexOutOfBoundsException的异常,错误产生的逻辑如下所示:
解决办法就是通过对这些复合操作加锁
3)迭代器并发问题
Java Collection进行迭代的标准时使用Iterator,无论是使用老的方式迭代循环,还是Java 5提供for-each新方式,都需要对迭代的整个过程加锁,不然就会有Concurrentmodificationexception异常抛出。
此外有些迭代也是隐含的,比如容器类的toString方法,或containsAll, removeAll, retainAll等方法都会隐含地对容器进行迭代
并发容器类
正是由于同步容器类有以上问题,导致这些类成了鸡肋,于是Java 5推出了并发容器类,Map对应的有ConcurrentHashMap,List对应的有CopyOnWriteArrayList。与同步容器类相比,它有以下特性:
• 更加细化的锁机制。同步容器直接把容器对象做为锁,这样就把所有操作串行化,其实这是没必要的,过于悲观,而并发容器采用更细粒度的锁机制,保证一些不会发生并发问题的操作进行并行执行
• 附加了一些原子性的复合操作。比如putIfAbsent方法
• 迭代器的弱一致性。它在迭代过程中不再抛出Concurrentmodificationexception异常,而是弱一致性。在并发高的情况下,有可能size和isEmpty方法不准确,但真正在并发环境下这些方法也没什么作用。
• CopyOnWriteArrayList采用写入时复制的方式避开并发问题。这其实是通过冗余和不可变性来解决并发问题,在性能上会有比较大的代价,但如果写入的操作远远小于迭代和读操作,那么性能就差别不大了
生产者和消费者模式
大学时学习操作系统多会为生产者和消费者模式而头痛,也是每次考试肯定会涉及到的,而Java知道大家很憷这个模式的并发复杂性,于是乎提供了阻塞 队列(BlockingQueue)来满足这个模式的需求。阻塞队列说起来很简单,就是当队满的时候写线程会等待,直到队列不满的时候;当队空的时候读线 程会等待,直到队不空的时候。实现这种模式的方法很多,其区别也就在于谁的消耗更低和等待的策略更优。以LinkedBlockingQueue的具体实 现为例,它的put源码如下:
1. public void put(E e) throws InterruptedException {
2. if (e == null) throw new NullPointerException();
3. int c = -1;
4. final ReentrantLock putLock = this.putLock;
5. final AtomicInteger count = this.count;
6. putLock.lockInterruptibly();
7. try {
8. try {
9. while (count.get() == capacity)
10. notFull.await();
11. } catch (InterruptedException ie) {
12. notFull.signal(); // propagate to a non-interrupted thread
13. throw ie;
14. }
15. insert(e);
16. c = count.getAndIncrement();
17. if (c + 1 < capacity)
18. notFull.signal();
19. } finally {
20. putLock.unlock();
21. }
22. if (c == 0)
23. signalNotEmpty();
24. }
撇开其锁的具体实现,其流程就是我们在操作系统课上学习到的标准生产者模式,看来那些枯燥的理论还是有用武之地的。其中,最核心的还是Java的锁实现,有兴趣的朋友可以再进一步深究一下
阻塞和可中断方法
由LinkedBlockingQueue的put方法可知,它是通过线程的阻塞和中断阻塞来实现等待的。当调用一个会抛出InterruptedException的方法时,就成为了一个阻塞的方法,要为响应中断做好准备。处理中断可有以下方法:
• 传递InterruptedException。把捕获的InterruptedException再往上抛,使其调用者感知到,当然在抛之前需要完成你自己应该做的清理工作,LinkedBlockingQueue的put方法就是采取这种方式
• 中断其线程。在不能抛出异常的情况下,可以直接调用Thread.interrupt()将其中断。
Synchronizer
Synchronizer不是一个类,而是一种满足一个种规则的类的统称。它有以下特性:
• 它是一个对象
• 封装状态,而这些状态决定着线程执行到某一点是通过还是被迫等待
• 提供操作状态的方法
其实BlockingQueue就是一种Synchronizer。Java还提供了其他几种Synchronizer
1)CountDownLatch
CountDownLatch是一种闭锁,它通过内部一个计数器count来标示状态,当count>0时,所有调用其await方法的线程都需等待,当通过其countDown方法将count降为0时所有等待的线程将会被唤起。使用实例如下所示:
1. public class TestHarness {
2. public long timeTasks(int nThreads, final Runnable task)
3. throws InterruptedException {
4. final CountDownLatch startGate = new CountDownLatch(1);
5. final CountDownLatch endGate = new CountDownLatch(nThreads);
6. for (int i = 0; i < nThreads; i++) {
7. Thread t = new Thread() {
8. public void run() {
9. try {
10. startGate.await();
11. try {
12. task.run();
13. } finally {
14. endGate.countDown();
15. }
16. } catch (InterruptedException ignored) { }
17. }
18. };
19. t.start();
20. }
21. long start = System.nanoTime();
22. startGate.countDown();
23. endGate.await();
24. long end = System.nanoTime();
25. return end-start;
26. }
27. }
2)Semaphore
Semaphore类实际上就是操作系统中谈到的信号量的一种实现,其原理就不再累述,可见探索并发编程------操作系统篇
具体使用就是通过其acquire和release方法来完成,如以下示例:
1. public class BoundedHashSet<T> {
2. private final Set<T> set;
3. private final Semaphore sem;
4. public BoundedHashSet(int bound) {
5. this.set = Collections.synchronizedSet(new HashSet<T>());
6. sem = new Semaphore(bound);
7. }
8. public boolean add(T o) throws InterruptedException {
9. sem.acquire();
10. boolean wasAdded = false;
11. try {
12. wasAdded = set.add(o);
13. return wasAdded;
14. }
15. finally {
16. if (!wasAdded)
17. sem.release();
18. }
19. }
20. public boolean remove(Object o) {
21. boolean wasRemoved = set.remove(o);
22. if (wasRemoved)
23. sem.release();
24. return wasRemoved;
25. }
26. }
3)关卡
关卡和闭锁类似,也是阻塞一组线程,直到某件事情发生,而不同在于关卡是等到符合某种条件的所有线程都达到关卡点。具体使用上可以用CyclicBarrier来应用关卡
以上是Java提供的一些并发工具,既然是工具就有它所适用的场景,因此需要知道它的特性,这样才能在具体场景下选择最合适的工具。
很多开发者谈到Java多线程开发,仅仅停留在new Thread(...).start()或直接使用Executor框架这个层面,对于线程的管理和控制却不够深入,通过读《Java并发编程实践》了解到了很多不为我知但又非常重要的细节,今日整理如下。
不应用线程池的缺点
有些开发者图省事,遇到需要多线程处理的地方,直接new Thread(...).start(),对于一般场景是没问题的,但如果是在并发请求很高的情况下,就会有些隐患:
• 新建线程的开销。线程虽然比进程要轻量许多,但对于JVM来说,新建一个线程的代价还是挺大的,决不同于新建一个对象
• 资源消耗量。没有一个池来限制线程的数量,会导致线程的数量直接取决于应用的并发量,这样有潜在的线程数据巨大的可能,那么资源消耗量将是巨大的
• 稳定性。当线程数量超过系统资源所能承受的程度,稳定性就会成问题
制定执行策略
在每个需要多线程处理的地方,不管并发量有多大,需要考虑线程的执行策略
• 任务以什么顺序执行
• 可以有多少个任何并发执行
• 可以有多少个任务进入等待执行队列
• 系统过载的时候,应该放弃哪些任务?如何通知到应用程序?
• 一个任务的执行前后应该做什么处理
线程池的类型
不管是通过Executors创建线程池,还是通过Spring来管理,都得清楚知道有哪几种线程池:
• FixedThreadPool:定长线程池,提交任务时创建线程,直到池的最大容量,如果有线程非预期结束,会补充新线程
• CachedThreadPool:可变线程池,它犹如一个弹簧,如果没有任务需求时,它回收空闲线程,如果需求增加,则按需增加线程,不对池的大小做限制
• SingleThreadExecutor:单线程。处理不过来的任务会进入FIFO队列等待执行
• SecheduledThreadPool:周期性线程池。支持执行周期性线程任务
其实,这些不同类型的线程池都是通过构建一个ThreadPoolExecutor来完成的,所不同的是 corePoolSize,maximumPoolSize,keepAliveTime,unit,workQueue,threadFactory这 么几个参数。具体可以参见JDK DOC。
线程池饱和策略
由以上线程池类型可知,除了CachedThreadPool其他线程池都有饱和的可能,当饱和以后就需要相应的策略处理请求线程的任 务,ThreadPoolExecutor采取的方式通过队列来存储这些任务,当然会根据池类型不同选择不同的队列,比如FixedThreadPool 和SingleThreadExecutor默认采用的是无限长度的LinkedBlockingQueue。但从系统可控性讲,最好的做法是使用定长的 ArrayBlockingQueue或有限的LinkedBlockingQueue,并且当达到上限时通过 ThreadPoolExecutor.setRejectedExecutionHandler方法设置一个拒绝任务的策略,JDK提供了 AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy几种策略,具体差异可 见JDK DOC
线程无依赖性
多线程任务设计上尽量使得各任务是独立无依赖的,所谓依赖性可两个方面:
• 线程之间的依赖性。如果线程有依赖可能会造成死锁或饥饿
• 调用者与线程的依赖性。调用者得监视线程的完成情况,影响可并发量
当然,在有些业务里确实需要一定的依赖性,比如调用者需要得到线程完成后结果,传统的Thread是不便完成的,因为run方法无返回值,只能通过 一些共享的变量来传递结果,但在Executor框架里可以通过Future和Callable实现需要有返回值的任务,当然线程的异步性导致需要有相应 机制来保证调用者能等待任务完成,关于Future和Callable的用法见下面的实例就一目了然了:
1. public class FutureRenderer {
2. private final ExecutorService executor = ...;
3. void renderPage(CharSequence source) {
4. final List<ImageInfo> imageInfos = scanForImageInfo(source);
5. Callable<List<ImageData>> task =
6. new Callable<List<ImageData>>() {
7. public List<ImageData> call() {
8. List<ImageData> result
9. = new ArrayList<ImageData>();
10. for (ImageInfo imageInfo : imageInfos)
11. result.add(imageInfo.downloadImage());
12. return result;
13. }
14. };
15. Future<List<ImageData>> future = executor.submit(task);
16. renderText(source);
17. try {
18. List<ImageData> imageData = future.get();
19. for (ImageData data : imageData)
20. renderImage(data);
21. } catch (InterruptedException e) {
22. // Re-assert the thread's interrupted status
23. Thread.currentThread().interrupt();
24. // We don't need the result, so cancel the task too
25. future.cancel(true);
26. } catch (ExecutionException e) {
27. throw launderThrowable(e.getCause());
28. }
29. }
30. }
以上代码关键在于List<ImageData> imageData = future.get();如果Callable类型的任务没有执行完时,调用者会阻塞等待。不过这样的方式还是得谨慎使用,很容易造成不良设计。另外对于这种需要等待的场景,就需要设置一个最大容忍时间timeout,设置方法可以在 future.get()加上timeout参数,或是再调用ExecutorService.invokeAll 加上timeout参数
线程的取消与关闭
一般的情况下是让线程运行完成后自行关闭,但有些时候也会中途取消或关闭线程,比如以下情况:
• 调用者强制取消。比如一个长时间运行的任务,用户点击"cancel"按钮强行取消
• 限时任务
• 发生不可处理的任务
• 整个应用程序或服务的关闭
因此需要有相应的取消或关闭的方法和策略来控制线程,一般有以下方法:
1)通过变量标识来控制
这种方式比较老土,但使用得非常广泛,主要缺点是对有阻塞的操作控制不好,代码示例如下所示:
1. public class PrimeGenerator implements Runnable {
2. @GuardedBy("this")
3. private final List<BigInteger> primes
4. = new ArrayList<BigInteger>();
5. private volatile boolean cancelled;
6. public void run() {
7. BigInteger p = BigInteger.ONE;
8. while (!cancelled ) {
9. p = p.nextProbablePrime();
10. synchronized (this) {
11. primes.add(p);
12. }
13. }
14. }
15. public void cancel() { cancelled = true; }
16. public synchronized List<BigInteger> get() {
17. return new ArrayList<BigInteger>(primes);
18. }
19. }
2)中断
中断通常是实现取消最明智的选择,但线程自身需要支持中断处理,并且要处理好中断策略,一般响应中断的方式有两种:
• 处理完中断清理后继续传递中断异常(InterruptedException)
• 调用interrupt方法,使得上层能感知到中断异常
3) 取消不可中断阻塞
存在一些不可中断的阻塞,比如:
• java.io和java.nio中同步读写IO
• Selector的异步IO
• 获取锁
对于这些线程的取消,则需要特定情况特定对待,比如对于socket阻塞,如果要安全取消,则需要调用socket.close()
4)JVM的关闭
如果有任务需要在JVM关闭之前做一些清理工作,而不是被JVM强硬关闭掉,可以使用 JVM的钩子技术,其实JVM钩子也只是个很普通的技术,也就是用个map把一些需要JVM关闭前启动的任务保存下来,在JVM关闭过程中的某个环节来并 发启动这些任务线程。具体使用示例如下:
1. public void start() {
2. Runtime.getRuntime().addShutdownHook(new Thread() {
3. public void run() {
4. try { LogService.this.stop(); }
5. catch (InterruptedException ignored) {}
6. }
7. });
8. }
大家使用多线程无非是为了提高性能,但如果多线程使用不当,不但性能提升不明显,而且会使得资源消耗更大。下面列举一下可能会造成多线程性能问题的点:
• 死锁
• 过多串行化
• 过多锁竞争
• 切换上下文
• 内存同步
下面分别解析以上性能隐患
死锁
关于死锁,我们在学习操作系统的时候就知道它产生的原因和危害,这里就不从原理上去累述了,可以从下面的代码和图示重温一下死锁产生的原因:
1. public class LeftRightDeadlock {
2. private final Object left = new Object();
3. private final Object right = new Object();
4. public void leftRight() {
5. synchronized (left) {
6. synchronized (right) {
7. doSomething();
8. }
9. }
10. }
11. public void rightLeft() {
12. synchronized (right) {
13. synchronized (left) {
14. doSomethingElse();
15. }
16. }
17. }
18. }
预防和处理死锁的方法:
1)尽量不要在释放锁之前竞争其他锁
一般可以通过细化同步方法来实现,只在真正需要保护共享资源的地方去拿锁,并尽快释放锁,这样可以有效降低在同步方法里调用其他同步方法的情况
2)顺序索取锁资源
如果实在无法避免嵌套索取锁资源,则需要制定一个索取锁资源的策略,先规划好有哪些锁,然后各个线程按照一个顺序去索取,不要出现上面那个例子中不同顺序,这样就会有潜在的死锁问题
3)尝试定时锁
Java 5提供了更灵活的锁工具,可以显式地索取和释放锁。那么在索取锁的时候可以设定一个超时时间,如果超过这个时间还没索取到锁,则不会继续堵塞而是放弃此次任务,示例代码如下:
1. public boolean trySendOnSharedLine(String message,
2. long timeout, TimeUnit unit)
3. throws InterruptedException {
4. long nanosToLock = unit.toNanos(timeout)
5. - estimatedNanosToSend(message);
6. if (!lock.tryLock(nanosToLock, NANOSECONDS))
7. return false;
8. try {
9. return sendOnSharedLine(message);
10. } finally {
11. lock.unlock();
12. }
13. }
这样可以有效打破死锁条件。
4)检查死锁
JVM采用thread dump的方式来识别死锁的方式,可以通过操作系统的命令来向JVM发送thread dump的信号,这样可以查询哪些线程死锁。
过多串行化
用多线程实际上就是想并行地做事情,但这些事情由于某些依赖性必须串行工作,导致很多环节得串行化,这实际上很局限系统的可扩展性,就算加CPU加线程,但性能却没有线性增长。有个Amdahl定理可以说明这个问题:
其中,F是串行化比例,N是处理器数量,由上可知,只有尽可能减少串行化,才能最大化地提高可扩展能力。降低串行化的关键就是降低锁竞争,当很多并行任务挂在锁的获取上,就是串行化的表现
过多锁竞争
过多锁竞争的危害是不言而喻的,那么看看有哪些办法来降低锁竞争
1)缩小锁的范围
前面也谈到这一点,尽量缩小锁保护的范围,快进快出,因此尽量不要直接在方法上使用synchronized关键字,而只是在真正需要线程安全保护的地方使用
2)减小锁的粒度
Java 5提供了显式锁后,可以更为灵活的来保护共享变量。synchronized关键字(用在方法上)是默认把整个对象作为锁,实际上很多时候没有必要用这么 大一个锁,这会导致这个类所有synchronized都得串行执行。可以根据真正需要保护的共享变量作为锁,也可以使用更为精细的策略,目的就是要在真 正需要串行的时候串行,举一个例子:
1. public class StripedMap {
2. // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
3. private static final int N_LOCKS = 16;
4. private final Node[] buckets;
5. private final Object[] locks;
6. private static class Node { ... }
7. public StripedMap(int numBuckets) {
8. buckets = new Node[numBuckets];
9. locks = new Object[N_LOCKS];
10. for (int i = 0; i < N_LOCKS; i++)
11. locks[i] = new Object();
12. }
13. private final int hash(Object key) {
14. return Math.abs(key.hashCode() % buckets.length);
15. }
16. public Object get(Object key) {
17. int hash = hash(key);
18. synchronized (locks[hash % N_LOCKS]) {
19. for (Node m = buckets[hash]; m != null; m = m.next)
20. if (m.key.equals(key))
21. return m.value;
22. }
23. return null;
24. }
25. public void clear() {
26. for (int i = 0; i < buckets.length; i++) {
27. synchronized (locks[i % N_LOCKS]) {
28. buckets[i] = null;
29. }
30. }
31. }
32. ...
33. }
上面这个例子是通过hash算法来把存取的值所对应的hash值来作为锁,这样就只需要对hash值相同的对象存取串行化,而不是像HashTable那样对任何对象任何操作都串行化。
3)减少共享资源的依赖
共享资源是竞争锁的源头,在多线程开发中尽量减少对共享资源的依赖,比如对象池的技术应该慎重考虑,新的JVM对新建对象以做了足够的优化,性能非常好,如果用对象池不但不能提高多少性能,反而会因为锁竞争导致降低线程的可并发性。
4)使用读写分离锁来替换独占锁
Java 5提供了一个读写分离锁(ReadWriteLock)来实现读-读并发,读-写串行,写-写串行的特性。这种方式更进一步提高了可并发性,因为有些场景大部分是读操作,因此没必要串行工作。关于ReadWriteLock的具体使用可以参加一下示例:
1. public class ReadWriteMap<K,V> {
2. private final Map<K,V> map;
3. private final ReadWriteLock lock = new ReentrantReadWriteLock();
4. private final Lock r = lock.readLock();
5. private final Lock w = lock.writeLock();
6. public ReadWriteMap(Map<K,V> map) {
7. this.map = map;
8. }
9. public V put(K key, V value) {
10. w.lock();
11. try {
12. return map.put(key, value);
13. } finally {
14. w.unlock();
15. }
16. }
17. // Do the same for remove(), putAll(), clear()
18. public V get(Object key) {
19. r.lock();
20. try {
21. return map.get(key);
22. } finally {
23. r.unlock();
24. }
25. }
26. // Do the same for other read-only Map methods
27. }
切换上下文
线程比较多的时候,操作系统切换线程上下文的性能消耗是不能忽略的,在构建高性能web之路------web服务器长连接 可以看出在进程切换上的代价,当然线程会更轻量一些,不过道理是类似的
内存同步
当使用到synchronized、volatile或Lock的时候,都会为了保证可见性导致更多的内存同步,这就无法享受到JMM结构带来了性能优化。
探索并发编程(七)------分布式环境中并发问题 收藏
在分布式环境中,处理并发问题就没办法通过操作系统和JVM的工具来解决,那么在分布式环境中,可以采取一下策略和方式来处理:
• 避免并发
• 时间戳
• 串行化
• 数据库
• 行锁
• 统一触发途径
避免并发
在分布式环境中,如果存在并发问题,那么很难通过技术去解决,或者解决的代价很大,所以我们首先要想想是不是可以通过某些策略和业务设计来避免并 发。比如通过合理的时间调度,避开共享资源的存取冲突。另外,在并行任务设计上可以通过适当的策略,保证任务与任务之间不存在共享资源,比如在以前博文中 提到的例子,我们需要用多线程或分布式集群来计算一堆客户的相关统计值,由于客户的统计值是共享数据,因此会有并发潜在可能。但从业务上我们可以分析出客 户与客户之间 数据是不共享的,因此可以设计一个规则来保证一个客户的计算工作和数据访问只会被一个线程或一台工作机完成,而不是把一个客户的计算工作分配给多个线程去 完成。这种规则很容易设计,例如可以采用hash算法。
时间戳
分布式环境中并发是没法保证时序的,无论是通过远程接口的同步调用或异步消息,因此很容易造成某些对时序性有要求的业务在高并发时产生错误。比如系 统A需要把某个值的变更同步到系统B,由于通知的时序问题会导致一个过期的值覆盖了有效值。对于这个问题,常用的办法就是采用时间戳的方式,每次系统A发 送变更给系统B的时候需要带上一个能标示时序的时间戳,系统B接到通知后会拿时间戳与存在的时间戳比较,只有当通知的时间戳大于存在的时间戳,才做更新。 这种方式比较简单,但关键在于调用方一般要保证时间戳的时序有效性。
串行化
有的时候可以通过串行化可能产生并发问题操作,牺牲性能和扩展性,来满足对数据一致性的要求。比如分布式消息系统就没法保证消息的有序性,但可以通 过变分布式消息系统为单一系统就可以保证消息的有序性了。另外,当接收方没法处理调用有序性,可以通过一个队列先把调用信息缓存起来,然后再串行地处理这 些调用。
数据库
分布式环境中的共享资源不能通过Java里同步方法或加锁来保证线程安全,但数据库是分布式各服务器的共享点,可以通过数据库的高可靠一致性机制来 满足需求。比如,可以通过唯一性索引来解决并发过程中重复数据的生产或重复任务的执行;另外有些更新计算操作也尽量通过sql来完成,因为在程序段计算好 后再去更新就有可能发生脏复写问题,但通过一条sql来完成计算和更新就可以通过数据库的锁机制来保证update操作的一致性。
行锁
有的事务比较复杂,无法通过一条sql解决问题,并且有存在并发问题,这时就需要通过行锁来解决,一般行锁可以通过以下方式来实现:
• 对于Oracle数据库,可以采用select ... for update方式。这种方式会有潜在的危险,就是如果没有commit就会造成这行数据被锁住,其他有涉及到这行数据的任务都会被挂起,应该谨慎使用
• 在表里添加一个标示锁的字段,每次操作前,先通过update这个锁字段来完成类似竞争锁的操作,操作完成后在update锁字段复位,标示已归还锁。这种方式比较安全,不好的地方在于这些update锁字段的操作就是额外的性能消耗
统一触发途径
当一个数据可能会被多个触发点或多个业务涉及到,就有并发问题产生的隐患,因此可以通过前期架构和业务设计,尽量统一触发途径,触发途径少了一是减少并发的可能,也有利于对于并发问题的分析和判断。