> 文档中心 > Java多线程基础——两万字详解

Java多线程基础——两万字详解

Java多线程基础——两万字详解

⭐️前言⭐️

🍉博客主页: 🍁【如风暖阳】🍁
🍉精品Java专栏【Javase】、【Java数据结构】、【备战蓝桥】【JavaEE初阶】
🍉欢迎点赞 👍 收藏留言评论 📝私信必回哟😁

🍉本文由 【如风暖阳】 原创,首发于 CSDN🙉

🍉博主将持续更新学习记录收获,友友们有任何问题可以在评论区留言

🍉博客中涉及源码及博主日常练习代码均已上传码云(gitee)


📍内容导读📍

  • 🍅1.认识线程
    • 🍉1.1进程与线程
      • 1.1.1两者的概念认识
      • 1.1.2两者的区别和联系【经典面试题】
    • 🍉1.2第一个多线程程序
      • 1.2.1 start()和run()之间的区别
      • 1.2.3使用` jconsole `命令观察线程
    • 🍉1.3 创建线程
      • 1.3.1 继承Thread类,重写run方法
      • 1.3.2 实现Runnable接口,重写run方法
      • 1.3.3 继承Thread类,重写run方法,使用匿名内部类的方式
      • 1.3.4 实现Runnable,重写run,使用匿名内部类
      • 1.3.5 使用lambda表达式来表示要执行的任务
      • 1.3.6 总结
    • 🍉1.4多线程的优势-增加运行速度
  • 🍎2.Thread 类及常见方法
    • 🍉2.1 Thread的常见构造方法
    • 🍉2.2 Thread 的几个常见属性
    • 🍉2.3 启动一个线程-start()
    • 🍉2.4 中断一个线程
    • 🍉2.5 等待一个线程-join()
    • 🍉2.6 获取当前线程引用
    • 🍉2.7 休眠当前线程
  • 🍎3. 线程的状态
    • 🍉3.1线程的所有状态
    • 🍉3.2 线程的状态和转移
  • 🍎4. 线程安全
    • 🍉4.1线程不安全
    • 🍉4.2线程不安全的原因
  • 🍎 5. synchronized 关键字
    • 🍉5.1 synchronized 的特性
    • 🍉5.2 synchronized 使用示例
    • 🍉5.3 Java 标准库中的线程安全类
  • 🍎 6. volatile 关键字
    • 🍉6.1 volatile 能保证内存可见性
    • 🍉6.2 volatile 不保证原子性
    • 🍉6.3 volatile与编译器优化密切相关
    • 🍉6.4 synchronized 也能保证内存可见性
    • 🍉6.5 volatile与synchronized的区别与联系【经典面试题】
  • 🍎 7.wait 和 notify
    • 🍉7.1 wait()方法
    • 🍉7.2 notify()方法
    • 🍉7.3 notifyAll()方法
    • 🍉7.4 wait 和 sleep 的对比(面试题)

🍅1.认识线程

🍉1.1进程与线程

1.1.1两者的概念认识

进程简单来说就是正在运行的程序,是可以通过双击执行的.exe文件,打开我们电脑的任务管理器,可以看到我们的电脑正在执行的进程,目前我们的电脑都是多进程模式。
Java多线程基础——两万字详解
但是因为进程是系统资源分配的基本单位,通过创建进程/销毁进程/调度进程都会产生较大的开销,于是程序猿就发明了“线程(Thread)”的概念,线程在有些系统上也叫做“轻量级进程”,完成创建线程/销毁线程/调度线程要比创建进程/销毁进程/调度进程更高效。

而且进程与线程是包含关系,一个进程可以包含一个或多个线程,在这个进程里面创建线程,线程与进程共用资源,节省了资源的开销。

1.1.2两者的区别和联系【经典面试题】

  • 直观理解:进程是包含线程的,一个进程里可以有一个线程,也可以有多个线程。 类比:进程时工厂,线程是生产线
  • 系统角度:进程是操作系统资源分配的基本单位,线程是操作系统调度执行的基本单位。
  • 每个进程都有独立的内存空间(虚拟地址空间),同一个进程的多个线程之间,共用这个虚拟地址空间(例如共同使用同一个变量)。

