线程安全性

线程安全性

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的可变的状态的访问。

"共享"意味着变量可以由多个线程同时访问,而可变则意味着变量的值在其生命周期内可以发生变化。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问,这指的是程序中访问对象的方式,而不是对想要实现的功能,要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能导致数据破坏以及其他不该出现的结果。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但是"同步"这个术语还包括volatile类型的变量,显示锁以及原子变量。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题

  • 不在线程之间共享该状态变量
  • 将状态变量修改为不可变的变量
  • 在访问状态变量时使用同步

什么是线程安全?

在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。

线程安全概念

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

原子性

我自己写的一篇博客:浅分析volatile关键字

public class UnsafeCountingFactorizer implements Servlet {

  private long count = 0;

  public long getCount() {
     return count;
  }

  @Override
  public void service(ServletRequest req, ServletResponse resp) {
      BigInteger i = extractFromRequest(req);
      BigInteger[] factors = factor(i);
      ++count;
      encodeIntoResponse(resp, factors);
  }
}

上面的示例是在没有同步的情况下统计已处理请求数量的Servlet,尽管该Servlet在单线程环境中能正确运行。++count看似是一个原子性的操作,可这看上去紧凑的操作,实际上要分为三步来完成,多线程情况下,每条线程的工作内存①从主存中读取count的值②为本线程中的count副本+1③写回主存,并且其结果依赖于之前的状态。也正是在这看似是原子性的自增操作的情况下,多线程的环境下,这个程序就会出现错误

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,他有一个正式的名字:竞态条件(Race Condition)

竞态条件

当某个计算的正确性取决于多个线程的交替执行的时序的时候,那么就会发生竞态条件。 最常见的竞态条件就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作

这种观察结果的失效就是大多数竞态条件的本质,——基于一种可能失效的观察结果来做出判断或者执行某个计算,这种类型的竞态条件称为“先检查后执行”:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但是事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未检查的异常,数据被覆盖,文件被破坏等)。

延迟初始化

延迟初始化的目的就是将对象的初始化操作退出到实际被使用时才进行,同时要确保只被初始化一次。比如下面这一段代码

public class Singleton {

    private Singleton singleton = null;

    public synchronized Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

在上述的类中就存在一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getObject这个方法。此时A线程看到object为null,因而会创建一个新的Object实例,B同样需要判断object是不是为null。这个时候的object是否为null,取决于不可预测的时序(时序在这里可以简单地理解为一个总线周期内,CPU在各个时钟周期完成的操作 ),包括线程的调度方式,以及A线程需要花多长时间来初始化Object并设置object。如果当B线程检查object也为null,那么在两次调用getObject时可能会出现不同的结果,即使getObject通常被认为是返回相同的实例。

与大多数并发错误一样,竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。

解决问题的方法也同样很简单,使用synchronized关键字

public class Singleton {

    private Singleton singleton = null;

    public synchronized Singleton getSingleton() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

这样解决了多线程并发的问题,但是却带来了效率的问题,我们的目的只是去创建一个实例,即只有实例化使用new关键字的语句需要被同步,后面创建了实例之后,singleton非空就会直接返回单例对象的引用,而不用每次都在同步代码块中进行非空验证,那么这样可以考虑只对new关键字实例化对象的时候进行同步

public class Singleton {

    private Singleton singleton = null;
            public Singleton getSingleton() {
     if (singleton == null) {
         synchronized (Singleton.class) {
             singleton = new Singleton();
         }
     }
     return singleton;
    }
}

这样会带来与第一种一样的问题,即多个线程同时执行到条件判断语句时,会创建多个实例。问题在于当一个线程创建一个实例之后,singleton就不再为空了,但是后续的线程并没有做第二次非空检查。那么很明显,在同步代码块中应该再次做检查,也就是所谓的双重检测

双重检测:

public class Singleton {

    private Singleton singleton = null;

