• 【JavaEE初阶 -- 多线程2】


    1.线程安全

    1.1 什么是线程安全

    某个代码在单线程下执行没有任何问题,在多线程下执行出现bug

    1.2 原子性

    把代码比如一个房间,每个线程就是要进入这个房间的人。如果没有任何保护机制,A进入之后,进行一些列隐私操作,然后B也可以进入房间,从而打断A,这样就是不具备原子性的。把线程A进入房间的一系列操作进行打包成一个整体进行上锁,其他线程就进不来,这样就保证了代码的原子性

    1.3 线程不安全的原因

    • 根本原因:操作系统上的线程是随即调度、抢占式执行的,线程之间执行的顺序带来了很多变数
    • 代码结构:代码中多个线程,同时修改一个变量(一个线程修改,一个线程读也可能存在问题)
    • 直接原因:多线程的修改操作下,本身不是原子的一条Java语句不一定是原子的,也不一定只是一条指令,比如 i++,有三步操作:1.从内存把数据读到CPU;2.进行数据更新;3.把数据写回到CPU;
    • 多个CPU指令,一个线程执行这些指令,执行到一半被调度走,从而其他线程可能会被调度;每个CPU指令,都是原子的,要么执行完,要么不执行
    • 内存可见性问题
    • 指令重排序问题

    1.4 通过synchronized进行加锁解决线程安全问题

    synchronized关键字,随便放Object对象都行,两个线程之间是否使用的是同一个对象,是同一个会产生竞争,反则是不会。进入代码块加锁,出代码块是解锁。synchronized 修饰普通方法,相当于给this加锁(锁对象是this),修饰静态方法,相当于给类对象加锁

    public class ThreadDemo19 {
    
        private static int count = 0;
        private static int count2 = 0;
        public static void main(String[] args) throws InterruptedException {
            // 随便创建个对象
            Object locker = new Object(); //两个线程之间是否使用的是同一个对象,是同一个会产生竞争,反则是不会
            Object locekr2 = new Object();
            // 创建两个线程,每个线程都针对上述count变量循环5w次 循环自增的代码存在线程安全问题
            int tmp = 0;
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    /*// 1)一下线程修改一个变量,没有影响
                    count++;*/
    
                    // 加锁  synchronized
                    synchronized(locker) { //进入大括号 就会加锁
                        count++;
                    } // 出大括号 就会解锁
    
                    // 2)多个线程读取同一个变量,不会影响
                    //System.out.println(count); //只是读取变量,变量的内容是固定不变的
                }
            });
    
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    //3)多个线程修改不同的变量,没有影响
                    //count2++;
                    //System.out.println(count);
    
                    //两个线程针对不同对象加锁,存在不了锁竞争,就会出现线程安全问题
                    /*synchronized (locekr2) {
                        count++;
                    }*/
                    synchronized (locker) {
                        count++;
                    }
                }
            });
            t1.start();
            t2.start();
    
            t1.join();
            t2.join();
    
            //打印count的结果
    
            System.out.println("count = "+ count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    synchronized 加锁的效果也称为 互斥性

    class Test {
        public int count;
        // synchronized 是加到static方法上,就等价于给 类对象加锁
        /*synchronized public static void func() {
    
        }
    
        public static void func() { // 使用较少
            synchronized (Test.class) {
    
            }
        }*/
        // 等同于下面 synchronized (this)的代码
        /*synchronized public void add() {
            count++;
        }*/
        public void add() {
            /*synchronized (this) {
                count++;
            }*/
    
            // 获取Test类的对象 (在一个java进程中,一个类的类对象都是只有一个)
            // 所以在这里第一线程和第二个线程拿到的类对象是同一个类对象,因此锁竞争仍然存在能保证线程安全
            synchronized (Test.class) {
                count++;
            }
        }
    
    }
    public class ThreadDemo20 {
        public static void main(String[] args) throws InterruptedException {
            Test test = new Test();
            Thread thread1 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    test.add();
                }
            });
            Thread thread2 = new Thread(() -> {
                for (int i = 0; i < 50000; i++) {
                    test.add();
                }
            });
    
            thread1.start();
            thread2.start();
    
            thread1.join();
            thread2.join();
    
            System.out.println("count = "+test.count);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52

    1.5 可重入锁

    可重入锁:一共就只有一把锁,同一个线程,此时锁对象就知道第二次加锁的线程就是持有锁的线程,第二次进行加锁的发现加锁线程和持有锁线程是同一个线程,即能加锁
    判定当前加锁线程是否是加锁的线程,如果不是同一个线程,阻塞;如果是同一个线程,++计数器。

    public class ThreadDemo21 {
        public static void main(String[] args) {
            Object locker = new Object();
            Thread t = new Thread(() -> {
                synchronized (locker) { //可重入锁:在最外层的{进行加锁 真正加锁,同时把计数器+1(初始是0,+1
                    //之后就变成了1,说明当前这个对象被该线程加锁一次 )同时记录线程是谁
    
                    //再加一个锁,当前由于是同一个线程,此时锁对象就知道了第二次加锁的线程,就是持有锁的线程
                    // 第二次操作,就可以直接放行通过,不会出现阻塞   ——》这个特性 称为“可重入”
                    synchronized (locker) { //第二次加锁的时候,发现加锁线程和持有锁线程是同一个线程,即能加锁
                        //成功,++计数器,如果不是同一个线程,阻塞
                        System.out.println("hello");
                    } // 把计数器-1,2-1=》1,不为0.不会真的解锁
                } // 在最外层的}进行解锁  1-1=0》 进行解锁
            });
            t.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    1.6 死锁

    加锁是解决线程安全问题,但是加锁方式不当就可能产生死锁。
    死锁三种典型场景:

    1. 一个线程,一把锁,如果锁不是可重入锁,并且是一个线程对这把锁加锁两次就会出现死锁。(钥匙锁屋里了)
    2. 两个线程,两把锁,线程1获取到 锁A,线程2 获取到锁B,然后线程1 尝试获取B,线程2 尝试获取 锁A,就会出现死锁。(钥匙锁车里,车钥匙锁房间里),解决方案:约定加锁顺序,先对A进行加锁,再对B进行加锁)
    public class ThreadDemo22 {
        public static void main(String[] args) {
            Object A = new Object();
            Object B = new Object();
            Thread t1 = new Thread(() -> {
                synchronized (A) {
                    //sleep一下,给t2时间,让t2也能拿到B
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //尝试获取B,并没有释放A
                    synchronized (B) {
                        System.out.println("t1拿到了两个线程");
                    }
                }
            });
            /*Thread t2 = new Thread(() -> {
                synchronized (B) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //尝试获取A,并没有释放B
                    synchronized (A) {
                        System.out.println("t2拿到了两个线程");
                    }
                }
            });*/
            Thread t2 = new Thread(() -> {
                synchronized (A) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 解决方案:先对A进行加锁,再对B进行加锁
                    synchronized (B) {
                        System.out.println("t2拿到了两个线程");
                    }
                }
            });
            t1.start();
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    1. N个线程,M把锁(哲学家就餐问题),解决方案:指定加锁顺序,针对五把锁,都进行编号,约定每个线程获取锁的时候,一定要先获取编号小的锁,后获取编号大的锁。

    产生死锁的四个必要条件

    1. 互斥使用:获取锁的过程是互斥的,一个线程拿到了这把锁,另一个线程也想获取,就需要阻塞 等待(锁最基本的特性,不太好破坏)
    2. 不可抢占:一个线程拿到了这把锁,只能主动解锁,不能让别的线程强行把锁抢走。(也是锁最基本的特性)
    3. 请求保持:一个线程拿到了锁A之后,在持有锁A的前提下,尝试获取锁B。(根据代码结构看实际情况可破坏)
    4. 循环等待/环路等待 (代码结构最容易破坏的
      解决死锁问题,核心思路就是破坏上述的必要条件,破坏其一,就能解决死锁问题。只要指定一定的规则,就可以有效的避免循环等待

    1.7 Java标准库中的线程安全类

    Java标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施

    • ArrayList
    • LinkedList
    • HashMap
    • TreeMap
    • HashSet
    • TreeSet
    • StringBuilder

    还有一些是线程安全的,使用了一些锁机制来控制:

    • Vector(不推荐使用)
    • HashTable(不推荐使用)
    • ConcurrentHashMap
    • StringBuffer
      没加锁,不涉及修改,仍然是线程安全的:
    • String

    1.8 通过volatile关键字解决内存可见性引起的线程安全问题

    • volatile能够保证内存可见性;另一个功能:禁止指令重排序
    • 什么叫内存可见性:就是高度依赖编译器的优化的具体实现
    • 如果一个线程写,一个线程读也可能存在线程安全问题

    设计一个预期通过 t2 线程输入的整数,只要输入的不为0,就可以使t1线程结束。

    • 下面代码中 t2修改了内存,但是 t1没有看到这个内存的变化,即内存可见性问题。
    • 在这里的内存可见性问题:
      在多线程下,编译器对代码的优化出现了误判,本来编译器期望 把读内存操作给优化成读寄存器中缓存的值,这样优化有助于我们提高循环的执行效率,并且编译器发现没有谁来修改flag,从而进行了错误的判断,在后续通过scanner用户输入来修改flag,导致这边 t2 线程修改了,而上面 t1 线程判断flag是否为0这边没有生效,因此出现了这边不能让t1结束的bug

    原理
    解决方案:给判断的变量 添加volatile关键字,在写入的时候

    • 改变线程工作中内存volatile变量副本的值
    • 将改变后的副本的值从工作内存刷新到主存中

    在读取volatile修饰的变量的时候

    • 从内存中读取volatile变量的最新值到线程的工作内存中
    • 从工作内存中读取volatile变量的副本
    import java.util.Scanner;
    public class ThreadDemo23 {
        // t2修改了内存,但是t1没有看到这个内存的变化,就称为 内存可见性 问题
    
        //volatile关键字 核心功能:保证内存可见性; 另一个功能:禁止指令重排序
        private volatile static int flag = 0;
        public static void main(String[] args) {
            // 预期通过t2线程输入的整数,只要输入的不为0,就可以使t1线程结束
            Thread t1 = new Thread(() -> {
               while (flag == 0) {
                   // 循环体里,没有内容
                   /*try {
                       // 不加sleep,一秒循环上百亿次,load操作的整体开销非常大,优化的迫切程度就更高
                       // 加了之后,一秒循环1000次,load整体开销就没这么大,优化的迫切程度就降低了
                       Thread.sleep(1);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }*/
               }
                System.out.println("t1 线程结束");
            });
            Thread t2 = new Thread(() -> {
                System.out.println("请输入flag的值");
                Scanner scanner = new Scanner(System.in);
                flag = scanner.nextInt();
            });
    
            t1.start();
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    volatile和synchronized区别

    • volatile保证的是内存可见性
    • synchronized保证的是原子性,也可以保证内存可见性

    会出现线程安全:

    // 会出现线程安全,无法保证最后结果是 100000
    static class Counter {
    	volatile public int count = 0;
    		void increase() {
    		count++;
    		}
    	}
    	public static void main(String[] args) throws InterruptedException {
    	final Counter counter = new Counter();
    	Thread t1 = new Thread(() -> {
    		for (int i = 0; i < 50000; i++) {
    		counter.increase();
    		}
    	});
    	Thread t2 = new Thread(() -> {
    		for (int i = 0; i < 50000; i++) {
    		counter.increase();
    		}
    	});
    	t1.start();
    	t2.start();
    	t1.join();
    	t2.join();
    	System.out.println(counter.count);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    使用synchronized,加锁,去掉volatile,给t1的循环内部加锁,并借助counter对象加锁:

    static class Counter {
    	public int flag = 0;
    }
    
    public static void main(String[] args) {
    	Counter counter = new Counter();
    	Thread t1 = new Thread(() -> {
    		while (true) {
    			synchronized (counter) {
    				if (counter.flag != 0) {
    					break;
    				}
    			}
    			// do nothing
    		}
    		System.out.println("循环结束!");
    	});
    	Thread t2 = new Thread(() -> {
    		Scanner scanner = new Scanner(System.in);
    		System.out.println("输入一个整数:");
    		counter.flag = scanner.nextInt();
    	});
    	t1.start();
    	t2.start();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    2. wait 和notify

    由于线程之间是抢占式执行的,因此线程之间执行的先后顺序是随机的。

    • wait() / wait(long timeout): 让当前线程进入等待状态.
    • notify() / notifyAll(): 唤醒在当前对象上等待的线程.
    • 这些都是Object方法

    2.1 wait() --使当前线程进行等待,释放当前锁;满足一定条件时被唤醒,重新尝试获取这个锁

    wait做的事情:

    • 使当前执行代码的线程进行阻塞等待,(把线程放到等待队列中)
    • 释放当前锁
    • 满足一定条件时被唤醒,重新尝试获取这个锁

    wait() 要搭配synchronized来使用,脱离synchronized使用wait会直接抛出异常。即要先有锁,才能调用wait且对象必须是同一对象
    wait结束等待的条件:

    • 其他线程调用该对象的notify方法
    • wait等待时间超时( wait(long timeout),来指定等待时间)
    • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

    wait 被唤醒后也要重新参与锁竞争

    public class ThreadDemo24 {
        public static void main(String[] args) throws InterruptedException {
            Object object = new Object();
            synchronized (object) {
                System.out.println("wait 之前");
                object.wait();
                System.out.println("wait 之后");
    
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    2.2 notify()–是唤醒等待的线程

    使用的哪个对象就是唤醒哪个对象的wait,如果两个wait是同一个对象调用的,随机唤醒其中一个,而notifyAll 唤醒这个对象所有等待的线程

    下面代码执行的过程:

    1. t1 执行起来之后,就会立即拿到锁,并且打印t1 wait之前,进入wait方法(释放锁+阻塞等待)
    2. t2 执行起来之后,先进行sleep(这个sleep作用是让 t1 能够先拿到锁)
    3. t2 sleep结束之后,由于t1是wait状态,锁是释放的,t2 就能拿到锁,接下来打印 t2 notify之前,执行notify操作,即唤醒t1,(此时t1就从WAITING状态恢复回来)
    4. 但是由于t2 此时还没有释放锁,t1 WAITING恢复之后,尝试获取锁,就可能会出现一个小小的阻塞,这个阻塞时由于锁竞争引起的。
    5. t2 执行完t2 notify之后,释放锁,t2 执行完毕,t1 的wait就可以获取锁,继续执行打印t1 wait之后。
    public class ThreadDemo25 {
        public static void main(String[] args) {
            Object locker = new Object();
    
            Thread t1 = new Thread(() -> {
               synchronized (locker) {
                   System.out.println("t1 wait之前");
                   try {
                       // 释放锁,阻塞等待
                       locker.wait();  // 死等
                       //locker.wait(100);  带有超时的等待,ms 如果这个时间内没有进行notify,就不等
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
                   System.out.println("t1 wait之后");
               }
            });
    
            Thread t2 = new Thread(() -> {
                try {
                    // 如果sleep写到synchronized外面的话,由于t1和t2执行顺序不确定,就可能t2先拿到锁,
                    // t1 没执行到 wait  t2就先notify
                    Thread.sleep(5000); // 让t1先拿到锁
    
                    // 由于locker.wait(),锁是释放的,t2就能拿到锁
                    synchronized (locker) {
                        System.out.println("t2 notify 之前");
                        // 唤醒t1,t1从WAITING 状态恢复过来
                        // 由于t2此时还没有释放锁,t1恢复之后尝试获取锁,就可能出现锁竞争从而导致阻塞
                        locker.notify();
                        System.out.println("t2 notify 之后");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
    
            t1.start();
            t2.start();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41

    2.3 wait 和sleep直接的区别:

    • wait提供了一个带有超时的版本,sleep也是能指定时间,都是时间到就继续执行,解除阻塞。
    • wait 和sleep 都可以被提前唤醒,wait通过notify,sleep通过interrupt唤醒
    • 使用wait 在不知道要等多久的前提下使用,所谓超时时间,即兜底的
    • 而使用sleep,要知道具体等多久,能提前唤醒,但是是异常唤醒,=。
  • 相关阅读:
    MySQL学习笔记(一 mysql简介)
    C语言 ini 文件读写【Iniparser库】
    Linux 使用gcc编译一个helloworld程序
    Java最全面试攻略,吃透25个技术栈Offer拿到手软
    数字逻辑·时序线路设计【原始状态表】
    关于spark配置项 和 hive serDe 和 spark serDe
    Spring中Bean循环依赖详解
    UWB NI框架嵌入式实现——Qorvo示例
    SpringBoot事务失效场景、事务正确使用姿势
    下划线命名转驼峰
  • 原文地址:https://blog.csdn.net/m0_63440113/article/details/136677178