🍉1.2第一个多线程程序

感受多线程程序和普通程序的区别:

  • 每个线程都是一个独立的执行流
  • 多个线程之间是 “并发” 执行的.
    (并发是指在CPU上完成多个线程的快速切换运行,在宏观上看起来好像多个线程在共同执行)
class MyThread extends Thread {    @Override    public void run() { while (true) {     System.out.println("hello thread!");     try {  Thread.sleep(1000);//线程休眠     } catch (InterruptedException e) {  e.printStackTrace();     } }    }}public class Demo1 {    public static void main(String[] args) { Thread t=new MyThread(); t.start(); //t.run(); while (true) {     System.out.println("hello main!");     try {  Thread.sleep(1000);     } catch (InterruptedException e) {  e.printStackTrace();     } }    }}

//运行结果:
Java多线程基础——两万字详解

1.2.1 start()和run()之间的区别

可以发现我在上述代码中,有一行注释掉的代码为//t.run();。

既然我们是想调用线程中的main方法,为什么不直接调用run方法,而要去调用start方法呢?
我们先来看运行结果:
Java多线程基础——两万字详解
我们可以发现,此时的两个线程不像上边的样例代码一样两个线程交替执行,而是执行MyThread线程。

当启动程序时,首先系统会创建一个进程,这个进程里已经包含了一个线程执行的代码,就是main方法,通过start方法,就会建立一个新的线程,从而两个线程可以并发执行,而如果调用run方法并没有创建新的线程,直接在main方法中形成阻塞,无法完成main方法中的线程。

1.2.3使用jconsole命令观察线程

Java多线程基础——两万字详解
通过jconsole工具可以查看线程的信息

🍉1.3 创建线程

线程的创建方式有以下几种

1.3.1 继承Thread类,重写run方法

如1.2中的程序

1.3.2 实现Runnable接口,重写run方法

class MyRunnable implements Runnable {    @Override    public void run() { while (true) {     System.out.println("hello thread");     try {  Thread.sleep(1000);     } catch (InterruptedException e) {  e.printStackTrace();     } }    }}public class Demo2 {    public static void main(String[] args) { //实现Runnable接口的类不能直接调用start方法,还需要实例化Thread来调用 Thread t=new Thread(new MyRunnable()); t.start();    }}

1.3.3 继承Thread类,重写run方法,使用匿名内部类的方式

匿名内部类就是脱离类来写方法,写起来更为简单。

public class Demo3 {    public static void main(String[] args) { Thread t=new Thread() {     @Override     public void run() {  while (true) {      System.out.println("hello thread");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }     } }; t.start();    }}

1.3.4 实现Runnable,重写run,使用匿名内部类

public class Demo4 {    public static void main(String[] args) { Thread t=new Thread(new Runnable() {     @Override     public void run() {  while (true) {      System.out.println("hello thread");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }     } }); t.start();    }}

1.3.5 使用lambda表达式来表示要执行的任务

lambda表达式,直接了当的指定任务,既不需要类也不需要方法

public class Demo5 {    public static void main(String[] args) { Thread t=new Thread(()-> {     while (true) {  System.out.println("hello thread");  try {      Thread.sleep(1000);  } catch (InterruptedException e) {      e.printStackTrace();  }     } }); t.start();    }}

1.3.6 总结

以上这些创建线程的方式,本质都相同,都是借助Thread类,在内核中创建新的PCB,加入到内核的双向链表中…

🍉1.4多线程的优势-增加运行速度

多线程在一些场合下是可以提高程序的整体运行效率的。

