多线程系列3


一、多线程引入

我们先写一个简单的代码,体会一下单线程 和 多线程之间执行速度的差别。

实现一下对两个变量分别进行自增1000,000,000次

1.1 单线程版本

// 单线程版本
    public static void serial() {
        long start = System.currentTimeMillis();

        long a = 0;
        for (long i = 0; i < 10000_000_000L; i++) {
            a++;
        }

        long b = 0;
        for (long i = 0; i < 10000_000_000L; i++) {
            b++;
        }

        long end = System.currentTimeMillis();
        System.out.println("单线程执行时间:" + (end-start));
    }

执行时间:
在这里插入图片描述

1.2 多线程版本

 public static void concurrency() {

        Thread t1 = new Thread(() -> {
           long a = 0;
            for (long i = 0; i < 10000_000_000L; i++) {
                a++;
            }
        });

        Thread t2 = new Thread(() -> {
            long b = 0;
            for (long i = 0; i < 10000_000_000L; i++) {
                b++;
            }
        });
        
        long start = System.currentTimeMillis();
        
        t1.start();
        t2.start();
        
        long end = System.currentTimeMillis();
        System.out.println("多线程执行时间:" + (end-start));
    }

执行时间:
在这里插入图片描述
由上面 单线程 和 多线程 执行时间结果来看,很明显,多线程执行时间明显缩短(7000 - > 3000),但是两个线程并发处理时间不是单线程时间的一半?

1.首先,使用多线程更快,是因为可以充分利用到多核心 CPU 资源。
2.其次,t1 线程 和 t2 线程不一定在两个核心上运行的,不能保证,它们一定是并发执行的。
3.另一方面,线程调度本身也是有时间消耗的,到底多少次是并发还是并行,不好预估。也取决于当前运行环境。比如,当前运行的程序很多,那么并发的概率就很小,有更多的线程来抢占 CPU 了。

二、线程安全【重点】

线程安全 是大家公认的多线程编程中,最难且最重要的地方,还是一个最容易出错和最爱考的地方。

2.1 啥是线程安全

简单来说,【万恶之源,就是多线程的抢占性执行,带来的随机性】

【单线程】如果没有多线程,此时的代码执行顺序就是固定的(只有一条路),代码顺序固定后,程序的结果就是固定的,此时就不会有线程安全问题。
【多线程】如果有多线程,此时抢占性执行下,代码的执行顺序会出现很多变数,所以就得保证无数种线程调度顺序情况下,代码的执行结果都是正确的。如果其中有一种情况,代码的结果不正确,就都视为有 bug,都是线程不安全。

那么,能够消除这样的随机性?

因为调度的源头来自于操作系统内核的实现,所以
1.改不了
2.即使改了,大家也不买账

2.2 线程安全案例

下面看一下线程安全的小例子。

例子:两个线程分别对 count 变量自增 5w 次

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

        //创建两个 线程,分别对 count这个变量 自增5w次
        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();

        //main线程 等待 t1 和 t2 线程结束
        t1.join();
        t2.join();

        //打印最终的 count的值
        System.out.println("count = " + counter.count);
    }

在这里插入图片描述
问题来了,预期结果是10w,此时程序不符合需求,就是bug,咱们需求就是两个线程各自自增 5w 次,实际结果跟预期不一样,这个就称为 bug。


为啥程序会出现这样呢?

count++; ++操作本质上要分为三步!!!
1.先把内存中的值,读取到 CPU 寄存器中。 load
2.把 CPU 寄存器中的值 +1 操作。 add
3.把得到的结果写回内存中。 save

如果是两个线程并发执行count++, 此时就相当于两组 load、add、save进行执行,此时不同的线程调度顺序,就可能产生一些结果上的差异。
在这里插入图片描述
由于线程之间是随机调度的,所以上述都可能是调度执行的结果。

2.2.1 安全情况

线程 t1 和 t2 分别自增,结果为 2。
在这里插入图片描述

2.2.2 不安全情况

