并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。
这几天比较低沉。无论是天气还是心情。不过今天在睡了一整天之后总算是“活”过来了。而且心态也调整过来了,决定把之前写的这个深入JVM系列写完。其实也不过剩下最后一章,就是本文的线程安全与锁优化。这本书其实读的没有多精细,通篇读下来还是有一部分是没有理解的。然后我着重记忆的也都是考点,或者说经常提到或者用到的。我经常说自己是考试前夕的学生。头悬梁锥刺股为的也不过是及格。有时候挺讨厌这样的自己的。闲话少叙,看文章内容吧。
概述
书中概述的内容较多。从面向过程的编程思想到面向对象的编程思想。虽然我们的整体变成已经进步了很多。但是整体的思路是没有变的。这句话我在以前也提过:先实现,再优化。
书中原文:
有时候,良好的设计原则不得不向现实做出一些让步,我们必须让程序在计算机正确无误地运行,然后再考虑如何将代码组织得更好,让程序运行得更快。对于这部分的主题 “高效并发” 来将,首先需要保证并发的正确性,然后在此基础上实现高效。本章先从如何保证并发的正确性和如何实现线程安全讲起。
线程安全
“线程安全”这个名称,很多人都会听说过,甚至在戴拿编写和走查的时候可能还会经常挂在嘴边。但是如何找到一个不太拗口的概念来定义线程安全却不是一个容易的事情。
网上的定义“如果一个对象可以安全的被多个线程同时使用,那么他就是线程安全的”。在书中还有Brian Goetz对线程安全的定义:“当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调工作。调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的”。
其实总而言之,就是使用这个对象不用考虑多线程问题,更没必要采取措施保证多线程的调用。这个对象就是线程安全的。
java语言中的线程安全
我们按照线程安全的“安全程度”由强到弱排序,分成五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立。
-
不可变: 在java语言中不可变的对象一定是线程安全的。无论是对象的方法实现还是方法的调用者,都不需要采取任何的线程安全保障措施。 java语言中,共享数据是一个基本数据类型,只要在定义时加上final关键字就可以保证它是不可变的。如果共享数据是一个对象,我们要保证对象的行为不会对其状态产生任何影响。(可以想想string类型。调用它的substring(),replace()等都不影响它本来的值)
保证对象行为不影响自己状态的途径有好多。最简单的就是把对象的属性都设置为final(书中说的是把对象的带有状态的变量都声明为final。这样在构造函数结束以后,他就是不可变的。我理解就是所有属性都是final。如果我说错了欢迎大家指出来)。 -
绝对线程安全:绝对线程安全就能满足上面我们对线程安全的定义。其实这个定义很严格的。在java API中很多标注自己是线程安全的类其实都不是绝对的线程安全。
我们都知道java.util.Vector是一个线程安全的容器。因为它的add(),get(),size()这类方法都是被synchronized修饰的,虽然这样效率很低,但是确实是安全的。但是即使他的所有的方法都是同步的,也不意味着调用它的时候永远都不需要同步手段了。
package demo;
import java.util.Vector;
public class VectorDemo {
//首先创建一个vector的对象。
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
//这个书上说线程过多会操作系统假死。
while (Thread.activeCount() > 20);
}
}
}
讲一下上面的代码,我跑了不到五分钟,然后报错java.lang.ArrayIndexOutOfBoundsException。集合下标越界。其实就是删除和打印最后冲突了。书中说的解决办法就是把线程中的for循环前面加上线程锁。改成如下这样
package demo;
import java.util.Vector;
public class VectorDemo {
//首先创建一个vector的对象。
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized(vector) {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
}
});
removeThread.start();
printThread.start();
//这个书上说线程过多会操作系统假死。
while (Thread.activeCount() > 20);
}
}
}
- 相对线程安全:相对线程安全才是我们通常意义上所讲的线程安全。它需要保证对这个对象的单独操作是线程安全的,我们不需要做额外的保证措施。但是对一些特定顺序的连续调用,需要在调用段使用额外的同步手段来保证调用的正确性。上面vector的代码就是这种。
- 线程兼容:线程兼容值对象本身不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用。我们平常说的一个类不是线程安全的,绝大多数就是这种情况。比如ArrayList和HashMap。
-
线程对立:指无论是够采用同步措施,都无法在多线程环境中并发使用的代码。
比如Thread的suspend()和resume()方法,同时使用很容易产生死锁。
线程安全的实现方法
互斥同步:互斥同步是一种常见的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥则是实现同步的一种手段。在这四个字中,互斥是因,同步是果。互斥是手段,同步是目的。
java语言中最基本的互斥同步手段就是synchronized。
synchronized有两点需要注意:- synchronized同步块对同一个线程可重复入。
- synchronized同步块在已进入的线程执行完毕之前会阻塞其他的线程。
然后因为线程阻塞或唤醒一个线程很消耗性能,所以 synchronized在java中是一个重量级的操作。
除了 synchronized以外,还可以用java.util.concurrent包中的重入锁实现同步,基本用法上,ReentrantLock和 synchronized很相似。都具备同一个线程可重入。并且相比 synchronized,ReentrantLock增加了一些高级功能。主要有:
-
等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。 -
可实现公平锁
多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。 -
锁可以绑定多个条件
一个 ReentrantLock 对象可以同时绑定多个 Condition 对象,而在 synchronized 中,锁对象的 wait() 和 notify() 或 notifyAll() 方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁,而 ReentrantLock 则无须这样做,只需要多次调用 newCondition() 方法即可。
(我个人感觉就是ReentrantLock 比较灵活,可中断,可排队,可有多个锁条件)
然后书中还有两个锁的性能对比。但是是jdk1.5和1.6的。然后书中也说了synchronized在不断优化。1.6的时候两者性能就持平了。我觉得目前的开发都是1.6以上,所以就不额外说这个了。
-
非阻塞同步:互斥同步最主要的问题就是线程阻塞和唤醒带来的性能问题。因此这个也叫做阻塞同步。从处理问题的方式来说,互斥同步属于悲观锁。而接下来要说的则属于乐观锁。
通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,知道成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
书中用到了以前volatile的例子(我再次贴出来防止大家忘记了。)。
package demo;
public class VolatileDemo {
private static volatile int num = 0;
public static void add() {
num++;
}
public static void main(String[] args) {
Thread[] threads = new Thread[20];
for (Thread thread : threads) {
thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
add();
}
}
});
thread.start();
}
while (Thread.activeCount()>1)
Thread.yield();
System.out.println(num);
}
}
当时运行的结果怎么都不是20w。因为num++这个操作不是原子性的。在jvm运行时会拆成三个指令。而我们如果想保证得到的是20w则要保证自增的原子性。用num.incrementAndGet()代替num++。
下面是incrementAndGet() 方法的 JDK 源码:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
incrementAndGet() 方法在一个无限循环中,不断尝试将一个比当前值大 1 的新值赋给自己。如果失败了,那说明在执行 “获取-设置” 操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。其实这个方法也有一个问题。一个变量初次读取是A,检查赋值的时候也是A。但是能确定它的值没被改过?
-
无同步方案:有一些类不需要同步就可以保证线程安全。
简单的介绍两个类:
- 可重入代码:也叫纯代码。就是任何时刻中断执行别的代码在控制权回来之后还可以正确运行。所有可重入代码都是线程安全的。但是并非所有线程安全的代码都是可重入的。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。 - 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
锁优化
-
自旋锁与自适应自旋
互斥同步最大的消耗是线程阻塞和唤醒。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。
如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程 “稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
自适应的自旋锁。意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。这个具体的时间算法有点复杂,但是都由虚拟机实现,所有我这里就不多说了。毕竟我自己看了几遍都懵。这里只是简单的了解。 -
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。这个其实我感觉是虚拟机的优化,和我们本身的关系。。就跟语法糖似的,知道不知道我们编写代码的时候都是差不多没影响的。 -
锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到这种情况,会把加锁同步的范围扩展(粗化)到整个操作序列的外部。 -
偏向锁
其实这个概念挺好玩的。
偏向锁的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。
打个比方,你父母就一个孩子,那么什么都是你的,眼里心里都是你。但是如果没有二胎会一直这么下去。但是假如有了二胎,这种偏心立刻没了!这个锁也是这样。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为 “01”,即偏向模式。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行如何同步操作。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态。(ps:这里有个轻量级锁我没写。因为看了半天还没太理解。等我理解了再补充上来)
然后到此,深入理解java虚拟机这本书就看完了。其实心情很复杂,有着看完一本书的骄傲和自豪,也有着遗憾和可惜。我一直讲敲代码是乐趣。学习一些知识也是。但是如此的填鸭式学习,死记硬背加上删删减减,我自己都觉得是对知识的不尊重。可是然后呢?聊一下题外话,最近的面试遇到过各种各样的问题,简单的,基础的,重要的,核心的,奇葩的,超预计的。也心里不平衡过,觉得问的什么狗屁问题!也自卑过,惊讶于自己很多基础都不扎实,应该会的问题都没说清。不过学海无涯。我觉得应该坚持一下自己的一直以来的态度:多学学多看看,总不会有坏处的。
最后~~~~全文手打不易。如果你觉得稍微帮到了你一点点,请点个喜欢点个关注。有不同意见或者问题的欢迎评论或者私信。祝大家工作生活都顺顺利利吧。