多线程核心基础
创建线程
public class CreateNewThread {
// 第一种创建方式
static class MyThread extends Thread {
@Override
public void run() {
System.out.println("T1");
}
}
// 第二种创建方式
static class Myrun implements Runnable {
@Override
public void run() {
System.out.println("T2");
}
}
public static void main(String[] args) {
new MyThread().start();
new Thread(new Myrun()).start();
new Thread(() -> {
System.out.println("T3");
}).start();
}
}
启动线程的三种方式:
- 继承Thread
- 实现Runnable
- Executors.newCachedThread 线程池启动
Synchronized关键字
对某个对象上锁,锁的不是代码,是对象。
synchronized可以保持可见性、原子性和有序性。
下面两种上锁方式效果是一样的:
// 这块代码锁的是o这个对象
public class JUC02_Synchronized {
private int count = 10;
private Object o = new Object();
public void m() {
synchronized (o) {
count--;
}
}
}
// 这块代码锁的是JUC02_Synchronized2实例化的对象
public class JUC02_Synchronized2 {
private int count = 10;
private Object o = new Object();
public synchronized void m() {
count--;
}
}
// 对class进行上锁
public class JUC02_Synchronized3 {
private int count = 10;
private Object o = new Object();
public synchronized static void main(String[] args) { // 锁的是JUC02_Synchronized3.class
System.out.println("hello");
}
}
-
对于普通同步方法,锁是当前实例对象。
-
对于静态同步方法,锁是当前类的Class对象。
-
对于同步方法块,锁是Synchonized括号里配置的对象。
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽 (Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽 等于4字节,即32bit。
在32位虚拟机下,Mark word是32bit大小,在64位虚拟机下,Mark Word是64bit大小的。
Synchronized是可重入锁
一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,再次申请的时候仍然会得到该对象的锁。
注意:是同一个对象,同一把锁。
public class JUC02_Synchronized4 {
synchronized void m1() {
System.out.println("m1 start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
System.out.println("m1 start");
}
synchronized void m2() {
System.out.println("m2 start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2 start");
}
public synchronized static void main(String[] args) {
new JUC02_Synchronized4().m1();
}
}
默认情况下,程序出现异常,锁会被释放。
锁升级
TODO
偏向锁—>自旋锁—>重量级锁
刚开始Synchronised对 对象加锁后,只是在对象头上记录下当前线程ID,并不是真正“锁”住对象,此时是偏向锁;如果有别的线程(假设为线程2)也想访问此对象,此时锁将升级为自旋锁,线程2会一直循环请求读此对象,如果循环了10次依然没有读到此对象,此时锁将升级为重量级锁。当为重量级锁时,如果有更多线程想来访问此对象,将进入一个等待队列里面,直到当前加锁线程访问完此对象。
加锁代码执行时间短,线程数少,用自旋锁;
加锁代码执行时间长,线程数多,用系统锁。
Volatile
volatile作用:
-
保证线程可见性
import java.util.concurrent.TimeUnit; public class T01_HelloVolatile { /*volatile*/ boolean running = true; // 对比一下有无volatile的情况下,整个程序运行结果的区别 void m() { System.out.println("m start"); while(running) { } System.out.println("m end!"); } public static void main(String[] args) { T01_HelloVolatile t = new T01_HelloVolatile(); new Thread(t::m, "t1").start(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } t.running = false; } }
running变量如果不加volatile,"m end!"不会输出,加了volatile之后才会输出。
执行main和执行m的线程之间会有一个共享内存,共享内存保存类的成员变量,每个线程只是从共享内存里面把running变量复制一份副本,每次修改副本会立即更新running变量值,但是m方法并不会立即读共享内存里面的被修改后running变量,导致"m end!"一直不输出。加了volatile之后,main和m的执行线程互相可见,running被修改,其他线程立即能知道。
- MESI
- 缓存一致性协议
-
禁止指令重排序
-
DCL单例模式
单例模式是指内存里只有一个实例。下面Singleton就是一个单例类。
public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {}; public static Singleton getInstance() { return INSTANCE; } public static void main(String[] args) { Singleton s1 = Singleton.getInstance(); Singleton s2 = Singleton.getInstance(); System.out.println(s1 == s2); } }
-
Double Check Lock
Synchronized进一步细粒化后,如果使用双重检查,大概率不会运行出错,但是还是线程不安全的,要想实现线程安全,必须要加volatile。加volatile禁止指令重排序保证运行结果正确。
public class DoubleCheck { private static volatile DoubleCheck INSTANCE; // JIT private DoubleCheck() { } public static DoubleCheck getInstance() { // 省略业务代码 if (INSTANCE == null) { // 双重检查 synchronized (DoubleCheck.class) { if(INSTANCE == null) { try { Thread.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } INSTANCE = new DoubleCheck(); } } } return INSTANCE; } public void m() { System.out.println("m"); } public static void main(String[] args) { for(int i=0; i < 100; i++) { new Thread(() -> { System.out.println(DoubleCheck.getInstance().hashCode()); }).start(); } } }
-
-
volatile不能保证原子性
Volatile并不能保证多个线程共同修改running变量说带来的不一致性问题,也就是说volatile不能替代synchronized。
import java.util.ArrayList; import java.util.List; public class VolatileVsSync { volatile int count = 0; /*synchronized*/ void m() { for (int i = 0; i < 10000; i++) { count++; } } public static void main(String[] args) { VolatileVsSync t = new VolatileVsSync(); List<Thread> threads = new ArrayList<Thread>(); for (int i = 0; i < 10; i++) { threads.add(new Thread(t::m, "thread-" + i)); } threads.forEach((o) -> o.start()); threads.forEach((o) -> { try { o.join(); } catch (InterruptedException e) { e.printStackTrace(); } }); System.out.println(t.count); } }
count++被编译成字节码,会分成三个指令,第一个从主内存拿到原始count,第二个在工作线程中执行+1操作,第三把累加后的值写回主内存。
上面的程序输出count的结果并不是10000,这是因为volatile不能保证原子性导致的脏读。对m()加synchronized关键字可以保证原子性,count最后结果一定是10000。
第二种保持volatile原子性的方法是,用AtmoicInteger。
-
volatile 引用类型(包括数组)只能保证引用本身的可见性,不能保证内部字段的可见性。
下面代码几秒之内并不会输出"m end!"。
import java.util.concurrent.TimeUnit; public class T02_VolatileReference1 { boolean running = true; volatile static T02_VolatileReference1 T = new T02_VolatileReference1(); void m() { System.out.println("m start"); while(running) { } System.out.println("m end!"); } public static void main(String[] args) { new Thread(T::m, "t1").start(); //lambda表达式 new Thread(new Runnable( run() {m()} try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } T.running = false; } }
Volatile实现原理
- 可见性实现原理
在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令(具体的大家可以使用一些工具去看一下,这里我就只把结果说出来)。我们想这个Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:
- 将当前处理器缓存行的数据写回系统内存;
- 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:
- Lock前缀的指令会引起处理器缓存写回内存;
- 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
- 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。
- 禁止指令重排序实现原理
volatile通过添加内存屏障,实现禁止指令重排序。
JMM内存屏障分为四类见下图:
java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。
JMM采取了保守策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障;
- 在每个volatile写操作的后面插入一个StoreLoad屏障;
- 在每个volatile读操作的后面插入一个LoadLoad屏障;
- 在每个volatile读操作的后面插入一个LoadStore屏障。
需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
参考链接
推荐阅读
欢迎关注我的公众号呦,率先更新内容,并且后续还有一些源码级的免费教程推出。