《Java并发编程实战》线程安全性和对象共享

引言

  1. 多进程和多线程的优点?
  2. 多线程的优势与风险?
  3. 竞态条件是什么?

早期计算机中还不存在操作系统,一台机器从头到尾只能执行一个程序,并且这个程序能访问所有的计算机资源。

操作系统的引入是的计算机“同时”能运行多个程序,不同程序都在单独的进程中运行:操作系统为各个独立的进程分配各种资源,包括内存、文件句柄以及安全证书等。不同进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。

促成操作系统协调多进程同时运作的主要原因有:

  • 资源利用率:对于某些需要等待IO或磁盘操作的程序,不应该要求CPU等待,在这种情况下,多进程操作系统的CPU可以在等待时运行其他程序;
  • 公平性:通过时间分片使得不同程序对计算机的资源有同等的使用权;
  • 便利性:一个系统的多个任务应该被拆分成多个进程进行独立开发和维护,进程之间通过通信进行协调和共享数据;

而同样的原因也促使线程的出现。线程也被称为轻量级进程,在大多数现代操作系统中,都是以线程为基本的调度单位,而不是进程

线程的出现,允许同一个进程中同时存在多个程序控制流,线程会共享进程范围内的资源(正是因为多线程共享同一个进程的资源,加大了多线程使用的负责性),例如内存句柄和文件句柄,每个线程都有各自的程序计数器、栈以及局部变量等

多线程的优势:

  • 发挥多核处理器的强大能力;
  • 建模的简单性;
  • 异步事件的简化处理;
  • 响应更灵敏的用户界面;

多线程带来的风险:

  • 安全性问题:由于CPU对多个线程的调度存在随机性,即在没有充分运用同步机制(例如,volatile、synchronized、基于AQS的各种锁)的情况下,多个线程的操作执行次序是不可预测的
// java源码
private int i; // 类字段
public void incAssign(){
    i++;
}     

// javap.exe -verbose查看class指令码
public void incAssign();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0           // 将第一引用类型本地变量推送至栈顶
         1: dup               // 复制栈顶数值并将复制值压入栈顶
         2: getfield      #2  // Field i:I  // 获取指定类的实例域,并将其值压入栈顶
         5: iconst_1          // 将int型1推送至栈顶  
         6: iadd              // 将栈顶两个int型值相加,并将结果压入栈顶
         7: putfield      #2  // Field i:I  // 为指定类的实例域赋值
        10: return            // 从当前方法返回void
      LineNumberTable:
        line 10: 0
        line 11: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LTest;
}

分析上述源码和编译后的字节码可知,value++看上去是单个操作,但事实上在JVM执行字节码时,它包含了几个独立的指令操作,如本例中的2-7指令行:获取实例域数值并压入栈顶、将常量1推送至栈顶、栈顶的两个值相加、给实例域重新赋值,即一个简单的自加操作,在字节码的处理过程中至少涉及到了四步骤的操作。

如果A线程执行到第6行,还尚未执行第7行的赋值,而此时B线程已经执行完第2行的读取指令,那么A线程的加1的效果还没来得及被B线程读取到,也就意味着两个线程对同一个i的值进行自加操作,虽然加了2次,但两者得到的结果是一样的(B在A之后putfield,相当于把A的劳动成果覆盖掉了)。这就是所谓的多线程并发执行时的不安全问题,而本例还尚未考虑指令重排序带来的更多复杂性。上述示例说明的是一种常见的并发安全问题,称为竞态条件,即程序执行结果的正确与否依赖于多线程(进程)对共享资源的操作次序。

由于不恰当的执行次序导致出行不正确的结果的情况成为竞态条件

竞态条件(race condition),从多进程间通信的角度来讲,是指两个或多个进程对共享的数据进行读或写的操作时,最终的结果取决于这些进程的执行顺序。

出现竞态条件的场景除了上述i++操作外(读取-修改-写入),还有一个典型的场景是在单例模式创建单例对象时(先检查后执行),如果不做同步操作,那么也会线程不安全,例如:

