0.
顺序编程
并行编程
1.并发要解决的问题:
更快的执行:
并发提升了运行在单处理器上的程序的性能
使用多线程
改进代码设计:
并发提供了一个重要的组织上的好处,你的程序设计可以极大地简化
多线程的协作
2.基本的线程机制:
3种多线程:
继承Thread类:
实现Runnable接口
实现Callable接口,并使用Executor线程池管理
任务: Xxx实现Callable接口
线程池: ExecutorService pool = Executors.newCachedThreadPool();
执行任务并返回值: Future f = pool.submit(new Xxx());f.get();
休眠:
Thread.sleep(100);--->TimeUnit.MILLISECONDS.sleep(100);//后者指定了sleep()延迟的时间单元,因此有更好的可读性。
优先级:
getPriority() //获取当前线程的优先级
setPriority() //修改当前线程的优先级
MAX_PRIORITY,NORM_PRIORITY,MIN_PRIORITY //3个优先级级别
让步:
Thread.yield();
当知道一个线程的工作已经做得差不多的时候,可以让别的线程使用cpu了。这个暗示将通过yield()作出。
当调用yield()时,你也只是建议具有相同优先级的其他线程可以运行了。
大体上,对于任何重要的控制或在调整应用时,都不能依赖于yield()。实际上,yield()经常被误用。
后台线程:
所谓后台线程,是指在程序运行的时候在后台提供的一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。
只要有任何非后台线程还在运行,程序就不会终止。例如main()就是一个非后台线程。
术语:
在描述将要执行的工作时使用术语“任务”,在引用到驱动任务的工作机制时才使用“线程”
加入一个线程:
如果一个线程在另一个线程t上调用t.join(),则此线程将被挂起,直到目标线程t结束才恢复、
一个线程加入另一个线程,则被挂起等待加入的这个线程结束后才恢复
捕获异常:---->实现ThreadFactory接口,将在每个新创建的Thread对象上附着一个Thread.UncaughtHandler,通过其uncaughException()来捕获异常。
由于线程的本质特性,使得你不能捕获从线程中逃逸的异常。
此时main()主线程加try-catch是没用的,可以使用Executor解决这个问题。
为解决这个问题,我们要修改Executor产生线程的方式。
Thread.UncaughtExceptionHandler允许你在每个Thread对象上都附着一个异常处理器。
Thread.UncaughtExceptionHandler.uncaughException()会在线程因未捕获的异常而临近死亡时被调用。
为了使用它我们创建了一个新类型的ThreadFactory,他将在每个新创建的Thread对象上附着一个Thread.UncaughtExceptionHandler。
未捕获的异常是通过uncaughException()来捕获的。
3.共享受限资源:
单线程--->无共享冲突问题
多线程--->可能会有共享冲突问题
不正确的访问资源:使任务依赖于非任务对象,而不依赖于另一个任务对象。
一个任务不能依赖于另一个任务,因为任务关闭的顺序无法得到保证,通过使任务依赖于非任务对象,我们可以消除潜在的竞争条件。
解决共享资源竞争:
对于并发工作,你需要某种方式来防止俩个任务访问相同的资源,至少在关键阶段不能出现这种情况。
防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。
关键字synchronized用来防止资源冲突。
使用显式的Lock对象:
private Lock lock = new ReentrantLock();//定义可重入锁
lock.lock();//锁定
lock.unlock();//锁释放
大体上,当你使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低。
因此,只有解决特殊问题时,才使用显式的Lock对象,有了显式的Lock对象,你就可以使用finally字句将系统维护在正确的状态了。
ReentrantLock允许你尝试着获取但最终未获取锁,可以先去干一些别的事,而不是一直等待着这个锁被释放,后续可以重试。
原子性和易变性:
volatile关键字使得64位(long和double)获得原子性:
JVM可以将64位(long和double)的读取和写入当做俩个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导 致不同的任务可以看到不正确结果的可能性(这有时被称为字撕裂,因为你可能看到部分被修改过得数值)。
但是,当你使用volatile关键字,就会获得原子性。
不具备用原子操作来替换同步的能力:
原子操作可由线程机制来保证其不可中断性,这些代码不需要同步,但即使是这样,看起来应该是安全的原子操作,实际上也不安全。
因此不具备用原子操作来替换同步的能力。试着移除同步通常是一种不成熟优化的表现并且可能招致大量的麻烦。
volatile关键字还确保了应用中的可视性:
如果你将一个域声明为volatile的,那么只要对这个域使用了写操作,那么所有的读操作都可以看到这个修改。
优先选择synchronize,而不是volatile:
使用volatile而不是synchronize的唯一安全的情况是类中只有一个可变的域。使用synchronize是最安全的,尝试其他方式都是有风险的。
原子类:
原理:原子性条件更新操作:boolean compareAndSet(expectedValue,updateValue)
AtomicInteger
AtomicLong
AtomicReference
使用AtomicXxx消除了synchronize关键字,在设计性能调优时大有用武之地。
应该强调的是:Atomic类被设计用来构建java.util.concurrent中的类;但依赖于锁(关键字synchronize或者显式的Lock对象)要更安全一些。
临界区:
有时你只是希望防止多个线程同时访问方法内部的部分代码而不是防止访问整个方法。
通过这种方式分离出来的代码叫做临界区。
它也用synchronize关键字建立,这里,synchronize被用来指定某个对象syncObject,此对象的锁被用来对花括号内的代码进行同步控制。
这也被称为同步控制块,在进入此段代码前,必须得到syncObject对象的锁。如果其他线程已经得到这个锁,则等待锁被释放后才能进入临界区。
通过使用同步控制块,而不是对整个方法进行同步控制,可以使多个任务访问对象的时间性能得到显著提高。(使用同步代码块比对整个方法同步性能更高)
在其他对象上同步:
有时必须在另一个对象上同步,但是如果你要这么做,就必须确保所有的任务都是在同一个对象上同步的。
线程本地存储:
防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享。
线程本地存储是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的存储。
创建和管理线程本地存储可以由java.lang.ThreadLocal类来实现。
ThreadLocal对象通常只能当做静态域存储。
在创建ThreadLocal时,你只能通过get(),set()来访问该对象的内容,其中get()将返回与线程相关联的对象的副本,而set()会将参数插入到为其线程存储的对象中,
并返回存储中的原有的对象。
4.终结任务:
产生背景:
在某些情况下,任务必须更加突然地停止。
装饰性花园:
一方面为了说明终止多线程时你必须十分小心;
另一方面也为了演示interrupt()的值。
在阻塞时终结:
线程状态:
new: 新建
Runnable: 就绪
Blocked: 阻塞
Dead: 死亡
进入阻塞状态:
通过sleep(milliseconds)使任务进入休眠状态,这种情况下,任务在指定的时间内不会运行。
通过wait()使线程挂起,直到线程得到了notify()或notifyAll()消息,线程才会进入就绪状态。
任务在等待某个输入/输出完成。
任务试图在某个对象上调用其同步控制方法,但是对象锁不可用,因为另一个任务已经获取到了这个锁。
中断:
Thread类包含interrupt()方法,因此你可以终止被阻塞的任务,这个方法将设置线程的中断状态。
如果一个线程已经被阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将抛出InterruptException。
当抛出该异常或者任务调用Thread.interrupt()时,中断状态将被复位。
Thread.interrupt()提供了离开run()循环而不抛出异常的第二种方式。
为了调用interrupt(),你必须持有Thread对象。
新的concurrent类库似乎在避免对Thread对象的直接操作,转而尽量通过Executor来执行所有操作。
如果你在Executor上调用shutdownNow(),那么他将发送一个interrupt()调用给它启动的所有线程。
被互斥所阻塞:
只要任务以不可中断的方式被阻塞,那么都有潜在的会锁住程序的可能。
在ReetrantLock阻塞的任务具有被可以被中断的能力,这与在synchronize方法或临界区上阻塞的任务完全不同。
interrupt()可以打断被互斥阻塞的调用。
检查中断:
停止某个任务的方式:第一种方式:interrupt();第二种方式:
当你在线程上调用interrupt()时,中断发生的唯一时刻是在任务要进入阻塞操作中,或者已经在堵塞操作内部。
如果你只能通过在阻塞调用上抛异常来退出,那么你就无法总是可以离开run()循环。
因此,如果你调用interrupt以停止某个任务,那么在run()循环碰巧没有产生任何阻塞调用的情况下,你的任务将需要第二种方式来退出。
这种机会是由中断状态来表示的,其状态可以通过调用interrupt()来设置。
你可以通过调用interrupted()来检查中断状态,这不仅告诉你interrupt()是否被调用过,而且还可以清除中断状态。
清除中断状态可以确保并发结构不会就某个任务被中断这个问题通知你俩次,你可以经由单一的InterruptedException或者单一的成功的Thread.interrupt()测试来得到这种通知。
如果想要再次检查以了解是否被中断,则可以在调用Thread.interrupt()时将结果存储起来。
被设计用来响应interrupt()的类必须建立一种策略,来确保他将保持一致的状态。这通常意味着所有需要清理的对象创建操作的后面,都必须紧跟try-finally字句,从而使得无论 run()循环如何退出,清理都会发生。依赖于程序员去编写字句。finally中进行清理操作。
5.线程间的协作:
线程间协作:
当任务协作时,关键问题是这些任务间的握手。
为了实现这种握手,我们使用了相同的基础特性:互斥。
在这种情况下,互斥能够确保只有一个任务可以响应某个信号,这样就可以根除任何可能的竞争条件。
在互斥上,我们为任务添加了一种途径,可以将其自身挂起,直至某些外部条件发生变化,表示是时候让这个任务向前开动了为止。
线程间的握手可以通过Object的wait(),notity()来安全地实现。Java SE5的并发类库还提供了具有await()和signal()方法的Condition对象。
wait(),notify(),notifyAll(),这些方法都是基类Object的一部分,而不是属于Thread的一部分。
wait():
使你可以等待某个条件变化,而改变这个条件超出了当前方法的控制能力。通常,这种变化将由另一个任务来改变。
会在等待外部世界产生变化的时候将任务挂起,并且只有在notify(),notifyAll()发生后时,即表示产生了某些感兴趣的事物,这个任务才会被唤醒并去检查所产生的变化。
因此,wait()提供了一种在任务之间对活动同步的方式。
wait()期间,线程被挂起,锁被释放,而锁被释放这一点正是本质所在,因为为了安全地改变对象的状态,其他任务就必须能够获得这个锁。
notify(),notifyAll():
因为在技术上,可能会有多个任务在某个对象上处于wait()状态,因此调用notifyAll()比只调用notify()要更安全。
使用notify()而不是notifyAll()是一种优化。
使用notify()时,在众多等待同一个锁的任务中只有一个会被唤醒,因此如果你希望使用notify(),就必须保证被唤醒的是恰当的任务。
表面上notifyAll()似乎将唤醒所有正在等待的任务,但事实上,当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。
生产者和消费者:
生产者和消费者之间的协作可以通过while和wait()共同实现:
while(conditionIsNotMet){
wait();
}
这可以保证在你退出等待循环前,条件将得到满足,并且如果你收到了关于某事物的通知,而它与这个条件并无关系,
或者在你完全退出等待循环之前,这个条件发生了变化,都可以保证你可以重返等待状态。
使用显式的Lock对象和Condition对象:
使用互斥并允许任务挂起的基本类是Condition,你可以通过在Condition上调用一个await()来挂起一个任务。
当外部条件发生变化,意味着某个任务应该继续执行时,你可以通过调用signal()来通知这个任务,从而唤醒一个任务,或者调用signalAll()来唤醒所有在这个Condition 上被其自身挂起的任务。
Lock和Condition对象只有在更加困难的多线程问题中才是必须的,因为这个解决方案比前一个更复杂,而且这种复杂性并未使你收获更多。
生产者和消费者和队列:
wait()和notifyAll()方法以一种非常低级的方式解决了任务互操作问题,即每次交互时都握手。
在许多情况下,你可以瞄向更高的抽象级别,使用同步队列来解决任务协作问题,同步队列在任何时刻都只允许一个任务插入或移除元素。
在java.util.concurrent.BlockingQueue接口中提供了这个队列,这个队列有大量的标准实现。
你通常可以使用LinkedBlockingQueue无界队列,还可以使用ArrayBlockingQueue,它具有固定尺寸,因此你可以在它被阻塞之前,向其中放置有限数量的元素。
如果消费者任务试图从队列中获取对象,而该队列此时为空,那么这些队列还可以挂起消费者任务,并且当有更多的元素可用时恢复消费者任务。
阻塞队列可以解决非常大量的问题,而其跟wait(),notifyAll()相比,则简单并可靠的多。
BlockingQueue:
LinkedBlockingQueue无界队列
ArrayBlockingQueue,它具有固定尺寸,因此你可以在它被阻塞之前,向其中放置有限数量的元素
吐司BlockingQueue:三个任务,一个制作吐司,一个给吐司加黄油,一个给加完黄油的吐司上加果酱
任务间使用管道进行输入和输出:
通过输入/输出在线程间进行通信通常很有用。提供线程功能的类库以管道的形式对线程间的输入和输出提供了支持。
它们在java输入/输出类库中对应物就是PipedWriter类(允许任务向管道写),PipedReader类(允许不同任务从同一个管道读)。
6.死锁:
任务可以变成阻塞态,所以就可能出现这种情况:多个任务之间互相等待的连续循环,没有哪个线程能继续。这被称之为死锁。
真正的问题在于,程序可能看起来工作良好,但是具有潜在的死锁危险。这时死锁可能发生,但事先没有任何征兆,潜伏在你的程序里,直到客户发现它出乎意料的发生。
因此在编写并发程序的时候,进行仔细的程序设计以防止死锁是关键部分。
发生死锁的四个条件:
互斥条件:
一根筷子一次只能被一个哲学家使用
至少有一个任务它必须持有一个资源并且正在等待获取一个当前被别的任务持有的资源:
哲学家必须拿着一根筷子并且等待另一根筷子
资源不能被任务抢占,任务必须把资源释放当成普通事件:
哲学家不会从其他哲学家那里抢筷子
必须有循环等待:
每个哲学家都试图先得到右面的一根筷子,然后得到左面的另一根筷子,所以发生了循环等待。
防止死锁:破坏死锁产生的其中一个条件即可,防止死锁最容易破坏的是第四个条件。另外也可以通过破坏其他条件来防止死锁。
如果,最后一个哲学家被初始化成先拿左面的筷子,再拿右面的筷子,那么这个哲学家永远不会阻止其右面的哲学家拿起他们的筷子。这样在本例中就可以防止循环等待。
7.新类库中的构件:java.util.concurrent引入了大量设计用来解决并发问题的新类。有助于你编写出更加简单而且健壮的并发程序。
CountDownLatch:
它被用来同步一个或多个任务,强制它们等待由其他任务执行的一组操作完成。
你可以向CountDownLatch对象设置一个初始计数值,任何在这个对象上调用await()的方法都将堵塞,直至这个计数值变为0。
其他任务在结束时,可以在该对象上调用countDown()来减小这个计数值。
CountDownLatch被设计为只触发一次,计数值不能被重置。如果你需要能够重置计数值的版本,则可以使用CyclicBarrier。
CountDownLatch的典型用法是将一个程序分为n个互相独立的可解决任务,并创建值为n的CountDownLatch。当每个任务完成时,都会在这个锁存器上调用countDown()。
等待问题被破解的任务在这个锁存器上调用await(),将它们自己拦住,直至锁存器计数结束。
CyclicBarrier:
CyclicBarrier适用于这样的情况,你希望创建一组任务,它们并行的执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成。
它使得所有的并行任务都将在栅栏处列队,因此可以一直地向前移动。这非常像CountDownLatch,只是CountDownLatch是只触发一次的事件,而CyclicBarrier可以多次重用。
可以向CyclicBarrier提供一个栅栏动作,它是一个Runnable,当计数值到达0时自动执行,这是CyclicBarrier和CountDownLatch之间的另一个区别。
这里,栅栏动作是作为匿名内部类创建的,它被提交给了CyclicBarrier的构造器。
DelayQueue:
这是一个无界的BlockingQueue,用于放置实现了Delayed接口的对象(延迟时间后执行的对象),其中的对象只能再其到期时才能从队列中取走。
这种队列是有序的。而且这样DelayedQueue就成为了优先级队列的一种变体。
Delayed接口有一个方法getDelay(),用来告知延迟到期有多长时间。或者延迟在多长时间前已经到期。
PriorityBlockingQueue:
这是一个很基础的优先级队列,它具有可阻塞的读取操作。
其中在优先级队列中的对象是按照优先级顺序从队列中出现的任务。
被赋予一个优先级数字,以此来提供这种顺序。
ScheduleExecutor的温室控制器:
温室的控制系统,它可以控制各种设施的开关,或者是对它们进行调节。这可以看做是一种并发问题,每个期望的温室事件都是一个在预定时间运行的任务。
ScheduleExecutor提供了解决该问题的服务。通过schedule()(运行一次任务)或者scheduleAtFixedRate()(每隔规则的时间重复执行任务),你可以将Runnable对象设置为在将来的某个时刻执行。
当你使用ScheduleExecutor这样的预定义工具时,要简单许多。
Semaphore:
正常的锁(比如concurrent.locks或者内建的synchronize锁)在任何时候都只允许一个任务访问一项资源。
计数信号量允许n个任务同时访问这个资源。你还可以将信号量看成是向外分发使用资源的“许可证”,尽管实际上没有使用任何许可证对象。
可以考虑对象池的概念,它管理着数量有限的对象,当要使用对象时可以签出它们,而在用户使用完毕时,可以将它们签回。这种功能被封装到了一个泛型类中。
在这个简化的形式中,构造器使用newInstance()来把对象加载到池中。如果你需要一个新对象,那么可以调用checkOut(),并且在使用完之后,将其递交给checkIn()。
Exchanger:
Exchanger是在俩个任务间交换对象的栅栏。当这些任务进入栅栏时,它们各自拥有一个对象,当他们离开时,它们都拥有之前由对象持有的对象。
Exchanger的典型应用场景是,一个任务在创建对象,这些对象的生产代价很高昂,而另一个任务在消费这些对象。通过这种方式,可以有更多的对象在被创建的同时被消费。
当你调用Exchanger.exchanger()方法时,它将阻塞直至对方任务调用它自己的exchange()方法,那时,这俩个exchage()方法将全部完成,相应的对象则被互换。
8.仿真:
并发最有趣也最令人兴奋的用法就是创建仿真。通过使用并发,仿真的每个构件都可以成为其自身的任务,这使得仿真更容易编程。
银行出纳员仿真:
适用场景:对象随机的出现,并且要求由数量有限的服务器提供随机数量的服务时间。通过构建仿真可以确定理想的服务器数量。
在本例中,每个银行顾客要求一定数量的服务时间,这是出纳员必须花在顾客身上,以服务顾客需求的时间单位的数量。
服务时间的数量对于每个顾客来说是不同的,并且是随机确定的。另外你不知道在每个时间间隔内有多少顾客会到达,因此这也是随机确定的。
Customer: 顾客
CustomerLine: 顾客在等待被某个Teller服务时所排成的单一的行
CustomerGenerator: 附着在CustomerLine上,按照随机的时间间隔向这个队列中添加Customer
Teller: Teller从CustomerLine中取走Customer,在任意时刻他都只处理一个顾客,并且跟踪在这个特定的班次中有他服务的Customer的数量。
当没有足够多的顾客时,他会被告知去执doSomethingElse(),而当出现了许多顾客时,他会被告知去执行serverCustomerLine()。
为了选择下一个出纳员,让其回到服务顾客的业务上,compareTo()方法将查看出纳员服务过的顾客数量,使得PriorityQueue可以自动地将工作量最小的出纳员推向前台。
TellerManager: 各种活动的中心,他跟踪所有的出纳员以及等待服务的顾客。
在这个仿真中有一件有趣的事,即它试图发现对于给定的顾客流,最优的出纳员数量是多少。你可以adjustTellerNumber()中看到这一点。
这是一个控制系统,它能够以稳定的方式添加或移除出纳员。
所有的控制系统和都具有稳定性,如果它们对变化反映过快那就是不稳定的,反映过慢则系统会迁移到它的某种极端情况。
饭点仿真:
这个仿真添加了更多的仿真组件,引入了SynchronousQueue,这是一种没有内部容量的阻塞队列,因此每个put()必须等待一个take(),反之亦然。
这就像是你再把一个对象交给某人---没有任何桌子可以放置这个对象,因此只有在这个人伸出手,准备好接受这个对象时,你才能工作。
在本例中,SynchronousQueue表示设置在用餐人面前的某个位置,以加强在任何时刻都只能上一道菜这个概念。
关于这个示例,需要观察的一项非常重要的事项,就是使用队列在任务间通信所带来的管理复杂度。
这个单项技术通过反转控制极大地简化了并发编程的过程:任务没有直接地互相干涉,而是经由队列互相发送对象。
接受任务并处理对象,将其当作一个消息来对待,而不是向它发送消息。
如果只要可能遵循这项技术,那么你构建出的健壮的并发系统的可能性就会大大增加。
分发工作:
仿真示例:考虑一个假想的用于汽车的机器人组装线,每辆Car都将分为多个阶段构建,从创建底盘开始,紧跟着是安装发动机,车厢和轮子。
机器人组装线--->组装Car:多阶段构建:底盘-发动机-车厢-轮子
需要回过头来好好看懂代码---然后再做笔记
Car将其所有方法都设置成了synchronize的。表面看这是多余的,但是当这个系统连接到另一个需要Car被同步的系统时,它将会奔溃。
9.性能调优:
比较各种互斥技术:
synchronize关键字,Lock类,Atomic类
使用Lock通常会比使用synchronize要高效许多,而且synchronize的开销看起来变化范围太大,而Lock相对比较一致。
synchronize关键字所产生的代码,与Lock所需的“加锁-try/finally-解锁”惯用法所产生的代码相比,可读性提高了很多。
因此,以synchronize关键字入手,只有在性能调优时才替换为Lock对象这种做法,是具有实际意义的。
Atomic对象只有在非常简单的情况下才有用,这些情况通常包括你只有一个要被修改的Atommic对象,并且这个对象独立于其他所有的对象。
更安全的做法是:以更加传统的互斥方式入手,只有在性能方面的需求能够明确指示时,再替换为Atomic。
免锁容器:
像Vector和Hashtable这类早期容器具有许多synchronize方法,当他们用于非多线程的应用程序时,便会导致不可接受的开销。
在java1.2中,新的容器时不同步的,且Collections类提供了各种static的同步的装饰方法。尽管这是一种改进,因为它使你可以选择在你的容器中是否要使用同步,但这种开销仍旧是基于synchronize加锁机制的。
Java SE5特别添加了新的容器,通过使用更灵巧的技术来消除加锁,从而提高线程安全的性能。
这些免锁容器背后的通用策略是:对容器的修改可以与读写操作同时发生,只要读取者只能看到完成修改的结果即可。
修改是在容器数据结构的某个部分的一个单独的副本(有时是整个数据结构的副本)上执行的,并且这个副本在修改过程中是不可视的。
只有当修改完成时,被修改的结构才会主动地与主数据结构进行交换,之后读取者就可以看到这个修改了。
在CopyOnWriteArrayList中,写入将导致整个创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组再被在被修改时,读取操作可以安全的执行。
当修改完成时,一个原子性的操作将把新的数组换入,使得新的读取操作可以看到这个新的修改。
CopyOnWriteArrayList的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException,因此你不必编写特殊的代码去防范这种异常,就像你以前必须作的那样。
CopyOnWriteArraySet将使用CopyOnWriteArrayList来实现其免锁行为。
ConcurrentHashMap和ConcurrentLinkedQueue使用了类似的技术,允许并发的读取和写入,但是容器中只有部分内容而不是整个容器可以被复制和修改。然而,任何修改在完成之前,读取者仍旧不能看到他们。
类似的,ConcurrentHashMap也不会抛出ConcurrentModificationException。
只要你主要是从免锁容器中读取,那么就会比其synchronize对应物快很多,因为获取和释放锁的开销被省掉了。如果需要向免锁容器中执行少量写入,那么情况仍旧如此。经过实测,在有5个写入者时,仍旧如此。
当然,你必须在你的具体应用中尝试这俩种不同的方式,以了解到底哪种更好一些。
synchronizedHashMap和ConcurrentHashMap在性能方面:向ConcurrentHashMap添加写入者的影响甚至还不如CopyOnWriteArrayList明显,这是因为ConcurrentHashMap使用了分段锁机制,明显地最小化写入造成的影响。
乐观加锁:
当你执行某项计算时,实际上没有使用互斥,但是在这项计算完成,并且你准备提交更新时,你需要使用一个称为compareAndSet()的方法。
你将旧值和新值一起提交给这个方法,如果旧值与它当前值不一致,那么操作就失败---这意味着某个其他的任务已经于此操作期间修改了这个对象。
如果compareAndSet()失败会发生什么?这正是棘手的地方,也是你在应用这项技术时的受限之处,即只能针对能够吻合这些需求的问题。
如果compareAndSet()失败,那么你就必须决定做些什么,这是一个非常重要的问题,因为如果不执行某些恢复操作,那么你就不能使用这项技术,从而必须使用传统的互斥。
你可能会重试这个操作,如果在第二次成功,那么万事大吉,或者可能忽略这次失败,直至结束。
ReadWriteLock:
显式锁,ReentrantReadWriteLock类
使用场景:写入不频繁,但多个任务经常读取。
你的程序的第一个草案应该是用更直观的同步,并且只有在必须时再引入ReadWriteLock。
10.活动对象:
java中的线程机制看起来非常复杂并难以正确使用。
除了多线程,还存在着另一种不同的并发模型,它更适合面向对象编程。
另一种可替换的方式被称为活动对象或行动者。之所以称这些对象是活动的,是因为每个对象都维护者它自己的工作器线程和消息队列,并且所有对这种对象的请求都将进入队列排队,任何时刻都只能运行其中的一个。
因此,有了活动对象,我们就可以串行化消息而不是方法,这意味着不再需要防备一个任务在其循环的中间被中断这种问题了。
当你想一个活动对象发送消息时,这条消息会转变为一个任务,该任务会被插入到这个对象的队列中,等待在以后的某个时刻运行。Java SE5的Future在实现这种模式时将派上用场。
活动对象:
每个对象都可以拥有自己的工作器线程
每个对象都将维护对它自己的域的全部控制权
所有在活动对象之间的所有消息都将以在这些对象之间的消息形式发生
活动对象之间的所有消息都要排队
实例:
活动对象类{
//活动对象将一个单线程池的线程以组合的方式作为其工作器线程
ExecutorService pool = Executors.newSingleThreadExecutor();
//负责单线程池资源释放
shutdown(){
pool.shutdown();
}
//该活动对象的线程可执行的任务1
Future calculateInt(x,y){
return pool.submit(匿名类任务);
}
//该活动对象的线程可执行的任务2
Future calculateFloat(x,y){
return pool.submit(匿名类任务);
}
main(){
new 活动对象d;
//定义一个CopyOnWriteArrayList来保存每个Future返回结果,并在结果被处理完后移出List(使用CopyOnWriteArrayList保证list的线程安全)
List<Future> results = new CopyOnWriteArrayList<Future>();
for(int i = 0;i < 5;i++){
results.add(d.calculateInt(i,i));
}
for(float f = 0.0f;f < 5.0f;f=f+0.2f){
results.add(d.calculateInt(f,f));
}
//遍历每个Future返回结果,并在结果被处理完后移出List(使用f.isDone()过滤,若未完成留下,后期继续过滤,若完成则打印结果)
while(results.size()>0){
for(Future f:results){
if(f.isDone()){
try{
print(f.get();)
}catch{
throw new RuntimeException(e);
}
results.remove(f);
}
}
}
//线程池资源释放
d.shutdown();
}
}
遗憾的是,若没有直接的编译器支持,上面这种编码方式实在是太过于麻烦了。但是,这在活动对象和行动者领域,或者更有趣的被称为基于代理的变成领域,确实产生了进步。
代理实际上就是活动对象,但是代理系统还支持跨网络和机器的透明性。如果代理编程最终成为了面向对象编程的继任者,我一点也不觉得惊讶,因为它把对象和相对容易的并发解决方案结合了起来。
通过搜索web,你会发现更多关于活动对象,行动者或代理的信息,特别是某些在活动对象幕后的概念,它们都来自通信顺序进程理论CSP。
11.总结:
使用并发原因:
要处理很多任务,即均衡资源: 例如:等待输入输出时使用CPU
要能够更好地组织代码: 例如:仿真中可看到
要更便于用户使用: 例如:在长时间的下载过程中监视停止按钮是否被按下
多线程的主要缺陷:
等待贡献资源的时候性能低下
需要处理线程的额外CPU花费
糟糕的程序设计导致不必要的复杂度
有可能产生一些病态行为:饿死,竞争,死锁,活锁(多个运行各自任务的线程使得整体无法完成)
不同平台导致的不一致性
问题的一些解决方法:
共享资源--->加锁等方式