    public Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

到这里真的可以说是很完美了,但是因为Java的无序写入,在JDK1.5之前都是有问题的,在下面的实例化一个对象的过程中会叙述这个问题。 JDK1.5之后,可以使用volatile关键字修饰变量来解决无序写入产生的问题,因为volatile关键字的一个重要作用是禁止指令重排序,即保证不会出现内存分配、返回对象引用、初始化这样的顺序,从而使得双重检测真正发挥作用。

实例化一个对象的过程

object = new Object();

简单地来说上面的代码中简简单单的一句实例化一个对象,看似是一种原子性的操作,但其实不是的,就如同

++count;

同样的++count;这种对变量的基本自增赋值也是一种非原子性的操作,这类对一个变量执行自增的操作一般也分为三个步骤 ①将主存中的变量值读取至该线程的工作内存中②对变量进行自增操作③将对变量的自增后改变的值写回主存,也就是这看似简简单单自增操作实际上分成了三步去实现,也正是因为这个非原子性的操作,可能会导致并发问题。按我的理解,一切存在线程安全的问题一定会在某一个时刻出现并发问题。

实例化一个对象简单地来说也会分成三步去实现

  1. 在实例化一个对象的时候,首先会去堆开辟空间,分配地址
  2. 调用对应的构造函数进行初始化,并且对对象中的属性进行默认初始化
  3. 初始化完毕中,将堆内存中的地址值赋给引用变量

一般来讲,当初始化一个对象的时候,会经历内存分配、初始化、返回对象在堆上的引用等一系列操作,这种方式产生的对象是一个完整的对象,可以正常使用。

但是JAVA的无序写入可能会造成顺序的颠倒,即内存分配、返回对象引用、初始化的顺序 ,这种情况下对应到代码中的实例化对象,就是singleton已经不是null,而是指向了堆上的一个对象,但是该对象却还没有完成初始化动作。 当后续的线程发现singleton不是null而直接使用的时候,就会出现意料之外的问题。(就是说在Java1.5之前允许无序写入的时候,一旦初始化对象和返回对堆上对象的引用两条指令被乱序执行,有可能出现线程安全问题)

指令重排

什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

加锁机制

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或是交替方式,都要保证不变性条件不被破坏。

当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新

内置锁

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。

当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束,因此,当更新某一个变量的时候,需要在同一个原子操作中对其他变量同时进行更新。

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个是作为锁的对象的引用,一个作为有这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized (lock) {
 //访问或修改由锁保护的共享状态
}

每个Java对象都可以用作一个实现同步的锁,这些所被称为内置锁或者监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出还是通过代码块中抛出异常退出。获得内置锁的唯一途径就是进入有这个所保护的同步代码块或方法。

Java的内置锁相当于一种互斥体(或者叫互斥锁)这意味着最多只有一个线程能持有这种锁当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,指导线程B释放这个锁。如果B永远不释放锁,那么A也将永远等下去由于被保护的代码块或者被保护的同步方法同时只能被一条线程访问,也就相当于这个同步代码块或者同步方法是一种原子性操作,这种同步是通过加锁保证的原子性操作进而保证的线程安全

并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块

重入

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

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

class Father {

    public synchronized void doSomething() {
        
    } 
}

class Son extends Father{
    
    @Override
    public synchronized void doSomething() {
        System.out.println(toString());
        super.doSomething();
    }
}

上述代码子类Son继承父类Father并且重写父类doSomething方法,然后调用父类中的方法,这个时候如果没有可重入的锁,那么上述代码将会出现死锁。

由于Father和Son中的doSomething方法都是同步方法(synchronized修饰),因此每个doSomething方法在执行前都会获取Father上的锁。

然而,如果内置锁不是可重入的,那么在调用super.doSomething()时将无法获取Father上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获取的锁,重入则避免了这类死锁的情况发生

对于上述代码还有对重入的理解可能有些复杂,我同样对理解这个结论有些困难,到底这个重入的情况是锁住了谁,搜索了许久发现一篇帖子的讨论,跟大家分享一下

public class Test {

