线程安全-搞清synchronized的真面目

       多线程编程中,最难的地方,也是最重要的一个地方,还是一个最容易出错的地方,更是一个特别爱考的地方,就是线程安全问题

万恶之源,罪魁祸首,多线程的抢占式执行,带来的随机性.
如果没有多线程,此时程序代码执行顺序就是固定的.(只有一条路)﹒代码顺序固定,程序的结果就是固定的.[单线程的情况下,只需要理清楚这一条路即可)
如果有了多线程,此时抢占式执行下,代码执行的顺序,会出现更多的变数。代码执行顺序的可能性就从一种情况变成了无数种情况。
所以就需要保证这无数种线程调度顺序的情况下,代码的执行结果都是正确的。
只要有一种情况下,代码结果不正确,就都视为是有bug,线程不安全。

目录

线程安全

原因

synchronized

synchronized使用方法

1.修饰方法

2.修饰代码块             

3.可重入

4.其他的锁

5.Java标准库中的线程安全类

死锁

死锁的三种典型情况

1.一个线程一把锁

2.两个线程两把锁

3.多个线程多把锁

 死锁的四个必要条件

1.互斥使用

2.不可抢占

3.请求和保持

4.循环等待

如何避免死锁

内存可见性

​编辑

volatile

wait notify


线程安全

class Counter{
    public int count = 0;

    public void add(){
        count++;
    }
}

public class demo1 {
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread(() -> {
            for(int i = 0 ; i < 50000 ; i++){
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0 ; i < 50000 ; i++){
                counter.add();
            }
        });

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("coount=" + counter.count);
    }
}

count=59005

进程已结束,退出代码0


coount=75148

进程已结束,退出代码0


count=67437

进程已结束,退出代码0

我们先来看到这样一个代码:

两个线程各自自增5w次,一共自增10w次,预期结果count是10w,但是实际结果并不是10w,而且每一次都不一样,这个就称为bug。

为什么会出现这样的情况?

count++;

对于count++这个操作本质上要分为三步:

1.把内存中的值,读取到CPU的寄存器中去  load

2.把CPU寄存器里的数值进行+1运算            add

3.把得到的结果写到内存中去                        save

如果是两个线程并发的执行count++,此时就相当于两组load,add,save进行执行,此时不同的线程调度顺序就可能会产生一些结果上的差异~

线程安全-搞清synchronized的真面目_第1张图片

 但是那么多种情况,只有这种情况才是我们所需的正确的情况(t1 t2可以交换)线程安全-搞清synchronized的真面目_第2张图片

 下面这种情况就是一个不正确的,类似于事务中的读到了一个脏数据。t1读到的是一个t2还没来得及提交的脏数据,于是就出现了脏读问题~

线程安全-搞清synchronized的真面目_第3张图片

此处讲的多线程,和前面的并发事务,本质上都是“并发编程”问题,并发处理事务,底层也是基于多线程这样的方式来实现的 。

一个线程是完成一个任务,要做一些工作,你这个工作是可以分解成一个一个的小步骤的,每一个小步骤就是一个指令。由于线程的抢占式执行,导致当前执行到任意一个指令的时候,线程都可能被调度走CPU让别的线程来执行。

当前这个代码,是否可能结果正好是10w呢?是有可能的,只是概率非常小,假设两个线程的每次调度顺序都是先t1再t2或者先t2再t1,那么还是有可能的~

同时也有可能最后的结果小于5w,可能t1先加载,t2连续执行三次,最后的结果count只加1。

原因

到底是什么样的情况会出现线程安全问题?

1.[根本原因] 抢占式执行,随机调度

2.代码结构:多个线程同时修改一个变量(注意,这里说的是修改,也就是写)

        一个线程修改一个变量,没事

        多个线程读取一个变量,没事

        多个线程修改多个不同的变量,也没事

3.原子性:如果修改操作是原子的,那么不会有事

   但是如果是非原子的,出现问题的概率就非常高了

count++可以拆分成 load add save 三个操作

我们需要通过操作把这个非原子的操作变成原子的:加锁

4.内存可见性问题

5.指令重排序(本质上是编译器优化出bug了)

以上分析出的是五个典型的原因,不是全部

一个代码究竟是线程安全还是不安全,都得具体问题 具体分析

如果一个代码踩中了上面的原因,也可能线程安全
如果一个代码没踩中上面的原因,也可能线程不安全.......

结合原因,结合需求,具体问题具体分析.
最终抓住的原则:多线程运行代码,不出bug,就是安全的!!!

如何从原子性入手,来解决线程安全问题呢?

synchronized

这是一个关键字,表示加锁

线程安全-搞清synchronized的真面目_第4张图片

 加了synchronized之后,进入方法就会加锁,出了方法就会解锁

