按照Java虚拟机的实现方式,锁包括synchronize实现的内部锁,和实现Lock接口的显示锁。

Java锁对线程安全的保障:

  • 原子性。不管是读还是写,都要获得响应的锁。
  • 可见性。 Java平台中,获得锁隐含着刷新处理器缓存的动作,使得读线程在进入临界区之前将对线程共享变量的更新同步到处理器的高速缓存中;而锁的释放隐含这冲刷处理器的动作,使得写线程对共享的更新刷新到处理器的高速缓存中。对于引用变量,还能包装读取到最新的对象字段。数组也能包装读取到最新元素。
  • 有序性。锁还能保障有序性,因为读线程没必要知道写线程是以什么顺序更新变量的。临界区只是保证临界区内的操作不会被重排到临界区之外。

可重入锁允许一个线程多次获得一个锁,

syncronized

java中的任何一个对象都有唯一一个与之关联的锁,称为监视器(monitor)或内部锁(intrinsic)。内部锁是一种排他锁。内部锁通过synchronized关键字实现,可以修饰代码块和方法。被synchronized修饰的方法称为同步方法。synchronized的锁是一个对象,或能够返回对象的表达式。锁可以是this。

public class Foo {
    private int serial = 0;
    public int next() {
        synchronized(this) {
            return ++serial;
        }
    }

    // 也可以这样写
    public synchronized int next() {
        return ++serial;
    }
}

一般用final和private修饰锁对象,一旦改变,会使一个同步块使用不同的锁。

private final Object LOCK = new Object();

同步静态方法相当于用当前类对象作为锁,类本身也是一个对象。

class Foo {
    public static synchronized void bar() {
        // 以当前类对象作为锁
    }

    // 等价
    public static void bar2() {
        synchronized(Foo.class) {

        }
    }
}

内部锁的获得和释放都是由虚拟机自动完成的,内部锁的使用的不会导致锁泄露,即使代码发生异常。

显式锁:Lock接口

Lock的默认实现是ReentrantLock,是可重入锁。ReentrantLock即是公平锁也是非公平锁,可以通过构造函数ReentrantLock(boolean fair)指定。公平锁适合锁持有时间较长,或申请锁平均时间较长的情况,增加了上下文切换作为代价。 相关方法:

  • Thread.holdsLock(Object)检测当前线程是否持有指定的内部锁
  • ReentrantLock.isLocked检测相应的锁是否被某个线程持有
  • ReentrantLock.getQueueLength

读写锁

读写锁也被称为共享锁、排他锁,运行多个线程同时读取共享变量,但只允许一个线程对共享变量更新。任何线程读取共享变量的时候,其他线程无法更新改变量。一个线程更新共享变量时,其他线程无法读取、写入该共享变量。读锁对读线程共享,写锁对其他写锁和读锁排他。

读写锁适合在只读操作比写操作频繁的多、读线程持有锁的时间比较长的场景中使用。ReentrantReadWriteLock支持锁降级,即一个线程持有写锁的情况下可以继续获得相应的读锁。降级的反面是锁升级,即一个线程持有读锁的情况下,申请相应的写锁,ReentrantReadWriteLock不支持锁升级。

public interface ReadWriteLock {

    Lock readLock();

    Lock writeLock();
}

内存屏障

JVM借助内存屏障来实现处理器的刷新和冲刷,内存屏障是对处理器的抽象。内存屏障被插入到两个指令之间,作用是禁止编译器、处理器重排序,从而保障有序性。这些指令也会刷新和冲刷处理器,从而保障可见性。

内存屏障可以划分为一下几个部分:

  • 按可见性。加载屏障和存储屏障,它们分别会刷新处理器缓存和冲刷处理器缓存。JVM会在monitorexit(释放锁的JVM指令)对应的机器码指令插入 存储屏障 ,保证了写线程在释放锁前在临界区对共享变量的更新对其他线程是可见的。相应的,JVM会在monitorenter对应的机器码指令之后的 临界区开始之前 插入加载屏障,使得读线程对应的处理器能够将写线程从对相应共享变量的更新从其他处理器同步到高处理器的高速缓存中。可见性的保障是通过读线程和写线程使用相应的屏障实现的。

  • 按有序性,可分为获取屏障(acquire)和释放屏障(release)。获取屏障在一个 读操作之后 插入该内存屏障,作用是禁止该读操作与其后的任何读写操作之间进行重排序(如read-modify-write),相当于在进行后续操作之前要先获得共享数据的所有权。释放屏障在一个 写操作之前 插入该内存屏障,作用是禁止该写操作与其前面的任何读写操作之间进行重排序,相当于对相应的共享变量更新结束后释放所有权。JVM会在monitorenter(包含读操作)对应的机器码之后临界区之前插入获取屏障,并在临界区之后monitorexit对应的机器码之前插入释放屏障

