0
点赞
收藏
分享

微信扫一扫

基于spingboot的websocket订阅、广播、多人聊天室示例

灯火南山 2023-09-13 阅读 31

目录

1. 线程安全

1.1 线程不安全的原因

1)修改共享数据

2)原子性

2)可见性

2. synchronized 关键字-监视器锁 monitor lock

2.1 synchronized 的特性

1)互斥

2)可重入

2.2 使用 synchronized 解决上面的线程不安全问题

2.3 synchronized使用

2.4 什么情况会发生阻塞等待

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

4. volatile关键字

5. wait 和 notify

5.1 wait() 方法

5.2 notify()方法

5.3  notifyAll()方法

5.4 wait 和 sleep 的对比(面试题)


1. 线程安全

线程安全(Thread Safety)是指在多线程环境中,当多个线程同时访问共享的数据或资源时,不会出现不可预期的错误或不一致的结果。线程安全的目标是确保多线程并发操作不会导致数据损坏、程序崩溃或不正确的计算结果。

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

可以通过代码来看一下线程不安全的情况是什么样子的:

public class Demo1 {

    static class Counter{
        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);
    }
}

直接看运行结果:

此时程序就没有按照我们实现预期的结果执行。很明显我们想要对count进行十万次自增,然而程序的执行结果并不是这样子。这就是因为线程安全问题导致的程序bug。

1.1 线程不安全的原因

1)修改共享数据

上面的线程不安全的代码中, 涉及到多个线程针对 counter.count 变量进行修改.
此时这个 counter.count 是一个多个线程都能访问到的“共享数据”

2)原子性

将客户端A、B看做成两个线程。当客户端A执行判断时候还有一张票,于是执行到第一步,想要将票卖掉,还没有执行更新数据库时。客户端B也检查了票数,发现此时的票数也是大于0,于是又卖了一次票。后面A,B两个客户端都对数据库进行了更新,这样就出现了一张票被卖两次的情况。

什么是原子性?

我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。

有时也会把这个现象叫做同步互斥,表示操作是相互排斥的。

在上面的代码示例中我们看到的n++实际上就可以分成三步:

如果不保证原子性会给多线程带来什么问题?

如果一个线程正在对一个变量操作,中途其他线程插入进来了(如上图),如果这个操作被打断了,结果就可能是错误的。

2)可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型.
目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.

  • 线程之间的共享变量存在 主内存 (Main Memory).
  • 每一个线程都有自己的 "工作内存" (Working Memory) .
  • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
  • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存. 

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。因此也就导致了线程不安全问题。

1)初始情况下,两个线程的工作区内容保持一致。

2) 一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定能及时同步。

此时代码就容易出现因为线程不安全导致的bug。

3)代码顺序性

先了解一下代码重排序。

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以避免来回再宿舍和驿站之间跑动。

2. synchronized 关键字-监视器锁 monitor lock

2.1 synchronized 的特性

1)互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到同一个对象 synchronized 就会阻塞等待

synchronized 用的锁是存在java对象头里的。

当一个线程先上了锁,那么其他线程只能等待这个线程释放。

synchronized的底层是使用操作系统的mutex lock实现的.

2)可重入

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

public class Demo2 {
    static class Counter1 {
        public int count = 0;

        synchronized void increase1() {
            System.out.println("获取到第一把锁");
            increase2();
        }

        synchronized void increase2() {
            System.out.println("获取到内部锁");
            count++;
        }

    }

    public static void main(String[] args) {
        Counter1 counter1 = new Counter1();
        Thread t1 = new Thread(() -> {
            counter1.increase1();
        });
        t1.start();
    }
}

在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.

2.2 使用 synchronized 解决上面的线程不安全问题

public class ThreadDemo6 {
    public static class Counter {
        public int count = 0;

        synchronized 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);
    }
}

2.3 synchronized使用

synchronized 本质上要修改指定对象的 "对象头". 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.

1)修饰普通方法:

public class Demo3 {
    public synchronized void method1() {
        // 这是一个普通方法,锁定的是实例对象
    }
}

2)修饰静态方法:

public class Demo3 {
    public static synchronized void method1() {
        // 这是一个静态方法,锁定的是类对象
    }
}

3)修饰代码块

修饰代码块也分为两种情况:

锁当前对象:

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
        }
    }
}

锁类对象:

public class SynchronizedDemo {
    public void method() {
        synchronized (SynchronizedDemo.class) {
        }
    }
}

2.4 什么情况会发生阻塞等待

情况一:两个(多个)线程竞争同一把锁。此时会发生阻塞等待。

情况二:两个线程竞争两把不同的锁。此时不会发生阻塞等待。

情况三:两个线程其中一个加锁,另一个没有加锁。此时不会发生阻塞等待。

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

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

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilde

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

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer
  • String

