Thinking in Java学习笔记 第二十一章:并发

来源:互联网 发布:淘宝女包店铺简介 编辑:程序博客网 时间:2024/06/05 14:18

实现并发最直接的方式是在操作系统级别使用进程。进程是运行在它自己地址空间内的自包容的程序

线程就是在进程中的一个单一的顺序控制流,因此,单个进程可以拥有多个并发执行的任务。

静态方法Thread.yield()的调用是对线程调度器(Java线程机制的一部分,可以将CPU从一个线程转移给另一个线程)的一种建议。

线程调度机制是非确定性的,所以一次运行的结果可能与另一次运行的结果不同
当创建Thread时,它并没有捕获任何对这些对象的引用。在使用普通对象时,这对于GC来说是公平的,但对于Thread时,情况就不同了。每个Thread都“注册了自己”,因此确实会有一个对它的引用,而且在它的任务退出其run()并死亡之前,GC都无法清除它,因此,一个线程会创建一个单独的执行线程,在对start()调用完成之后,它仍然会继续存在

Executorjava.util.concurrent包中的执行器将为你管理Thread对象,从而简化了并发编程。Exexutor在客户端和任务执行之间提供了一个间接层;与客户端直接执行任务不同,这个中介对象将执行任务。

Executor允许你管理异步任务的执行,而无须显式地管理线程的生命周期

FixedThreadPool:一次性预先执行代价高昂的线程分配,从而限制线程的数量。Executors.newFixedThreadPool(5)

在任何线程池中,现有线程在可能的情况下,都会被自动复用

SingleThreadExecutor提交了多个任务,那么这些任务将排队,每个任务都会在下一个任务开始之前运行结束,所有的任务将使用相同的线程。会序列化所有提交给它的任务,并会维护它自己(隐藏)的悬挂任务队列

优先级:线程的优先级将该线程的重要性传递给调度器。尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先权最高的线程先执行。然而,这并不意味着优先权较低的线程将得不到执行(优先权不会导致死锁)。优先级较低的线程仅仅是执行的频率较低

JDK有10个优先级,但它与多数操作系统都不能映射的很好。比如,Windows有7个优先级并且不是固定的,所以这种映射关系也是不确定的。Sun的Solaris有2的31次方个优先级。唯一可移植的方法是当调整优先级的时候,只使用MAX_PRIORITYNORM_PRIORITYMIN_PRIORITY三种级别

通过yield()方法来作出暗示:你的工作已经做的差不多了,可以让别的线程使用CPU了(没有任何机制保证它将会被采纳)。调用时,你也是在建议具有相同优先级的其他线程可以运行。但大体上,对于任何重要的控制或在调整应用时,都不能依赖于yield(),实际上,yield()经常被误用。

后台线程是指在程序运行的时候在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。因此,当所有非后台线程结束时,程序也就终止了,同时杀死所有后台线程。反过来说,只要有任何非后台线程还在运行,程序就不会终止。main()就是一个非后台线程

当最后一个非后台线程终止时,后台线程会“突然”终止。因此一旦main()退出,JVM就会关闭所有的后台进程,而不会有任何你希望出现的确认形式。

* join()方法*:如果某个线程在另一个线程t上调用t.join(),此线程将被挂起,直到目标线程t结束才恢复(即t.isAlive()返回为假)

解决共享资源竞争:对于某个对象来说,其所有synchronized方法共享一个锁,这可以被用来防止多个任务同时访问被编码为对象内存。

使用并发时,将域设置为private是非常重要的,否则synchronized关键字就不能防止其他任务直接访问域,这样就会产生冲突

一个任务可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者又调用了同一对象上的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果变为1。每当这个相同的任务在这个对象上获得锁时,计数都会递增。显然,只有首先获得了锁的时候,锁被完全释放,此时别的任务就可以使用此资源

针对每个类,也有一个锁(作为Class对象的一部分),所以synchronized static方法可以在类的范围内防止对static数据的并发访问

如果你正在写一个变量,它可能接下来将被另一个线程读取,或者正在读取一个上一次已经被另一个线程写过的变量,那么你必须使用同步,并且,读写线程都必须用相同的监视器锁同步

如果你的类中有超过一个方法在处理临界数据,那么你必须同步所有相关的方法。如果只同步一个方法,那么其他方法将会随意地忽略这个对象锁,并可以在无任何惩罚的情况下被调用。这是很重要的一点:每个访问临界共享资源的方法都必须被同步,否则它们就不会正确的工作。

原子性与易变性:

原子性可以应用于除long和double之外的所有基本类型之上的“简单操作”。对于读取和写入除long和double之外的基本类型变量这样的操作,可以保证它们会被当作不可分(原子)的操作来操作内存。

但JVM可以将64位(long 和 double变量)的读取和写入当作两个分离的32为操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性(字撕裂)

如果在定义long和double变量时,如果使用volatile关键字,就会获得(简单的赋值与返回操作的)原子性。不同的JVM可以任意地提供更强的保证,但是你不应该依赖于平台相关的特性。

volatile关键字确保了应用中的可视性。如果将一个域声明为volatile的,那么只要对这个域产生了写操作,那么所有的操作就都可以看到这个修改。即便使用了本地缓存,情况也是如此,volatile域会立即被写入到主存中,而读取操作就发生在主存中。