    public static void main(String[] args) throws InterruptedException {
        final TestChild t = new TestChild();
    
        new Thread(new Runnable() {
          @Override
          public void run() {
            t.doSomething();
          }
        }).start();
        Thread.sleep(100);
        t.doSomethingElse();
    }

    public synchronized void doSomething() {
         System.out.println("something sleepy!");
         try {
           Thread.sleep(1000);
           System.out.println("woke up!");
         }
         catch (InterruptedException e) {
           e.printStackTrace();
         }
    }

    private static class TestChild extends Test {
        public void doSomething() {
          super.doSomething();
        }

        public synchronized void doSomethingElse() {
          System.out.println("something else");
        }
    }
}

上述代码,作为一个实验,可以证明上面的重入情况锁住子类对象和父类对象是一个锁

如果super锁住了父类对象,没有锁住子类对象,那么另一个线程仍然可以获得子类对象的锁。按照这个假设,上述程序应该输出

  • something sleepy!
  • something else
  • woke up!

但输出的是

  • something sleepy!
  • woke up!
  • something else

现在我们一起来分析一下上述程序

  • 上述程序在main方法中开启了一个新线程去执行子类对象t的doSomething()方法
  • 子类对象的doSomething()通过super关键字调用父类的doSomething()方法,因为父类的doSomething()方法被synchronized关键字修饰,所以这个时候程序对某一个对象上了锁
  • 如果调用父类方法的时候锁住了父类的对象,那么另一个线程仍然可以获得子类t对象的锁,我们看一下父类的doSomething()方法,方法块中有让这条线程sleep 1s的操作,并且在main方法中新线程之后也有一步让当前线程sleep 0.1s的这个操作,那么按理说,如果锁住的是父类的隐式对象,这个时候新线程sleep之后,按理说子类对象t可以去执行doSomethingElse()这个方法,可是根据执行结果来看,并不是这样的
  • 所以通过上面的结论以及一个示例的代码,我们不难看出,整个内置锁的重入其实只是锁住了子类对象,这样的话在上述的例子中,在新线程中调用父类方法锁住的是子类对象t,这样即使是在父类线程休眠之后,也不会使得子类对象去调用自己的doSomethingElse()方法成功,因为这个时候,子类对象的锁的持有还是在那条新的线程,所以程序会按照上述的输出执行

用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议来实现对共享状态的独占访问,如果在符合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的

如果通过同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁

对于可能被多个线程同时访问的可变状态的变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的

当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁

每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁

当类的不变性条件涉及多个状态变量时,那么还有另一个需求:在不变形条件中的每个变量都必须由同一个锁保护。如果同步可以避免竞态条件问题,那么为什么不在每个方法声明时都使用关键字synchronized?事实上,如果不加区分地滥用synchronized,可能导致程序中出现过多的同步,此外,如果只是将每个方法都作为同步方法还可能会导致活跃性问题或者性能问题

活跃性与性能

如果现在处理的是一个Servlet,相对其进行并发处理,直接对service方法上锁添加synchronized关键字,虽然这种简单且粗粒度的方法能够确保线程安全性,但是代价却很高

由于service是一个synchronized方法,因此每次只能有一个线程可以执行。这就背离了Servlet框架的初衷,即Servlet需要能同时处理多个请求,这在负载过高的情况下将给用户带来糟糕的体验,如果处理一条请求耗时较长,那么其余用户的请求就将一直等待,直到Servlet处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个CPU系统,那么当负载较高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,仍然需要很长时间,这些请求必须等待前一个请求处理完成,我们将这种Web应用程序称之为不良并发(Poor Concurrency)应用程序,可同时调用的数量,不仅受到可用的处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,就会很容易做到Servlet的并发性,同时又维护线程安全性。

要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中去,应该尽量不影响共享状态且执行时间较长的操作,从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态

上一篇:Linux协议栈(1)——协议介绍


下一篇:什么云服务好呢