JMM
参考:JavaGuide
并发问题
CPU Cache
Cahce中某个变量的值可能与 Main memory中的值不一致,导致计算出错。
指令级重排
编译器优化重排
JVM, JIT做这件事。对于Java程序,在不改变它的语义的情况下,编译时进行指令重排序,类似g1, g2 optimization的重排。
解决:禁止编译器重排序
指令并行重排
CPU做这件事,与CPU的流水线工作相关。指的是一个线程在CPU不同硬件上可以实现并行运行。
指令级并行不能保证多线程并发执行时的安全性。
解决:内存屏障
JMM
Java Memory Model, 即Java内存模型。
为什么Java需要自己的内存模型?因为它需要在不同操作系统上运行时效果一致,所以不能直接用操作系统提供的内存模型,而是自己提供内存模型。
抽象
JMM 抽象出本地内存和主内存的概念。所有对象实例存在于主内存中,本地内存这是某个线程专有的。一个线程不可以访问其它线程的本地内存。
JMM定义了八种同步操作(lock, read, load…)和一些规范,比如一个新的变量只能在主内存里诞生
happens before
如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,(不管他们是否属于同一个线程),并且第一个操作的执行顺序排在第二个操作之前。
规则:
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。
- 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作。
三个重要特性
原子性:一组操作要么全部成功执行,要么全都不执行。 可见性:如果一个线程对某个共享变量做了修改,那么其它线程马上可以看到这个变量最新的值。 有序性:指令重排序在多线程下是不安全的,使用volatile可以禁止重排序。
Volatile
如果把某个变量声明为volatile,相当于告诉JVM,这个变量是共享且不稳定的。它可以保证可见性。
- 每次读这个变量,都需要从主存中获取。
- 对这个变量进行读写操作时,通过插入内存屏障来禁止指令重排序。
- 无法保证原子性
在创建单例对象时,需要用到 volatile
|
|
uniqueInstance = new Singleton()这个操作会分成三步执行。
- 在堆内分配内存空间
- 初始化uniqueInstance对象
- 让uniqueInstance指向被分配的内存地址
指令重排序会执行 1,3,2。如果线程 A 执行了 1,3,此时轮到线程 B 执行 if (uniqueInstance == null)操作,会判断niqueInstance还没有被创建。
|
|
实际输出的值会小于 2500。原因:inc++并不是一个原子操作,实际上分了三步。
- 读取inc的值
- 执行+1的操作
- 把计算出的值放回内存
如果线程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实现。
|
|
想把变量o在offset偏移位置的字段修改为update,需要比较此时该字段的值是否为expected。如果值被成功更新,返回true,否则返回false。
Atomic类
Java有一些原子类,调用Unsafe类里的原子操作来实现一些逻辑。比如AtomicInteger类,可以原子性地对一个变量加一。
|
|
这里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更轻量。
- 修饰实例方法(锁调用方法的对象)
- 修饰静态方法(锁当前类)
- 修饰代码块(给括号里指定的类/对象加锁)
底层原理
- synchronized 修饰代码块。查看字节码指令,发现在同步代码块的之前和之后,分别有指令“monitorenter”和“monitorexit”。monitor是由 C++实现的。
- synchronized 修饰方法。查看字节码文件,发现方法的 flag里有一个ACC_SYNCHRONIZED,代表这是一个同步方法。
锁升级
Mark Word里面的信息:
锁状态 | 56 位 | 4 位 | 1 位(偏向锁标志) | 2 位(锁标志) |
---|---|---|---|---|
无锁 | 对象的哈希码 | 分代年龄 | 0 | 01 |
偏向锁 | 线程 ID + Epoch | 分代年龄 | 1 | 01 |
轻量级锁 | 指向锁记录的指针 | - | - | 00 |
重量级锁 | 指向Monitor的指针 | - | - | 10 |
GC 标记 | - | - | - | 11 |
- 无锁:对象头的mark word中记录的是该对象的哈希码、分代信息
- 偏向锁:只有一个线程需要该锁。 通过CAS操作在mark word中写入当前线程的ID. 若当前线程再次想要获取锁,就可以直接获取到,所以是可重入锁。
- 轻量级锁:多线程交替访问锁。当另一个线程想要访问同步代码块,发现该锁已经偏向其它线程了,所以升级为轻量级锁。此时会在该线程的栈上创建一个锁记录。mark word变成指向锁记录的指针,锁记录由原来的mark word+指向锁对象的指针组成。线程会通过自旋来尝试获取锁(用户态层面实现)。
- 重量级锁:多线程并发访问锁。
ReentrantLock
与 synchronized 的不同点:
- 可中断 (demo) 调用方法lock.lockInterruptibly(),这样其它线程就可以在该线程获取不到锁时,中断他。
- 可以设置超时时间(如果等一段时间还拿不到锁,就放弃获取锁了,lock.tryLock(2, TimeUnit.SECONDS))
- 可以设置为公平锁(多个线程按照申请锁的顺序来获取锁,即先到先得)。但是因为要保证完全按FIFO的顺序执行,会导致线程频繁切换,影响性能。
- 支持多个条件变量(等待的线程可以被不同条件唤醒)
- synchronized是JVM实现的,我们看不到底层实现,但ReentrantLock是JDK中的 API 层实现的,我们通过lock(), unlock()来调用。
可重入性
synchronized和ReentrantLock都是可重入锁。意思是同一个线程可以再次获取自己已经获得的锁。
条件变量
某个线程在获取锁之后,如果它还没有资格运行逻辑,它执行condition1.wait() 来表明自己等待condition1成立,同时把自己加入condition1的阻塞队列中。
此时另一个线程执行一些操作,使得condition1成立了,那么它会调用condition1.signal()来唤醒一个等待condition1的线程;调用condition1.signal()则是唤醒所有等待condition1的线程。
与 synchronized 不同之处:synchronized 相当于只有一个 condition,也就是说当其他线程执行 signalAll()时,所有处于等待状态的线程都会被唤醒。