  • 使用System.currentTimeMillis()可以记录当前系统的毫秒级时间戳.
  • serial 串行的完成一系列运算. concurrency 使用两个线程并行的完成同样的运算.

下边这段代码通过两种方式使得a,b各增加10亿次,比较两种方式的运行速度。

public class Demo6 {    private static final long count=10_0000_0000L;    //串行    public static void serial() { long beg=System.currentTimeMillis(); int a=0; for (int i = 0; i < count; i++) {     a++; } int b=0; for (long i = 0; i < count; i++) {     b++; } long end=System.currentTimeMillis(); System.out.println("time: "+(end-beg));    }    //并发    public static void concurrency() { long beg=System.currentTimeMillis(); Thread t1=new Thread() {     @Override     public void run() {  int a=0;  for (long i = 0; i < count; i++) {      a++;  }     } }; t1.start(); Thread t2=new Thread() {     @Override     public void run() {  int b=0;  for (long i = 0; i < count; i++) {      b++;  }     } }; t2.start(); //需要保证t1,t2都执行完了之后再结束计时 try {     //join就是等待对应的线程结束     //当t1和t2没执行完之前,join方法就会阻塞等待     t1.join();     t2.join(); } catch (InterruptedException e) {     e.printStackTrace(); } long end=System.currentTimeMillis(); System.out.println("time: "+(end-beg));    }    public static void main(String[] args) { serial(); //concurrency();    }}

通过运行我们可以发现,通过利用线程并发的方式要比串行的运行速度快很多,但也不是正好提高一倍左右,因为在程序运行过程中会发生线程的调度,调度线程也会花一定的时间,这个调度时间完全不确定。

🍎2.Thread 类及常见方法

🍉2.1 Thread的常见构造方法

方法 说明
Thread() 创建线程对象
Thread(Runnable target) 使用 Runnable 对象创建线程对象
Thread(String name) 创建线程对象,并命名
Thread(Runnable target, String name) 使用 Runnable 对象创建线程对象,并命名
Thread t1 = new Thread();Thread t2 = new Thread(new MyRunnable());Thread t3 = new Thread("这是我的名字");Thread t4 = new Thread(new MyRunnable(), "这是我的名字");

Threadname存在的意义就是为了方便调试,如果线程多了就容易弄混,给线程起了一个名字之后,调试的时候就可以很清楚的看到当前的线程是谁。
如下代码:

public class Demo8 {    public static void main(String[] args) { Thread t=new Thread(new Runnable() {     @Override     public void run() {  while (true) {      System.out.println("hello thread");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }     } },"MyThread"); t.start();    }}

Java多线程基础——两万字详解

🍉2.2 Thread 的几个常见属性

属性 获取方法
ID getId()
名称 getName()
状态 getState()
优先级 getPriority()
是否后台线程 isDaemon()
是否存活 isAlive()
是否被中断 isInterrupted
  • ID 是线程的唯一标识,不同线程不会重复
  • 名称在各种调试工具会用到
  • 状态表示线程当前所处的一个情况,下面我们会进一步说明
  • 优先级高的线程理论上来说更容易被调度到
  • 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
  • 是否存活,即简单的理解,为 run 方法是否运行结束了
  • 线程的中断问题,下面我们进一步说明
    代码示例:
public class Demo9 {    public static void main(String[] args) { Thread t=new Thread(new Runnable() {     @Override     public void run() {  while (true) {      //打印当前线程的名字      //Thread.currentThread这个静态方法,获取到当前线程实例      //哪个线程调用这个方法,就能获取到对应的实例      System.out.println(Thread.currentThread().getName());      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }     } },"MyThread"); t.start(); //打印线程的属性 System.out.println("id: "+t.getId()); System.out.println("name: "+t.getName()); System.out.println("state: "+t.getState()); System.out.println("priority: "+t.getPriority()); System.out.println("isDaemon: "+t.isDaemon()); System.out.println("isInterrupted: "+t.isInterrupted()); System.out.println("isAlive: "+t.isAlive());    }}//id: 12MyThreadname: MyThreadstate: TIMED_WAITINGpriority: 5isDaemon: falseisInterrupted: falseisAlive: trueMyThreadMyThreadMyThreadMyThreadMyThreadMyThread......

🍉2.3 启动一个线程-start()

之前我们已经看到了如何通过覆写 run 方法创建一个线程对象,但线程对象被创建出来并不意味着线程就开始运行了,只有调用 start 方法, 才真的在操作系统的底层创建出一个线程。

🍉2.4 中断一个线程

让线程结束的关键,就是让线程对应的入口方法执行完毕
Java多线程基础——两万字详解
像这种情况,只要run执行完,线程就随之结束了

但是更多的情况下,线程不一定这么快能执行完run方法
Java多线程基础——两万字详解
如果run里面带的是一个死循环,此时这个线程就会一直持续运行,直到整个进程结束。

但是在实际开发中,我们并不希望线程的run就是一个死循环,更希望能够控制这个线程,按照咱们的需求随时结束~~

为了实现这个效果我们有下边两种方法:
1.使用boolean变量来作为循环结束标记

public class Demo10 {    private static boolean flag=true;    public static void main(String[] args) throws InterruptedException { Thread t=new Thread() {     @Override     public void run() {  while (flag) {      System.out.println("线程运行中...");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }  System.out.println("线程结束!");     } }; t.start(); //主循环等待3s,等待3s后就把falg改成false Thread.sleep(1000); flag=false;    }}//线程运行中...线程运行中...线程结束!进程已结束,退出代码0

2.使用标准库里内置的标记

  • 获取线程内置的标记位:线程的isInterrupted()判定当前线程是不是应该要结束循环。
  • 修改线程内置的标记位:Thread.interrupt()来修改这个标记位。
public class Demo11 {    public static void main(String[] args) throws InterruptedException { Thread t=new Thread() {     @Override     public void run() {  //获取线程标记位,默认情况下isInterrupted 值为 false  while (!Thread.currentThread().isInterrupted()) {      System.out.println("线程运行中...");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }     } }; t.start(); //在主线程休眠3s后改变线程标记位,将Thread.currentThread().isInterrupted()给设成true Thread.sleep(3000); t.interrupt();    }}

此时我们发现运行结果会出问题
Java多线程基础——两万字详解
这是因为t.interrupt()方法有以下两种行为:
1.如果当前线程正在运行中,此时就会修改Thread.currentThread().isInterrupted()标记为true
2.如果当前线程正在sleep/wait/等待锁…此时会触发InterruptedException
为了解决上边代码出现的异常,就需要在catch语句中加入break,就会使程序正常运行了
Java多线程基础——两万字详解
isInterrupted()这个是Thread的实例方法,和这个方法还有一个类似的方法:interrupted()这个是Thread的类方法(static)

但是这两者有个区别:
使用这个静态方法时,会自动清除标记位

例如调用interrupt()方法,把标记位设为true,就应该结束循环。

  • 当调用静态的interrupted()方法来判定标记位时,就会返回true,同时就会把标记位再改回为false,下次再调用interrupted()就会返回false
    (这种方法类似于开关按下去会自动弹起来)

代码示例:

public class Test1 {    public static void main(String[] args) { Thread t=new Thread(()->{     for (int i = 0; i < 10; i++) {  System.out.println(Thread.interrupted());     } }); t.start(); t.interrupt();    }}//truefalsefalsefalsefalsefalsefalsefalsefalsefalse进程已结束,退出代码0
  • 当调用非静态的isInterrupted()来判定标记位时,也会返回true,但不会对标记位再进行修改,后面再调用isInterrupted()仍然返回true
    (这种方法类似于开关按下去不会自动弹起来)
    代码示例:
public class Test2 {    public static void main(String[] args) { Thread t=new Thread(()->{     for (int i = 0; i < 10; i++) {  System.out.println(Thread.currentThread().isInterrupted());     } }); t.start(); t.interrupt();    }}//truetruetruetruetruetruetruetruetruetrue进程已结束,退出代码0

🍉2.5 等待一个线程-join()

线程和线程之间,调度顺序是完全不确定的(取决于操作系统调度器自身的实现)但是有的时候,我们希望这里的顺序是可控的,此时线程等待就是一种办法。

这里的线程等待,主要就是控制线程结束的先后顺序。

一种常见的逻辑:
创建t1线程,再创建t2,t3,t4,让这三个新的线程来分别执行一些任务,然后t1线程最后在这里汇总结果。

这样的场景就需要t1的结束时机要比t2,t3,t4都迟,就像是领导给员工指派工作任务,需要等员工都完成各自的任务以后,老板还需要汇总。
代码示例:

public class Demo12 {    public static void main(String[] args) throws InterruptedException { Thread t=new Thread() {     @Override     public void run() {  int count=0;  while (count<5) {      count++;      System.out.println("线程运行中....");      try {   Thread.sleep(1000);      } catch (InterruptedException e) {   e.printStackTrace();      }  }  System.out.println("线程运行结束");     } }; t.start(); //Thread.sleep(7000); System.out.println("join执行开始"); t.join(); System.out.println("join执行结束");    }}//join执行开始线程运行中....线程运行中....线程运行中....线程运行中....线程运行中....线程运行结束join执行结束进程已结束,退出代码0

当执行到t.join()代码时,调用这个代码的线程(main)就会阻塞等待(相当于让所调用的线程插队,本线程的代码不再继续往下走了)

假设调用join的时候,t线程已经结束了,会怎么样呢?
Java多线程基础——两万字详解
把上边代码示例中的注释行放开,让主线程先休眠7s,调用的线程其实已经完成了工作,结束了线程。
Java多线程基础——两万字详解
附录:
join无参数版本:相当于死等
join有参数版本,参数就是最大等待时间
在实际开发中,使用死等操作,往往是比较危险的…

典型的就是在网络编程中,发了一个请求,希望得到一个回应,由于种种原因回应没有到达,如果死等就会非常影响进行。

🍉2.6 获取当前线程引用

Java多线程基础——两万字详解
Java多线程基础——两万字详解
在这个代码中,看起来好像this和Thread.currentThread没啥区别,但是实际上,没区别的前提是使用继承Thread,重写run的方式创建的线程,但如果当前是通过Runnable或者lambda的方式,就不行了
Java多线程基础——两万字详解

🍉2.7 休眠当前线程

调用sleep方法

也是我们比较熟悉的一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的.

🍎3. 线程的状态

🍉3.1线程的所有状态

线程的状态用于辅助系统对于线程进行调度

public class Demo13 {    public static void main(String[] args) throws InterruptedException { Thread t=new Thread(()-> {     while (!Thread.currentThread().isInterrupted()) {  try {      Thread.sleep(1000);  } catch (InterruptedException e) {      e.printStackTrace();      break;  }     } }); System.out.println(t.getId()+": "+t.getState()); t.start(); Thread.sleep(1000); System.out.println(t.getId()+": "+t.getState()); Thread.sleep(1000); t.interrupt(); Thread.sleep(1000); System.out.println(t.getId()+": "+t.getState());    }}

Java多线程基础——两万字详解

  • NEW: Thread对象创建出来了,但是还未开始工作
  • RUNNABLE:可工作的。又可分为正在工作中和即将开始工作
  • BLOCKED,WAITING(死等),TIMED_WAITING(有时间限制的等待)都表示在排队等其他事情
  • TERMINATED:工作完成了

🍉3.2 线程的状态和转移

Java多线程基础——两万字详解
我们可以通过下图的示例来理解上边的状态转移图
Java多线程基础——两万字详解
理解线程的状态,最大的意义在于未来调试一些多线程的程序

🍎4. 线程安全

🍉4.1线程不安全

我们通过下边这段代码来引出线程安全这个问题:

public class Demo14 {    static class Counter { public int count=0; public void increase() {     count++; }    }    static Counter counter=new Counter();    public static void main(String[] args) { 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(); try {     //让两个线程完成以后,再打印counter.count的值     t1.join();     t2.join(); } catch (InterruptedException e) {     e.printStackTrace(); } System.out.println(counter.count);    }}//94182

以上代码的目的是想通过两个线程来完成count变量的10万次自增,预期应该打印出10万,但运行结果却不是这样的,这是由于多线程并发执行,导致代码中出现BUG,这样的情况就称为“线程不安全”。

下边我们来剖析这个程序执行的过程:
count++的详细过程分为三个步骤

  • 把内存中的值读取到CPU中 LOAD
  • 执行++的操作 ADD
  • 把CPU的值写回到内存中 SAVE

Java多线程基础——两万字详解
如果线程1的SAVE在线程2的LOAD之后,那么此时就会出现当前的线程不安全问题。

也就是说如果两个线程并发的执行count++,会抢占性的在CPU上执行,两个线程执行的具体顺序是完全不可预期的。

刚才的代码中,两个线程并发的自增了5w次,在这5w次里面,有多少次触发了类似于上边的“线程不安全问题”并不确定,最终结果是几也就不确定,但是可以知道的是,最终结果一定是5w——10w之间的数据。

🍉4.2线程不安全的原因

产生线程不安全的原因有以下五点:

1.线程之间是抢占式执行的【根本原因】

抢占式执行,导致两个线程里面操作的先后顺序无法确定,这样的随机性,就是导致线程安全问题的根本所在

2.多个线程修改同一个变量

一个线程修改同一个变量没有线程安全问题(不涉及并发,结果确定)
多个线程读取同一个变量也没有线程安全问题(读只是单纯把数据从内存放到CPU中,不管怎么读,内存的数据始终不变)
多个线程修改不同的变量,也没有线程安全问题,所以为了规避线程安全问题,就可以尝试变化代码的组织形式,达到一个线程只改变一个变量。

3.原子性

像++这样的操作,本质上是三个步骤,是一个“非原子”的操作,
像=操作,本质上就是一个步骤,认为是一个“原子"的操作

像当前咱们的++操作本身不是原子的,但是可以通过加锁的方式,把这个操作变成原子的

4.内存可见性(与编译器优化相关)

一个线程修改,一个线程读取,由于编译器的优化,可能把一些中间环节的SAVE和LOAD操作去掉了,此时读的线程可能读到的是未修改过的结果

5.指令重排序(也是和编译器优化相关)

编译器会自动调整执行指令的顺序,以达到提高执行效率的效果。
调整的前提是保证指令的最终效果是不变的。

🍎 5. synchronized 关键字

🍉5.1 synchronized 的特性

在产生线程不安全的五种原因下,我们如果想要解决线程安全问题,最普适的办法,就是通过“原子性”这样的切入点来解决问题。

我们在4.1中的代码示例Demo14中展示了线程不安全的问题,我们如何通过“原子性”的切入点来解决这个问题呢?
Java多线程基础——两万字详解
那就是在方法中加入synchronized关键字加锁,该关键字具有以下特性:

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

  • 进入 synchronized 修饰的代码块, 相当于 加锁
  • 退出 synchronized 修饰的代码块, 相当于 解锁
    Java多线程基础——两万字详解

synchronized用的锁是存在Java对象头里的
Java多线程基础——两万字详解

可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).

如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
Java多线程基础——两万字详解

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

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

2) 刷新内存(保证内存可见性)
4.2中介绍了线程不安全的原因,其中的第四条内存可见性就是由于编译器的优化而导致的,如果加上synchronized 关键字,就会禁止这样的优化,保证每次进行操作的时候都会把数据真的从内存中读,也真的写回内存中。

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

理解 “把自己锁死” 一个线程没有释放锁, 然后又尝试再次加锁
Java多线程基础——两万字详解
第一次加锁,加锁成功

第二次再尝试对这个线程加锁的时候,此时对象头的锁标记已经是true,线程就要阻塞等待,等待这个锁标记被改成false然后才重新竞争这个锁,但是第一个锁并不能释放,此时就出现死锁了

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

代码示例:
在下面的代码中,

  • increase 和 increase2 两个方法都加了 synchronized, 此处的 synchronized 都是针对 this 当前对象加锁的.
  • 在调用 increase2 的时候, 先加了一次锁, 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)

这个代码是完全没问题的. 因为 synchronized 是可重入锁.

static class Counter {    public int count = 0;    synchronized void increase() { count++;   }    synchronized void increase2() { increase();   }}

🍉5.2 synchronized 使用示例

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

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

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

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

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

3) 修饰代码块: 明确指定锁哪个对象
锁当前对象

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

锁类对象

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

注意:
注意synchronized修饰的是this对象还是还是类对象

如果是this对象,这个时候只有在多个线程并发的调用该对象的方法时,才会触发锁竞争,但如果是类对象(.class文件),由于类对象是单例的,多个线程并发的调用该对象的方法,一定会触发锁竞争。

🍉5.3 Java 标准库中的线程安全类

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

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

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

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

还有的虽然没有加锁, 但是不涉及 “修改”, 仍然是线程安全的

  • String

🍎 6. volatile 关键字

🍉6.1 volatile 能保证内存可见性

volatile 修饰的变量, 能够保证 “内存可见性”.
Java多线程基础——两万字详解
三个线程,就有各自的工作内存(也叫缓存)(每个线程都有自己独立的上下文,独立的上下文就是各自的一组寄存器)

CPU在和内存交互的时候,经常会把主内存的内容,拷贝到工作内存然后进行操作,再写回到主内存

这个过程中就非常容易出现数据不一致的情况,这种情况在编译器开启优化的时候会特别严重,这就是多线程编程中的典型问题

volatile或者synchronized就能够强制保证接下来的操作是操作内存。

代码示例

import java.util.Scanner;public class Demo16 {    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) {     }     System.out.println("循环结束"); }); Thread t2=new Thread(()-> {     Scanner scanner=new Scanner(System.in);     System.out.println("请输入一个整数:");     counter.flag=scanner.nextInt(); }); t1.start(); t2.start();    }}//

以上这段代码的两个线程,都是操作同一个静态内部类,第二个线程完成静态内部类里数据的更改,第一个线程根据该静态内部类里的数据判断循环是否结束。
Java多线程基础——两万字详解
但是程序运行结果并不像我们预期的逻辑(线程1没有正常结束循环),通过第二个线程完成数据更改后,第一个线程的循环也应该终止。

这是因为线程1在快速的循环读取flag的值(频繁的从内存读取数据),由于这里进行的太快了,所以编译器直接进行了优化,并不会每次都从内存里读取flag的值,而是读了一次之后,后续都直接从CPU中来读flag的值了,此时线程2的修改并不能让线程1感知,所以并不会按照预想逻辑是程序终止。

那我们该如何解决这个问题呢,就是使用volatile来禁止编译器进行刚才的优化
Java多线程基础——两万字详解

一旦给这个flag加上volatile之后,此时后续的针对flag的读写操作,就能保证一定是操作内存了
这是更改后程序的运行结果:
Java多线程基础——两万字详解

🍉6.2 volatile 不保证原子性

volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性
代码示例
这个是最初的演示线程安全的代码.

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

此时可以看到, 最终 count 的值仍然无法保证是 100000.

🍉6.3 volatile与编译器优化密切相关

编译器优化,是一个相当复杂的事情,啥时候优化,啥时候不优化,优化优化到什么程度,我们都不好把握。
代码示例:

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) {  try {      Thread.sleep(1000);  } catch (InterruptedException e) {      e.printStackTrace();  }     }     System.out.println("循环结束"); }); Thread t2=new Thread(()-> {     Scanner scanner=new Scanner(System.in);     System.out.println("请输入一个整数:");     counter.flag=scanner.nextInt(); }); t1.start(); t2.start();    }

如果flag属性并没有被volatile修饰,而是在线程1的循环中加入了休眠时间,线程1也能正常结束循环,这就是改变了代码的优化方式,通过volatile关键字,可以强制编译器进行内存读取禁止优化

一般来说,如果某个变量,在一个线程中读,一个线程中写,这个时候大概率需要使用volatile

🍉6.4 synchronized 也能保证内存可见性

synchronized 既能保证原子性, 也能保证内存可见性.
对上面的代码进行调整:

  • 去掉 flag 的 volatile
  • 给 t1 的循环内部加上 synchronized, 并借助 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也会正常终止循环

🍉6.5 volatile与synchronized的区别与联系【经典面试题】

  • volatile只能修饰一个具体的属性,而synchronized既可以修饰属性,也可以修饰方法(但修饰的具体还是对象)
  • volatile和synchronized都可以保证内存可读性,但是volatile不能保证原子性,synchronized可以

🍎 7.wait 和 notify

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

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

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

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

🍉7.1 wait()方法

wait 做的事情:

1.使当前执行代码的线程进行等待. (把线程放到等待队列中)
2.释放当前的锁(要想使用wait/notify,必须搭配synchronized(否则会直接抛出异常),需要先获取锁,才有资格谈wait)
3.满足一定条件时被唤醒, 重新尝试获取这个锁.

注意:1和2是要原子完成的

wait 结束等待的条件:

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

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

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()。

🍉7.2 notify()方法

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

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

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

  • 创建 WaitTask 类, 对应一个线程, run 内部循环调用 wait.
  • 创建 NotifyTask 类, 对应另一个线程, 在 run 内部调用一次 notify
  • 注意, WaitTask 和 NotifyTask 内部持有同一个 Object locker. WaitTask 和 NotifyTask 要想配合,就需要搭配同一个 Object.
public class Demo18 {    static class WaitTask implements Runnable { private Object locker=null; public WaitTask(Object locker) {     this.locker = locker; } @Override public void run() {     synchronized (locker) {  System.out.println("wait 开始");  try {      /*直接调用wait,相当于this.wait(),也就是针对WaitTask对象来进行等待,但是      我们需要在NotifyTask中要求得针对同一个对象来进行通知,然而在NotifyTask中      并没有那么容易拿到WaitTask实例*/      locker.wait();  } catch (InterruptedException e) {      e.printStackTrace();  }  System.out.println("wait 结束");     } }    }    static class NotifyTask implements Runnable { //为了解决刚才的问题,专门创建一个对象,去负责进行加锁/通知操作。 private Object locker=null; 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();    }}

Java多线程基础——两万字详解

🍉7.3 notifyAll()方法

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

范例:使用notifyAll()方法唤醒所有等待线程, 在上面的代码基础上做出修改.

  • 创建 3 个 WaitTask 实例. 1 个 NotifyTask 实例.
static class WaitTask implements Runnable { // 代码不变static class NotifyTask implements Runnable { // 代码不变}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();}

Java多线程基础——两万字详解

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

  • 修改 NotifyTask 中的 run 方法, 把 notify 替换成 notifyAll
public void run() {    synchronized (locker) { System.out.println("notify 开始"); locker.notifyAll(); System.out.println("notify 结束");   }}

Java多线程基础——两万字详解

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

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

🍉7.4 wait 和 sleep 的对比(面试题)

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

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

  1. sleep操作是指定一个固定时间来阻塞等待,wait既可以指定时间,也可以无限等待
  2. wait唤醒可以通过notify或者interrupt或者时间到来唤醒,sleep唤醒通过时间到或者interrupt唤醒
  3. wait主要的用途就是为了协调线程之间的先后顺序,这样的场景并不适合使用sleep。sleep单纯让该线程休眠,并不涉及到多个线程的配合。

⚡️最后的话⚡️

总结不易,希望uu们不要吝啬你们的👍哟(^U^)ノ~YO!!如有问题,欢迎评论区批评指正😁

在这里插入图片描述