多线程设计的目的是为了更多的榨取服务器硬件的性能,但是线程仍然会给运行时带来一定程度的开销。上下文切换——当调度程序临时挂起当前运行的线程时,另 外一个线程开始运行——这在多个线程组成的应用程序中是很频繁的,并且带来巨大的系统开销:保存和恢复线程执行的上下文,离开执行现场,并且 CPU 的时间会花费在对线程的调度而不是运行上。当线程共享数据的时候,它们必须使用的同步机制,这个机制会限制编译器的优化,能够清空或锁定内存和高速缓存,并在共享内存的总线上创建同步通信。
无论何时,只要有多余一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。
在没有正确同步的情况下,如果多个线程访问了同一个变量,有三种方法可以安全访问它: 不要跨线程共享变量;使状态变量为不可变;在任何访问状态变量的时候使用同步。
通常简单性与性能之间是相互牵制的。实现一个同步策略时,不要过早地为了性能而牺牲简单性。(因为你不能预测到该逻辑是否为性能的瓶颈。大多数的性能是由架构决定的,而非单独的某块逻辑。)
有些耗时的计算或操作,比如网络或者控制台 IO ,难以快速完成。执行这些操作期间不要占有锁。
在没有同步的情况下,不能保证读线程及时地读取其他线程写入的值。(因为线程有自己的工作内存,没有同步不能保证工作内存的值即时同步到主存中)
Java存储模型要求获取和存储操作都为原子的,但是对于非 volatile 的 long 和 double 变量, JVM 允许将 64 位的读或写划分为两个 32 位的操作。如果读和写发生在不同的线程,这种情况读取一个非 volatile 类型 long 就可能出现得到一个值为高 32 位和另一个值的低 32 位。因此对于 long 和 double 值在多线程的共享的情况下,应该声明为 volatile 类型。
锁不仅仅是关于同步与互斥的,也是关于 内存可见 的。为了保证所有线程都能够看到共享的,可变变量的最新值,读取和写入线程必须使用公共的锁进行同步。
当一个域被声明为 volatile 类型后,编译器与运行时会监视这个变量:它是共享的,而且对它的操作不会与其他的内存操作一起被重排(保证内存的可见性)。 volatile 变量不会缓存在寄存器或者缓存在对其他处理器隐藏的地方,所以,读取 volatile 类型的变量时,总会返回最新值,当然这也是会损耗一小部分性能。
加锁可以保证可见性与原子性; volatile 变量只能保证可见性。
在中等强度的负载水平下,“每任务每线程”方法是对顺序化 执行的良好改进。只要请求的到达速度尚未超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更大的吞吐量。在实际生产环境中,这方法存在 一些实际的缺陷:线程生命周期的开销,线程的创建和关闭会消耗一些系统资源;资源消耗量,当运行的线程数多于可用的处理器数时,大量空闲线程会占用更多的 内存,给垃圾回收器带来压力,而且大量线程在竞争 CPU 资源时,还会产生其他性能开销;稳定性,可创建线程的数量,依不同平台会有不同的限制,同时也受到 JVM 的启动参数、 Thread 的构造函数中请求的栈大小等因素的影响,以及底层操作系统线程的限制。
在线程池中执行任务线程,这种方法有很多“每任务每线程” 无法比拟的优势。重用存在的线程,而不是创建新的线程,这可以在处理多请求时抵消线程创建、消亡产生的开销。另一项好处就是,在请求到达时,工作者线程通 常已经存在,用于创建线程的等待时间并不会延迟任务的执行,因此提高了响应性。通过适当地调制线程池的大小,你可以得到足够多的线程以保持处理器忙碌,同时可以还防止过多的线程相互竞争资源,导致应用程序耗尽内存或者失败。在线程池中,一般不建议使用 ThreadLocal 线程变量,因为容易引起一些内存泄露。
要做到安全,快速,可靠地停止任务或者线程并不容易。 Java 没有提供任何机制,来安全地强迫线程停止手头的工作。它提供 了 中断——一个协作机制,是一个线程能够要求另一个线程停止当前的工作 。中断通常是实现取消最明智的选择 。但是,并不是所有的阻塞方法或阻塞机制都响应中断。 下面几种情况不能感知中断请求: Java.io 中的同步 Socket I/O , InputStream 和 OutputStream 中的 read 和 write 方法都不能响应中断,但是可通过关闭底层的 Socket ,可以让 read 和 write 所阻塞的线程抛出一个 SocketException ; Selector 的异步 I/O ,如果一个线程阻塞于 Seletor.select 方法, close 方法会导致它抛出 ClosedSelectirException 以前返回;获得锁,如果一个线程在等待内部锁,那么如果不能确保它最终获得锁,并且作出足够多的努力,让你能够以其他方式获得它的注意,你是不能停止它的。然而,显式 Lock 类提供了 lockInterruptibly 方法,允许你等待一个锁,并仍然能够响应中断。
在应用程序中,守护线程不能替代对服务的生命周期恰当 、 良好的管理,因为当 JVM 发现仅存在守护线程的时候,守护线程会自动退出,而且 不会执行 finally 块的操作 。
当任务是同类的,独立的时候,线程池才会有最佳的工作表现。如果将耗时的与短期的任务混合在一起,除非线程池很大,否则会有“阻塞”的风险;如果提交的任务 要依赖于其他任务,除非池是无限的,否则有产生死锁的风险。如需缓解耗时操作带来的影响,可以限定任务等待资源的时间,这样可以更快地将线程从任务中解放出来。
如果一个线程池过大,那么线程对稀缺的 CPU 和内存资源的竞争,会导致内存的高使用量,还可能耗尽资源;如果过小,由于存在很多可用的处理器资源却未在工作,会对吞吐量造成损失。
当一个有限队列满后,饱和策略开始起作用,饱和策略有几种: “中止( AbortPolicy )”策略 会引起 execute 抛出未检查的 RejectedException ,调用者可以捕获这个异常,并作相应的处理; “遗弃( DiscardPolicy )”策略 会默认放弃这个任务;“ 遗弃最旧的 DiscardOldestPolicy ”策略 选择丢弃的任务,是本应该接下来执行的任务,该策略还会尝试去重新提交新任务; “调用者运行( CallerRunsPolicy )”策略 的实现形式,既不会丢弃哪个任务,也不会抛出异常,它会把一些任务推回到调用者那里,以此缓解新任务。
串行化会损害可伸缩性,上下文切换会损害性能,竞争性的锁会同时导致这两种损失,所以减少锁的竞争能够改进性能和可伸缩性 。影响所的竞争性有两个原因: 锁被请求的频率,以及每次持有该锁的时间。有三种方式可以减少锁的竞争:减少持有锁的时间;减少请求锁的频率;用协调机制取代独占锁,从而允许更强的并发性。
缩减锁的范围(从而减少持有锁的时间);减少锁的粒度,可以通过分拆锁(如果一个锁守卫数量大于一,且相互独立的状态变量,你可以通过分拆锁,使每一个锁守护不同的变量,从而改进可伸缩性,结果是每个锁被请求的频率都减小了,比如 BlockQueue )和分离锁(把一个竞争激烈的锁分拆成多个锁的集合,并且它们归属于相互独立的对象,比如 ConcurrentHashMap )来实现;避免热点域。分拆锁和分离锁能改进可伸缩性,因为它们能够使不同的线程操作不同的数据或者相同数据结构的不同部分,而不会发生相互干扰,当不同线程操作的数据不能分离时,就出现了热点域,通常使用的优化方法是使用缓存,保存结果集,减少热点域的竞争;用非独占或非阻塞锁(自循环)来取代独占锁。
通常 CPU 没有完全利用的原因有几种:不充足的负载; I/O 限制;外部限制;锁竞争。
在早期的 JVM 版本中,对象的分配和垃圾回收是非常慢的,但是它们的性能在那之后又本质的提高。事实上, java 中的分配现在已经比 C 语言中的 malloc 更快了。针对对象的“慢”生命周期,很多程序员都会选择使用对象池技术,这项技术中,对象会被循环使用,而不是由垃圾回收并在需要的时候重新 分配。在并发的应用程序中,池化表现得更糟糕。当线程分配新的对象时,需要线程内部非常细微的协调,协调访问池的数据结构的同步成为了必然,由锁的竞争产生的阻塞,其代价比直接分配的代价多几百倍。所以对象池对性能优化有一定的局限性。
在对 Java 程序做性能测试时,应避免几个陷阱:避免在运行中的垃圾回收;当一个类被首次加载后, JVM 会以解释字节码的方式执行,如果一个方法运行得足够频繁,动态编译器最终会把它转成本机代码,当编译完成后,执行方法将由解释执行转换成直接执行。
读写锁的设计是用来进行性能改进的,使得特定情况下能够有更好的并发性。在实践中,当多处理器系统中,频繁的访问主要为读取数据结构的时候,读写锁能够改进性能;在其他情况下比独占锁要稍差一点,这归因于它更大的复杂性。
与基于锁的方案相比,非阻塞算法(这种算法使用底层原子化的机器指令取代锁,比如比较并交换 Compare and swap CAS )的设计和实现都要复杂的多 , 但是它们在可伸缩性和活跃度上占有很大的优势,因为非阻塞算法可以让多个线程在竞争相同资源时不会发生阻塞,进一步而言,它们对死锁具有免疫性。
在激烈的竞争下,锁胜过原子变量 ,但是在真实的竞争条件下,原子变量会胜过锁。这是因为锁通过挂起线程来响应竞争,减小了 CPU 的利用和共享内存总线上的同步通信量。 在中低程度的竞争下,原子化提供更好的可伸缩性;在高强度的竞争下,锁能够更好地帮助我们避免竞争。
CAS会出现 ABA 的问题,导致程序察觉不了变化,只能看到最终结果。
在缺少同步的情况下:编译器生成指令的次序,可以不同于源代码所指定的顺序,而且编译器还会把变量存储在寄存器,而不是内存中;处理器可以乱序或者并行地执行指令;缓存会改变写入提交到主内存的变量的次序;最后,存储在处理器本地缓存中的值,对于其他处理器并不可见。 Java 语言规范规定了 JVM 要维护内部线程类是顺序化语意:只要程序中的最终结果等同于它在严格的顺序化环境中执行的结果,拿货上述所有的行为都是允许的。
JMM为所有程序内部的动作定义了一个偏序关系,叫做 happens-before 。要想保证执行动作 B 的线程看到动作 A 的结果(无论 A 和 B 是否发生在同一个线程中), A 和 B 之间就必须满足 happens-before 关系,如果两个操作之间并没有依照 happens-before 关系排序, JVM 可以对它们所以地重排序。正确的同步的程序会表现出顺序的一致性,这就是说所有层序内部的动作会以固定的,全局的顺序发生。
在没有充分同步的情况下发布一个对象,会导致另外的线程看到一个部分创建对象。新对象的初始化涉及到写入变量——新对象的域。类似地,引用的发布涉及到写入另外一个变量——新对象的引用。如果你不能保证共享引用 happens-hefore 与另外的线程加载这个共享引用,那么写入新对象的引用与写入对象域可以被重排序。在这种情况下,另一个线程可以看到对象引用的最新值,不过也看到一些或全部对象状态的过期值——一个部分创建的对象。所以错误的惰性初始化会导致不正确的发布。