两种屏障想三明治一样把临界区的代码包括起来,禁止临界区中任何读写操作被重排序到临界区之前,释放屏障经禁止了读写操作被重排序到临界区之后。这使得临界区的操作具有原子性,同时读线程不用知道临界区的共享变量是以何种顺序被更新的。

临界区之外的代码可能会被JIT编译进临界区内,但由于内存屏障,处理器不会将其重排序到临界区外(也不会将临界区外的指令重排到临界区内)。x86的Lokc指令前缀能够禁止其前或其后的读写指令的重排序。

volatile

volatile修饰的变量意味着每次读取和更新,都必须从高速缓存或内存中读取。因此,volatile不会被编译器分配到寄存器进行存储。

volatile是轻量级锁,保证可见性和有序性。但仅能保证写操作的原子性,而不表示赋值有原子性(如value = count + 1),而没有排他性。其次,volatile的使用不会引起上下文的切换。

对volatile变量进行写操作,会在写操作前后加上释放屏障和存储屏障,以防止对volatile写操作被重排序到前面。并且保证volatile之前的任何读写操作都会先于volatile之前被提交,即读线程看到写线程更新volatile变量时,更新volatile变量之前的内存操作对读线程是可见的,保障了有序性。存储屏障会冲刷处理器缓存,使得volatile变量和之前的任何操作对其他处理器是可见的。

public class Foo {
    private int data = 0;
    private String data2 = null;
    private volatile boolean ready = false;
    
    public void write() {
        a = 1;
        data2 = "Read";
        ready = true;
    }

    public void read() {
        if (read) {
            // do something
            // 当read时,data和data2一定初始化了。
        }
    }
}

对于volatile的读操作,JVM会在该操作前后插入加载屏障和获取屏障。加载屏障可以刷新处理器,使得读线程所在的处理将其他处理器对共享变量(volatile变量和之前的变量)的更新同步到高速缓存中,获取屏障禁止了volatile读操作之后的任何读写操作与volatile读操作进行重排序。因此对volatile读操作之后的任何操作开始之前,写线程对相关共享变量(包括volatile变量)的更新对当前线程已经可见。

volatile有序性也可从禁止重排序理解:

  • 写volatile变量不会往前重排序
  • 读volatile变量不会向后重排序

如果volatile修饰数组,那么只会对数组引用本身起作用,而无法对数组元素的操作起作用。可以使用如AtomicIntegerArray来实现类似效果。

volatile开销

volatile变量读写不会导致上下文切换,因此volatile的开销比锁小。volatile变量的读取开销可能比普通要高,因为每次都要从高速缓存或内存中同步,而无法在寄存器中暂存。

应用场景:

  • volatile变量作为标志位,避免锁开销
  • 保障可见性。多个线程共享一个变量。其他线程在没有加锁的情况下也能看到更新
  • 在某些情况下可以代替锁,如多个线程共享一组数据的情况下,通常要用锁来保障这组变量操作的原子性。利用volatile变量写操作的原子性,可以将这组变量包装成对象,对这组变量的更新就可以通过创建新的对象,并将引用赋给变量来实现
  • 简易的单变量的读写锁

单例模式

双重检测

