在多线程并发编程中,总有一些代码需要使用同步保证代码有3个特征:原子性,可见性,有序性。
原子性:原子(atom)在化学中是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被中断的一个或一系列操作"。不可被中断意味着原子操作内部不会有线程调度所必须的中断,除非原子操作所在的程序被强制结束或计算机被强制关闭电源,否则原子操作必然被完整的执行。实际上原子操作的本质就是独占某些资源的使用权(比如内存变量)。在单处理器系统的运行环境下通过禁止线程调度,只有正在执行中的原子操作会被继续运行,除此以外的任何代码都不允许运行,从而达到独占某些资源的使用权的效果。
在多处理器上实现原子操作就变得有点复杂,多处理器会同时运行多个线程,多个线程可能同时试图对一个内存变量进行操作,此时单条指令都不能保证原子性了,单处理器保证原子性的手段自然是不合时宜的。多处理器下的原子操作大多依靠cpu的总线锁、缓存锁、比较并交换指令CAS、操作系统提供的互斥、资源访问权限来实现。
可见性:程序运行过程中,外部的数据会通过各种输入设备转换为二进制数据存放于内存中,CPU从内存中取出这些数据用于运算,由于CPU执行速度太快,而内存对数据的读写跟不上CPU对数据的需求,极大的拉低了指令执行的速度。于是CPU里面有了高速缓存和寄存器。当程序在运行过程中,先把要用到的数据从内存复制到高速缓存,CPU进行运算时直接从高速缓存取数据,在合适的时间再将高速缓存中的数据刷新到主存当中。如此一来虽然的确是降低了对内存的速度要求,但同时也造成线程对变量的修改无法及时同步到内存和其他线程中。所谓可见性就是对变量数据的修复能立即反映到其他线程中。可见性大多由CPU的缓存一致性协议来实现,当然也可以限制某些变量不允许使用缓存只允许直接使用内存。
有序性:程序在编译和执行的时候,为了提高程序运行效率,可能会对代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。这种优化在单线程应用中是极好的,在多线程应用中却是巨大的隐患。
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
线程1中原则上语句1先于语句2执行,线程2的代码依赖线程1中对变量的赋值顺序,但实际上可能线程1的语句2先于语句1执行,最终线程2的业务逻辑被破坏。
说起来很绕口,如果某条代码有是有序的,则可以保证这条代码前的代码总是先于这条代码运行,这条代码总是先于这条代码后的代码运行。实际上这条代码被执行时和按照源码顺序这条代码被执行时的效果是一致的。
java语言提供了一种弱同步机制,即volatile变量。每个用volatile修饰的变量都有可见性、有序性,但不具有原子的性。
static volatile long id = 0;
static long temp = 0;
volatile long _id = 0;
long _temp = 0;
public void run() {
long t = 0;
for (int i = 0; i < 100000000; i++) {
// id++;
// temp ++;
// id = i;
// temp = i;
// t = id;
t = temp;
//
// _id++;
// _temp ++;
// _id = i;
// _temp = i;
// t = _id;
// t = _temp;
}
// System.out.println(_t);
}
}
运行以下代码:
for (int thread_number = 1; thread_number <= 16; thread_number = thread_number * 2) {
System.out.println("线程数" + thread_number);
long startTime = System.nanoTime();
Thread[] ta = new Thread[thread_number];
for (int i = 0; i < thread_number; i++) {// 创建多个线程并执行
ta[i] = new Thread(new Factory());
ta[i].start();
}
for (Thread thread : ta) {// 等待所有线程结束
thread.join();
}
long estimatedTime = System.nanoTime() - startTime;
System.out.println("线程从创建到结束花费毫秒: " + estimatedTime / (1000 * 1000));
}
变量id、temp 是静态变量。id、_id是volatile 变量,t是方法内的临时变量。
归纳4、7、10、13可以知道,静态变量、volatile 变量、类变量、临时变量的读取速度基本一致,大规模并行的读取对速度的影响不大,只是在所有CPU核心都在频繁读取变量时,速度略有减慢,但实际应用中少见如此频繁的读取变量。
归纳2、3、5、6可知连续并行的写入某个变量时,随着并行的增多,单位时间内写入的次数会急剧下降。
归纳8、9、11、12可知在各自的线程中对各自的变量连续写入操作,线程数量对写入速度的影响不大,但在8线程时写入速度降低,怀疑瓶颈是内部总线速率。
归纳8、11可知对volatile 变量的读写操作是很耗时的。
序号 | 操作 线程数 | 1 | 2 | 4 | 8 | 16 |
|
1 | 空 | 33 | 40 | 49 | 70 | 135 | 时间(毫秒) |
2 | id++; | 1270 | 5901 | 10103 | 14513 | 29126 | |
3 | id = i; | 1592 | 4001 | 6570 | 9592 | 18923 | |
4 | t = id; | 99 | 106 | 128 | 207 | 406 | |
5 | temp++; | 217 | 341 | 508 | 1288 | 2595 | |
6 | temp = i; | 142 | 139 | 202 | 444 | 882 | |
7 | t = temp; | 98 | 103 | 110 | 156 | 296 | |
8 | _id++; | 1594 | 1290 | 1621 | 1978 | 3753 | |
9 | _id = i; | 1563 | 1583 | 1589 | 1739 | 3495 | |
10 | t =_id; | 101 | 106 | 130 | 213 | 417 | |
11 | _temp++; | 221 | 223 | 222 | 251 | 524 | |
12 | _temp = i; | 77 | 84 | 99 | 168 | 330 | |
13 | t = _temp; | 66 | 74 | 85 | 144 | 274 |
Volatile 变量实现了轻量级的同步,相对于重量级的synchronized和Lock,轻量级可以解释为更快的速度和简陋的功能。Volatile 变量实现的可见性、有序性,却没有实现原子性。这就是说线程每次从 volatile 变量得到的值都是最新值,却不能保证已经得到的值进行运算时还是最新的。因此,仅仅 volatile 还不足以实现计数器、互斥锁。
相对于重量级的同步方式,使用 volatile 变量更简单更高效。volatile 操作不会造成线程阻塞,可伸缩性强。volatile 变量通常能够减少同步的性能开销,尤其是读volatile 变量操作远远超过写volatile 变量操作时,几乎不会有同步的性能开销。因为目前大多数的处理器架构上,volatile 读操作几乎和普通内存变量的读操作一样。
即使volatile变量有如此多的优势,volatile却不如锁用的多,因为volatile变量没有原子性,更加容易出错。想要安全的使用volatile变量代替锁,volatile变量的值必须是完全独立的,不依赖程序内任何变量的。具体为:
①:对volatile变量的写操作不依赖于当前值。
②:该变量没有包含在具有其他变量的不变式中(不变式就是可以决定某段代码是否执行的关键特征,例如if条件判断语句、循环中终止判断、switch多重判断要判断的变量)