Java-多线程基础及线程安全

文章目录

  • 1. 线程的状态
    • 1.1 观察线程的所有状态
    • 1.2 观察线程的转态和转移
  • 2. 多线程带来的风险, 线程安全
    • 2.1 观察线程不安全
    • 2.2 线程安全的概念
    • 2.3 线程不安全的原因
    • 2.4解决上述代码的线程不安全问题
  • 3. synchronized 关键字
    • 3.1 synchronized 的特性
    • 3.2 synchronized 使用示例
    • 3.3 volatile 关键字
  • 4. wait 和 notify
    • 4.1 wait() 方法
    • 4.2 notify() 方法
    • 4.3 notifyall() 方法

1. 线程的状态

1.1 观察线程的所有状态

public class ThreadDemo1 {
    public static void main(String[] args) {
        for (Thread.State state : Thread.State.values()) {
            System.out.println(state);
        }
    }
}

Java-多线程基础及线程安全_第1张图片
线程的状态是一个枚举类型 Thread.State

  • NEW (新建状态) 新建了一个线程, 但是还没有启动, 此时线程还没有分配任何资源, 就是安排好了工作, 还没有开始行动.
  • RUNNABLE (就绪状态) 线程被启动后, 就绪状态也称为可运行状态, 线程已经被分配了处理器资源, 并被操作系统调度到处理器上运行, 等待CPU时间片, 也就是可工作的状态, 进而可分为正在工作中和即将开始工作.
  • BLOCKED (阻塞状态) 当一个线程试图获取一个内部的对象锁(不是java.util.concurrent库中的锁),而该锁被其他线程持有,则该线程进入BLOCKED状态。BLOCKED状态的线程不会进入队列等待,而是等待直到该锁被释放。一旦锁被释放,BLOCKED状态的线程就会进入RUNNABLE状态,可以被调度运行.
  • WAITING (等待状态) 线程调用了wait()方法后,线程就进入等待状态,继续执行wait()后面的代码。在等待过程中,线程不会释放自己占用的资源。如果其他线程调用了该线程的notify()方法,或者调用notifyAll()方法,该线程就会从等待状态进入RUNNABLE状态。如果既没有notify也没有notifyAll调用,那么这个线程将永远不会从等待状态退出.
  • TIME_WAITING (超时等待状态) 线程调用了Thread.sleep()或Thread.join()方法后,线程就进入超时等待状态。与WAITING不同,超时等待状态的线程会在指定的时间后自动从等待状态进入RUNNABLE状态。
  • TERMINATED (终止状态) 线程已经执行完毕或者异常结束,此时线程已经不属于程序的一部分.

Java-多线程基础及线程安全_第2张图片

1.2 观察线程的转态和转移

1. 关注 NEW , RUNNABLE , TEMINATED 状态的转换

  • 使用 isAlive 方法判定线程的存货状态
public class ThreadDemo2 {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000_0000; i++) {
                }
            }
        },"张三");
        System.out.println(thread.getState()); // 启动线程之前
        thread.start();  //启动线程
        while (thread.isAlive()) {
            System.out.println(thread.getState());
        }
        System.out.println(thread.getState());
    }
}

Java-多线程基础及线程安全_第3张图片
Java-多线程基础及线程安全_第4张图片

运行这个代码我们可以清楚的看到启动线程之前, 线程处于 NEW 状态, 线程启动后开始工作处于 RUNNABLE 状态, 工作完成后为 TERMINATED 状态

2. 关注 WAITING, BLOCK , TIMED_WAITING 状态的转化