在非volatile域上的原子操作不必刷新到主存中去,因此其他读取该域的任务也不必看到这个新值。如果多个任务在同时访问某个域,那么这个域就应该是volatile的,否则这个域就应该只能经由同步来访问。同步也会导致向主存中刷新,因此如果一个域完全由synchronized方法或语句块来防护,那么就不必将其设置为是volatile的

一个任务所作的任何写入操作对这个任务来说都是可视的,因此如果它只需要在这个任务内部可视,那么就不需要设置为volatile

自递增自递减操作不是原子性操作

线程本地存储:ThreadLocal对象创建时候,只能通过get和set方法访问该对象内容。get返回当前线程相关联对象的副本。set会将参数插入到为其线程存储的对象中,并返回存储中原有的对象。每个单独的线程都被分配了自己的存储。

终结任务:
ExecutorService.awaitTermination():等待每个任务结束,如果所有任务在超时之前全部结束,返回true,否则返回false

线程的四种状态

  • 新建:当线程被创建时,它只会短暂地处于这种状态。此时它已经分配了必需的系统资源,并执行了初始化。此刻线程已经有资格获取CPU时间了,之后调度器将把这个线程转变为运行状态或阻塞状态。
  • 就绪:这种状态下,只要调度器把时间片分配给线程,线程就可以运行。也就是说,在任意时刻,线程可以运行也可以不运行。只要调度器能分配时间片给线程,它就能运行,这不同于阻塞和死亡状态。
  • 阻塞:线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度器将忽略线程,不会分配给线程任何CPU时间。直到线程重新进入了就绪状态,它才有可能执行操作
  • 死亡:处于死亡或者终止状态的线程将不再是可调度的,并且再也不会得到CPU时间,它的任务已结束,或不再是可运行的。任务死亡的方式通常是从run()方法返回,但是任务的线程还可以被中断。

进入阻塞状态的原因:

  • 调用sleep()
  • 调用wait(),直到线程获得了notify()或notifyAll()(或者在java.util.concurrent类库中调用等价的signal()或signalAll),才会进入就绪状态
  • 任务在等待某个输入/输出完成
  • 任务试图在某个对象上调用同步控制方法,但对象锁不可用,因为另一个任务已经获取了这个锁

中断:

  • Thread,interrupt(),如果线程被阻塞,或者执行一个阻塞操作,那么设置这个线程的中断状态将抛出InterruptedException
  • 当抛出该异常或者该任务调用Thread.interrupted时,中断状态复位。interrupted()提供了离开run()循环而不抛出异常的第二种方法(第一种是维护一个cancel布尔值进行判断)
  • Executor.shutdownNow()将发送一个interrupt()调用给它启动的所有线程。
  • 如果使用Executor,调用submit()而不是executor来启动任务,将持有该任务的上下文。submit()返回一个Future,可以调用cancel()来中断某个特定任务。将true传给cancel,就有权限调用该线程的interrupt
  • 可以中断对sleep()的调用(或者任何要求抛出InterruptedException的调用)。但是不能中断试图获取synchronized锁或者试图执行I/0操作的线程,它们不需要InterruptedException处理器

被互斥所阻塞

  • 一个任务能够调用在同一个对象中的其他synchronized方法,这个任务已经持有锁了。也就是说同一个互斥可以被同一个任务多次获得。
  • ReentrantLock上阻塞的任务具备可以被中断的能力。

检查中断

检查中断可以通过调用interrupted()来检查中断状态,还可以帮助清除中断状态
被设计用来响应interrupt()的类必须建立一种策略,来确保它将保持一致的状态。这通常意味着所有需要清理的对象创建操作的后面,都必须紧跟try-finally子句,从而使得无论run()循环如何退出,清理都会发生

线程之间的协作

wait()使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力。wait()提供了一种在任务之间对活动同步的方式。调用wait()时候,线程执行被挂起,对象上的锁被释放,对象内其他synchronized方法可以在wait()期间被调用

wait()、notify()以及notifyAll()是基类Object的一部分,而不是作为Thread的一部分,这样做是有道理的,因为这些方法操作的锁也是所有对象的一部分

实际上只能在同步控制方法或者同步控制块中调用wait()、notify()或者notifyAll(),sleep()不用操作锁,所以可以在非同步控制方法里调用

当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。

使用互斥并允许任务挂起的基本类是Condition,你可以通过在Condition上调用await()来挂起一个任务。当外部条件发生变化,意味着某个任务应该继续执行时,你可以通过调用signal()来通知这个任务,从而唤醒一个任务,或者调用signalAll()来唤醒所有在这个Condition上被其自身挂起的任务(与使用notifyAll相比,signalAll更安全)

使用Lock通常会比使用synchronized高效的多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致。

Vector和HashTable有许多synchronized方法,当它们应用于非多线程的程序中时,将会导致不可接受的开销。

CopyOnWriteArrayList具有免锁行为,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改的时候,读取操作可以安全执行。修改完成后,一个原子性的操作将把新的数组换入,使得新的读取操作可以看到这个新的修改。

CopyOnWriteArraySet将使用CopyOnWriteArrayList来实现免锁行为,ConcurrentHashMap和ConcurrentLinkedQueue使用类似技术,允许并发的读取和写入,但是容器中只有部分内容而不是整个容器可以被复制和修改。然而,任何修改在完成之前,读取者仍旧不能看到它们,不会抛出ConcurrentModificationException

产生死锁需要同时满足的四个条件:

  • 互斥条件
  • 至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源
  • 资源不能被任务抢占
  • 必须有循环等待

本章源码地址:并发代码

0 0