线程安全性《java并发编程实战》
概述
线程或者锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。但这些终归是一些机制,要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。
java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括volatile类型的变量,显式锁以及原子变量。
如果当多线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:
1、不在线程之间共享该状态变量
2、将状态变量修改为不可变的变量
3、在访问状态变量时使用同步
程序状态的封装性越好,就越容易实现程序安全性,并且代码的维护人员也越容易保持这种方式。
什么是线程安全性?
正确性:某个类的行为与其规范完全一致
安全性:当多个程序访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
在线程安全类中封装了对应的同步机制,因此客户端无须进一步采取同步措施
无状态对象一定是线程安全的。
无状态:既不包含任何域,也不包含任何对其他类中域的引用。
计算过程中的临时状态仅存储在线程栈的局部变量中,并且只能由正在执行的线程访问。
大多数的Servlet都是无状态的,从而极大的降低了实现Servlet线程安全性时的复杂性。
原子性
原子性:操作原子性,操作都是不可分割的
@NotThreadSafe public class UnsafeCountingFactorizer implements Servlet { private long count = 0; public long getCount() { return count; } public void init(ServletConfig servletConfig) throws ServletException { } public ServletConfig getServletConfig() { return null; } public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { /* BigInteger i = extractFormRequest(servletRequest); BigInteger[] factors = factor(i); ++count; encodeIntoResponse(servletResponse, factors);*/ } public String getServletInfo() { return null; } public void destroy() { } }
在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件。
最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。
延迟初始化中的竞态条件(LazyInitRace)
假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作来说,这个操作是一个以原子方式执行的操作。
复合操作:
我们将“先检查后执行”以及“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。
在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子装换。用AtomicLong来代替long类型的计数器,能够确保对计数器状态的访问操作都是原子的。
在实际情况中,应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态以及状态转换情况要更为容易,从而也更容易维护和验证线程安全性。
加锁机制
要保证状态的一致性,就需要在单个原子操作中更新所有相关的状态变量
1、内置锁
java中提供了一种内置的锁机制来支持原子性:同步代码块(synchronized Block)
同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。
synchronized (lock) {
//访问或修改由锁保护的共享状态
}
每个java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或者监视锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,无论是通过正常控制路径退出,还是通过代码块中抛出异常退出。获得内置锁的唯一途径就是进入这个由锁保护的同步代码块或者方法。
java的内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁,因此由这个锁保护的同步代码块会以原子的方式执行,多个线程在执行该代码时也不会互相干扰
package chapter2; import common.GuardedBy; import common.ThreadSafe; import javax.servlet.*; import java.io.IOException; import java.math.BigInteger; /** * @author zhen * @Date 2018/10/10 16:48 * 这个Servlet能正确地缓存最新的计算结果,但并发性却非常糟糕 */ @ThreadSafe public class SynchronizedFactorizer implements Servlet { @GuardedBy("this") private BigInteger lastNumber; @GuardedBy("this") private BigInteger[] lastFactors; public void init(ServletConfig servletConfig) throws ServletException { } public ServletConfig getServletConfig() { return null; } public synchronized void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException { /* BigInteger i = extractFormRequest(servletRequest); if (i.equals(lastNumber)) { encodeIntoResponse(servletResponse, lastFactors); } else { BigInteger[] factors = factor(i); lastNumber = i; lastFactors = factors; encodeIntoResponse(servletResponse, factors); }*/ } public String getServletInfo() { return null; } public void destroy() { } }
2、重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果由某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法为,为每个所关联一个获取计数值和一个所有者线程。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数器置为1.如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会响应的递减。当计数值为0时,这个锁将被释放。
重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。
package chapter2; /** * @author zhen * @Date 2018/10/10 16:59 */ public class LoggingWidget extends Widget { //如果没有重入,那么这段代码将产生死锁。由于Widget和LoggingWidget中的doSomething方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁 public synchronized void doSomething() { System.out.println(toString() + ": calling doSomething"); super.doSomething(); } }
用锁来保护状态
由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。
对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有一同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
对象的内置锁和其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。
每个共享的和可变的变量低应该只由一个锁来保护,从而使维护人员指导是哪一个锁。
另一种常见的加锁约定式,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问
对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
活跃性与性能
前面的SynchronizedFactorizer中采用同步策略是通过Servlet对象的内置锁来保护每一个状态变量,该策略的实现方式也就是对整个service方法进行同步。这种简单且粗粒度的方法能确保线程安全性,但付出的代价却很高。幸运的是,通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。要确保同步代码块不要过小,且不要将本应是原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去。
通常,在简单性和性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)
当执行事件较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

浙公网安备 33010602011771号