原文地址:https://duktig.cn/archives/36/
线程安全:多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。
“线程安全”不是指线程的安全,而是指内存的安全。为什么如此说呢?这和操作系统有关。
目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。但是进程中的多个线程共享进程的堆内存,这就是造成问题的潜在原因。
假设某个线程把数据处理到一半,觉得很累,就去休息了一会,回来准备接着处理,却发现数据已经被修改了,不是自己离开时的样子了。可能被其它线程修改了。
比如把你住的小区看作一个进程,小区里的道路/绿化等就属于公共区域。你拿1万块钱往地上一扔,就回家睡觉去了。睡醒后你打算去把它捡回来,发现钱已经不见了。可能被别人拿走了。因为公共区域人来人往,你放的东西在没有看管措施时,一定是不安全的。内存中的情况亦然如此。
所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。
即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。
当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行,导致共享数据的错误。
/**
* 例子:创建三个窗口卖票,总票数为100张.使用继承Thread类的方式
*
* 存在线程的安全问题,待解决。
*/
class Window extends Thread{
private static int ticket = 100;
@Override
public void run() {
while(true){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(ticket > 0){
System.out.println(getName() + ":卖票,票号为:" + ticket);
ticket--;
}else{
break;
}
}
}
}
public class WindowTest {
public static void main(String[] args) {
Window t1 = new Window();
Window t2 = new Window();
Window t3 = new Window();
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
t1.start();
t2.start();
t3.start();
}
}
结果
出现了共享数据错误
窗口2:卖票,票号为:100
窗口1:卖票,票号为:100
窗口3:卖票,票号为:98
窗口3:卖票,票号为:97
窗口2:卖票,票号为:97
窗口1:卖票,票号为:95
窗口1:卖票,票号为:94
窗口2:卖票,票号为:93
窗口3:卖票,票号为:92
窗口3:卖票,票号为:91
......
窗口1:卖票,票号为:10
窗口3:卖票,票号为:10
窗口2:卖票,票号为:10
窗口2:卖票,票号为:7
窗口3:卖票,票号为:7
窗口1:卖票,票号为:5
窗口3:卖票,票号为:4
窗口2:卖票,票号为:4
窗口1:卖票,票号为:2
窗口2:卖票,票号为:1
窗口3:卖票,票号为:1
分析
如何解决线程安全问题?解决的过程其实就是一个取舍的过程,不同的方案有不同的侧重点。
不可变的对象一定是线程安全的,不需要采取任何的线程安全措施。只要一个不可变的对象被正确的构建出来,在多线程的状态下也不允许修改,那么一定不会发生不一致的状态。
形象比喻:只能看,不能摸。自然不会存在线程安全问题。
在多线程的环境下,应尽量是对象成为不可变的状态,来满足线程安全,这基本也是最小的开销方式来保证线程安全。
不可变类的意思是创建该类的实例后,该实例的实例变量是不可改变的。
不可变的类型:
String
BigInteger
和 BigDecimal
等大数据类型注:final修饰的引用数据类型不可变,但其成员变量的类可能会发生改变。
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
形象比喻:私有的东西就不该让别人知道。
如果一些数据只有某个线程会使用,其它线程不能操作也不需要操作,这些数据就可以放入线程的栈内存中。较为常见的就是局部变量。
double avgScore(double[] scores) {
double sum = 0;
for (double score : scores) {
sum += score;
}
int count = scores.length;
double avg = sum / count;
return avg;
}
这里的变量sum
,count
,avg
都是局部变量,它们都会被分配在线程栈内存中。
假如现在A线程来执行这个方法,这些变量会在A的栈内存分配。与此同时,B线程也来执行这个方法,这些变量也会在B的栈内存中分配。
也就是说这些局部变量会在每个线程的栈内存中都分配一份。由于线程的栈内存只能自己访问,所以栈内存中的变量只属于自己,其它线程根本就不知道,也就不存在线程安全问题。
问题:
不可能所有的变量都只声明成局部变量,而只供某个线程使用,去解决线程安全问题。如果想要声明成员变量,还想要保证线程安全怎么办?
可以使用ThreadLocal
,使每个线程都私有化一份这个变量的本地副本,互相不受影响。
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
可以使用java.lang.ThreadLocal
类来实现线程本地存储功能。
通俗来说:要让公共区域堆内存中的数据对于每个线程都是安全的,那就每个线程都拷贝它一份,每个线程只处理自己的这一份拷贝而不去影响别的线程的,这不就安全了嘛。
形象比喻:大家不要抢,人人有份。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
为了理解 ThreadLocal
,先看以下代码:
public class ThreadLocalExample1 {
public static void main(String[] args) {
ThreadLocal threadLocal1 = new ThreadLocal();
ThreadLocal threadLocal2 = new ThreadLocal();
Thread thread1 = new Thread(() -> {
threadLocal1.set(1);
threadLocal2.set(1);
});
Thread thread2 = new Thread(() -> {
threadLocal1.set(2);
threadLocal2.set(2);
});
thread1.start();
thread2.start();
}
}
它所对应的底层结构图为:
ThreadLocal实例内存结构
每个 Thread 都有一个 ThreadLocal.ThreadLocalMap
对象。
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
当调用一个 ThreadLocal
的 set(T value)
方法时,先得到当前线程的 ThreadLocalMap
对象,然后将 ThreadLocal->value
键值对插入到该 Map 中。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
get() 方法类似。
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
ThreadLocal
从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。ThreadLocal
就是,把一个数据复制N份,每个线程认领一份,各玩各的,互不影响。
在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap
的底层数据结构导致 ThreadLocal
有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal
后手动调用 remove()
,以避免出现 ThreadLocal
经典的内存泄漏甚至是造成自身业务混乱的风险。
前面给出的一些方案,有点“理想化”了,现实中的情况其实是非常混乱嘈杂的,没有规则的。
形象比喻:没有规则,那就先入为主。
例子:
比如在中午高峰期你去饭店吃饭,进门后发现只剩一个空桌子了,你心想先去点餐吧,回来就坐这里吧。当你点完餐回来后,发现已经被别人捷足先登了。
因为桌子是属于公共区域的物品,任何人都可以坐,那就只能谁先抢到谁坐。虽然你在人群中曾多看了它一眼,但它并不会记住你容颜。
解决方法就不用我说了吧,让一个人在那儿看着座位,其它人去点餐。这样当别人再来的时候,你就可以理直气壮的说,“不好意思,这个座位,我,已经占了”。
相信聪明的你已经猜到了我要说的东西了,没错,就是互斥锁 。
如果公共区域(堆内存)的数据,要被多个线程操作时,为了确保数据的安全(或一致)性,需要在数据旁边放一把锁,要想操作数据,先获取锁再说吧。
假设一个线程来到数据跟前一看,发现锁是空闲的,没有人持有。于是它就拿到了这把锁,然后开始操作数据。
这时,又来了一个线程,发现锁被别人持有着,按照规定,它不能操作数据,因为它无法得到这把锁。当然,它可以选择等待,或放弃,转而去干别的。
因为第一个线程持有锁,可以大胆干事而不用担心其他线程的影响。
对于互斥同步锁,可以使用synchronized
和 ReentrantLock
。
class ClassAssistant {
double totalScore = 60;
final Lock lock = new Lock();
void addScore(double score) {
lock.obtain();
totalScore += score;
lock.release();
}
void subScore(double score) {
lock.obtain();
totalScore -= score;
lock.release();
}
}
假定一个班级的初始分数是60分,这个班级抽出10名学生来同时参加10个不同的答题节目,每个学生答对一次为班级加上5分,答错一次减去5分。因为10个学生一起进行,所以这一定是一个并发情形。
因此加分和减分这两个方法被并发的调用,它们共同操作总分数。为了保证数据的一致性,需要在每次操作前先获取锁,操作完成后再释放锁。
问题:
互斥阻塞会有线程阻塞和唤醒所带来的性能问题。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
形象比喻:相信世界充满爱,即使被伤害。
由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁 。
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
例子解释:
例子,假如你往地上仍1万块钱,是不是一定会丢呢?这要看情况了,如果是在人来人往的都市,可以说肯定会丢的。如果你跑到无人区扔地上,可以说肯定不会丢。
可以看到,都是把东西无保护的放到公共区域里,结果却相差很大。这说明安全问题还和公共区域的环境状况有关系。
比如我把数据放到公共区域的堆内存中,但是始终都只会有1个线程,也就是单线程模型,那这数据肯定是安全的。
再者说,2个线程操作同一个数据和200个线程操作同一个数据,这个数据的安全概率是完全不一样的。肯定线程越多数据不安全的概率越大,线程越少数据不安全的概率越小。取个极限情况,那就是只有1个线程,那不安全概率就是0,也就是安全的。
因为锁的获取和释放是要花费一定代价的,如果在线程数目特别少的时候,可能可能就不会有别的线程来操作数据,此时你还要获取锁和释放锁,可以说是一种浪费。
针对这种“地广人稀”的情况,专门提出了一种方法,叫CAS。就是在并发很小的情况下,数据被意外修改的概率很低,但是又存在这种可能性,此时就用CAS。
CAS的全称是:比较并交换(Compare And Swap)。在CAS中,有这样三个值:
比较并交换的过程如下:
判断V是否等于E,如果等于,将V的值设置为N;如果不等,说明已经有其它线程更新了V,则当前线程放弃更新,什么都不做。
我们以一个简单的例子来解释这个过程:
i
原本等于5,我现在在线程A中,想把它设置为新的值6;i
的值被设置成了6;i
被其它线程改过了(比如现在i
的值为2),那么我就什么也不做,此次CAS失败,i
的值仍然为2。在这个例子中,i
就是V,5就是E,6就是N。
那有没有可能我在判断了i
为5之后,正准备更新它的新值的时候,被其它线程更改了i
的值呢?
不会的。因为CAS是一种原子操作,它是一种系统原语,是一条CPU的原子指令,从CPU层面保证它的原子性。
CAS是一种原子操作,在Java中,有一个Unsafe
类,它在sun.misc
包中。它里面是一些native
方法,其中就有几个关于CAS的,他们都是public native
的。
当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
ABA问题?(狸猫换太子)
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了,那 CAS 操作就会误认为它从来没有被改变过。
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。
Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
Unsafe类支持CAS的方法。那Java具体是如何使用这几个方法来实现原子操作的呢?
JDK提供了一些用于原子操作的类,在java.util.concurrent.atomic
包下面。在JDK 8中,有如下17个类:
从名字就可以看得出来这些类大概的用途:
“栈封闭”:找个只有自己知道的地方藏起来,当然安全了。
“ThreadLocal
”:每人复制1份,各玩各的,互不影响,当然也安全了。
“不可变”:更狠了,直接规定,只能读取,禁止修改,当然也安全了。
互斥同步和非阻塞同步,分别对应悲观锁和乐观锁的策略。