public class ThreadDemo3 {
    public static void main(String[] args) {
        final Object object = new Object();
        Thread thread1 = new Thread(() -> {
            synchronized (object) {
                while (true) {
                    try {
                        System.out.println("t2没执行");
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        },"t1");
        thread1.start();

        Thread thread2 = new Thread(() -> {
            synchronized (object) {
                System.out.println("hello");
            }
        },"t2");
        thread2.start();
    }
}

使用 jconsole 可以看到 t1 的状态是 TIMED_WAITING , t2 的状态是 BLOCKED
Java-多线程基础及线程安全_第5张图片

Java-多线程基础及线程安全_第6张图片
下面我们修改代码, 将上面的sleep() 换成 wait()

public class ThreadDemo4 {
    public static void main(String[] args) {
        final Object object = new Object();
        Thread thread1 = new Thread(() -> {
            synchronized (object) {
                while (true) {
                    System.out.println("t2没执行");
                    try {
                        object.wait();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        },"t1");
        thread1.start();

        Thread thread2 = new Thread(() -> {
            synchronized (object) {
                System.out.println("hello");
            }
        },"t2");
        thread2.start();
    }
}

Java-多线程基础及线程安全_第7张图片
使用 jconsole 可以看到 t1 的状态是 WAITING

由上面可得:
BLOCKED 表示等待获取锁, WAITING 和 TIMED_WAITING 表示等待其他线程发来通知.
TIMED_WAITING 线程在等待唤醒,但设置了时限; WAITING 线程在无限等待唤醒

WAITING(等待状态):当线程进入WAITING状态时,它放弃当前锁,并等待其他线程执行特定的操作(如notify或notifyAll)。如果没有其他线程通知或唤醒它,它将一直处于等待状态,直到具有相同锁的其他线程完成其任务。
TIMED_WAITING(定时等待状态):这是WAITING状态的一种特殊形式。当线程进入TIMED_WAITING状态时,它不仅会放弃当前锁,而且会在指定的时间内等待其他线程的通知或唤醒。如果在指定的时间内没有其他线程通知或唤醒它,那么它将自动回到RUNNABLE状态
注意:无论是WAITING还是TIMED_WAITING状态,线程都不会释放自己占用的资源(如内存),而是会一直等待直到被其他线程唤醒。此外,这两种状态都是在synchronized块或方法中进入的,所以需要释放锁才能继续执行

3. yield()
当线程执行yield()方法时,它会释放当前CPU时间片的控制权,并且操作系统会尝试将CPU分配给其他正在等待的线程。这使得其他线程有机会获得CPU时间片并执行它们的代码.
需要注意的是,yield()方法并不保证一定会将CPU时间片分配给其他线程,具体取决于操作系统的调度策略。如果操作系统认为当前线程仍然适合继续执行,那么它可能会忽略yield()的请求,继续执行当前线程.
yield()方法通常在编写多线程程序时使用,当一个线程已经完成了一些工作,但还没有到达可以继续执行的下一个条件时,可以使用yield()方法来主动放弃CPU时间片,以便其他线程可以执行.

public class ThreadDemo5 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (true) {
                System.out.println("hello");
                Thread.yield();
            }
        });

        Thread thread2 = new Thread(() -> {
            while (true) {
                System.out.println("你好");
            }
        });
        thread1.start();
        thread2.start();
    }
}

按正常来说, 不使用Thread.yield() , 打印 “hello” 和 “你好” 的次数应该五五开, 但是使用这个Thread.yield()之后, “hello” 的数量就远远少于了"你好"了.

结论:yield() 不改变线程的状态, 但是会重新去排队.

2. 多线程带来的风险, 线程安全

2.1 观察线程不安全

我们先看下面一个代码

public class ThreadDemo6 {
    public static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
             count++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread1.join();
        System.out.println(count);
    }
}

Java-多线程基础及线程安全_第8张图片

2.2 线程安全的概念

想给出一个线程安全的确切定义是复杂的,但我们可以这样认为:
如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线
程安全的

2.3 线程不安全的原因

修改共享数据
Java-多线程基础及线程安全_第9张图片

我们上述的代码中. 涉及多个线程对 count 的修改, 此时这个 count 是一个多线程能访问到的 “共享数据”
在多线程环境中对数据进行修改,会出现数据不一致的情况

原子性
Java-多线程基础及线程安全_第10张图片

但客户端A检查还有一张票的时候, 将票卖了. 还没有更新数据库时, 客户端B检查了票数, 发现大于1 , 又买了一张票, 这就出现一张票被卖出去两次的问题.

说到这里, 我们就得了解一下什么是原子性.
我们把一段代码想象成一个房间, 每个线程就是想进入房间的人, 如果A进入到房间里, 没有任何保护机制的话, 是不是B也可以进入房间啊, 如果B进去了话, 就打断了A的隐私, 这就是不具备原子性的.
那么我们应该如何解决这个问题呢, 那就是加锁 , A进入房间的时候, 加个锁,把门关上, 这样就保证了这段代码的原子性了.

一条Java语句不一定就是原子性的, 也不一定就只是一条命令
就比如上面的代码, count++, 其实是分三步完成的

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

可见性

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

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

Java-多线程基础及线程安全_第11张图片

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

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化

初始情况下, 两个线程的工作内存内容一致.
Java-多线程基础及线程安全_第12张图片

一旦线程1 修改了 a 的值, 此时主内存不一定能及时同步. 对应的线程2 的工作内存的 a 的值也不一定
能及时同步.
Java-多线程基础及线程安全_第13张图片
这个时候代码中就容易出现问题.

那我们就得考虑这两个问题了