线程 t1 和 t2 分别自增,结果为 1。
在这里插入图片描述
分析原因:
出现的问题关键是这两个 load 操作,t1 线程先 load 没问题,t2 load的是 t1 修改之前的值,导致 t2 后续保存数据的时候就会和 t1 打架。
在这里插入图片描述
由于 CPU 是抢占性执行,执行到任意一个指令的时候,线程可能被调走,CPU 让别的线程来执行。

2.3 线程安全原因

到底是啥样的情况会出现线程安全问题?是所有的多线程代码都会涉及到线程安全问题吗?

2.3.1 根本原因

抢占性执行随机调度。这是我们无法避免和解决的。

2.3.2 代码结构

一个线程修改一个变量,没问题。
多个线程读取一个变量,没问题。
多个线程修改不同变量,没问题。
多个线程同时修改一个变量,有线程安全问题。

因此,可以通过调整代码结构来规避这个问题。调整代码是一个方法,但不是普适性的方法。

2.3.3 原子性

如果修改操作是原子的,那没问题。如果修改操作是非原子的,出现问题的概率就非常高了!!!
原子:不可拆分的基本单位。

比如前面的 count++ 操作,++ 可以拆分为 load、add、save 三步,那么这就不是原子的了。
针对线程安全,最主要的手段就是通过原子性下手,把这个 非原子 的操作变成 原子的。【加锁】

2.3.4 内存可见性

如果针对同一个变量,一个线程去读,一个线程去改,也可能出现问题。这个如何解决后面细说,这里先只涉及线程安全原因。

2.3.5 指令重排序

本质上是,编译器优化出现 bug 了。重排序指的是,单个线程里,顺序发生调整。

编译器优化:编译器觉得你写的代码太 lj 了,就把你的代码自作主张调整了,保持逻辑不变的情况下,进行调整(调整代码的执行顺序),从而加快程序的执行效率。

以上分析的5种典型原因,并不是全部。一个线程安全还是不安全,还得具体问题具体分析

2.4 线程安全解决

从原子性入手,解决线程安全问题!!! 方案就是,加锁,把不是原子的操作变成原子。
在 count++ 操作加锁!!
synchronized关键字 : 加了synchronized,进入方法就加锁,出了方法就是解锁。

class Counter {
    public int count;
    synchronized public void add() {
        count++;
    }
}

在这里插入图片描述
t2 线程的 lock 操作是让 t2 的 load 操作阻塞到 t1 的 unlock 解锁后,才继续往下执行,也就避免了脏读(t1 提交完数据之后,t2 才来读数据)。

加锁,说是保证原子性了,其实不是说让 t1 的 load、add、save 这三步一起完成了,也不是这三步操作中不进行调度,而是想让其他也想操作的线程阻塞等待了。
一旦加锁之后,程序的执行效率一定是大打折扣的。

2.4.1 Synchronized

2.4.1.1 两个都加锁

修饰普通方法,进入方法就加锁,出了方法就解锁。(锁对象是 this)
修饰静态方法,进入方法就加锁,除了方法就解锁。 (锁对象是 类名.class)

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

		//创建两个 线程,分别对 count这个变量 自增5w次
        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();
            }
        });

这里的情况就是,两个线程针对一个方法进行加锁,此时会产生 锁冲突/锁竞争,一个线程能够获取到锁,另外一个线程阻塞等待,一直等到上个线程解锁,它才能获取锁成功。

如果两个线程对不同的对象进行加锁,此时不会发生 锁冲突,这两个线程都能获取到对应的锁,不会出现阻塞等待。

2.4.1.2 一个加锁,一个不加锁
    // add 方法加锁
     public void add() {
         synchronized (this) {
             count++;
         }
    }
    // add2 方法不加锁
    public void add2() {
        count++;
    }
    
    //创建两个 线程,分别对 count这个变量 自增5w次
        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.add2();
            }
        });

这里的情况是两个线程一个加锁,一个不加锁,此时跟没加锁没啥区别。

