美文网首页
第二章——线程安全性

第二章——线程安全性

作者: 你可记得叫安可 | 来源:发表于2020-10-10 16:41 被阅读0次

    2.1 什么是线程安全性

    无状态对象一定是线程安全的

    public class StatelessFactorizer implements Servlet {
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            encodeIntoResponse(resp, factors);
        }
    }
    

    上面的 StatelessFactorizer 是无状态的,它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。因此无状态对象是线程安全的。

    2.2 原子性

    2.2.1 竞态条件(Race condition)

    由于不恰当的执行时序而出现不正确的结果,就叫竞态条件。当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,正确的结果要取决于运气。

    竞态条件非常容易与另一个术语“数据竞争(Data Race)” 相混淆。数据竞争是指,如果在访问共享的非 final 类型的域是没有采用同步来进行协同,那么就会出现数据竞争

    最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

    public class LazyInitRace {
        private ExpensiveObject instance = null;
        
        public ExpensiveObject getInstance() {
            if (instance == null) {  // 可能多个线程同时判断
                instance = new ExpensiveObject();
            }
            return instance;
        }
    }
    

    另一种常见的竞态条件类型就是“读取 - 修改 - 写入”操作,其结果依赖于之前的状态。

    public class UnsafeCountingFactorizer implements Servlet {
        private long count = 0;
        
        public long getCount() {
            return count;
        }
        
        public void service(ServletRequest req, ServletRespose resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            ++count; // 这里其实是3步操作
            encodeIntoResponse(resp, factors);
        }
    }
    
    2.2.3 复合操作

    我们将“先检查后执行”以及“读取 - 修改 - 写入” 等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。对于 UnsafeCountingFactorizer 我们可以用线程安全对象(例如 AtomicLong) 来管理类的状态。

    public class CountingFactorizer implements Servlet {
        private final AtomicLong count = new AtomicLong(0); // 替换为线程安全类
        
        public long getCount() {
            return count.get();
        }
        
        public void service(ServletRequest req, ServletRespose resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            count.incrementAndGet();
            encodeIntoResponse(resp, factors);
        }
    }
    

    使用 AtomicLong 来代替 long 类型的计数器,并通过使用线程安全类 AtomicLong 来管理计数器的状态,从而确保了代码的线程安全性。当在无状态类中添加一个状态时,如果说状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。但是,当状态变量的数量由一个变为多个时,并不会向状态变量数量由零个变为一个那样简单。

    2.3 加锁机制

    现在就来回答上面的问题:当类有多个状态时,是否只需添加多个线程安全状态变量就足够了?

    public class UnsafeCachingFactorizer implements Servlet {
        private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>(); // 用 AtomicReference 保存上一次的输入
        private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>(); // 用 AtomicReference 保存上一次的结果
        
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromResult(req);
            if (i.equals(lastNumber.get())) {
                encodeIntoResponse(resp, lastFactors.get());
            } else {
                BigInteger[] factors = factor(i);
                lastNumber.set(i);
                lastFactors.set(factors); // 由于 lastNumber 和 lastFactors 是由关联性的,因此这两组 set 操作并不是原子的
                encodeIntoResponse(resp, factors);
            }
        }
    }
    

    上面的例子就有两个状态域,虽然都用线程安全类存储了状态,但是由于两个状态域之间是由关联性的,因此它们一起又形成了一组复合操作,这组操作并不是线程安全的。

    要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

    2.3.1 内置锁

    Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。每个 Java 对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock) 或 监视器锁(Monitor Lock)。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java 的内置锁相当于是一种互斥锁,这意味着最多只有一个线程能持有这种锁。
    我们可以使用内置锁机制来使 UnsafeCachingFactorizer 类变成线程安全的:

    public class SynchronizedFactorizer implements Servlet {
        private BigInteger lastNumber;
        private BigInteger[] lastFactors;
    
        // 这里添加了 synchronized 关键字,使同一时刻只有一个线程可以执行 service 方法
        public synchronized void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromResult(req);
            if (i.equals(lastNumber)) {
                encodeIntoResponse(resp, lastFactors);
            } else {
                BigInteger[] factors = factor(i);
                lastNumber = i;
                lastFactors = factors;
                encodeIntoResponse(resp, factors);
            }
        }
    }
    

    上面通过 synchronzed 关键字来保证了线程安全,但是却过于极端,因此为多个客户端无法同时使用因数分解 Servlet,服务的响应性非常低。但这是一个性能问题,而不是线程安全问题。同时,注意到使用 synchronized 关键字后,状态域 lastNumberlastFactors 也都可以不再使用线程安全类型了。这个涉及到 可见性 问题。

    2.3.2 重入

    当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁时可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。重入意味着获取锁的操作粒度是“线程”,而不是“调用”。

    这与 pthread(POSIX 线程)互斥体的默认加锁行为不同,pthread 互斥体的获取操作是以“调用”为粒度的。

    重入的一种实现是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM 将记下锁的持有者,并且将获取指数值置为 1。如果同一个线程再次获取这个锁,计数值降递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为 0 时,这个锁将被释放。

    重入的设计进一步提升了加锁行为的封装性,简化了面向对象并发代码的开发。

    public class Widget {
        public synchronized void doSomething() {
            ...
        }
    }
    public class LoggingWidget extends Widget {
        public synchronized void doSomething() {
            System.out.println(toString() + ": calling doSomething");
            super.doSomething(); // 重入了
        }
    }
    

    如果内置锁不是可重入的,那么上面代码将进入死锁。

    2.5 性能与活跃性

    public class CachedFactorizer implements Servlet {
        private BigInteger lastNumber;
        private BigInteger[] lastFactors;
        private long hits;
        private long cacheHits;
        public synchronized long getHits() {
            return hits;
        }
        public synchronized double getCacheHitRatio() {
            return (double) cacheHits / (double) hits;
        }
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = null;
            // 同步代码块1:负责保护判断是否只需返回缓存结果的“先检查后执行”操作序列
            synchronized (this) {
                ++hits;
                if (i.equals(lastNumber)) {
                    ++cacheHits;
                    factors = lastFactors.clone();
                }
            }
            if (factors == null) {
                // 在执行时间较长的因数分解之前需要释放锁
                factors = factor(i);
                // 负责确保对缓存的数值和因数分解结果为进行同步更新
                synchronized (this) {
                    lastNumber = i;
                    lastFactors = factors.clone();
                }
            }
            encodeIntoResponse(resp, factors);
        }
    }
    

    我们前面的因数分解 service 使用 synchronized 关键字来达成对整个方法的同步,但是这样造成的问题就是多个客户端无法同时使用因数分解 service,造成性能下降。我们可以通过上面的代码改造,将其业务分为两个独立的同步代码块,每个同步代码块都只包含一小段代码。位于同步代码块之外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程间共享,因此不需要同步。
    上面重构代码的一个很大的优点就在于,在执行时间较长的因数分解函数 factor 时,并不在同步代码中,因此能够支持多个客户端同时访问。这样就实现了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。

    相关文章

      网友评论

          本文标题:第二章——线程安全性

          本文链接:https://www.haomeiwen.com/subject/jkadpktx.html