4. volatile关键字

volatile 能保证内存可见性

代码在写入 volatile 修饰的变量的时候,

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

代码在读取 volatile 修饰的变量的时候,

  • 从主内存中读取volatile变量的最新值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

代码示例:

在这个代码中

  • 创建两个线程 t1 和 t2
  • t1 中包含一个循环, 这个循环以 flag == 0 为循环条件.
  • t2 中从键盘读入一个整数, 并把这个整数赋值给 flag.
  • 预期当用户输入非 0 的值的时候, t1 线程结束.
public class ThreadDemo8 {

    static class Counter {
        public  int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.flag == 0) {
                // 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();
    }
// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug) 因此需要加volatile关键字让县城修改的变量别其他线程看到(内存可见)
}

 如果给flag 加上volatile:

static class Counter {
        public volatile int flag = 0;
    }

此时,当用户输入非0的数字,t1线程能够从主存中重新读取变量,从而感受到flag的变化,循环结束。

volatile不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.

代码示例
这个是最初的演示线程安全的代码.

  • 给 increase 方法去掉 synchronized
  • 给 count 加上 volatile 关键字.
public class ThreadDemo6 {
    public static class Counter {
        public volatile 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);
    }
}

最终 count 的值仍然无法保证是 100000.

5. wait 和 notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.
但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序.

完成这个协调工作, 主要涉及到三个方法

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

注意: wait, notify, notifyAll 都是 Object 类的方法.

5.1 wait() 方法

wait 做的事情:

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

 wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常

 代码示例: 观察wait()方法使用

public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("等待中...");
            object.wait();
            System.out.println("等待结束");
        }
    }
}

这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

5.2 notify()方法

notify方法是唤醒等待的线程。

  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行
  • 完,也就是退出同步代码块之后才会释放对象锁。

代码示例:使用notify()方法唤醒线程

  • 创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
  • 创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify
  • 注意, WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合就需要搭配同一个 Object.
public class WaitAndNotify {
    static class WaitTask implements Runnable {
        private Object locker;

        public WaitTask(Object locker) {
            this.locker = locker;
        }

        @Override
        public void run() {
            synchronized (locker) {
                while (true) {
                    try {
                        System.out.println("wait 开始");
                        locker.wait();
                        System.out.println("wait 结束");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    static class NotifyTask implements Runnable {
        private Object locker;

        public NotifyTask(Object locker) {
            this.locker = locker;
        }

        @Override
        public void run() {
            synchronized (locker) {
                System.out.println("notify 开始");
                locker.notify();
                System.out.println("notify 结束");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(new WaitTask(locker));
        Thread t2 = new Thread(new NotifyTask(locker));
        t1.start();
        Thread.sleep(1000);
        t2.start();
    }
}

5.3  notifyAll()方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.
范例:使用notifyAll()方法唤醒所有等待线程, 在上面的代码基础上做出修改.

public class WaitAndNotify {
    static class WaitTask implements Runnable {
        private Object locker;

        public WaitTask(Object locker) {
            this.locker = locker;
        }

        @Override
        public void run() {
            synchronized (locker) {
                while (true) {
                    try {
                        System.out.println("wait 开始");
                        locker.wait();
                        System.out.println("wait 结束");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

    static class NotifyTask implements Runnable {
        private Object locker;

        public NotifyTask(Object locker) {
            this.locker = locker;
        }

        @Override
        public void run() {
            synchronized (locker) {
                System.out.println("notify 开始");
                locker.notify();
                System.out.println("notify 结束");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Object locker = new Object();
        Thread t1 = new Thread(new WaitTask(locker));
        Thread t3 = new Thread(new WaitTask(locker));
        Thread t4 = new Thread(new WaitTask(locker));
        Thread t2 = new Thread(new NotifyTask(locker));
        t1.start();
        t3.start();
        t4.start();
        Thread.sleep(1000);
        t2.start();
    }
}

此时可以看到,调用notify只可以唤醒一个线程。

如果将NotifyTask 中的 run 方法, 把 notify 替换成notifyAll方法的话:

static class NotifyTask implements Runnable {
        private Object locker;

        public NotifyTask(Object locker) {
            this.locker = locker;
        }

        @Override
        public void run() {
            synchronized (locker) {
                System.out.println("notifyAll 开始");
                locker.notifyAll();
                System.out.println("notifyAll 结束");
            }
        }
    }

此时可以看到, 调用 notifyAll 能同时唤醒 3 个wait 中的线程。

注意:

5.4 wait 和 sleep 的对比(面试题)

其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间,唯一的相同点就是都可以让线程放弃执行一段时间。

当然为了面试的目的,我们还是总结下:

  • 1. wait 需要搭配 synchronized 使用. sleep 不需要.
  • 2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.
举报

相关推荐

0 条评论