如果两个线程同时尝试加锁,此时一个能获取成功,另一个只能阻塞等待(BLOCKED),一直阻塞到刚才的线程释放锁(解锁),当前线程才能加锁成功。

引出之前介绍的线程的几种状态之一:BLOCKED 等待另一个线程解锁的状态

线程安全-搞清synchronized的真面目_第5张图片

加锁之后,代码执行速度一定是大打折扣的,但是仍然是比单线程要快。

刚刚的例子中,加锁只是针对了count++加锁了,但是除了count++之外,还有for循环的代码,for循环是可以并行的,只是count++串行了。一个任务中,一部分并发,一部分串行,仍然是比所有的代码串行要快~

synchronized使用方法

1.修饰方法

1)修饰普通方法        修饰普通方法,锁对象就是this

2)修饰静态方法        修饰静态方法,锁对象就是类对象(Counter.class)

2.修饰代码块             

修饰代码块,显示\手动指定锁对象

所以加锁是要明确执行对哪个对象加锁的

如果两个线程针对同一个对象加锁,会产生阻塞等待(锁竞争、锁冲突)

如果两个线程针对同一个对象进行加锁,就会出现锁竞争/锁冲突,一个线程能够获取到锁(先到先得)另一个线程阻塞等待,等待到上一个线程解锁,它才能获取锁成功~~否则就不会
如果两个线程针对不同对象加锁,此时不会发生锁竞争/锁冲突.这俩线程都能获取到各自的锁.不会有阻塞等待了.
还是两个线程,一个线程加锁,一个线程不加锁这个时候是否有锁竞争呢??没有的!!!

eg1:

public synchronized void add(){
    count++;
}这里直接把synchronized修饰到方法上了,此时相当于针对this加锁

eg2:线程安全-搞清synchronized的真面目_第6张图片

 eg3:

public void add(){
    synchronized(this){
        count++;
    }
}
进入代码块就解锁
出了代码块就解锁

这里的this可以指定任意你想指定的对象(不一定非要是this)

3.可重入

synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题

一个线程针对同一个对象,连续加锁两次,是否会有问题~~如果没问题,就叫可重入的。如果有问题,就叫不可重入的。

synchronized public void add(){
    synchronized(this){
        count++;
    }
}

 在这个代码块中,锁对象是this,只要有线程调用add,进入add方法的时候,就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁。

站在this(锁对象)的视角,它认为自己已经被线程占用了,这里的第二次加锁要不要阻塞等待呢?

这里的第二个线程和第一个线程,其实是同一个线程

在是相同线程的前提下如果允许第二个锁不用阻塞等待,那么就说这个锁是可重入的

反之(第二次加锁会阻塞等待),就说是不可重入的

(就是在锁对象里面记录一下,当前的锁是哪个线程持有的,如果加锁线程和持有线程是同一个,就直接放过,否则就阻塞)

因为Java代码中很容易出现死锁,所以Java就把synchronized设定成可重入的了

4.其他的锁

除了Java的synchronized之外,很多别的语言别的库,加锁解锁往往是两个分开的操作,比如:加锁lock(),解锁unlock(),但是这样分开写容易忘记写unlock

所以synchronized基于代码块的方式,就有效的解决了上述问题

5.Java标准库中的线程安全类

线程安全-搞清synchronized的真面目_第7张图片

死锁

       死锁是一个非常影响程序员幸福感的问题,一但程序出现死锁,就会导致无法执行后续工作,程序就会有严重bug。并且死锁是非常隐蔽的,开发阶段不经意间就会写出死锁代码,不容易测试出来。

死锁的三种典型情况

1.一个线程一把锁

连续加锁两次

如果锁是不可重入锁,就会死锁。

Java中synchronized和ReentrantLock都是可重入锁,C++,Python,操作系统原生的加锁API都是不可重入的,就会在这种情况下出现死锁。

2.两个线程两把锁

t1,t2各自先针对锁A,锁B加锁,再尝试获取对方的锁

(在这段代码中要加入sleep,否则会出现线程执行速度差别较大从而能够获取到对方的锁)

线程安全-搞清synchronized的真面目_第8张图片

线程安全-搞清synchronized的真面目_第9张图片

locker1和locker2分别加锁,再申请对方的锁,这样就会进入死锁,结果什么也没有,于是我们可以运用jconsole来看一下线程的情况:

线程安全-搞清synchronized的真面目_第10张图片

可以很清楚的看到,两个线程都进入了BLOCKED状态,表示获取锁,获取不到的阻塞状态。

针对这样的死锁问题,也是需要借助像jconsole这样的工具来进行定位的。看线程的状态和调用栈,就可以分析出代码是在哪里死锁了。

3.多个线程多把锁

线程安全-搞清synchronized的真面目_第11张图片

 死锁的四个必要条件

1.互斥使用

线程1拿到了锁,线程2就得等着(锁的基本特性)

2.不可抢占