// 存在竞态条件的创建单例示例(线程不安全的示例)
public class Singleton {

    private static Singleton uniqueInstance= null;

    public static Singleton getInstance(){
        if (uniqueInstance == null){
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

// 解决竞态条件的同步机制优化方案
public class Singleton {

    private volatile static Singleton uniqueInstance= null;

    public static Singleton getInstance(){
        if (uniqueInstance == null){
            synchronized (Singleton.class){
                if (uniqueInstance == null){
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}
  • 活跃性问题:例如,如果A线程在等待B线程释放其所持有的资源,而B线程永远都不释放该资源,那么A就会永久地等待下去。类似的活跃性问题还有死锁、饥饿、活锁等,即并发错误发生与否依赖于不同线程时间发生的次序。
  • 性能问题:在多线程程序中,线程调度器需要临时挂起活跃线程转而运行另一个线程,会导致频繁的上下文切换操作,这种操作会带来极大的开销:保存和恢复执行的上下文、丢失局部性,并且CPU时间将更多的花在线程调度而不是线程运行商。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化(Java内存模型中,工作内存会缓存主内存的变量以提升程序性能),使内存缓存区中的数据无效,以及增加共享内存总线的同步流量(如,volatile会通过缓存无效、锁总线等来保证变量操作的可见性)。

线程安全性具体是什么?

  1. 线程安全的定义?
  2. 内置锁
  3. 可重入性
  4. 锁保护状态的根本原因(所有位置同步,所有同步设定同一把锁)

线程安全的定义:

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


在线程安全类中封装了必要的同步机制,因此客户端无需进一步采用同步措施。(这是“绝对线程安全”期待的效果,但是现实情况下,即便是在类中封装了足够的同步机制,仍不能保证客户端任意操作下一定线程安全(举例,Vector...???),即所谓的线程安全类,都是相对的线程安全)

synchronized + 内置锁

Java中关键字synchronized是一种同步代码块实现机制,其包括两部分内容:1. 锁的对象引用,2. 这个锁保护的代码块

  • 每个Java对象都可以作为实现同步的锁,这些锁成为内置锁或者监视器锁。代码在进入同步块之前会自动获得锁,并且在退出同步块是自动释放锁,而不论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出;
  • 获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法;
  • 内置锁是可重入的,即某个线程试图获取一个已经由它自己持有的锁,那么这个请求不会阻塞,而是会成功。“可重入”意味着获取锁的操作的粒度是“线程”而不是“调用”;
  • 重入在JVM中的实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有,当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取的技术值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步块时,计数器会相应地递减。当计数值为0时,这个锁将被释放;
  • 可重入有利于面向对象开发,考虑一个场景,如果子类重写了父类的synchronized方法,并且在方法体中调用了父类的方法,如果synchronized同步机制没有可重入的锁,那么由于子类方法先获取了锁,而在调用父类方法时,父类方法陷入阻塞,导致死锁情况的发生。例如:
class ReEntrantTest{

    static class Father{
        public synchronized void doSomething(){
            System.out.println("father method is doing");
        }
    }

    static class Son extends Father{
        @Override
        public synchronized void doSomething() {
            super.doSomething();
            System.out.println("Son is continue doing");
        }
    }

    public static void main(String[] args){
        new Son().doSomething();
    }

}

// 运行结果:
father method is doing
Son is continue doing

// 结果分析:
synchronized机制的内置锁是可重入的,该情况下才不会阻塞当前线程

// 关于父类synchronized方法被子类继承的相关问题补充
1. 如果子类方法不重写父类synchronized方法,那么子类实例调用该方法时,仍具有同步效果;
2. 如过子类重写了父类的方法,即使子类不使用synchronized修饰该方法,也构成重写效果。具体来说,
子类可以选择是否使用synchronized关键字修饰重写的父类方法,如过是使用了关键字,那么该方法就具备同步效果,如果没使用,就不具备

用锁来保护状态

仅仅将复合操作封装到一个同步代码块中是不够的,如果用同步来协调对某个对象的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁

即为保证对一个共享可变变量访问的安全性,需要在所有访问该变量的位置都要加锁实现同步,并且都必须用同一个锁来保护。

对象的内置锁与其状态之间没有内在的关联。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式创建锁对象。

一个常见的加锁约定是:将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

// vector是线程安全类Vector的实例对象
if (!vector.contains(element)){
    vector.add(element);
}

虽然vector的containers方法和add方法都是线程安全的方法(可理解为原子方法),但上述多个操作合并为一个复合操作仍然存在竞态条件,即还需要额外的加锁机制。


如何共享和发布对象?

  1. 什么叫内存可见性?
  2. synchronized和volatile各自如何实现内存可见性?
  3. 理解什么是引用逸出?(this逸出、引用对象逸出等)
  4. 线程封闭有什么用?有哪些实现方式?

一方面关键字synchronized可以用于实现原子性,而另一方面也可以实现:内存可见性。

我们不仅需要保证多个线程在对共享变量访问时的安全性,而且希望确保当一个线程修改了对象的状态后,其他线程能够看到发生的状态变化,即保证对象被安全的发布。

加锁:互斥性和可见性

加锁的涵义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

注意两点

  • 所有涉及访问变量的位置都需要同步;
  • 所有的同步都要加持同一把锁;

保证这两点才能保证任何时候对于该变量的操作都不会被其他线程忽视掉,换句话说任何线程对共享可见变量的操作,都会被其他线程看到,即可见性。

举个例子,假如有四个线程各存在一处对共享可见变量有读写操作,在都进行同步机制和设定同样的内置锁前提下,那么在某一时刻,至多只会有一个线程获取锁,当持锁线程对变量修改,其他线程只能等待锁释放,而当锁被释放后,其他线程看到的变量值,都是最新的值,不存在失效值的情况。

而同样的例子,如果存在第五个线程没有进行同步或者同步的锁不是同一把锁,那么该线程无需等待上述的任何持锁线程释放锁,就可以直接读取到共享变量,这种情况读取的值可能是失效的或其他未知错误。

通过这个例子也在说明,其实同步机制、内置锁都跟同步代码块里的共享变量没有什么必然的关系,只要其他地方没有设置同步或同步要求的锁不是同一把锁,那么就无需阻塞等待锁释放,就可以直接访问目标共享变量;而之所以所谓的同步和内置锁能保证多线程环境下共享可变变量的安全性和可见性,最核心的保障措施在于所有涉及访问共享变量的地方都必须对同一把锁进行加锁竞争,这种策略完全保证了永远只会有一个线程能操作变量,不存在竞争条件,而持锁线程释放锁后,其他线程再去访问变量,都能看到最新修改的变量值,即保证可见性。

volatile与可见性

关键字volatile用来修饰变量,当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,这意味着:

  • 抑制重排序:不会将该变量上的操作与其他内存操作一起重排序;
  • 抑制共享变量缓存:不会将该变量缓存在寄存器或者对其他CPU核不可见的地方;不缓存变量一方面表明当进行写操作时,直接写到是所有线程共享可见的主内存,而不是当前线程自己的工作内存中,另一方面也表明当进行读操作时,读到的不是缓存在本地线程内存中的值,而是直接读所有线程可见的共享内存中的值,即读取volatile类型的变量总会返回最新写入的值。(当然volatile只用来修饰成员变量,不能用了修饰局部变量,因为局部变量是存在于线程私有的虚拟机栈的栈帧中,不会存在多线程访问的情况,而成员变量在类创建实例时是存在于线程共享的Java堆中,因此volatile修饰的成员变量直接在堆上操作,能实时对所有线程可见)

由于重排序和共享变量缓存都是对提升性能做出的优化,因此使用volatile关键字会抑制这两种优化,虽能保证共享变量的可见性,但是会以牺牲部分性能为代价。

volatile变量通常用作某个操作完成、发生中断或者状态的标志。例如一个典型的用途:检查某个状态标记以判断是否退出循环:

public class Test {

    static volatile boolean flag = true;

    public static void main(String[] args){

        while (!flag){
            doSomething();
        }

    }

    public static void doSomething(){}
}

一个常见误区:volatile的语义不足以确保递增操作(count++)的原子性:

// volatile修饰的变量自增操作源码
public class Test {

    volatile int count = 0;

    public static void main(String[] args){
        
    }

    public void inc(){
        count++;
    }

}


// javap.exe -verbose查看字节码
public void inc();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field count:I
         5: iconst_1
         6: iadd
         7: putfield      #2                  // Field count:I
        10: return
      LineNumberTable:
        line 10: 0
        line 11: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   LTest;

由上述字节码指令可以看出,即便是volatile修饰的共享变量,在自增操作时,仍会在线程私有的栈帧中对应2-7一共四条指令,也就意味着,这个四条指令的复合过程不是原子操作,存在多线程下的竞态条件。

发布和逸出

私有数组逸出:当发布一个对象时,在该对象的非私有域中引用的所有的对象同样会被发布。

public class Test {

    private String[] privateArr = {"hello", "world"};

    public static void main(String[] args){

        Test test = new Test();
        String[] arr = test.getArray();
        for (String str: arr){
            System.out.println(str);
        }

        for (int i=0; i<arr.length; i++){
            arr[i] = "modified";
        }

        test.print();
    }

    public String[] getArray(){
        return privateArr;
    }

    public void print(){
        System.out.println("--- print Test class's private String array ---");
        for (String str: this.privateArr){
            System.out.println(str);
        }
    }
}

// 输出结果:
hello
world
--- print Test class's private String array ---
modified
modified

隐式this引用逸出,未完待续。。。。

线程封闭

仅在单线程内访问数据,则无需同步,也不存在竞态条件,实现这样的单线程访问变量的技术被称为线程封闭

从定义上可以看出,线程封闭有很多好处:

  • 由于不需要同步,因此程序性能得以提升;
  • 由于不存在竞态条件,只在单线程内操作变量,因此降低了并发程序的出错风险;

方式一:Ad-hoc线程封闭

Ad hoc是拉丁文常用短语中的一个短语。这个短语的意思是'特设的、特定目的的(地)、即席的、临时的、将就的、专案的'。这个短语通常用来形容一些特殊的、不能用于其它方面的的,为一个特定的问题、任务而专门设定的解决方案。

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的(程序中尽量少用,多用下述两种栈封闭技术),因为没有借由任何一种语言特性(借助语言特性的有下面的:借助局部变量/栈封闭、借助ThreadLocal)来辅助实现单线程访问,不借助语言特性,只从程序逻辑上实现单线程访问。

方式二:栈封闭

栈封闭要求,只能通过局部变量才能访问对象。由于局部变量的固有属性之一就是封闭在执行线程中,其他线程无法访问到该线程的JVM栈。

对于基本类型的局部变量,由于Java中无法通过任何机制获取对基本类型的引用,所以基本类型的局部变量不存在逃逸的可能性(逃逸多指可变对象的引用地址被其他线程获取到,从而出现意料之外的并发修改等),因此对其栈封闭是完全安全的。而维护引用局部变量的栈封闭性时,程序中需要多做一些工作以确保被引用的对象不会逃逸出当前线程之外。

方式三:ThreadLocal类

ThreadLocal类能使线程中的某个值与保存值的对象关联起来。

理解:ThreadLocal是一个泛型类,其创建的实例对象可以通过该对象的set实例方法或者protected修饰的initialValue方法进行赋值,从而实现实例对象与某个类型的值之间的一一绑定关系。

另一篇关于ThreadLocal的详细讲解:ThreadLocal的源码分析

猜你喜欢

转载自blog.csdn.net/WalleIT/article/details/88595678