【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>
一、线程的安全性
对于共享变量,当多个线程对其进行访问并且其中有一个线程执行写入操作时,必须采用同步机制来协同线程对变量的访问。Java中主要使用synchronized,volatile变量,显示锁以及原子变量等方式进行同步。如果当多个线程访问同一个状态可变的变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
- 不在线程之间共享该状态变量
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
在编写并发程序时一个指导原则是:首先使代码正确运行,然后提高代码的速度。并且最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。
线程安全性的定义是当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
这里需要说明的是,无状态对象一定是线程安全的。
二、原子性
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行到B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。
在线程原子性中有两个比较重要的概念,一个是竞态条件,另一个是复合操作:
竞态条件是指一种由于不恰当的执行时序而出现不正确结果的情况。常见的静态条件有两种:①先检查后执行;②读取-修改-写入。
- 先检查后执行:首先观察某个条件为真(例如文件X不存在),然后根据这个观察结果采用响应的动作(创建文件X),但事实上,在你观察这个结果以及开始创建文件之间,观察结果可能变得无效(另一线程在这期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。
- 读取-修改-写入:根据对象之前的状态来定义对象状态的转换,例如变量的自增操作。
要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。
复合操作是指一组相互组合起来的原子化操作协同来完成的一组操作,这里"先检查后执行"和“读取-修改-写入”都属于复合操作。
当在无状态类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态以及状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
三、加锁机制
加锁机制主要表现在两个方面:内置锁和重入。内置锁指的是可以用作实现同步的java对象(每个java对象都可以作为一个内置锁对象),内置锁是一种互斥体(或互斥锁),这意味着最多只有一个线程能够获得这种锁,因而同步代码块能够通过持有内置锁来实现同步代码的原子性和互斥性。重入指的是当某个线程请求由其他线程持有的锁时,发送请求的线程就会阻塞,然而,由于内置锁是可重入的,因此,如果某个线程试图获得一个由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作粒度是“线程”,而不是“调用”。重入的一种实现方式是,为每个锁关联一个获取计数值和所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程所持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
四、用锁来保护状态
如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步,并且在用锁来协调变量的访问时,在所有的访问点上都要使用同一个锁。一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。
这里需要注意的是:①并不是所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护;②对于包含多个变量的不变性条件,其中涉及到的所有变量都要用同一个锁来进行保护。
使用synchronized的缺点:①虽然使用synchronized方法可以确保单个操作的原子性,但如果把多个操作合并为一个复合操作,还是需要额外的加锁机制;②将每个方法都作为同步方法还可能导致活跃性问题和性能问题。
五、活跃性与性能(解决活跃性与性能问题的方案)
当多个请求到达一个同步方法时,这些请求将排队等待处理。我们将这种web应用程序称为不良并发应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保被同步方法的并发性,同时又维护线程的安全性。要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。应尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作执行过程中,其他线程能够访问共享状态。
这里需要说明的是:①通常在简单性和性能之间存在着相互制约的因素,当实现某个同步策略时,一定不要盲目的为了性能而牺牲简单性;②当执行时间较长的计算或者可能无法快速完成的操作(例如,网络I/O或控制台I/O),一定不要持有锁。