线程1拿到锁之后,必须是线程1主动释放,不能说是线程2就把锁给强行获取到。

3.请求和保持

线程1拿到锁A之后,再次尝试获取锁B,A这把锁没有释放,就仍然是保持的。

4.循环等待

线程1尝试获取到锁A和锁B;线程2尝试获取到锁B和锁A。

线程1在获取B的时候等待线程2释放B;同时线程2在获取A的时候等待线程1释放A。

只有这四个条件同时具备,才出现死锁。

循环等待是这四个条件里唯一一个和代码结构相关的,也是我们可以控制的。

如何避免死锁

避免死锁,突破点就是循环等待

方法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候,都让线程遵守上述顺序,此时循环等待自然破除。

内存可见性

class Mycounter{
    int flag = 0;
}
public class demo1 {
    public static void main(String[] args) {
        Mycounter mycounter = new Mycounter();

        Thread t1 = new Thread(() -> {
            while(mycounter.flag == 0){            t1这里要快速重复的读取flag的值

            }
            System.out.println("循环结束");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入值");
            mycounter.flag = scanner.nextInt();
        });
    }
}

线程2修改了flag的值,理论上线程1应该会打印循环结束,但是实际上并不会。当输入1的时候,这个线程并不会结束循环。

线程安全-搞清synchronized的真面目_第12张图片

这个问题就叫做:内存可见性问题

这是一个bug,也是一个线程安全问题

while(mycounter.flag == 0)

这里用汇编来理解,就是两步操作:

1.load,把内存中flag的值,读取到寄存器中

2.cmp,把寄存器的值,和0进行比较,根据比较结果再进行下一步的执行

上述是一个循环,这个循环执行速度极快,一秒钟执行百万次以上。

循环执行这么多次,在t2真正修改之前,load得到的结果都是一样的,另一方面,load操作和cmp操作相比,执行速度慢非常非常多~

由于load执行的速度太慢(相比于cmp来说),再加上反复的load到的结果都一样,JVM就做出了一个大胆的决定:不再真正的重复load,判定好像flag的值不会被修改,干脆就只读取一次就好了。

因为CPU针对寄存器的操作,要比内存快很多!于是通过编译器优化,从而导致了这样的结果。

内存可见性问题:

一个线程对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值,不一定是修改过后的值。

线程安全-搞清synchronized的真面目_第13张图片

volatile

这时候就需要我们手动干预,需要用到的关键字是violatile。

volatile关键字的作用主要有如下两个:
1. 线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
2. 顺序一致性:禁止指令重排序。

同时volatile只能修饰变量,synchronized可以修饰方法,静态方法,代码块。

这就相当于告诉编译器,这个变量是易变的,你要每次都重新读取这个变量的内容。

一个变量在两个线程中,一个读,一个写就需要考虑violatile了。

线程安全-搞清synchronized的真面目_第14张图片

wait notify

现在有一个场景:t1 t2俩线程,希望t1先干活,干的差不多了,再让t2来干。就可以让t2先wait (阻塞,主动放弃cpu)等t1干的差不多了,再通过notify通知t2,把t2唤醒,让t2接着干。

是不是这个场景和join有点类似,也是让其中一个线程等待另一个线程。但是如果我们想先让t1执行50%,再执行t2,join就做不到了。

这个时候就需要用到wait和notify

当t1执行到50%时,手动让其wait,让其进入WAITING状态,然后等待t2执行完毕再执行t1,仅需要用notify唤醒就行了。

线程安全-搞清synchronized的真面目_第15张图片

 但是报错了线程安全-搞清synchronized的真面目_第16张图片

 为什么会有这个异常?先来了解一下wait的操作:

1.先释放锁

2.进行阻塞等待

3.收到通知之后,重新尝试获取锁,并且在获取锁后,继续往下执行。

因此wait操作要搭配synchronized来使用

public class demo2 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();

        System.out.println("t1 wait之前");
        Thread t1 = new Thread(() -> {
            synchronized (object){
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("t1 wait之后");
        });

        Thread t2 = new Thread(() -> {
            System.out.println("t2 notif之前");
            synchronized (object){
                object.notify();
            }
            System.out.println("t2 notif之后");
        });
        t1.start();
        t2.start();
    }
}

同时要注意,只有object四次引用的对象是同一个对象,那么这里的结果才是我们想要的。

线程安全-搞清synchronized的真面目_第17张图片

wait的带有等待时间的版本,看起来就和sleep有点像,其实还是有本质差别的
虽然都是能指定等待时间,虽然也都能被提前唤醒(wait是使用notify唤醒, sleep使用interrupt唤醒)但是这里表示的含义截然不同。
notify唤醒wait,这是不会有任何异常的。(正常的业务逻辑)interrupt唤醒sleep 则是出异常了。(表示一个出问题了的逻辑)

你可能感兴趣的:(java-ee)