前面一篇讲了很多多线程的基础知识,现在这篇我们讲一下多线程并发中的共享模型。
一、共享问题:
共享是什么意思?通过字面意思我可以知道,共享就是多对一。在线程中,简单来说就是指多个线程可能会同时去改变同一个变量。
那么这会带来什么问题呢?看一个例子:
1、老王(操作系统)有一个功能强大的算盘(cpu),现在想把它租出去,赚点外块。
2、小明(线程1)想使用这个算盘进行一些计算,并按照时间给老王支付费用。
3、但是小明不可能一天24小时都在使用算盘,他还要睡觉(sleep),又或者是吃饭(InputStream)上厕所(OutputStream)(阻塞IO操作)。有时还需要抽根烟,没烟的时候没思路(wait)(以上这些情况统称为阻塞)。
4、在这些时候,算盘没利用起来(不能收钱了),老王觉得很亏。
5、这时候,有个小红(线程2)也想使用算盘。
6、于是老王灵机一动,想了个办法:让他们每人用一会,轮流使用算盘(分时系统)。
7、这样,当小明阻塞的时候,算盘可以给小红用,反之亦然。
8、最近呢,小明和小红执行的计算比较复杂,需要存储一些中间结果,而学生们的脑容量(工作内存)不够,所以老王申请了一个笔记本(主存),把一些中间结果记在笔记本上。
9、计算流程是这样的:笔记本上有一个初始值是0,小明呢,只会用算盘做加法,就是一直+1。小红呢,只会用算盘做减法,一直-1。
10、按照这个思路,如果小明在笔记本上+1,而小红在笔记本上-1,那应该最后结果还是0。但是现实是不是这样的呢?
11、有一天,小明刚读取了一个初始值0,然后做了一个+运算,,还没来得及在笔记本上写回结果,老王来了,说:小明,你的时间到了,该别人了,记住结果就走吧。于是小明就一直念叨着:结果1,结果1,结果1。。。不甘心地到一边等待着去了(上下文切换)。
12、这时候小红开始使用笔记本,看到笔记本上还是初始的0,然后她就做了-1,然后将结果-1写入笔记本。
13、然后小红的时间用完了,老王又来叫小明:小明,把你上次的计算做完吧。小明于是将脑海中的结果1写入了笔记本。
14、小明和小红都觉得自己没做错,但是笔记本里面的结果却是1而不是0。
我们再通过一个Java代码例子看一下:
@Slf4j
public class Test1 {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", counter);
}
}
有两个线程t1,t2,一个将counter变量自增5000,一个将counter自减5000,那么最终结果是不是还是0呢?
第一次运行:
第二次运行:
第三次运行:
第四次运行:
运行了很多次,发现结果有可能是0,也有可能不是0。
那为什么会这样呢?
这其实就是我们所说的线程安全问题。是由于分时系统造成的线程切换而导致的安全问题。
那如果我们想要彻底理解为什么会产生这种安全问题,我们的得从字节码层面来解释,如果对于字节码没有什么概念的小伙伴,可以去看一下博主之前写的关于JVM的博客:浅析JVM(一)、浅析JVM(二)。关于什么是字节码,这里就不展开讲了。我直接来说原理:
对于这种静态变量而言,如果进行++操作,那么i++实际会产生如下的JVM字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
i-- 同理:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
我们可以发现,在java中只是一个i++或者i--,在字节码层面,却不是一条指令,而是被拆分成了四个步骤,那就说明它并不是一个原子操作。
完成静态变量的自增或者自减需要在主存和工作内存中进行数据交换,来Java的内存模型:
我们的静态变量,在主内存中,多个线程都可以读取它的值,如果要计算都是在线程内做的计算。如图,线程要做i++操作,需要先把静态变量加载到线程1中,然后再做计算,计算完成再把结果写回主内存。线程2做i--同理。
如果是单线程执行i++和i--操作,代码都是按顺序执行的,那肯定没有问题。但是多线程下,由于分时系统的存在,那么在不同线程之间,有可能会产生指令的交错执行。什么意思呢?就是线程1的i++指令,执行到iadd这条指令时,得到结果1,但是这时它的时间片用完了,那么它的计算结果还并没有写回主内存中,然后cpu就去执行线程2中的指令了,等到线程2的指令执行完,线程2的计算结果-1已经写回到主内存中。然后cpu再回来执行线程1剩下的指令,那么其实就是把线程1之前已经计算好的1,直接写入主内存,那么线程2之前计算的-1其实是被直接覆盖了。
二、临界区与竞态条件:
通过上面的例子,我们可以总结成以下几点:
1、一个程序其实运行多个线程,这点是没问题的。
2、问题出在多个线程访问共享资源:多个线程去读共享资源其实也没问题;在多个线程对共享资源读写操作时发生指令交错,就会出现问题。
3、一段代码块内如果存在对共享资源的多线程读写操作,我们就可以称这段代码块为临界区(Critical Section)。
例如:
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件(Race Condition)。
三、synchronized:
那么我们如何避免这种临界区发生的线程安全的问题呢?我们有多种手段可以达到目的,我这里把解决方案分成两类:
1、阻塞式解决方案:synchronized、lock。
2、非阻塞式解决方案:原子变量。
这里我先将一下阻塞式的解决方案:synchronized来解决上述问题。synchronized俗称【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其他线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区的代码,不用担心线程上下文切换。
synchronized基本使用:
下面我们来看一下synchronized的语法:
synchronized (对象)
{
// 临界区
}
那么我们现在就用它来解决一下上面我们出现线程安全问题的那段代码:
@Slf4j
public class Test1 {
static int counter = 0;
static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
counter++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (lock) {
counter--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", counter);
}
}
我们这里创建了一个static的lock对象,因为synchronized只能对对象加锁。然后我们在t1和t2线程中用synchronized对这个lock对象加锁,然后执行临界区中代码:
然后我们发现,无论我执行几次,最后的结果都是0。这就是synchronized的基本使用。
synchronized原理:
1、其实呢,你可以把synchronized(对象)想象成一个房间(room),这个房间只有一个唯一的入口(门),房间每次只能进去一个人(线程)在里面做事情。
2、这时候有t1、t2两个人(线程),当线程t1执行到synchronized(room)时就好比t1进入了这个房间,锁住了门并拿走了钥匙,在房间里执行counter++代码。
3、这时候如果t2也运行到了synchronized(room)时,他发现门被锁住了,只能在门外等待,发生了上下文切换,阻塞住了。
4、这中间即使t1的时间片用完了,被提出了门外(不要错误理解为锁住了对象就能一直执行下去哦),这是门还是锁住的,t1仍旧拿着钥匙,t2线程还在阻塞状态进不去房间,只有下次轮到t1自己再次获得时间片时才能开门进入。
5、当t1执行完synchronized(room){}块内的代码,这时候才会从房间出来并解开门上的锁,唤醒t2线程,并把钥匙给它。t2线程这才进入房间,锁住门并拿住钥匙,执行它的count--代码。
以上的过程如图:
所以synchronized实际上是用对象锁保证了临界区代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
然后我们再来思考一下:
1、如果t1 synchronized(obj1),而t2 synchronized(obj2)会怎样运作?
这个其实很好理解,还是那个房子的例子,现在obj1和obj2不是同一个room了,t1和t2分别进入的是不同的房子,所以他们分别锁住的是不同的操作,如果这个操作是共享的资源,那么是起不到锁的作用的,如果要保护一个共享的资源,那么就要让多个线程锁住的是同一个对象。
2、如果t1 synchronized(obj),而t2没有加synchronized会怎么样?
这样其实也是没有达到锁的效果的,很好理解,以上面的图为例,当我的t1线程执行counter++操作的时候,虽然是加了锁,但是发生了上下文切换的时候,t2线程是没有加synchronized的,那么t2就不会有获取锁的动作,既然不获取锁,那么就代表t2并不会被阻塞住!没被阻塞住,那么t2自然就能执行它的代码,所以就不能达到保护共享资源的效果。
我们上面的代码,是面向过程的,而Java是面向对象的,我们用面向对象的思想怎么去实现呢?我们来试一下:
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.increament();
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
room.decreament();
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", room.getCounter());
}
static class Room {
private int counter = 0;
public void increament() {
synchronized (this) {
counter++;
}
}
public void decreament() {
synchronized (this) {
counter--;
}
}
public int getCounter() {
synchronized (this) {
return counter;
}
}
}
}
我们直接把房间定义为一个类Room,类里面有++方法increament(),--方法decreament(),还有获取counter值的方法,他们都用synchronized锁住同一个Room对象,这样,这三个方法就是线程安全的。对于共享资源的保护,由Room内部来实现。这样我们的面向对象的改造就完成了。
synchronized加在方法上:
我们的synchronized关键字加在方法上,有两种:
一种是加在成员方法上的:
class Test {
public synchronized void test() {
}
}
效果等同于:
class Test {
public void test() {
synchronized(this){
}
}
}
相当于锁住的是this对象。那么我们上面的例子中的代码就可以改造成:
class Room {
private int counter = 0;
public synchronized void increament() {
counter++;
}
public synchronized void decreament() {
counter--;
}
public synchronized int getCounter() {
return counter;
}
}
效果是一样的。
一种是加在静态方法上的:
class Test {
public synchronized static void test() {
}
}
效果等同于:
class Test {
public static void test() {
synchronized (Test.class) {
}
}
}
相当于锁住类对象,不是this对象了。
四、变量的线程安全:
成员变量和静态变量是否线程安全?
1、如果它们没有共享,则线程安全。
2、如果它们被共享了,根据它们的状态是否能够改变,分成两种情况:
a、如果只有读操作,则线程安全;
b、如果有读写操作,则这段代码是临界区,需要考虑线程安全。
局部变量是否线程安全?
1、局部变量是线程安全的。
2、但局部变量引用的对象则未必:
a、如果该对象没有逃离方法的作用访问,它是线程安全的;
b、如果该对象逃离方法的作用范围,需要考虑线程安全。
最后这两个概念,是不是看的有点蒙?没关系,我们来详细说明一下,先看个例子:
public static void test1() {
int i = 0;
i++;
}
这个方法,如果有多个线程都调用了,那么它的局部变量i是不是线程安全的?很多小伙伴是不是说前面讲过了,i++不会原子操作,它不是线程安全的。但其实呢,它是线程安全的。为什么呢?我来解释一下:
每个线程调用test1()方法时局部变量i,会在每个线程的栈帧内存中被创建,也就是每个线程进入这个方法,都会初始化一份自己的局部变量i,局部变量存在虚拟机栈中,是线程私有的,因此不存在共享(这里可能有的小伙伴没有学过Java虚拟机的知识,可以去看博主写的两篇关于JVM原理的博客:浅析JVM(一)、浅析JVM(二))。
如图,每个线程调用test1方法时,都是在自己的虚拟机栈中执行,而局部变量i存在于方法栈帧的局部变量表中,所以局部变量i每个线程独一份。
但是这里需要注意,如果是局部变量引用,那么情况就稍有不同了。
我们先来看一个例子:
@Slf4j
public class Test1 {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadUnsafe threadUnsafe = new ThreadUnsafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
threadUnsafe.method1(LOOP_NUMBER);
}, "Thread" + (i + 1)).start();
}
}
}
class ThreadUnsafe {
ArrayList<String> list = new ArrayList<>();
public void method1(int loopNumber) {
for (int i = 0; i < loopNumber; i++) {
method2();
method3();
}
}
private void method2() {
list.add("1");
}
private void method3() {
list.remove(0);
}
}
上面这段代码,开始执行后有没有问题?
答案是有的。因为method2和method3访问的都是一个共享资源list,method2和method3都对这个共享资源有修改操作,所以当多个线程交错执行的时候,就有可能出现问题。
执行结果:
第一次:
第N次:
可以看到,执行结果是不可预测的,有时候正常,有时候会数组越界,说明这段代码是存在线程安全问题的。
那么为什么操作这个成员变量会报错呢?我们来看一张图:
可以看到,虽然list是个成员变量,但是它同时也是一个对象,而对象的存在于Java虚拟机运行时数据区的堆中,而堆区是线程共享的,所以存在线程安全问题。
现在我们来改一下:
@Slf4j
public class Test1 {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafe threadSafe = new ThreadSafe();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
threadSafe.method1(LOOP_NUMBER);
}, "Thread" + (i + 1)).start();
}
}
}
class ThreadSafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
private void method2(ArrayList<String> list) {
list.add("1");
}
private void method3(ArrayList<String> list) {
list.remove(0);
}
}
可以看到,我们把list对象变成了局部变量,那么现在method2和method3使用过的list对象还是同一个吗?我们执行一下:
现在无论执行多少次,都不会再出现数组越界的异常了。
所以:
1、当list是局部变量时,每个线程调用都会创建不同实例,没有共享。
2、而method2、method3的参数是从method1中传递过来的,与method1中引用的是同一个对象。
现在我们再来看一种情况,就是将method2和method3方法的修饰符由private改为pubilc,这样这两个方法就有可能会被别的类的方法调用到,那么这样会不会有问题呢?
就比如说如果线程一调用method2方法,而线程二调用method3方法,这样会不会有问题?
答案是没问题,因为线程一调用method2方法和线程二调用method3方法,它们传过来的list对象参数会是同一个吗?肯定不是同一个,所以说它们其实还是互不干扰的。
那么这种局部变量的对象引用有没有哪一种情况会有问题吗?其实是有的:
现在,我们为ThreadSafe类添加一个子类,子类覆盖method2或者method3方法:
@Slf4j
public class Test1 {
static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
ThreadSafeSubClass threadSafeSubClass = new ThreadSafeSubClass();
for (int i = 0; i < THREAD_NUMBER; i++) {
new Thread(() -> {
threadSafeSubClass.method1(LOOP_NUMBER);
}, "Thread" + (i + 1)).start();
}
}
}
class ThreadSafe {
public void method1(int loopNumber) {
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < loopNumber; i++) {
method2(list);
method3(list);
}
}
public void method2(ArrayList<String> list) {
list.add("1");
}
public void method3(ArrayList<String> list) {
list.remove(0);
}
}
class ThreadSafeSubClass extends ThreadSafe {
@Override
public void method3(ArrayList<String> list) {
new Thread(() -> {
list.remove(0);
}, "Thread-ThreadSafeSubClass").start();
}
}
可以看到我们的method3被重写了,而且是new了一个线程来执行list的remove操作,那么现在来执行,有没有问题呢?
我们可以看到,或许我们运行到某一次的时候,会出现数组越界。说明这样的方式,是非线程安全的。其实也很容易理解,因为子类集成父类,子类中想如何去重写父类的方法,父类是没法去控制的,如果在子类中new了一个新的线程去改变父类中原本是线程安全的局部变量,那么其实本质上还是两个线程去操作同一个共享资源,所以是非线程安全的。
那么举这个例子,是为了说明什么呢?其实我想说明的是,方法的修饰符是有意义的,在一定程度上可以保证我们线程的安全。例如上面的例子中,如果父类中的method3的修饰符是private,那么子类就没法覆盖这个父类中的method3。
其实这个就是我们常说的用【private】或者【final】来修饰是【安全】的,这里的安全其实就是指的线程安全,因为如果父类中的一个方法是private或者是final的,那么它的子类就没有办法影响父类的行为,所以如果父类中是安全的,那么就是安全的。
五、常见的线程安全类:
好了,前面说了一些类中不是线程安全的情况,其实在Java中,有一些JDK中自带的类,其实是线程安全的,我们使用的时候,就可以不用担心线程安全问题,例如:
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- Java.util.concurrent包下的类
这里说它们是线程安全的是指,多个线程调用它们同一实例的某个方法时,是线程安全的。也可以理解为:
1、它们的每个方法都是原子的
2、但注意它们多个方法的组合不是原子的。
第二点怎么理解?来看一个小栗子:
Hashtable table = new Hashtable();
// 线程1,线程2调用
if (table.get("key") == null) {
table.put("key", value);
}
这段代码是线程安全的吗?
答案显然不是。虽然Hashtable的get方法和put方法都是原子的,但是这两个方法组合在一起,那么就不是原子的了。
这段代码的本意是put一个key的值,但其实现在put是有可能被执行两次的,后执行的put方法会覆盖掉先执行的put方法的值。
不可变类线程安全性:
String、Integer等都是不可变类,因为其内部的状态不可改变,因此他们的方法都是线程安全的。
可能这里有小伙伴会疑惑:String有replace、substring等方法可以改变值啊。那么这些方法又是如何保证线程安全的呢?
我们可以先来看看String类中substring方法的源码:
这方法中前几行都是一些判断,我们直接看最后一行:这一行的意思是如果beginIndex是0,那么就返回this,如果不是0,那么就new一个String对象!然后我们再来看一下这里这个String的构造方法:
我们可以看到,创建新字符串的时候,就会对原有的字符串内容进行一个复制。因此我们可以知道,substring方法根本没有改变对象的属性,而是重新创建了一个对象,因此达到不可变的效果。
所以这种不可变类,都是安全的,将来它们即使被多个线程共享,因为它们不会改变,所以就不会存在线程安全的问题。
好了,这一章节就先写到这里,这一章只是在广义上解释了一些锁和线程安全的概念,后续章节会详细深入去讲解。
最后,欢迎大家扫码关注我的公众号。在公众号上会有更多干货内容等待你来获取。