JUC
1、volatile
1.1、谈谈对volatile的理解
(1)volatile是java虚拟机提供的轻量级的同步机制
- 保证可见性
- 不保证原子性
- 禁止指令重排
(2)谈谈JMM
JMM(Java内存模型Java Memory Model,简称JMM)本身是一种抽象的概念并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素) 的访问方式。
JMM关于同步的规定:
- 线程解锁前,必须把共享变量的值刷新回主内存
- 线程加锁前,必须读取主内存的最新值到自己的工作内存
- 加锁解锁是同一把锁
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
三大特性:
- 可见性
- 原子性
- 有序性
VolatileDemo代码演示可见性:
class MyData {
int number = 0;
public void add() {
this.number = 60;
}
}
/**
* 验证volatile的可见性
*/
public class VolatileDemo {
public static void main(String[] args) {
// 线程要操作的资源类
MyData myData = new MyData();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\tcome in");
// 暂停一会儿
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 修改值
myData.add();
System.out.println(Thread.currentThread().getName() + "\tupdate number value: " + myData.number);
}, "线程A").start();
// 第二个线程为main线程
while (myData.number == 0) {
// main线程一致循环,直到number值不再等于0
}
System.out.println(Thread.currentThread().getName() + "\tmission is over");
}
}
上述代码一直会卡死,不会结束运行,这里的number没有修改。加上volatile:volatile int number = 0; 代码可以正常运行
VolatileDemo代码演示不保证原子性
class MyData {
volatile int number = 0;
void add() {
this.number = 60;
}
/**
* 请注意,此时已经添加了volatile关键字
*/
void addPlusPlus() {
number++;
}
}
/**
* 验证volatile的可见性
* 1、添加了volatile,可以解决可见性问题
* 2、验证volatile不保证原子性
* 原子性指:不可分割,完整性,即某个线程正在做某个具体业务时,中途不可被分割或抢占。业务要么都成功、要么都失败
*
* @author fx
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();
int n = 20;
for (int i = 1; i <= n; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
try {
// 延时一下,保证效果展现
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addPlusPlus();
}
}, String.valueOf(i)).start();
}
// 等待上面20个线程全部计算完成后,再用main取得最终的结果
// 后台默认至少有两个线程:mian与gc线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t number :" + myData.number);
}
}
执行上述代码:发现其结果并不是 20 * 1000 = 20000,一般情况下比这个值小,如果相等请将循环次数变大或者延时时间加长。
volatile不具备原子性分析:
如何解决原子性问题:
class MyData {
volatile int number = 0;
void add() {
this.number = 60;
}
/**
* 请注意,此时已经添加了volatile关键字,但不保证原子性
*/
void addPlusPlus() {
number++;
}
/**
* 默认为0
*/
AtomicInteger atomicInteger = new AtomicInteger();
public void addAtomic() {
// 自增1
atomicInteger.getAndIncrement();
}
}
/**
* 验证volatile的可见性
* 1、添加了volatile,可以解决可见性问题
* 2、验证volatile不保证原子性
* 原子性指:不可分割,完整性,即某个线程正在做某个具体业务时,中途不可被分割或抢占。业务要么都成功、要么都失败
*
* @author fx
*/
public class VolatileDemo {
public static void main(String[] args) {
MyData myData = new MyData();
int n = 20;
for (int i = 1; i <= n; i++) {
new Thread(() -> {
for (int j = 1; j <= 1000; j++) {
try {
// 延时一下,保证效果展现
TimeUnit.MILLISECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addPlusPlus();
myData.addAtomic();
}
}, String.valueOf(i)).start();
}
// 等待上面20个线程全部计算完成后,再用main取得最终的结果
// 后台默认至少有两个线程:mian与gc线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(Thread.currentThread().getName() + "\t number :" + myData.number);
System.out.println(Thread.currentThread().getName() + "\t atomic :" + myData.atomicInteger);
}
}
输出结果:
main number :19981
main atomic :20000
1.2、volatile指令重排
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须要考虑指令之间的数据依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象
先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性(利用改特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
1.3 线程安全性获得保证
工作内存与主内存同步延迟现象导致的可见性问题:
可以使用synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。
对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另一个作用就是禁止重排序优化。
2、单例模式
DCL机制下的单例代码:
public class SingletonDemo {
private static SingletonDemo instance = null;
private SingletonDemo() {
System.out.println(Thread.currentThread().getName() + "\t我是构造方法");
}
/**
* DCL (Double Check Lock 双端检锁机制)
* @return 返回单例
*/
public static SingletonDemo getInstance() {
if (instance == null) {
synchronized (SingletonDemo.class) {
if (instance == null) {
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
int n = 10;
for (int i = 1; i <= n; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
分析:
DCL机制不一定线程安全,原因是有指令重排的存在,加入volatile可以禁止指令重排。
原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
instance = new SingletonDemo(); 可以分为以下3步完成(伪代码)
memory = allocate(); // 1、分配对象内存空间
instance(memory); // 2、初始化对象
instance = memory; // 3、设置instance指向刚分配的内存地址,此时instance != null
步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
instance = new SingletonDemo(); 可以分为以下3步完成(伪代码)
memory = allocate(); // 1、分配对象内存空间
instance = memory; // 3、设置instance指向刚分配的内存地址,此时instance != null,但是对象还没有初始化完成!
instance(memory); // 2、初始化对象
但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。
所有当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
3、CAS
3.1、CAS是什么?
比较并交换。
(1)底层原理
atomicInteger.getAndIncrement():unsafe.getAndAddInt(0, 1) // 自增1
(2)Unsafe
是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中CAS操作的执行依赖于Unsafe类的方法。
注意:Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。
变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
变量value用volatile修饰,保证了多线程之间的内存可见性。
CAS全程为Compare-And-Swap,它是一条CPU并发原语。它的功能时判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。
CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用Unsafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。
Unsafe类中的compareAndSwapInt,是一个本地方法,该方法的实现位于unsafe.cpp中
3.2 CAS的缺点
(1)循环时间长,开销大
如果CAS失败,会一直进行操作。如果长时间一直不成功,可能会给CPU带来很大的开销。
(2)只能保证一个共享变量的原子操作
对于多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
(3)ABA问题
CAS算法实现一个重要前提需要取出内存中某一时刻的数据并在当下时刻比较并替换,那么这个时间差可能会导致数据的变化。
比如一个线程one从内存位置V中取出A,这时另一个线程two也从内存中取出A,并且线程tow进行一些操作将值变成了B,然后线程two又将V位置的数据变成了A,这时线程one进行CAS操作发现内存中仍然是A,然后线程one操作成功。
尽管线程one的CAS操作成功了,但不代表这个过程是没问题的。
1)原子引用
@Data
@ToString
@AllArgsConstructor
class User {
String userName;
int age;
}
public class AtomicReferenceDemo {
public static void main(String[] args) {
User z3 = new User("z3", 22);
User li4 = new User("li4", 23);
AtomicReference<User> atomicReference = new AtomicReference<>();
atomicReference.set(z3);
System.out.println(atomicReference.compareAndSet(z3, li4) + "\t" + atomicReference.get().toString());
System.out.println(atomicReference.compareAndSet(z3, li4) + "\t" + atomicReference.get().toString());
}
}
2)ABA问题解决
public class ABADemo {
/**
* 普通原子引用
*/
static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
/**
* 带戳的原子引用
*/
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
System.out.println("=====================以下产生ABA问题==========================");
new Thread(() -> {
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "t1").start();
new Thread(() -> {
// 暂停1s,保证t1线程完成一次ABA操作
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicReference.compareAndSet(100, 121) + "\t" + atomicReference.get());
}, "t2").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("=====================以下解决ABA问题==========================");
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t第1次版本号:" + stamp);
atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第2次版本号:" + atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第3次版本号:" + atomicStampedReference.getStamp());
}, "t3").start();
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "\t第1次版本号:" + stamp);
atomicStampedReference.compareAndSet(100, 111, stamp, stamp + 1);
System.out.println(Thread.currentThread().getName() + "\t当前最新值:" + atomicStampedReference.getReference());
}, "t4").start();
}
}
注:其中原子引用代码只是用来演示ABA问题,这里面Integer会有自动包装机制问题,AtomicReference比较的是引用的地址,Integer中128以内的会使用缓存,超了就不是一个对象了,就会返回false导致无法交换值。
4、集合类之不安全
4.1 并发修改异常
案例代码:
public class ContainerNotSafeDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 60; i++) {
new Thread(() -> {
list.add(UUID.randomUUID().toString().substring(0, 8));
System.out.println(list);
}, String.valueOf(i + 1)).start();
}
}
}
(1)故障现象
Exception in thread "58" java.util.ConcurrentModificationException
(2)导致原因
并发争抢修改,参考例子花名册签名情况。一个人正在写入,另外一个同学过来直接抢。
(3)解决方案
方案一:
List<String> list = Collections.synchronizedList(new ArrayList<>());
方案二:
List<String> list = new CopyOnWriteArrayList<>();
写时复制:
CopyOnWrite容器即写时复制的容器。往一个容器添加元素的时候,不直接往当前容器Object[] 添加,而是先将当前Object[]进行Copy,复制出一个新的容器Objectp[] newElements,然后新的容器Object[] newElements里添加元素,添加完元素之后,再将原容器的引用指向新的容器setArray(newElements);这样做的好处是可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所有copyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
4.2 Set
Set<String> set = new CopyOnWriteArraySet<>();
CopyOnWriteArraySet实际底层还是一个 CopyOnWriteArrayList
4.3 Map
Map<String, String> map = new ConcurrentHashMap<>();
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}