2.4.1.3 可重入

一个线程针对同一个对象连续加锁两次,是否会出现问题?
如果没出现问题,那么就是可重入的,如果出现问题,就是不可重入的。

2.4 Java标准库线程安全类

如果多个线程操作同一个集合类,就需要考虑到线程安全问题。

2.4.1 线程安全类

2.4.1.1 Vector

这个类已经不推荐使用了。

2.4.1.2 HashTable

这个类也已经不推荐使用了。

2.4.1.3 ConcurrentHashMap
2.4.1.4 StringBuffer

以上这些类已经内置了 synchronized加锁,相对来说,更安全一点。

2.4.2 线程不安全类

2.4.2.1 ArrayList
2.4.2.2 LinkedList
2.4.2.3 HashMap
2.4.2.4 TreeMap
2.4.2.5 HashSet
2.4.2.6 TreeSet
2.4.2.7 StringBuilder

这些类在多线程代码中使用格外注意!! 可能需要自己手动加锁。

问题?既然有些类内置了加锁机制,那为啥所有的类为啥不都加上锁呢?

加锁这个操作是有副作用的,有额外的时间开销。

三、死锁

一旦程序出现死锁,那么就导致线程就挂了(无法继续正常执行后续的任务),程序势必会出现严重的 bug。而死锁又非常隐蔽,开发阶段,不经意间就会写出死锁代码,还不容易测出来。

3.1 死锁三个典型情况

3.1.1 一线程一把锁

一个线程针对一把锁,连续加锁两次,如果锁是不可重入锁,就会死锁。
但是幸运的是,java中的 synchronized 和 ReentrantLock都是可重入锁。这个线程演示不了。

3.1.2 两线程两把锁