// 有问题的代码,没有加volatile
public Singleton {
    private static volatile instance;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

对象的初始化操作如一下伪代码,对象的初始化操作可以被分为如下3步,如果变量中没加volatile,就会发生将操作2重排序到操作3。其它线程会读取到一个没有初始化的对象。volatile能够禁止该变量写操作与之前的任何读写操作进行重排序,保障instance读取时实例已经初始化完毕。

objRef = allocate(Singleton.class) // 1. 分配空间
involkeConstruct(objRef); // 2. 调用构造器
instance = objRef; // 3. 将对象引用写入共享变量

CAS

许多多线程的库最终实现都会借助CAS。CAS能够将read-modift-write和check-and-act之类的操作转为原子操作。CAS是原子的if-then-act操作。CAS保障更新的原子性,但不保障可见性。如果其他线程没有修改过原子变量的值,那么多个线程修改一个原子变量时,下手最快的线程更新成功。

原子变量类基于CAS实现,一共有12个

  • 基础类型,AtomicInteger AtomicLong AtomicBoolean
  • 数组型,AtomicIntegerArray AtomicLongArray AtomicReferenceArray
  • 字段更新器,AtomicIntegerFieldUpdater AtomicLongFieldUpdater AtomicReferenceFiledUpdater
  • 引用型,AtomicReference AtomicStampedReference AtomicMarkableReference

AtomicBoolean看起来有些多余,但实际上当执行read-modify-write需要加锁操作。

public class Foo {
    private AtomicBoolean atomicBool = new AtomicBoolean(false);
    private volatile boolean bool = false;

    public void casInit() {
        if (atomicBoolean.compareAndSet(false, true)) {
            // ...
        }
    }

    public void lockInit() {
        synchronized (this) {
            if (bool) {
                return;
            }
            bool = true;
            // ...
        }
    }
}

使用atomic变量能够避免锁的开销,又避免了原子性。AtomicBoolean和AtomicReference从理解上是相似的(有条件更新)。在数组变量上即使加上volatile,也无法使对应元素的读写具有可见性和原子性,Java引入了AtomicIntegerArray等来解决。

ABA问题:对于共享变量V,一个线程在看到它的值为A的时刻,其他线程将新为B,接着在当前线程将其执行CAS的时刻又被其他线程更新为A,那么可否认为V的值没有变化过?这就是ABA问题,即共享变量的值经历了A -> B -> A的变化。

要解决ABA问题,可以共享变量进行扩展,引入版本号,每更新一次就在版本号增加1,对应的Java类为AtomicStampedReference。

AtomicIntegerFieldUpdater(Long、Reference)是字段更新器,更加底层,可理解为对CAS的封装,原子变量的其他几个类都可以用它们来实现。

对象发布与逃逸

对象发布指能够被其作用域之外的线程访问到。常见的对象发布有一下几种方法:

  • 将对象存储到public变量中
  • 在非private方法中返回一个对象
  • 创建内部类,使当前对象能够被这个内部类访问,如new Runnerble()中使用”外层类名.this”的语法

对象初始化安全

static

Java中类的初始化技术是延迟加载的,一个类被虚拟机加载后,该类所有静态变量都为默认值,知道有个线程初次访问该类的任意一个静态变量,才使这个类被初始化(静态初始化块static{}被执行),类的所有变量配赋初值。

static在多线程环境下有特殊含义,能够保障一个线程在没有其它同步机制下,能够读取到读取到一个类静态变量的初始值。但仅能保障初次读取,不能保障其他线程更新的可见性。对于静态引用变量,static保障一个变量读取该变量初值时,这个引用所指的对象也初始化完毕。

final

由于重排序的作用,一个线程读取一个对象的引用时,该对象可能尚未初始化完毕,线程可能读取到该变量默认值而不是初始值。多线程环境下final关键字有特殊的作用。

当一个对象发布到其他线程时,该对象所有final字段都是初始化完毕的,而非final字段则没有这种保证。对于 引用型final字段 ,该字段所引用的对象读取时已经初始化完毕,即线程读取到该对象的各个字段都已经初始化完毕,读取时都是初始化值。

public clas Foo {
    private final Bar bar;
    private String str;
    public Foo (int x, int y, String s) {
        this.str = s;
        this.bar = new Bar(x, y);
    }

    public static Bar {
        private int x;
        private int y;
        public Bar(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
}

在JIT内联优化下,this.bar = new Bar(x, y)可能会被有编译为以下伪指令:

objRef = allocate(Foo.class); // 1
objRef.str = "xxxx"; // 2
objBar = allocate(Bar.class); // 3
objBar.x = 1; // 4
objBar.y = 2; // 5
objRef.bar = objBar; // 6

bar采用final修饰,Java会保证构造器中对该变量的初始化,以及锁引用对象的初始化被限定在操作5之前完成。而url没有采用final修饰,所以任然可能被重排序到操作5之后。

final只能保障有序性,即保障该对象对外可见的时候,final字段必然是初始化完毕的,但不保证对象本身的可见性。