线程安全问题
多个线程对共享变量进行读写操作,出现脏读或者其他数据不一致的问题
共享变量
多个线程同时访问的资源
临界区代码
访问共享资源的代码段
竞态条件
在Java中,竞态条件是指多个线程同时访问共享资源,且最终结果取决于线程执行顺序的情况
1、synchronized
每个java对象都有一把锁,使用synchronized相当于获取该java对象的内置锁
public synchronized void f1(){
}
synchronized(this){
}
public static synchronized void f2(){
}
释放锁什么时候释放:执行完synchronized代码或者程序发生运行时异常
1.1 生产者和消费者
生产者和消费者模式中关键点:
(1)生产者和生产者之间,消费者和消费者之间对于数据缓冲区的操作是并发的
(2)数据缓冲区满之后,生产者不能再生产;数据缓冲区空之后,消费者不能再消费
(3)数据缓冲区是线程安全的,并发操作数据缓冲区不会出现数据不一致或者出现脏数据
public class Test {
public static void main(String[] args){
MessageQueue messageQueue = new MessageQueue(2);
for (int i = 0; i <3 ; i++) {
int id =i;
//lamdba表达式里面run方法引用的局部变量不能改变
new Thread(()->{
messageQueue.put(new Message(id,"值"+id));
},"生产者"+i).start();
}
new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
while(true){
Message message = messageQueue.take();
}
},"消费者").start();
}
}
class MessageQueue{
//消息的队列集合
private LinkedList<Message> list = new LinkedList<>();
//队列容量
private int capcity;
public MessageQueue(int capcity){
this.capcity=capcity;
}
//获取消息
public Message take(){
//检查对象是否为空
synchronized (list) {
while (list.isEmpty()) {
try {
System.out.println("队列为空消费者线程只能等待");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//从队列头部获取消息并且返回
Message message = list.removeFirst();
System.out.println(Thread.currentThread().getName() + "已消费消息" + message);
list.notifyAll();
return message;
}
}
//存入消息
public void put(Message message){
synchronized (list){
while(list.size()==capcity){
try {
System.out.println("队列为满生产者线程只能等待");
list.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
//从队列尾部加入信息
System.out.println(Thread.currentThread().getName()+"已生产消息"+message);
list.add(message);
list.notifyAll();
}
}
}
class Message{
private int id;
private Object value;
public Message(int id, Object value) {
this.id = id;
this.value = value;
}
public int getId(){
return id;
}
public void setId(int id){
this.id =id;
}
@Override
public String toString() {
return "Message{" +
"id=" + id +
", value=" + value +
'}';
}
}
1.2 Java 对象结构
Java对象(Object实例)结构包括三部分:对象头、对象体和对齐字节。
对象体存储的主要是类变量值,这部分内存按4字节对齐。
对齐字节保证java对象所占字节数为8的倍数
对象头包括Mark Word、Class Pointer、Array Length,他们与JVM的位数有关。32位的虚拟机中Mark Word、Class Pointer、Array Length都是32位;64位的虚拟机中Mark Word、Class Pointer都是64位,但是64位JVM中的Class Pointer和Array Length可以压缩为32位。Class Pointer是定义该对象类信息(class metadata)的指针。
1.3、无锁、偏向锁、轻量级锁和重量级锁
JDK1.6之前都是重量级锁,重量级锁会造成cpu在用户态和核心态之间切换频繁,会消耗大量的资源。后面对于synchronized进行改进,Java内置锁的状态总共有4种,级别由低到高依次为:无锁、偏向锁、轻量级锁和重量级锁
四种状态会随着竞争的情况逐渐升级并且不可逆。
下面参考原文:https://blog.youkuaiyun.com/lengxiao1993/article/details/81568130
表格的第一行是偏向锁被禁用时,对象锁的状态;表格的第二行是偏向锁被启用时,对象锁的状态。
锁升级的过程
偏向锁
线程一进入同步代码块会检查是否禁用偏向锁,如果禁用的话直接升级为轻量级锁;如果没禁用的话将mark word的线程id使用cas方式设置为当前线程,如果成功获取偏向锁成功,如果失败升级为轻量级锁。线程一第二次直接进入同步代码块。其他线程进入同步代码块会升级为轻量级锁。
轻量级锁
通过Object对象找到持锁线程,持锁线程会在线程栈中增加一条Lock Record存储对象MarkWord的拷贝,然后虚拟机会使用CAS操作尝试将对象的MarkWord指向栈中的Lock Record,cas成功便升级为轻量级锁。如果cas失败可能有竞争会尝试自旋或者是当前线程重入会在线程栈中增加一条空的lockRecord。当自旋的次数较多或者存在大量竞争的线程就升级为重量级锁。当线程退出同步代码块的时候,如果有取值为null的LockRecord表示重入锁记录-1,如果锁记录不为null需要使用cas将LockRecord和markword进行交换,成功的话说明解锁成功,失败的话说明轻量级锁已经膨胀为重量级锁需要使用重量级锁的解锁方法。
重量级锁
为Object对象申请一个Monitor锁,通过Object对象头获取到持锁线程,将Monitor对象的owner换成持锁线程,将Object对象的对象头指向持锁线程的LockRecord,竞争的线程会进入EntryList进行BLOCKED,持锁线程执行结束,根据Object对象的Monitor地址将Owner设为空,把线程栈的LockRecord设置会MarkWord,唤醒EntryList等待的线程,线程竞争锁时非公平的。
偏向锁
撤销:
(1)第二个线程竞争偏向锁
(2)调用偏向锁对象的hashcode(),升级为重量级锁
(3)调用wait/notify,需要申请Monitor,进入WaitSet,升级为重量级锁
批量重偏向
如果对象被多个线程访问,但没有竞争,此时偏向了线程T1的对象仍然有机会偏向T2,重偏向会重置对象的ThreadID,当撤销偏向锁的锁阈值超过20次后,jvm会认为偏向错了,于是会在给这些对象加锁时重新偏向至加锁线程。
批量撤销
当撤销偏向锁超过40次,jvm会将整个类里面的对象都设置为不可偏向
注意
- 偏向锁可以关闭,偏向锁延迟加载
- 偏向锁撤销升级为轻量级锁的过程,该操作需要等待全局安全点JVM safepoint(此时间点没有线程在执行字节码)
轻量级锁(乐观锁)
引入轻量级锁的目的是-通过cas方式竞争锁减少重量级锁从用户态转为核心态产生的性能损耗。
轻量级锁分为两种:一种是普通自旋锁另一种是自适应自旋锁。普通自旋锁的自旋时间固定,自适应自旋锁如果抢锁线程在同一个锁对象之前成功获得锁,JVM就会认为这次自旋很有可能再次成功,因此允许自旋的时间会更长。
注意:
- 轻量级锁仅适用于临界区的代码比较短并且线程竞争不激烈的情况
重量级锁
重量级锁使用的是操作系统底层的Mutex Lock会导致线程在用户态和核心态来回切换
用户态
运行用户程序
内核态
运行操作系统程序,操作硬件
WaitSet 中的 Thread-0,是以前获得过锁,但条件不满足进入 WAITING 状态的线程(wait-notify 机制),使用notify唤醒之后进入EntryList重新竞争。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的,这些阻塞状态都是核心态。
1.5 锁优化
锁消除
java虚拟机在JIT编译时,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。
锁粗化
加锁和解锁需要资源消耗,如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化就是将多个连续的加锁,解锁操作连接在一起扩大成一个范围更大的锁
2、CAS和JUC原子类
2.1乐观锁和悲观锁
乐观锁:乐观锁认为读多写少,每次读取数据不会加锁,只有写数据的时候才会加锁,java中的乐观锁一般使用cas操作实现。
悲观锁:悲观锁认为并发写多,每次读取数据都会上锁。
2.2 Unsafe类中的CAS操作
Unafe类里面封装的是c++代码,调用了底层的cpu指令cmpxchg,这是一条原子操作。Unsafe类的构造方法是private的,构造方法只能使用反射的方式。
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeExample {
public static void main(String[] args) throws Exception {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe) field.get(null);
System.out.println(unsafe);
}
}
获取某字段的偏移量
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class UnsafeExample {
private int value;
public static void main(String[] args) throws Exception {
Unsafe unsafe = getUnsafe();
long offset = unsafe.objectFieldOffset(UnsafeExample.class.getDeclaredField("value"));
System.out.println(offset);
}
private static Unsafe getUnsafe() throws Exception {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
}
}
关键
当CAS将内存地址的值与预期值进行比较时,如果相等,就证明内
存地址的值没有被修改,可以替换成新值,然后继续往下运行;如果
不相等,就说明内存地址的值已经被修改,放弃替换操作,然后重新
自旋
ABA
问题:假设有一个元素的插入和删除都在链表的头部的链表。链表的构造为head->E2->E1。t1线程执行CAS操作,如果队首元素是E2那么删除队首元素,让E1作为队首元素;但是如果t2线程已经将E1和E2删除插入了E3和E2。那么t1执行CAS操作就会让队首元素变成E1,head->E1->null。从而链表丢失元素E3。
解决方法:使用版本号
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class CASExample {
private volatile int value;
private volatile long version;
public CASExample(int value) {
this.value = value;
this.version = 0;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
public void increment() {
Unsafe unsafe = getUnsafe();
long valueOffset;
long versionOffset;
do {
valueOffset = unsafe.objectFieldOffset(CASExample.class.getDeclaredField("value"));
versionOffset = unsafe.objectFieldOffset(CASExample.class.getDeclaredField("version"));
int currentValue = this.value;
long currentVersion = this.version;
if (unsafe.compareAndSwapInt(this, valueOffset, currentValue, currentValue + 1)
&& unsafe.compareAndSwapLong(this, versionOffset, currentVersion, currentVersion + 1)) {
break;
}
} while (true);
}
private static Unsafe getUnsafe() {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
return (Unsafe) field.get(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
//AtomicStampedReference
public class AtomicStampedReferenceExample {
private static AtomicStampedReference<String> reference = new AtomicStampedReference<>("hello", 0);
public static void main(String[] args) {
new Thread(() -> {
int stamp = reference.getStamp();
String value = reference.getReference();
System.out.println(Thread.currentThread().getName() + ": stamp=" + stamp + ", value=" + value);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean success = reference.compareAndSet(value, "world", stamp, stamp + 1);
System.out.println(Thread.currentThread().getName() + ": success=" + success);
}).start();
new Thread(() -> {
int stamp = reference.getStamp();
String value = reference.getReference();
System.out.println(Thread.currentThread().getName() + ": stamp=" + stamp + ", value=" + value);
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean success = reference.compareAndSet(value, "world", stamp, stamp + 1);
System.out.println(Thread.currentThread().getName() + ": success=" + success);
}).start();
}
}
3、可见性和有序性的原理
可见性和有序性的根源来源于操作系统的内存结构:为了缩小cpu和内存之间的差异设置了cache,cache分为三个分别是l1,l2,l3。l1和l2cpu独占,l3所有cpu共享。
3.1 并发编程的三大问题
原子性问题
:不可中断的一个或者一系列操作 i++,new Object()
可见性问题
:一个线程对于共享变量的修改,另一个线程可以立刻看见
(1)主存中有变量sum,初始值为0。
(2)线程A计划将sum加1,先将sum=0复制到自己的私有内存中,然后更新sum的值。线程A操作完成之后其私有内存中sum的值为1,然而线程A将更新后的sum值回刷到主存的时间是不固定的。
(3)在线程A没有回刷sum到主存前,刚好线程B同样从主存中读取sum,此时值为0,和线程A进行同样的操作,最后期盼的sum=2目标没有达成,最终sum=1
可见性涉及到java内存模型问题
有序性问题
:程序的执行顺序和代码顺序不同,并且导致了错误结果
下面这段代码的输出:第4812次 (0,0)。因为此时2先于1发生,4先于3发生
有序性涉及到指令重排序问题
public class InstructionReorder {
private volatile static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws
InterruptedException {
int i = 0;
for (;;) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(new Runnable() {
public void run() {
a = 1; //①
x = b; //②
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1; //③
y = a; //④
}
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y
+ ")";
if (x == 0 && y == 0) {
System.err.println(result);
}
}
}
}
3.2 硬件层的MESI协议原理
cpu处理数据的过程:先将数据缓存在cpu的高速缓存中,从高速缓存中读取数据并且进行计算,计算完成之后再将数据写回内存中。因为每个线程可能运行在不同的cpu中,同一份数据可能会缓存到多个cpu内核中,在不同的cpu内核看到的数据的缓存值不一样,这样就会发生内存的可见性问题。硬件层的mesi协议就是解决内存的可见性的手段。
总线锁和缓存锁
总线锁:当不同的cpu访问同一个缓存行的时候,只允许一个cpu内核进行读取,其他cpu只能等待。总线锁的力度太大了
缓存锁:当某个cpu对于缓存的数据进行操作,通知其他cpu放弃存储在他们内部的缓存数据或者从主存中进行读取。
//TODO MESI协议待补充
Volatile关键字
(1)使用volatile修饰的变量在变量值发生改变时会立刻同步到主存,并且使得其他线程中保存的副本失效
(2)禁止指令重排序:使用volatile修饰的变量在硬件层面会加入内存屏障,保证可见性和有序性。
(3)但是volatile不能保证原子性
3.3有序性和内存屏障
由于编译器和cpu都会优化待执行的指令序列,可能会导致代码执行顺序和程序顺序不同,在指令间插入一个内存屏障禁止在内存屏障指令前(或后)执行指令重排序。
编译器重排序:满足as-if-serial原则
指令级重排序:不影响程序执行结果的前提下,cpu会将多条指令重叠执行,满足as-if-serial原则
as-if-serial原则无论如何重排序,都保证单线程下的执行结果正确
内存系统重排序:由于cpu缓存+MESI协议+storebuffer+invalidQueue的存在
内存屏障
读屏障:高速缓存的相应数据失效,强制从主内存中加载数据,并且读屏障会让先于这个屏障的指令先执行
写屏障:写屏障指令能让高速缓存的数据最新数据更新到主存,并且写屏障会让后于这个屏障的指令后执行
全屏障:兼具读屏障和写屏障
3.4 JMM详解
JMM是一种规范,核心价值在于解决可见性和有序性问题。
主存
·本地方法区和堆
工作内存
线程私有栈,共享变量的拷贝
java的内存模型规定如下:JMM将所有的变量都存放在公共主存中,当线程使用变量时,会把公共主存中的变量复制到自己的工作内存(或者叫作私有内存)中,线程对变量的读写操作是自己的工作内存中的变量副本,不同线程之间无法直接访问工作内存的变量,如果要访问只能通过主存传递。
JMM的8个操作
JMM定义了主存和自己工作内存的交互协议,这8个操作必须保证每一个操作都是原子的不可再分的。
lock、unlock、load、read、use、assign、store、write
lock:作用于主存,把一个变量地址标记为一个线程独享;
unlock: 作用于主存,释放这个变量地址,其他线程方可共享;
read: 作用于主存,将主内存的变量值传输到工作内存,供随后的 load 指令使用;
load: 作用于工作内存, 将 read 传输过来的变量值赋值给本地(工作内存)变量副本;
use: 作用于工作内存,把变量的值传给 cpu 计算单元来使用;
assign: 作用于工作内存,把 cpu 计算单元计算出来的值给本地变量;
store:作用于工作内存,把本地变量传输到主存,供随后的 write 指令使用;
write: 作用于主存,把 store 传输过来的变量值,赋值给主存中的变量副本;
JMM内存屏障
Load Barrier:在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从
主存加载数据
Store Barrier:在写指令之后插入写屏障,能让写入缓存的最新数据写回主存
LL:Load1; LoadLoad; Load2;在Load2要读取的数据被访问前,使用LoadLoad
屏障保证Load1要读取的数据被读取完毕。
SS:Store1; StoreStore; Store2;在Store2及后续写入操作执行前,使StoreStore屏障保证Store1的写入结果对其他CPU可见。
LS:Load1; LoadStore; Store2;在Store2及后续写入操作执行前,使LoadStore屏障保证Load1要读取的数据被读取完毕。
SL:Store1; StoreLoad; Load2;在Load2及后续所有读取操作执行前,使StoreLoad屏障保证Store1的写入对所有CPU可见。
其中开销最大的是SL。
使用volatile关键字时,不同处理器的JVM会实现不同的内存屏障,只要保证volatile进制指令重排序即可。
3.5 happens-before
上面的内存屏障是针对jvm和操作系统实现的内存可见性和有序性,那么对于java工程师如何保证自己开发java代码不存在内存可见性和有序性呢?通过Happens-Before,确保只要两个Java语句之间必须存在Happens-Before关系,JMM尽量确保Java语句之间的内存可见性和指令有序性。
规则1
顺序性规则
单线程内的执行顺序满足as-if-serial规则,仅仅用来保证单线程执行结果的正确性,但是无法保证程序在多线程执行结果的正确性
规则2
volatile规则
如果第二个操作为volatile写,无论第一个操作是什么都不能重排序,这就确保了volatile写之前的操作不会被重排序到自己之后。
如果第一个操作为volatile读,无论第二个操作是什么都不能重排序,这确保了volatile读之后的操作不会被重排序到自己的前面。
规则3
传递性规则
如果A操作先行发生于B操作,且B操作先行发生于C操作,那么A操作先行发生于C操作
规则4
监视器锁
对一个锁的unlock操作先行发生于后面对同一个锁的lock操作,即无论在单线程还是多线程中,同一个锁如果处于被锁定状态,那么必须先对锁进行释放操作,后面才能继续执行lock操作。
规则5
start规则
如果线程A执行ThreadB.start()操作启动线程B,那么线程A的ThreadB.start()操作先行发生于线程B中的任意操作
规则6
join规则
如果线程A执行threadB.join()操作并成功返回,那么线程B中的任意操作先行发生于线程A的ThreadB.join()操作