两个线程两把锁,线程t1 和 线程t2 各自针对 锁A 和 锁B 加锁,再去尝试获取对方的锁。

 public static void main(String[] args) {
        Object A = new Object();
        Object B = new Object();

		// 线程t1
        Thread t1 = new Thread(() -> {
			// 对 A 对象加锁
            synchronized (A) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
				// 对 B 对象加锁
                synchronized (B) {
                    System.out.println("t1线程 锁A 和 锁B 都获取到了");
                }
            }
        });

		// t2 线程
        Thread t2 = new Thread(() -> {
        	// 对 B 对象加锁
           synchronized (B) {
               try {
                   Thread.sleep(1000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
				// 对 A 对象加锁
               synchronized (A) {
                   System.out.println("t2线程 锁A 和 锁B 都获取到了");
               }
           }
        });

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

        System.out.println("main方法执行了");
    }

在这里插入图片描述
如上面结果,没有打印任何日志信息。
针对这样的死锁问题,就可以借助 jconsole 来定位查看线程的状态和调用栈,就可以分析出代码在哪里死锁了。
在这里插入图片描述

3.1.3 多个线程多把锁

这里有个很典型的 “哲学家就餐问题”
有五个哲学家吃面条,桌子上只有五根筷子,如何才能让每个哲学家都吃到面。
在这里插入图片描述
此时这里的哲学家都有两种情况:
1.思考人生。(相当于线程阻塞了)
2.拿起筷子吃面。(相当于线程获取到锁,然后执行)
由于操作系统的随机调度,这五个哲学家,随时都有可能拿起筷子吃面,也随时会思考人生。要想吃面条,就要拿起左手和右手的筷子。


假设出现了极端情况,同一时刻,所有的哲学家同时拿起左手的筷子,那么所有的哲学家都拿不到右手的筷子,都要等待右边的哲学家把筷子放下。此时也会出现死锁情况。

3.2 死锁四个必要条件

3.2.1 互斥使用

线程1 获取到锁,线程2 就必须等待。(锁的基本特性)

3.2.2 不可抢占性

线程1获取到锁,必须是线程1主动释放锁,线程2才能使用,不能是线程2就把锁给强制性占用了。

3.2.3 请求和保持

线程1 拿到锁A之后,再尝试对锁B加锁,A这把锁还是存在的。(不会因为获取到锁B就把锁A给释放了)

3.2.4 循环等待

线程1 尝试获取锁A 和 锁B,线程2 尝试获取锁B 和 锁A。线程1必须等待线程2释放锁B才能获取,线程2必须等待线程1释放锁A才能获取。

以上虽然说的是四个必要条件,其实是是一个条件。前三个都是锁的基本特性,对于 synchronized 这把锁来说,前三点动不了。

循环等待是唯一和代码结构相关的条件,也是程序员可以控制的。

3.3 避免死锁

如何避免死锁,其实很简单,就是打破条件4,循环等待!!

简单来说,就是,给锁编号,然后指定一个顺序(比如从小到大)来获取锁,任意线程加多把锁的时候,都让线程遵守是上述规则,此时循环等待就自然解除了。【这也是解决死锁最简单的办法】


四、volatile

4.1 内存可见性原因

volatile只能修饰变量,它是和 内存可见性 是相关的。
还是先结合个例子看一下:

两个线程,一个线程去读数据,一个线程去改这个数据。

class Count {
   	public int flag = 0;
}

public class Thread13 {
    public static void main(String[] args) {
        Count count = new Count();
        
        Thread t1 = new Thread(() -> {
            while (count.flag == 0) {
                // 这个循环里啥也不写  
            }
            System.out.println("t1线程结束了");
        });

        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            count.flag = scanner.nextInt();
        });

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

在这里插入图片描述
可以看到,程序运行结果跟预期的不一致。预期是 t2 把 flag 的值改成 非0 ,t1 就结束循环了。
此时通过 jconsole 也可以看到,线程2已经没有了,线程1 还在继续循环。
在这里插入图片描述
出现这种情况就叫做 “内存可见性问题”
在这里插入图片描述
小结:

内存可见性问题,
一个线程对一个变量进行读取操作,同时另一个线程对这个变量进行修改,此时读到的值,不一定是后面修改的值,这个读数据线程没有感知到变量的变化。
主要原因就是:JVM / 编译器 对于多线程代码的优化 产生了误判!!

4.2 解决内存可见性问题

出现上述内存可见性问题,这时候,就需要程序员是手动干预,可以在变量 flag 前面加个 volatile.意思就是告诉编译器,这个变量是 “易变的”,一定要每次重新读取内存中这个变量的值,指不定啥时候就变了,让编译器不能轻易对代码进行优化了。

class Count {
    volatile public int flag = 0;
}

在这里插入图片描述
另外,上述内存可见性是编译器优化的问题,也不是始终会出现的。(编译器可能会出现误判,但是也不是100%出现误判)

 Thread t1 = new Thread(() -> {
            while (count.flag == 0) {
                // 这个循环里啥也不写
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
            System.out.println("t1线程结束了");
        });

这个代码稍微调整了一下,使用 sleep 控制了循环速度之后,即使不加 volatile,代码也正确了。

总之,编译器的优化,很多时候是个 “玄学问题”,应用程序这个角度是无法感知的,因此稳妥的做法就是,把该加的 volatile 都给加上。

下面是一些扩展资料:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

五、wait 和 notify

5.1 使用wait、notify 原因

线程最大的问题是,抢占性执行,随机调度。咱们写代码不喜欢随机的东西,喜欢确定的。
然后程序员就发明了一些办法,来控制线程之间的执行顺序,虽然线程在操作系统内核调度是随机的,但是可以通过一些 api 让线程阻塞,主动放弃 CPU。

比如,有两个线程 t1 和 t2,希望 t1 干活干的差不多的时候,让 t2 开始来干,就可以让 t2 先 wait(阻塞,主动放弃 CPU),等 t1 干的差不多的时候,再通过 notify 通知 t2,把 t2 唤醒,让 t2 接着干。

那么,上述场景,使用 join 和 sleep,行不行呢?

使用 join : 必须等待 t1 全部执行完了,t2 才能运行。如果是希望 t1 先干 50%,再由 t2 来干 50%,那么 join 做不到。
使用 sleep : 指定一个休眠时间,那么 t2 到底要休眠多长时间,不好估计。

因此,使用 wait 和 notify 的组合,能更好的处理上述的问题。

5.2 如何使用

wait、notify、notifyAll 这几个类都是 Object 类的方法,因此,java 中任何类的对象都有这个方法。
根据上述的场景,编写一个简单的程序:

    public static void main(String[] args) {
        Object obj = new Object();

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

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

在这里插入图片描述
可以看到,程序出错了,出错的原因是【非法的锁状态异常】。


那么,为啥会有这个异常?
要想搞清楚这个原因,就要理解 wait 操作是干啥的?

1.先释放锁。
2.进行阻塞等待。
3.收到通知后,重新获取锁,并且在获取锁之后,继续往下执行。

所以,出现这个异常的原因就是,wait 再进行等待前必须释放锁,然而上述操作,wait 之前并没有加锁,谈何释放锁呢!!

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

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

            System.out.println("t1 wait之后");
        });

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

        t1.start();
        // 这里的 sleep 是保证 t1 线程能够先 wait
        Thread.sleep(100);
        t2.start();
    }

