目录
- 为什么要并发编程
- 并发编程带来的问题
- 安全性问题
- 原子性问题
- 可见性问题
- 有序性问题
- 活跃性问题
- 死锁
- 活锁
- 饥饿
- 性能问题
- 线程生命周期
- 管程
- synchronized
- voliate
- final
为什么要并发编程
最主要还是压榨硬件(上图为我cpu的使用率)。现在硬件都是过剩的状态,不压榨干嘛,也不能天天指望程序员拿头发来优化算法啊。
并发编程带来的问题
安全性问题
都知道并发编程能提高效率,但是这肯定有代价的,会带来很多问题主要分为3大类。
原子性问题
-
原子性:一个或多个操作在cpu执行过程中不可分割。不可分割也就是中间状态对外不可见,所以只要保证对外不可见即可。
-
为什么会有这个问题?
cpu会类似上图每隔一定时间铁环线程执行从而达到,多个程序同时在执行的感觉。也就是说你的程序执行到一半,就可能切换走了。java是种高级语言,一行往往对应多条cpu指令,加上cpu指令冲排序,很可能结果线出来,但是过程还没完成,一旦被外界拿去用,就会出现问题。比如Object o=new Object();
这一部操作对应了以下3步。- 申请内存,赋值默认值
- 成员变量初始化
- 赋值给对象引用
如果这个过程对外不可见,随便怎么重拍都无所谓,但是如果2和3掉了个位置,别人用的时候发现没有初始化,很可能就会出现问题。这还只是一行命令,就已经出现了风险,多行语句出问题的概率更大
-
如何解决?
- 加锁
可见性问题
- 可见性:一个线程对一个值的修改另外一个线程可以立马看见
- 为什么会有这个问题?
内存速度相对于cpu而言慢很多,就出现了cpu缓存,每个核都有自己的cpu缓存。如果多个cpu核处理同一个变量,处理完成之后没有及时刷回主存并通知其他线程重新读取就会导致可见性问题。 - 如何解决?
- voliate
- 加锁
有序性问题
- 有序性:程序按照代码的先后顺序执行
- 为什么会有这个问题?
前面也说了高级语言一行往往对应多条cpu指令,编译器为了优化性能,有时候会改变程序中语句的先后顺序。指令集并行的重排序是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:- 取指 IF
- 译码和取寄存器操作数 ID
- 执行或者有效地址计算 EX (ALU逻辑计算单元)
- 存储器访问 MEM
- 写回 WB (寄存器)
x代表在这里停顿了下,应为R2数据还没有准备好,所以在这里等待了一会。下面我们看另外一个情况
a=b+c
d=e-f
这会有很多停顿,对这些指令稍微重拍下就可以解决这些停顿,提高cpu利用率
在不影响结果的前提下,只是做了指令重排,但是效率提高了。但也不能为了减少停顿进行排序降低乱排序,JMM通过happens-before保证了可见性保证。 - 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
- 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
- 如何解决?
- voliate
- 加锁
- final
活跃性问题
死锁
一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。并发程序一旦死锁,一般没有特别好的方法,很多时候我们只能重启应用。因此,解决死锁问题最好的办法还是规避死锁。
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
活锁
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”
饥饿
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
性能问题
不能适当的发挥多线程性能的优势,不能为了用多线程而用多线程,因为用锁会不可避免带来一点性能问题,很可能在某些情况下很可能执行时间还不如多线程。我们之所以使用多线程搞并发程序,为的就是提升性能。
- 使用无锁优化
- 减少锁的持有时间
- 减少锁的粒度
- 使用读写锁分离锁来替换独占锁
- 锁分离
- 锁粗化
线程生命周期
管程
java多线程基本上都是基于Monitor实现的
管程博客这个博客写的不错建议看这个
synchronized
synchronized首先说下如何使用,作用于方法,作用于同步代码块。当写在静态方法中,锁的是Class对象,和同步代码块中写(xxx.class)效果一致,下面有个测试代码,感兴趣的可以测试下
public class SynchronizedDemo {
public synchronized void test1() throws InterruptedException {
System.out.println("test1");
Thread.sleep(10000);
System.out.println("test1 end");
test3();
}
public static synchronized void test2() throws InterruptedException {
System.out.println("test2");
Thread.sleep(10000);
System.out.println("test2 end");
}
public void test3() throws InterruptedException {
synchronized (this){
System.out.println("test3");
Thread.sleep(10000);
System.out.println("test3 end");
}
}
public void test4() throws InterruptedException {
synchronized (SynchronizedDemo.class){
System.out.println("test4");
Thread.sleep(10000);
System.out.println("test4 end");
test2();
}
}
public static void main(String[] args) {
SynchronizedDemo synchronizedDemo=new SynchronizedDemo();
Runnable r=new Runnable() {
@Override
public void run() {
try {
// synchronizedDemo.test1();
synchronizedDemo.test2();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Runnable r1=new Runnable() {
@Override
public void run() {
try {
// synchronizedDemo.test3();
synchronizedDemo.test4();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t1=new Thread(r);
Thread t2=new Thread(r1);
// t1.start();
t2.start();
}
}
synchronized用的锁其实是存在java对象头中,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata | Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
Mark Word在不同的锁状态下存储的内容不同
当只有一个线程获取锁时,偏向锁的标识改成1,线程id,时间戳。当有其他线程尝试获取锁的时候,会判断当前线程id和markword里面的线程id是否是一致,不一致,膨胀为轻量级锁,然后自旋比较,还没有拿到锁,就膨胀为重量级锁,重量级锁的指针就指向了一个monitor对象,结构如下
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的线程队列
- _EntryList:存放处于等待锁block状态的线程队列
- _recursions:锁的重入次数
- _count:用来记录该线程获取锁的次数
当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。
若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
voliate
该关键字确保了对一个变量的更新对其他线程可见。当一个变量被声明为volatile时候,线程写入时候不会把值缓存在寄存器或者或者在其他地方,当线程读取的时候会从主内存重新获取最新值,而不是使用当前线程的拷贝内存变量值。volatile虽然提供了可见性保证,但是不能使用他来构建复合的原子性操作,也就是说当一个变量依赖其他变量或者更新变量值时候新值依赖当前老值时候不在适用。
如图线程A修改了volatile变量b的值,然后线程B读取了改变量值,那么所有A线程在写入变量b值前可见的变量值,在B读取volatile变量b后对线程B都是可见的,图中线程B对A操作的变量a,b的值都可见的。volatile的内存语义和synchronized有类似之处,具体说是说当线程写入了volatile变量值就等价于线程退出synchronized同步块(会把写入到本地内存的变量值同步到主内存),读取volatile变量值就相当于进入同步块(会先清空本地内存变量值,从主内存获取最新值)。转自
final
final基础用法想必大家都已经了解了,他在多线程中的比较重要的2个点。
- 在构造函数中对final域写入,随后再把这个变量赋值给一个引用变量,这两个不能重排
- 读取对象的引用和读取这个final域,两个操作之间不能重排(大部分处理器是这样的,但是有少部分处理器抽风)
参考书籍
《实战Java高并发程序设计》