1. 底层原理
1.1 JVM内存结构 VS JMM内存模型
- JVM内存结构和JVM的运行区域有关,包括堆、方法区、虚拟机栈、本地方法栈、程序计数器
- 堆:线程共享,new出来的实例对象;
- 虚拟机栈:线程私有,基本数据类型以及对象的引用地址;
- 方法区:线程共享,static静态变量,类信息(方法代码,变量名,方法名,访问权限,返回值),常量,永久引用(static修饰的类);
- 本地方法栈:native方法;
- 程序计数器:程序的位置,行号数;
- JMM内存模型
- JMM是一种规范,防止在不同的虚拟机上运行结果不一样,可以更方便地开发出多线程程序
- volatile、synchronized、lock等的原理都是JMM,如果没有JMM,必须手动指定什么时候同步
2. 重排序
public class ReOrder {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
如上代码所示,正常情况下,x,y有以下三种结果:
- t1执行完t2再执行:x=0,y=1
- t2执行完t1再执行:x=1,y=0
- t1和t2各执行一半:x=1,y=1
那么会不会出现x=0,y=0的结果呢?只有x=b在a=1之前,或者y=a在b=1之前执行才会发生这种情况,这种情况一旦发生,就说明发生了指令重排序。
- 重排序的定义:当指令的执行顺序和Java代码的执行顺序不一样,就说明发生了指令重排序。
- 重排序的好处:提升处理速度。
- 重排序发生的2种情况:
- 编译器优化(JVM优化),尤其发生数据没有依赖关系的情况,更有可能会发生指令重排序;
- CPU指令重排序:就算编译器不重排序,CPU也可能会发生指令重排序;
2. 可见性
2.1 可见性问题演示
代码演示:
public class Visibility {
private static int a = 1, b = 2;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 3;
b = a;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(b+ "," + a);
}
});
t1.start();
t2.start();
}
}
正常情况下,会发生一下三种情况:
- t2先执行:a = 1, b = 2
- t1先执行:a = 3, b = 3
- t1执行一半给t2执行:a = 3, b = 2
但是由于内存可见性问题,也可能出现第四种情况:a = 1, b = 3,为什么会发生这种情况?
由于t1线程执行a=3,b=a后,b被写回到主内存,而a还没来得及写回到主内存,此时,t2已经在主内存中读取了a和b的值, 就造成了a=1,b=3的情况。t2线程没“看完整”t1线程的操作,只看到了b的赋值情况,而没看到a的赋值情况。
当使用volatile关键字之后,a的值改变,立刻刷回到主内存,t2读取到的一定是改变的值。
2.1 为什么发生可见性问题?
- 如图所示,数据从主内存到CPU过程中有多层缓存,分别是L3、L2、L1、寄存器。由于多层缓存的存在,可以大幅提升CPU的处理效率;
- 每个核心将自己需要的数据读到私有的缓存中,然后将修改后的值写回到缓存中,最后等待刷到内存中,由于这个等待的过程,当核心1更新某共享数据后,核心2还没有等到核心1将缓存刷回主内存就读取数据了,导致脏数据;
2.2 JMM如何解决可见性问题?
-
JMM定义了一套读写规范,我们不用关心寄存器、一级缓存、二级缓存等,JMM抽象出主内存和本地内存的概念。
-
本地内存包括寄存器、一级缓存、二级缓存;
-
主内存包括三级缓存和内存;
-
主内存和本地内存的关系:
- 所有的变量都存储在主内存中,同时每个线程都有自己的工作内存,工作内存中的变量是主内存中的拷贝;
- 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后同步到主内存中;
- 主内存是多个线程共享的,但是线程不共享工作内存,如果线程之间需要通信,必须借助于主内存中转完成
- 正是因为需要主内存来交换才导致了可见性问题;
2.3 happens-before原则
- 什么是happens-before:该原则是用来解决可见性问题的,在时间上,动作A发生在动作B之前,B保证能看见A;
- 另一种解释:如果一个操作happens-before另一个操作,那么我们说第一个操作对于第二个操作是可见的;
- 什么不是happens-before:两个线程没有相互配合的机制,所以代码A和B的执行结果不能保证总是被对象看到的,这就不具备happens-before;
- 只要符合了happens-before原则,就不会产生可见性问题;
- 符合happens-before原则的常见场景:
- 单线程原则:在一个线程之内,后面的语句一定能看到前面的语句做了什么 ,因为每个线程都有自己的工作内存,自己工作内存的变量都是可见的(如果数据之间没有依赖,单线程下会发生指令重排序,但是不影响结果,所以单线程原则不影响重排序);
- 锁操作(synchronized和lock):如果t1线程对a对象解锁了,紧接着t2线程对a对象加锁了,那么t2线程能看到t1线程的所有操作,无论t1做了什么修改,做了什么逻辑,t2都可以看到,不会发生脏数据的情况;
- volatile:volatile修饰的变量发生的读写操作,当t1线程发生写操作,t2线程进行读操作时一定能看到这个写操作;
- 线程启动:子线程一定能看到主线程在执行start()之前的操作;
- 线程join:主线程join()后面的语句一定能看到子线程运行的所有的语句;
- 传递性:如果a happens-before b, b happens-before c,那么a 一定happens-before c;
- 中断:一个线程被其他线程中断时,那么检测中断的线程一定能看到并抛出异常;
- 符合happens-before原则的工具类:ConcurrentHashMap、CountDownLatch、线程池、FutureTask、CyclicBarrier;
- 轻量级同步:给b加了volatile,不仅b被影响,还可以实现轻量级的同步,b = a 之前的代码对读取打印b后的代码可见,所以在写入线程里对a的赋值,一定会对读取线程可见,所以这里的a即使不加volatile,只要b读取到是3,就可以保证a读取到的都是3而不可能是1,所以只给b加volatile,b赋值操作执行之前的其他变量的赋值操作也具有可见性。
2.4 volatile关键字详解
- 定义:volatile是一种同步机制,比synchronized或Lock锁等更轻量,因为volatile仅仅是控制把缓存中的数据立刻刷回到主内存中,不会被线程缓存,不会给对象上锁,所以不会发生上下文切换等开销很大的行为;
- 如果一个变量被volatile修饰,那么JVM就知道这个变量有并发可能,就会禁止重排序;
- volatile无法保证原子性,且只能作用于属性,读写操作都是无锁的,不能替代synchronized,场景有限;
- 不适用场景:a++
- 适用场景1:boolean flag(作为一个标记位),如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作(修改,取反,对比),就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全。
- 适用场景2:作为刷新之前变量的触发器,只要volatile变量被赋值,那么在其执行之前的赋值操作都可见;
- volatile的两点作用:
- 可见性:读一个volatile变量之前,需要先使相应的本地缓存失效,这样就必须到主内存读取最新值,写一个volatile属性会立即刷入到主内存。
- 禁止指令重排序优化:解决单例双重锁乱序问题
- 保证可见性的措施:synchronized、Lock、并发集合、join、start都保证可见性;
- synchronized可见性:不仅保证了原子性,还保证了可见性;凡是被synchronized修饰的代码,上一个线程的操作可以被下一个线程所看到;
- synchronized近朱者赤:在单个线程中synchronized修饰的代码块,在其之前的赋值操作也是对另一个线程可见的;
3. 原子性
3.1 什么是原子性?
- 定义:一系列的操作要么全部都成功,要么全部都不成功,是不可分割的。
- i++不是原子性的。
- 用synchronized实现原子性:保证同一时刻只有一个线程执行这段代码
- 原子操作 + 原子操作 != 原子操作。
- Java中的原子操作有哪些:
- 除了long和double外的基本数据类型的赋值操作,在32位的JVM上,long和double的操作不是原子性的,在64位上是原子性的,在商用JVM中不会出现这种问题;
- 所有引用的赋值操作;
- Atomic包中的所有类的原子操作;
3.2 synchronized关键字详解
3.2.1 synchronized基本用法
- 定义:如果一个对象对多个线程可见,synchronized能够保证在同一时刻最多只有一个线程操作这个对象,以达到保证并发安全的效果。
- 作用:保证可见性和原子性,可以避免线程安全问题:运行结果错误
- 两种使用方法:
-
对象锁:
-
方法锁,默认锁对象为this当前实例对象
public class ObjectLock3 implements Runnable { @Override public void run() { method(); } public synchronized void method() { System.out.println(Thread.currentThread().getName() + "进入同步方法"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { ObjectLock3 objectLock3 = new ObjectLock3(); Thread t1 = new Thread(objectLock3); Thread t2 = new Thread(objectLock3); t1.start(); t2.start(); } }
-
同步代码块锁,自己指定锁对象
public class ObjectLock1 implements Runnable { @Override public void run() { synchronized (this) { System.out.println(Thread.currentThread().getName() + "进入同步代码块"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代码块"); } } public static void main(String[] args) { ObjectLock1 objectLock1 = new ObjectLock1(); new Thread(objectLock1).start(); new Thread(objectLock1).start(); } } Thread-0进入同步代码块 Thread-0退出同步代码块 Thread-1进入同步代码块 Thread-1退出同步代码块
public class ObjectLock2 implements Runnable { private static final Object lock1 = new Object(); private static final Object lock2 = new Object(); @Override public void run() { synchronized (lock1) { System.out.println(Thread.currentThread().getName() + "进入同步代码块1"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代码块1"); } synchronized (lock2) { System.out.println(Thread.currentThread().getName() + "进入同步代码块2"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代码块2"); } } public static void main(String[] args) { ObjectLock2 objectLock2 = new ObjectLock2(); new Thread(objectLock2).start(); new Thread(objectLock2).start(); } } Thread-0进入同步代码块1 Thread-0退出同步代码块1 Thread-0进入同步代码块2 Thread-1进入同步代码块1 Thread-1退出同步代码块1 Thread-0退出同步代码块2 Thread-1进入同步代码块2 Thread-1退出同步代码块2
-
-
类锁:
-
静态方法锁,synchronized加在static方法上,锁对象为当前类
public class ObjectStaticLock1 implements Runnable { @Override public void run() { method(); } public static synchronized void method() { System.out.println(Thread.currentThread().getName() + "进入到同步静态方法中"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步静态方法"); } public static void main(String[] args) { ObjectStaticLock1 objectStaticLock1 = new ObjectStaticLock1(); ObjectStaticLock1 objectStaticLock2 = new ObjectStaticLock1(); Thread t1 = new Thread(objectStaticLock1); Thread t2 = new Thread(objectStaticLock2); t1.start(); t2.start(); } }
-
同步代码块锁,synchronized(*.class)代码块,指定锁对象为class对象,所谓的类锁,不过是Class对象的锁而已
public class ObjectStaticLock2 implements Runnable { @Override public void run() { synchronized (ObjectStaticLock2.class) { System.out.println(Thread.currentThread().getName() + "进入到同步代码块"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "退出同步代码块"); } } public static void main(String[] args) { ObjectStaticLock2 objectStaticLock1 = new ObjectStaticLock2(); ObjectStaticLock2 objectStaticLock2 = new ObjectStaticLock2(); Thread t1 = new Thread(objectStaticLock1); Thread t2 = new Thread(objectStaticLock2); t1.start(); t2.start(); } }
-
-
3.2.2 多线程访问同步方法的7种情况
- 两个线程同时访问一个对象的同步方法:会发生同步,锁对象都为同一个实例对象;
- 两个线程同时访问两个对象的同步方法:互不影响,锁对象不同;
- 两个线程访问的是synchronized的静态方法:会发生同步,锁对象都为Class对象,Class对象只有一个;
- 同时访问同步方法和非同步方法:非同步方法不受影响,不发生同步;
- 访问同一个对象不同的普通同步方法:会发生同步,锁对象默认为同一个实例对象;
- 同时访问静态synchronized和非静态synchronized方法:互不影响,静态syn方法的锁对象为Class对象,非静态syn方法的锁对象为一个实例对象this,实例对象和Class对象不是同一个对象,实例对象在堆中,Class对象在方法区中;
- 方法抛出异常后,会释放锁;
总结:- 一把锁只能同时被一个线程获取,没拿到锁的线程必须等待,如1、5;
- 每个实例都有自己的一把锁,不同实例互不影响,当使用Class对象以及synchonized修饰的static方法的时候,所有对象共用同一把类锁,对应2、3、4、5;
- 遇到异常,会释放锁,对应7;
3.2.3 synchronized关键字的性质
3.2.3.1 可重入
- 定义:一个线程已经获取到锁,想再次获取到这把锁时不需要释放,直接可以用;
- 什么是不可重入:一个线程获取到锁之后,想再次使用这个锁,必须释放锁之后还其他线程竞争;
- 好处:避免死锁:假如一个类有两个synchronized方法,当一个线程执行了方法1获得了默认的this对象锁,这个时候要执行方法2,如果synchronized不具备可重入性,那么这个线程就无法获取到访问方法2的锁,又无法释放锁,就造成了死锁。
- 粒度:线程范围,在一个线程中,只要这个线程拿到了这把锁,在这个线程内部就可以一直使用
- 同一个方法是可重入的;
- 可重入不要求是同一个方法;
- 可重入不要求是同一个类中;
3.2.3.2 不可中断
一旦这个锁已经被别的线程获得了,如果本线程还想获得,该线程只能等待或阻塞,直到别的线程释放这个锁。如果别的线程永远不释放锁,那么本线程则永远等待下去。
相比之下,Lock类,拥有可以中断的能力:
- 如果等的时间过长,可以中断现在已经获取的锁的线程的执行;
- 如果等待时间过长,也可以退出。
3.2.4 synchronized原理
3.2.4.1 加锁和释放锁原理
- 每个一个对象都有一个内置的monitor锁,这个锁存储在对象头中的,锁的获取和释放实际上需要执行两个指令:monitorenter和monitorexit,当线程执行到monitorenter的时候会尝试获取这个锁;
- 反编译:先javac demo.java,然后javap -verbose demo.class文件;
- monitorenter和monitorexit在执行的时候会让对象锁的计数+1或-1;
- 获取锁的过程:首先一个线程要获取一个对象锁的时候会查看这个monitor锁的计数器如果为0,那么就给他+1,这样别的线程就进不来了,如果一个线程有了这把锁,又重入了,在计数器再+1;如果monitor被其他线程持有了,直到计数器=0,才会获取这个锁。
- 释放锁的过程:将monitor的计数器-1,直到=0,表示不再拥有所有权了,如果不是0,说明刚才是可重入进来的
3.2.4.1 可重入原理
一个线程拿到一把锁之后,还想再次进入由这把锁所控制的方法,则可以再次进入,原理是用了monitor锁的计数器。
- JVM负责跟踪被加锁的次数
- 线程第一次给对象加锁的时候,计数+1.每当这个相同的线程再次获取该对象锁的时候,计数器会递增;
- 每当任务离开的时候,计数递减,当计数为0的时候,锁被完全释放;
3.2.4.1 可见性原理
线程A和线程B通信:
- 本地内存A把修改后的内容放到主内存中;
- 本地内存B从主内存从读取修改后的内容;
synchnized修饰的代码块对对象的任何修改,在释放锁之前都要将修改的内容先写回到主内存中,所以从主内存中读取的内容都是最新的。
3.2.5 synchronized的缺陷
- 效率低:锁的释放情况少(只有代码执行完和抛异常)、试图获得锁时候不能设定超时、不能中断一个正在试图获得锁的线程
- 不够灵活:加锁和释放的时机单一,每个锁仅仅有单一的条件,可能是不够的。读写锁更灵活。
- 无法知道是否成功获取到锁,没法去尝试获取,去判断。Lock是可以通过tryLock方法尝试获取,返回true代表成功加锁。