JMM

参考:JavaGuide

并发问题

CPU Cache

Alt text Cahce中某个变量的值可能与 Main memory中的值不一致,导致计算出错。

指令级重排

编译器优化重排

JVM, JIT做这件事。对于Java程序,在不改变它的语义的情况下,编译时进行指令重排序,类似g1, g2 optimization的重排。

解决:禁止编译器重排序

指令并行重排

CPU做这件事,与CPU的流水线工作相关。指的是一个线程在CPU不同硬件上可以实现并行运行。

指令级并行不能保证多线程并发执行时的安全性。

解决:内存屏障

JMM

Java Memory Model, 即Java内存模型。

为什么Java需要自己的内存模型?因为它需要在不同操作系统上运行时效果一致,所以不能直接用操作系统提供的内存模型,而是自己提供内存模型。

抽象

JMM 抽象出本地内存和主内存的概念。所有对象实例存在于主内存中,本地内存这是某个线程专有的。一个线程不可以访问其它线程的本地内存。 Alt text JMM定义了八种同步操作(lock, read, load…)和一些规范,比如一个新的变量只能在主内存里诞生

happens before

如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,(不管他们是否属于同一个线程),并且第一个操作的执行顺序排在第二个操作之前。

规则:

  1. volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。
  2. 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作。

三个重要特性

原子性:一组操作要么全部成功执行,要么全都不执行。 可见性:如果一个线程对某个共享变量做了修改,那么其它线程马上可以看到这个变量最新的值。 有序性:指令重排序在多线程下是不安全的,使用volatile可以禁止重排序。

Volatile

如果把某个变量声明为volatile,相当于告诉JVM,这个变量是共享且不稳定的。它可以保证可见性

  1. 每次读这个变量,都需要从主存中获取。
  2. 对这个变量进行读写操作时,通过插入内存屏障来禁止指令重排序
  3. 无法保证原子性

在创建单例对象时,需要用到 volatile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public  static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

uniqueInstance = new Singleton()这个操作会分成三步执行。

  1. 在堆内分配内存空间
  2. 初始化uniqueInstance对象
  3. 让uniqueInstance指向被分配的内存地址

指令重排序会执行 1,3,2。如果线程 A 执行了 1,3,此时轮到线程 B 执行 if (uniqueInstance == null)操作,会判断niqueInstance还没有被创建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class VolatileAtomicityDemo {
    public volatile static int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService threadPool = Executors.newFixedThreadPool(5);
        VolatileAtomicityDemo volatileAtomicityDemo = new VolatileAtomicityDemo();
        for (int i = 0; i < 5; i++) {
            threadPool.execute(() -> {
                for (int j = 0; j < 500; j++) {
                    volatileAtomicityDemo.increase();
                }
            });
        }
        // 等待1.5秒,保证上面程序执行完成
        Thread.sleep(1500);
        System.out.println(inc);
        threadPool.shutdown();
    }
}

实际输出的值会小于 2500。原因:inc++并不是一个原子操作,实际上分了三步。

  1. 读取inc的值
  2. 执行+1的操作
  3. 把计算出的值放回内存

如果线程A读inc的值为1,它在执行第二步之前,就切换到线程 B 读取 inc的值,也读到1. 这样线程A,B都会把2放回内存。

乐观锁、悲观锁

悲观锁

对共享资源的读写操作上锁。没有获得锁的线程被阻塞。比如synchronized, reentrantLock. 适用于写多的场景,因为可以尽量避免失败重试。

缺点:高并发情况下,大量阻塞线程会影响线程。

乐观锁

提交修改时再判断共享资源是否被其它资源修改过了。适用于读多的场景,避免频繁枷锁影响性能。

版本号

线程A读取某数据,得到version=1;线程B读取某数据,得到version=1。A修改数据后放回,更新version=2. 而B对数据执行更新之后,更新版本为version=2,但是放回数据时,比较B期待的版本version=1,和现在数据的版本version=2. 发现不一致,于是B的操作无法成功。

CAS

Java的CAS操作是native方法,在Unsafe类里面,由CPU的原子指令cmpxchg实现。

1
public final native boolean compareAndSwapObject(Object o, long offset,  Object expected, Object update);

想把变量o在offset偏移位置的字段修改为update,需要比较此时该字段的值是否为expected。如果值被成功更新,返回true,否则返回false。

Atomic类