此处的 wait 要和 notify 匹配,并且 wait 使用的对象必须和 notify 使用的对象一致。

注意!!!
这里的 t1.start 和 t2.start ,由于 线程调度的不确定性,此时不能保证先执行 wait 还是 先执行 notify,如果先调用 notify,此时没有线程 wait,此处的 wait 是无法被唤醒的。这种通知就是无效通知,但是也不会有啥副作用!!!
在这里插入图片描述
上述使用的 wait 无参版本,只要 t2 不进行notify,t1 就会一致 wait 死等下去。


除了无参数版本,还有一个有参版本。wait(执行等待的时间)。
wait带参数版本看起来和sleep差不多,其实还是有本质差别的。

虽然都能指定等待时间,也能提前唤醒,wait 使用 notify 唤醒,sleep 使用 interrupt 唤醒。但是表示的含义截然不同。
notify 唤醒 wait 是不会有任何异常的。(正常的业务逻辑)
interrupt 唤醒 sleep,是会触发异常的。(表示一个出了问题的逻辑)


这里还有个小问题:
假如此时这里有多个线程在 wait,有一个 线程 notify,此时是随机唤醒一个正在等待的线程。
这里,我们可针对对个 wait,使用多个对象。

举个例子,有三个线程,希望先执行线程1,再执行线程2,再执行线程3。
方案:
1.先创建 obj1,供线程1 和 2 使用。
2.创建 obj2,供线程2 和 3 使用。
3.线程3 执行 obj2.wait()。
4.线程2执行 obj1.wait(),然后执行 obj2.notify(),唤醒线程3。
5.线程1 先执行完自己任务,执行完了再调用 obj1.notify(),唤醒线程2。

public static void main(String[] args) throws InterruptedException {
        Object obj1 = new Object();
        Object obj2 = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("线程1执行");
            synchronized (obj1) {
                obj1.notify();
            }
        });

        Thread t2 = new Thread(() -> {
            try {
                synchronized (obj1) {
                    obj1.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程2执行");
            synchronized (obj2) {
                obj2.notify();
            }
        });

        Thread t3 = new Thread(() -> {
            try {
                synchronized (obj2) {
                    obj2.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程3执行");
        });

		// 这里先要保证 t2 和 t3 的 wait 先执行
        t2.start();
        t3.start();
        Thread.sleep(1000);
        t1.start();
    }

5.3 notifyAll

多个线程 wait 的时候,notify 随机唤醒一个,notifyAll 是唤醒所有的线程,这些线程一起重新去竞争锁。

总结

多线程系列3讲解到这就结束了,小伙伴们有觉得小编写的不错的,或者对你有帮助的,希望能点个关注,小编也会再接再厉。谢谢大家的支持!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值