前言
学习并发编程过程中,锁肯定是避不开绕不过的一道门!下面就先聊一聊爱而恨得并发锁由来之共享变量…
刚接触并发编程的时候第一反应就是代码复杂度这么高,干嘛用它!
1)快速响应用户请求,提高和用的交互实时性
2)提高系统运行效率(让CPU在单位时间内处理尽量多的运算),特别现在服务器基本都是多核的时代 使并发程序更是推到分口浪尖之上。当然也不是说单核处理就不适用并发程序,在单核cpu执行并发程序代码,是通过CPU本身被分割为多个时间片来切换执行(实际上每个执行片段的切换都是有时间间隔的 只是非常短暂 让我们感觉到多个线程是同时执行的) CPU单位片段分割及分配机制可查阅操作系统原理相关书籍
并行执行代码一定会比串行执行效率快吗
/**
* Created by Administrator on 2016/9/10 0010.
*/
public class CompareTest {
public static final long count =1000000001;
public static void main(String[] args) throws Exception {
System.out.println("count ="+count);
concurrencyTest();
serialTest();
}
public static void concurrencyTest() throws Exception{
long start = System.currentTimeMillis();
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
int a =0 ;
for (int i = 0; i < count; i++) {
a+=1;}
}});
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
int a =0 ;
for (int i = 0; i < count; i++) {
a+=1;}
}});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
long time = System.currentTimeMillis() - start;
System.out.println("concurrency :" + time +"ms");
}
public static void serialTest(){
long start = System.currentTimeMillis();
int a=0;
for (int i = 0; i <count ; i++) {
a+=1;}
int b=0;
for (int i = 0; i <count ; i++) {
a+=1;}
long time =System.currentTimeMillis() -start;
System.out.println("serial:" + time +"ms");
}
}
循环次数 串行执行时间 并行执行时间
Count=1万 0~1ms 2~4ms
Count=100万 6~10ms 5~13 ms
Count=1亿 140~210ms 95~125ms
Count=10亿 1300~1600ms 880~1000ms
附:以上运行结果并只是大致趋势不是一成不变(会随着系统环境及硬件配置还有执行任务而变化 需要外网的执行任务和网络资源也相关)
从运行结果可以发现,当运行次数较小时 串行执行比并行执行的效率更高。原因就是因为每个CUP的单位执行片段的切换是有一定的开销的(由并发执行代码提高的效率不足以弥补上下文切换开销时 串行执行反而会比并发执行的效率更高)
叉,好像有点跑题了,好吧切入正题……
线程之间的通信-共享内存
二 了解为什么要用并发编程了,那接下来就是并发编程中我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递,java的并发采用的是共享内存模型。
线程共享变量之JMM模型
Java内存模型决定了一个线程对共享变量的写入何时对另一个线程可见,而每一个线程对共享变量的操作包含几个步骤:
1) 从主存复制变量到当前工作内存 (read and load)
2) 执行代码,改变共享变量值 (use and assign)
3) 用工作内存数据刷新主存相关内容 (store and write)
由此可见线程对于共享变量的操作默认情况下并非为原子操作,这个意味一个线程操作变量的时候给其他线程留有侵入的机会,所以这个三个步骤管理也决定了多个线程能否准确的协同工作;
如线程A执行x=x+1。从上面的描述中可以知道x=x+1并不是一个原子操作,它的执行过程如下:
1 从主存中读取变量x副本到工作内存 (A1步骤)
2 给x加1 (A2步骤)
3 将x加1后的值写回主存(A3步骤)
共享变量执行步骤
注: 处理器A执行内存操作的顺序为A1->A2 但内存操作实际发生的顺序却是A2-A1;
JMM同样遵守:在不改变程序执行结果的前提下 尽可能提高并发度;详细可查阅happens-before的程序顺序规则
线程B执行x=x-1。,它的执行过程如下:
1 从主存中读取变量x副本到工作内存(A1步骤)
2 给x减1(A2步骤)
3 将x减1后的值写回主存(A3步骤)
线程C获取判断最后X的值分别执行不同任务
1:从主存中读取变量x副本到工作内存(C1步骤)
2:判断X的值执行任务(C2步骤)
假设这个三个线程A和B及C并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。线程C两个操作C1->C2
那么显然,最终C得到的的值是不可靠的。假设x现在为10,从表面上看,似乎最终x还是为10,但是多线程情况下会有这种情况发生:
1:线程A从主存读取x副本到工作内存,工作内存中x值为10
2:线程B从主存读取x副本到工作内存,工作内存中x值为10
—-线程C从主存读取x副本到工作内存,而X的值为10
3:线程a将工作内存中x加1,工作内存中x值为11
4:线程a将x提交主存中,主存中x为11
—-线程C从主存读取x副本到工作内存,而X的值为11
5:线程b将工作内存中x值减1,工作内存中x值为9
6:线程b将x提交到中主存中,主存中x为9
—-线程C从主存读取x副本到工作内存,而X的值为9
如果X是一个比较敏感的数据(存款),线程A存款,线程B扣款,线程C查询余额显然这样是有严重问题的,要解决这个问题,必须保证线程A,B,C是有序执行的,并且每个线程执行的加1或减1是一个原子操作
import java.util.Random;
/**
* Created by Administrator on 2016/9/10 0010.
*/
public class UserAccountTest {
private int balance;
public UserAccountTest(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public void add(int num) {
balance = balance + num;
}
public void withdraw(int num) {
balance = balance - num;
}
public static void main(String[] args) throws InterruptedException {
UserAccountTest account = new UserAccountTest(1000);
Thread addThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 200; i++) {
account.add(100);
try {
Thread.sleep(new Random().nextInt(10)*2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("add money finished");
}
});
Thread withdrawThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 200; i++) {
account.withdraw(100);
try {
Thread.sleep(new Random().nextInt(10)*2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("withdraw money finished");
}
});
addThread.start();
withdrawThread.start();
//Thread.sleep(new Random().nextInt(10)*100);
addThread.join();
withdrawThread.join();
System.out.println(account.getBalance());
}
}
执行5次分别为:
add money finished
withdraw money finished
1200
add money finished
withdraw money finished
1000
add money finished
withdraw money finished
1100
withdraw money finished
add money finished
400
add money finished
withdraw money finished
1000
因为线程的执行顺序是不可预见的,每次执行的结果都是不确定的。这种情况肯定不是我们希望看到的,为了避免这种情况 并发机制中实现了原子操作的机制;换句话说 如果能保证上面的代码中的 存款和取款操作都分别为一个原子操作,自然就解决了结果不定性的问题;
不可预见及可见性处理方式:锁
并发中实现原子操作基础方式为为执行任务加锁,保证其原子性;
常见保证原子性的方法:
1:内置锁synchronized 使用(对象锁及类锁)
2:显示锁Reentrantlock,使用
理论上,每个对象都可以做为锁,但一个对象做为锁时,应该被多个线程共享,这样才显得有意义,在并发环境下,一个没有共享的对象作为锁是没有意义的;
每个线程对自己获得的锁都是可重入的,也就是说如果某个线程试图获得一个已经由它自己持有的锁,请求会成功。(重入实现方法:一个任务可以多次获得对象的锁。如果一个方法在同一个对象上调用了第二个方法,后者有调用了同一个对象的另一个方法,就会发生这种情况。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁-即锁被完全释放),其计数变为0.在任务第一次给对象加锁的时候,技术变为1.每当这个相同的任务在这个对象上获得锁时,计数就会递增。显然只有首先获得了锁的任务才能允许继续获取多个锁。每当任务离开一个锁方法,计数递减,当计数为零的时候,锁被完全释放,此时别的任务就可以使用此资源 —此段摘抄java编程思想677page)
模拟锁机制:
3:volatile轻量级锁(个人不认为是锁机制,强制使其标记变量的修改 立即刷新到主存并失效其他线程对此变量副本)
4:使用CAS检查机制,满足预期再写入+volatile立即可见(但有ABA,修改不成功耗时等问题)
当然每一种实现机制并不是针对某一场景的完美解决方案,也就是每一种方式都有着自己的优势和缺陷但对不同情况也可以通过一些小技巧改善。至于使用哪一种方式要根据业务场景及其他涉及条件平衡利弊决定;这个方面相信大家在博客及书籍都看到不少,就不累述了哈
最好引用一句不知道哪位大神说过的话“并发具有可论证的确定性,但是实际上具有不可确定性”,希望大家对并发一直抱有自信而敬畏的态度^-^
参考文献
书籍:
《java编程思想》《并发编程的艺术》