线程安全性

  1. 什么是线程安全性

    在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中会定义各种不变性条件约束对象的状态,以及定义各种后验条件来描述对象操作的结果

    定义:当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为

    举例:Servlet是无状态,即它不包含任何域,也不包含任何对其他类中域的引用。也就是线程之间的变量不存在依赖关系,或者说线程之间没有共享的变量,彼此互不干扰。那么无状态对象都是线程安全的

  2. 原子性

    • 竞态条件

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

      书中举了一个计数的例子,那么计数的操作可以拆解成以下三个步骤:读取上一次的数值 => 修改值(++)=> 写入值。也就是值是依赖于上一次的值。那么当线程A将数值读取(检查)出来到修改(执行)这个过程中,可能线程B就已经把线程A读取出来的值改变了,那么线程A读取的数值就是失效了,这里就是一个竞态条件。

      if(instance == null){
          instance = new ExpensiveObject();
      }

      上面是一个经典的懒汉式单例(延迟加载),但是这种单例是线程不安全的,这里也存在竞态条件,在并发的环境下无法保证单例。

    • 复合操作

      要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。

      • 单个需要同步的变量

        比如上面计数的例子,只有一个count是需要同步的变量,这样可以直接用java为我们提供的原子变量(Atomic Variable)来保证在某个线程修改该变量时,为该变量上锁(实际上是锁住了读取 => 修改 => 写入这个过程),之后再访问该变量的线程会被阻塞。

      • 多个需要同步的变量

        举个例子:

            private final AtomicReference lastNumber
                    = new AtomicReference();
            private final AtomicReference lastFactors
                    = new AtomicReference();
        
            public void service(ServletRequest req, ServletResponse resp) {
                BigInteger i = extractFromRequest(req);
                if (i.equals(lastNumber.get()))
                    encodeIntoResponse(resp, lastFactors.get());
                else {
                    BigInteger[] factors = factor(i);
                    lastNumber.set(i);
                    lastFactors.set(factors);
                    encodeIntoResponse(resp, factors);
                }
            }

        可以看到lastNumber、lastFactors都加上了原子变量,但是依旧存在竞态条件。先解释一下这个代码的含义,它是实现了某种简单的缓存,当用户重复对某个number进行两次的factor计算,可以直接从数组中取出来,这就要求上一次的number和上一次对应计算出来的factor应该一一对应的存储在数组中(具体可以看P19)。

        那么这里的竞态条件的产生是因为多个变量引起的,比如说number=2,对应的factor=5,此时线程A将2存储到了lastNumber中,但是此时线程B突然将他的结果10存到了lastFactors中,这样导致2和10的错误对应。解决办法详见下文的内置锁

  3. 加锁机制

    • 内置锁

      含义:静态的synchronized的方法以Class对象作为锁,每个Java对象都可以用作一个实现同步的锁。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法

      获得锁时机:线程在进入同步代码块之前自动获取。

      释放锁时机:正常的控制路径退出 / 从代码块中抛出异常退出。

      缺点:虽然保证了线程安全但是效率很低,因为在事务设计中应该是短小精悍的,而这种对这个方法的加锁使得效率变低。

    • 重入

      内置锁是可以重入的,重入就意味着获取锁操作的粒度是“线程”,而不是“调用”。举个很简单的例子,比如一个方法是通过递归实现的,那么一个线程调用了该方法后,依旧可以不断递归完成而不会阻塞;也就是说一个线程获得了锁之后,可以反复对该同步代码调用。

  4. 用锁来保护状态

    这个其实在上文已经详细阐释了。那么此处我们将加锁的处理抽象化,在上文中,只对一个变量时,我们利用原子变量的方式加锁,实际上是锁住了那一个过程或者说操作。在多个变量时,我们利用内置锁解决,实际上也是锁住了一个“更宏观的操作”。所以加锁实际上是锁住了一系列需要原子性的操作。那么这个操作可以不断宏观,比如多个方法组成的一组操作,这样我们在这组操作之后再加锁。显然这样粗暴的加锁,可能会导致程序中出现过多的同步,从而导致性能下降。

  5. 活跃性和性能

    上文中我们理解一下锁的作用范围(或者说同步代码块的作用范围),synchronized是加在方法上的,所以作用域是在整个方法上。若我们能缩小同步代码块的作用范围,我们很容易做到既确保并发性,又维护线程安全性。

        @GuardedBy("this") private BigInteger lastNumber;
        //GuardedBy的含义就是该变量受内置锁保护
        @GuardedBy("this") private BigInteger[] lastFactors;
        @GuardedBy("this") private long hits;
        @GuardedBy("this") 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;
            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);
        }

    为了使得同步机制的统一,所以都采用synchronized来实现了,而抛弃了原子变量的方式。

你可能感兴趣的:(java多线程并发编程)