  1. 为什么要整这么多内存呢?
  2. 为什么要这么麻烦的拷来拷去

为啥整这么多内存
实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法.所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.

为啥要这么麻烦的拷来拷去?
因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍).

比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了. 效率就大大提高了

CPU 访问寄存器速度远远快于内存, 但是内存的访问速度又远远快于硬盘.
CPU 的价格最贵, 内存次之, 硬盘最便宜

代码顺序性
JVM的优化:虽然Java规范规定了顺序性,但具体的JVM实现可能会有自己的优化策略,这也可能影响代码的执行顺序。例如,JVM可能对代码进行重排、优化等操作以提高性能。

2.4解决上述代码的线程不安全问题

public class Test {
    public static int count = 0;
    synchronized static void sum() {
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

3. synchronized 关键字

3.1 synchronized 的特性

  1. 互斥

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

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁

理解阻塞等待.

针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程, 再来获取到这个锁

这里我们要注意的是:

  • 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 “唤醒”. 这也就是操作系统线程调度的一部分工作.
  • 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C 都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁, 而是和 C 重新竞争, 并不遵守先来后到的规则.
  1. 刷新内存

synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

所以 synchronized 也能保证内存可见性的.

  1. 可冲入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
那什么是把自己锁死呢?
一个线程没有释放锁, 然后又尝试再次加锁.

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待.
lock();

按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁, 这样的锁称为 不可重入锁

Java 中的 synchronized 是 可重入锁, 因此没有上面的问题.

我们看下面代码

static class Counter {
  public int count = 0;
  synchronized void increase() {
    count++;
 }
  synchronized void increase2() {
    increase();
 }
}
  • ncrease 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的.
  • 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)这个代码是完全没问题的. 因为 synchronized 是可重入锁.

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

  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

3.2 synchronized 使用示例

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

直接修饰普通方法:锁的 SynchronizedDemo 对象

public class SynchronizedDemo {
  public synchronized void methond() {
 }
}

修饰静态方法: 锁的 SynchronizedDemo 类的对象

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

修饰代码块: 明确指定锁哪个对象

锁当前对象

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

锁类对象

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

3.3 volatile 关键字

volatile 能保证内存可见性

Java-多线程基础及线程安全_第14张图片

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

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

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

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

我们看一个代码

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

public class Test2 {
    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);
                while (true) {
                    System.out.println("输入一个整数:");
                    counter.flag = scanner.nextInt();
                }
    });
        t1.start();
        t2.start();
    }
}

Java-多线程基础及线程安全_第15张图片

当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

t1 读的是自己工作内存中的内容.
当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化.

如果给 flag 加上 volatile

public class Test2 {

    static class Counter {
        public volatile 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(() -> {
                while (true) {
                    System.out.println("输入一个整数:");
                    counter.flag = scanner.nextInt();
                }
                counter.flag = scanner.nextInt();
    });
        t1.start();
        t2.start();
    }
}

Java-多线程基础及线程安全_第16张图片

volatile 不保证原子性

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

我们看上述的一个代码

public class Test {
    public static int count = 0;
    synchronized static void sum() {
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });
        
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

这个代码加锁后我们得到了我们想要的结果

  • 如果给 sum 方法去掉 synchronized
  • 给 count 加上 volatile 关键字.

public class Test3 {
    public static volatile int count = 0;
     static void sum() {
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5_0000; i++) {
                sum();
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

Java-多线程基础及线程安全_第17张图片
此时可以看到, 最终 count 的值仍然无法保证是 100000.

synchronized 既能保证原子性, 也能保证内存可见性

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

4. wait 和 notify

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

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

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

4.1 wait() 方法

wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

wait 做的事情

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

wait 结束等待的条件

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常
public class Test4 {
    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()。

4.2 notify() 方法

notify 方法是唤醒等待的线程

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

下面我们看一个代码

public class Test6 {
    private static Object lock = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("wait 开始");
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("wait 结束");
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("notify 开始");
                lock.notify();
                System.out.println("notify 结束");
            }
        });

        thread1.start();
        thread2.start();
    }
}

Java-多线程基础及线程安全_第18张图片

4.3 notifyall() 方法

notify方法只是唤醒某一个等待线程. 使用notifyAll方法可以一次唤醒所有的等待线程.

虽然是同时唤醒 多个线程, 但是这些线程需要竞争锁. 所以并不是同时执行, 而仍然是有先有后的执行.

你可能感兴趣的:(JavaEE,java,多线程)