Java有一些原子类,调用Unsafe类里的原子操作来实现一些逻辑。比如AtomicInteger类,可以原子性地对一个变量加一。

1
2
3
public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

这里Unsafe类的getAndAddInt实现用到了自旋锁,把compareAndSwapInt放在while循环判断中,如果更新失败,会自旋重新尝试。

但是如果一直失败,就会不断在此处自旋,影响性能。

ABA问题

如果线程A想把val从1变为2,它读取了val=1。但在它赋值之前,切换至线程B运行,线程 B 把 val从1变成3,又变回1。此时切换至线程A运行,A赋值的时候检查val的值和预期的1一致,就认为这段时间内 val的值没有被修改过。

解决思路:版本号/时间戳。

synchronized

示例代码

JVM 内置锁,通过对象头和锁升级机制实现。
synchronized 关键字通过 Monitor 机制实现。每个对象在 JVM 中都有一个与之关联的监视器锁。当执行到 synchronized 代码块时,线程会尝试获取该对象的监视器锁。如果锁已被其他线程持有,当前线程会等待。JVM 实现的监视器锁比操作系统的 mutex 锁更加轻量,通常会在阻塞线程之前先尝试自旋获取锁,减少线程切换的开销,尤其是在多核处理器上。

此外,synchronized还能保证对一个变量的修改,是马上对其它线程可见的。类似于volatile,不过在这一点上,volatile更轻量。

  1. 修饰实例方法(锁调用方法的对象)
  2. 修饰静态方法(锁当前类)
  3. 修饰代码块(给括号里指定的类/对象加锁)

底层原理

  1. synchronized 修饰代码块。查看字节码指令,发现在同步代码块的之前和之后,分别有指令“monitorenter”和“monitorexit”。monitor是由 C++实现的。
  2. synchronized 修饰方法。查看字节码文件,发现方法的 flag里有一个ACC_SYNCHRONIZED,代表这是一个同步方法。

锁升级

Mark Word里面的信息:

锁状态56 位4 位1 位(偏向锁标志)2 位(锁标志)
无锁对象的哈希码分代年龄001
偏向锁线程 ID + Epoch分代年龄101
轻量级锁指向锁记录的指针--00
重量级锁指向Monitor的指针--10
GC 标记---11
  1. 无锁:对象头的mark word中记录的是该对象的哈希码、分代信息
  2. 偏向锁:只有一个线程需要该锁。 通过CAS操作在mark word中写入当前线程的ID. 若当前线程再次想要获取锁,就可以直接获取到,所以是可重入锁
  3. 轻量级锁:多线程交替访问锁。当另一个线程想要访问同步代码块,发现该锁已经偏向其它线程了,所以升级为轻量级锁。此时会在该线程的栈上创建一个锁记录。mark word变成指向锁记录的指针,锁记录由原来的mark word+指向锁对象的指针组成。线程会通过自旋来尝试获取锁(用户态层面实现)。
  4. 重量级锁:多线程并发访问锁。

ReentrantLock

与 synchronized 的不同点:

  1. 可中断 (demo) 调用方法lock.lockInterruptibly(),这样其它线程就可以在该线程获取不到锁时,中断他。
  2. 可以设置超时时间(如果等一段时间还拿不到锁,就放弃获取锁了,lock.tryLock(2, TimeUnit.SECONDS))
  3. 可以设置为公平锁(多个线程按照申请锁的顺序来获取锁,即先到先得)。但是因为要保证完全按FIFO的顺序执行,会导致线程频繁切换,影响性能
  4. 支持多个条件变量(等待的线程可以被不同条件唤醒)
  5. synchronized是JVM实现的,我们看不到底层实现,但ReentrantLock是JDK中的 API 层实现的,我们通过lock(), unlock()来调用。

可重入性

synchronized和ReentrantLock都是可重入锁。意思是同一个线程可以再次获取自己已经获得的锁。

条件变量

生产者/消费者代码示例

某个线程在获取锁之后,如果它还没有资格运行逻辑,它执行condition1.wait() 来表明自己等待condition1成立,同时把自己加入condition1的阻塞队列中。

此时另一个线程执行一些操作,使得condition1成立了,那么它会调用condition1.signal()来唤醒一个等待condition1的线程;调用condition1.signal()则是唤醒所有等待condition1的线程。

与 synchronized 不同之处:synchronized 相当于只有一个 condition,也就是说当其他线程执行 signalAll()时,所有处于等待状态的线程都会被唤醒。