本文主要介绍 计算机基础、CPU与内存、缓存行、线程与进程、JMM(Java内存模型),并发三大特性、指令重排、重排约束、volatile 等基础知识,帮助大家再做多线程与并发时有夯实的基础知识。
计算机基础
计算机发展史
第一阶段(1946~1957):电子管计算机
第二次世界大战是电子管计算机产生的催化剂(英国为了解密德国海军的密文),在第二次世界大战中,战争使用了飞机和火箭,打得准需要计算设计参数,设计参数需要几千次运算才能计算出来,在没有计算机之前 ,这些都需要人工手动去计算,埃尼阿克的计算速度大约是手工计算的20万倍。
第二阶段(1957~1964):晶体管计算机
贝尔实验室的三个科学家发明了晶体管,第一台晶体管计算机产生于麻省理工大学的林肯实验室。
第三阶段(1964~1980):集成电路计算机
德州仪器的工程师发明了集成电路(IC),后面就有了集成电路计算机,集成电路计算机让计算机具备进入千家万户的条件。因为集成电路使计算机变得更小,功耗变得更低,计算速度变得更快
第四阶段(1980~至今):超大规模集成电路计算机
超大规模集成电路,一个芯片上集成了上百万的晶体管,这样使超大规模集成电路计算机速度更快,价格更低,体积更小,更能被大众所接受,而且用途丰富,可以用作文本处理,表格处理,高交互的由于与应用等
冯·诺依曼
冯·诺依曼,著名匈牙利裔美籍数学家 ,说过计算机应该有这部分组成:
- 运算器:包含算术逻辑单元(Arithmetic Logic Unit,ALU)和处理器寄存器(Processor Register)的处理器单元(Processing Unit),用来完成各种算术和逻辑运算,因为它能够完成各种数据的处理或者计算工作,因此也有人把这个叫作数据通路(Datapath)或者运算器。
- 控制器:包含指令寄存器(Instruction Register)和程序计数器(Program Counter)的控制器单元(Control Unit/CU),用来控制程序的流程,通常就是不同条件下的分支和跳转
- 存储器:用来存储数据(Data)和指令(Instruction)的内存。以及更大容量的外部存储,在过去,可能是磁带、磁鼓这样的设备,现在通常就是硬盘
- 输入和输出:各种输入和输出设备,以及对应的输入和输出机制,如个人电脑的鼠标键盘是输入设备,显示器是输出设备
计算机与软件结构
CPU与内存
CPU 结构
控制单元:
- 指令计数器 IC:指令计数器又称程序计数器,是中央处理器内的一个寄存器,其作用是存放当前正在进行的指令的地址。当指令计算器中指令被取出后,计数器内的地址加一或者指针下移一位,此时计数器内的地址即为下一条指令的地址。
- 指令寄存器 IR:指令寄存器的主要功能是存储将要执行的程序指令。由指令计数器中的指令地址找到内存中相对应的指令地址后,处理器将此条指令存储进指令寄存器中,以供后续使用。
- 指令译码器 ID:程序指令存储进处理器的指令寄存器后,由指令译码器将由高级语言表示的指令翻译成计算机能够识别的机器语言
- 操作控制器 OC:操作控制器的功能就是根据指令操作码和时序信号,产生各种操作控制信号,发送给运算单元,完成取指令和执行操作的控制
运算单元(ALU):
- 运算单元是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)
存储单元:
- 存储单元包括 CPU 片内缓存和寄存器组,是 CPU 中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU 访问寄存器所用的时间要比访问内存的时间短
内存
当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。 走得越远,运算耗费的时间就越长。所以要尽量确保数据在L1缓存中
MESI协议:是一个基于失效的缓存一致性协议,是支持回写(write-back)缓存的最常用协议。也称作伊利诺伊协议
MESI 是指4个状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
- M:modified–与主存的内容相比,有改动就标记为M。我改过,那么对于别人来说就是i了。
- E:exclusive–内容为我锁独占,标记为E。
- S:shared–与此同时别人也在读,标记为S。
- I:invalid–读时被其他CPU改过,说明我读到的数据是无效的,必须再读一次。
缓存行
内存比CPU慢很多、为了提高访问内存(RAM)的效率就诞生了CPU缓存(CPU Cache),缓存行 (Cache Line) 便是 CPU Cache 中的最小单位,CPU Cache 由若干缓存行组成,一个缓存行的大小通常是 64 字节(这取决于 CPU),并且它有效地引用主内存中的一块地址
缓存行实验
/**
* @author lm
**/
public class CacheLinePaddingBefore {
public volatile long x = 1L;
public volatile long y = 1L;
public static void main(String[] args) throws Exception {
CacheLinePaddingBefore cacheLinePaddingBefore =
new CacheLinePaddingBefore();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingBefore.x = i; }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingBefore.y = i; }
}, "ThreadB");
final long start = System.nanoTime();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 100_0000);
}
}
/**
* @author lm
**/
public class CacheLinePaddingAfter {
public volatile long p1, p2, p3, p4, p5, p6, p7;
public volatile long x = 1L;
public volatile long p11, p12, p13, p14, p51, p16, p17;
public volatile long y = 1L;
public static void main(String[] args) throws Exception {
CacheLinePaddingAfter cacheLinePaddingAfter
= new CacheLinePaddingAfter();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingAfter.x = i; }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000_0000; i++)
{ cacheLinePaddingAfter.y = i; }
}, "ThreadB");
final long start = System.nanoTime();
threadA.start();
threadB.start();
threadA.join();
threadB.join();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 100_0000);
}
}
基本一样的代码执行的耗时为何相差几倍?
结果分析
缓存行两大特性
- 空间局部性:当 CPU 把内存的数据载入 cache 时, 会把临近的共 64 Byte 的数据一同放入同一个Cache line
64 字节背景: 1、缓存行越大,局部性空间效率越高,但读取时间慢 2、反之缓存行越小,局部性空间效率越低,但读取时间快,所以就取了一个折中值:64字节
- 时间局部性:如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归等方法反复调用
伪共享
- 伪共享 指的是多个线程同时读写同一个缓存行的不同变量时导致的 CPU 缓存失效。CPU更新缓存行中数据,并将其写回缓存,同时其他处理器会使该缓存行失效,如需使用,还需从内存中重新加载
过程分析
Java 的 long 类型是 8 字节、因此在一个缓存行中可以存 8 个 long 类型的变量
- 假如变量x和变量y共处在同一缓存行中,core1需要操作变量x,core2需要操作变量y。
- core1修改缓存行内的变量x后,按照缓存一致性协议,core2需将缓存行置为失效,core1将最新缓存行数据写回内存。
- core2需重新从内存中加载包含变量y的缓存行数据,并放置缓存。如果core2修改变量y,需要core1将缓存行置为失效,core2将最新缓存写回内存。
- core1或其他处理器如需操作同一缓存行内的其他数据,同上述步骤
解决方案
1、缓存行对齐(Java7)-空间换时间
- 业界经典案例:Disruptor 2、@Contended 注解 (Java8)
线程与进程与超线程
进程:
- 系统资源分配和调度的独立单位
- 至少包含一个线程、拥有自己的资源、执行开销大 线程:
- CPU调度和分派的基本单位
- 同一进程内的线程共享本进程的资源,但是进程之间的资源是独立的、执行开销小 超线程:
- 超线程(超线程就是一个ALU(运算单元)对应了两组寄存器,这里同时活着的线程可以有两个,这时候线程切换的时候就不需要将数据更换,只需要ALU挪一下地方去计算就可以了)
JMM(Java内存模型)
为避免Java 线程每次读取与写入都直接操作主内存、对性能影响很大、所以通过Java 线程之间的通信由 Java 内存模型(Java Memory Model,简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见 ,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
并发三大特性
原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
/**
* 创建2个线程,然后分别执行1000次i++操作。目的是程序输出结果2000
* 但是,多次执行的结果都小于2000
* @author lm
*/
public class AtomicTest {
public int inc = 0;
public void increase() { inc++; }
public static void main(String[] args) throws InterruptedException {
final AtomicTest test = new AtomicTest();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(test.inc);
}
}
// 结果:
1700
分析:
Inc++需要执行三条CPU指令
- 将count的值从内存中读取到寄存器中。
- 在寄存器中进行+1操作。
- 将结果写入内存中
如何保证原子性
- 通过 synchronized 关键字定义同步代码块或者同步方法保障原子性。
- 通过 Lock 接口保障原子性。
- 通过 Atomic 类型保障原子性。
可见性
可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
/**
* 结果:明明将flag设置为true了 为什么没有看见我退出了。
* @author lm
**/
public class VisibilityTest {
public static boolean a = true;
public static void main(String[] args) throws Exception {
System.out.println("我开始了");
ExecutorService executorService =
Executors.newCachedThreadPool();
//线程开始
executorService.execute(() -> {
while(a){
}
System.out.println("我退出了");
});
Thread.sleep(100);
a = false;
}
}
// 结果:
我开始了
分析:
有序性
有序性:变量在并发的情况下,执行的结果和单线程的执行结果是一样的,不因为重排序问题导致结果不可预知
/**
* @author lm
* 预期结果:(0,1)(1,1)(1,0)
**/
public class ReorderingTest {
private static int x = 0, y = 0;
private static int a = 0, b =0;
public static void main(String[] args) throws Exception {
int i = 0;
while(true) {
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread one = new Thread(() -> {
a = 1;
x = b;
});
Thread two = new Thread(() -> {
b = 1;
y = a;
});
one.start();two.start();
one.join();two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
结果:
分析:
总结
指令重排
jvm 为了提高性能、提高并行度、在部分场景会对指令做了重排序,从而提高性能
重排约束
as-if-serial(约束重排)
as-if-serial(约束重排):不管怎么重排序,单线程程序的执行结果不被改变。编译器、runtime和处理器都须遵守
happens- before(先行发生)
happens- before(先行发生):要求编译器优化后一定遵守 Happens-Before 规则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- 程序中断规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始
volatile
volatile: volatile 是一个类型修饰符,volatile 的作用是作为指令关键字,确保本条指令不会因编译器的优化而省略,它是 具备可见性、非原子性、有序性的
可见性
可见性: Volatile保证了数据在多线程之间的可见性, 每个线程在获取volatile修饰的变量时候都会去主内存获取
/**
* @author lm
* 根据之前的不可见的case加上关键字volatile之后可见了
**/
public class VisibilityTest2 {
public volatile static boolean a = true;
public static void main(String[] args) throws Exception {
System.out.println("我开始了");
ExecutorService executorService =
Executors.newCachedThreadPool();
//线程开始
executorService.execute(() -> {
while(a){
}
System.out.println("我退出了");
});
Thread.sleep(100);
a = false;
}
}
//结果:
我开始了
我退出了
分析
- 写a时,JMM会把该线程对应的本地内存中的共享变量 a 刷新到主内存
- 读a时,JMM会把该线程对应的本地内存置为无效,将从主内存中读取a
非原子性
非原子性: volatile 只能保证对单次读/写的原子性,i++ 这种操作不能保证原子性,自增操作是不具备原子性的,它包括三步:
- 1、读取变量的原始值
- 2、进行加1操作
- 3、写入工作内存
public class NoAtomicTest {
public volatile int inc = 0;
public void increase() { inc++; }
public static void main(String[] args) throws Exception {
final NoAtomicTest test = new NoAtomicTest();
Thread threadA = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadA");
Thread threadB = new Thread(() -> {
for (long i = 0; i < 1000; i++)
{ test.increase(); }
}, "ThreadB");
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println(test.inc);
}
}
结果:小于2000
分析
有序性
有序性: volatile变量读写前后插入内存屏障以避免重排序,保证了有序性
内存屏障:通过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法。
x86架构的内存屏障
Store Barrier(sfence):能让当前线程写入高速缓存中的最新数据更新写入主内存,让其他线程可见
Load Barrier(lfence):可以让高速缓存中的数据失效,强制当前线程从主内存里面加载数据
Full Barrier(mfence):在该指令前的读写操作必须在该指令后的读写操作前完成
JVM 中内存屏障
LoadLoad屏障(对应读屏障):对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕
StoreStore屏障(对应写屏障):对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见
LoadStore屏障(对应读屏障):对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕
StoreLoad屏障(万能屏障):对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见
Volatile内存屏障
Normal Load, Normal Store 对应的是对普通引用的修改。好比有 int a = 1;就是 Normal Store。如果变量带着 volatile 修饰,那对应的读取和写入操作就是 Volatile Load 或者 Volatile Store。JVM 在将字节码编译为汇编时,如果碰见比如 getfield, putfield 这些字节码,并发现带着 volatile 标记的成员变量,就会根据 JMM 要求插入对应的 Barrier
总结:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序