0. 学习本内容之前的一些事情

0.1 主要学习目标

初步理解关于线程与锁的概念,为之后应对高并发场景打下理论基础。

0.2 学习内容

  1. JUC 简介及基本概念

  2. Java 线程

  3. 共享问题 synchronized 线程安全分析

0.3 预备知识

  1. 接触过Java Web开发、Web服务器开发、分布式框架

  2. JDK 1.8 的函数式编程与Lambda表达式

  3. lombok插件与slf4j日志插件

 <dependency>
     <groupId>org.projectlombok</groupId>
     <artifactId>lombok</artifactId>
     <version>1.18.10</version>
 </dependency>
 <dependency>
     <groupId>ch.qos.logback</groupId>
     <artifactId>logback-classic</artifactId>
     <version>1.2.3</version>
 </dependency>

1. JUC 简介与基本概念

1.1 进程与线程

进程

程序由指令核数据组成,但是指令要加载之CPU,数据要加载进内存,还需要用到程序、网络等设备。

一个程序被运行时,从磁盘加载这个程序的代码到内存,这是开启了一个进程。

进程是用来加载指令、管理内存、管理IO的,可以视为程序的一个实例。

大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

线程

一个进程之内可以分为一到多个线程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行

Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作 为线程的容器

二者对比

进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

进程拥有共享的资源,如内存空间等,供其内部的线程共享

进程间通信较为复杂 同一台计算机的进程通信称为 IPC(Inter-process communication) 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP

线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量

线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

1.2 并行与并发

  • 并发(concurrent)是同一时间应对(dealing with)多件事情的能力

    • 微观串行,宏观并行

    • 线程轮流使用 CPU

  • 并行(parallel)是同一时间动手做(doing)多件事情的能力

    • 多核 cpu下,每个核(core) 都可以调度运行线程,这时候线程可以是并行的。

1.3 简单的应用

  • 多线程可以让方法执行变为异步。

  • 多线程可以充分利用多核 cpu 的优势,提高运行效率。

需要在多核 cpu 才能提高效率,单核仍然时是轮流执行

1.4 初识 Java 线程

main 线程

首先,我们使用lombok的 @Slf4j 注解,在主方法中运行 log.debug() 代码:

@Slf4j
public class ThreadDemo {
    public static void main(String[] args) {
        log.debug("主线程正在执行操作");
    }
}

可以看到控制台输出以下内容:

22:57:13.944 [main] DEBUG com.werun.werunjuly.thread.ThreadDemo - 主线程正在执行操作

格式为:

时间戳 [线程名] 日志级别 类名 - 日志信息

我们这里主要关注 线程名 这里线程名为main,也就是主线程。

main 方法在 Java 等编程语言中确实代表着程序的入口点。当我们启动一个 Java 应用程序时,JVM(Java 虚拟机)会首先查找 main 方法,并从那里开始执行程序。

JVM 在启动应用程序时,会创建一个新的线程来执行 main 方法。这个线程通常被称为主线程,因为它是程序执行的主要路径。

主线程和其他线程在程序中并行执行,但 main 方法中的代码是在主线程中顺序执行的。也就是说,main 方法中的语句会按照它们在代码中出现的顺序,一个接一个地执行。

我们之前编写的简单代码,都是在主线程逐条语句执行的。

Thread 类

那么怎么再开启一个新进程呢?这里要隆重介绍一位新成员: Thread 类

Thread t = new Thread() {
    @Override
    public void run() {
        // 要执行的任务
    }
};

我们可以初步理解为,Thread类要是想实例化成为对象,就必须知道这个线程要干什么,也就是覆写 run() 方法。

它可以用lambda表达式简化如下:

// Lambda 表达式写法
Thread t = new Thread(() -> {
    // 实现了run()方法 要执行的任务
});

比如我们要实现一个线程,要执行的任务是让这个线程暂停两秒:

Thread thread = new Thread(() -> {
    // 实现了 run() 方法
    try {
        log.debug("该线程操作开始");
        Thread.sleep(2000);
        log.debug("该线程操作完成,等待了2秒");
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});

启动线程

main线程启动时,我们调用main方法就可以启动了,但是启动新thread线程就需要手动调用。

好了,刚才我们实现了thread线程的run方法,那么有人就猜想,我们就运行run方法吧!

thread.run();

然而一运行,控制台输出如下……咦,怎么还是main线程?

12:31:01.397 [main] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 该线程操作开始
12:31:03.408 [main] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 该线程操作完成,等待了2秒

于是我经过一同搜索,发现了如下内容:

注意:Thread 实例化的对象也可以直接使用被覆写的 run()方法(如 t.run() ),但是指的是将要执行的任务合并到主线程执行,实际几乎不会用到这个方法,IDEA会报黄。

启动线程的正确的打开方式是:调用对象的 start() 方法。

@Slf4j
public class ThreadDemo {
    public static void main(String[] args) {
        // 创建一个线程对象 thread
        Thread thread = new Thread(() -> {
            // thread 对象要执行的内容
            try {
                log.debug("该线程操作开始");
                Thread.sleep(2000);
                log.debug("该线程操作完成,等待了2秒");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        // 开启thread线程
        thread.start();
        // 执行主线程方法
        log.debug("主线程正在执行操作");
    }
}

控制台输出如下,我们得到了我们想要的新线程:Thread-0 :

12:36:10.827 [main] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 主线程正在执行操作
12:36:10.827 [Thread-0] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 该线程操作开始
12:36:12.844 [Thread-0] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 该线程操作完成,等待了2秒

并且也可以发现,我们把主线程的语句放在了Thread-0线程start方法之后,但是主线程的语句并没有等待Thread-0线程睡的两秒钟,而是和Thread-0线程同时开启了操作,这样我们就实现了异步

2. Java 线程

2.1 创建与运行线程

方法一 直接使用Thread

// 构造方法的参数是给线程指定名字,推荐
Thread t1 = new Thread("t1") {
   @Override
   // run 方法内实现了要执行的任务
   public void run() {
       log.debug("hello");
   }
};
t1.start();

// 控制台输出
13:22:55.133 [t1] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - hello

方法二 使用 Runnable 配合 Thread

// 创建任务对象
Runnable task2 = new Runnable() {
    @Override
    public void run() {
        log.debug("hello");
    }
};
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();

// 创建任务对象 (Lambda)
Runnable task2 = () -> log.debug("hello");
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread t2 = new Thread(task2, "t2");
t2.start();

方法三 FutureTask 配合 Thread

FutureTask<V> 实现了 RunnableFuture<V> 接口, 而 RunnableFuture<V> 接口继承了 Runnable, Future<V> 两个接口。注意Java接口可以多继承。

FutureTask 能够接收 Callable 类型的参数,用来处理有返回结果的情况

FutureTask<Integer> task3 = new FutureTask<>(() -> {
    log.debug("task3 FutureTask");
    return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
Thread thread = new Thread(task3, "t3");
thread.start();

// 主线程阻塞 同步等待task3执行完毕的结果
log.debug("结果是:{}",task3.get());

// 控制台输出
13:41:00.033 [t3] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - task3 FutureTask
13:41:00.036 [main] DEBUG com.werun.werunjuly.common.thread.ThreadDemo - 结果是:100

2.2 查看线程

  • jps 命令 查看所有 Java 进程

  • jstack 查看某个 Java 进程(PID)的所有线程状态

// 先在线程方法里打断点
PS D:\code\July2023\werunJuly> jps
1792 
21536 Launcher
5408 RemoteMavenServer36
31080 ThreadDemo
36844 Jps

PS D:\code\July2023\werunJuly> jstack 31080
// ....
"t3" #22 prio=5 os_prio=0 tid=0x000001ddd169b800 nid=0xa0ac runnable [0x000000475b0ff000]
   java.lang.Thread.State: RUNNABLE
        at com.werun.werunjuly.common.thread.ThreadDemo.lambda$main$0(ThreadDemo.java:13)
        at com.werun.werunjuly.common.thread.ThreadDemo$$Lambda$1/22429093.call(Unknown Source)
        at java.util.concurrent.FutureTask.run$$$capture(FutureTask.java:266)
        at java.util.concurrent.FutureTask.run(FutureTask.java)
        at java.lang.Thread.run(Thread.java:750)

// "Service Thread" #21 .....
// ......
// "..." #2 ...

"main" #1 prio=5 os_prio=0 tid=0x000001ddac1bd800 nid=0x7044 waiting on condition [0x0000004758cfe000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x000000072dadb8d0> (a java.util.concurrent.FutureTask) 
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:429)
        at java.util.concurrent.FutureTask.get(FutureTask.java:191)
        at com.werun.werunjuly.common.thread.ThreadDemo.main(ThreadDemo.java:20)  

2.3 线程运行简要原理

栈与栈帧

Java Virtual Machine Stacks (Java 虚拟机栈)

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。

每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存

每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完

  • 垃圾回收

  • 有更高优先级的线程需要运行

  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念

就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等

  • Context Switch 频繁发生会影响性能

2.4 sleep 方法

sleep方法是静态方法

  1. 调用 sleep 会让当前线程从 RUNNABLE 进入 TIMED WAITING 状态(阻塞)

  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException

Thread sleepingThread = new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("睡眠前...");
            Thread.sleep(5000); // 睡眠5秒钟
            System.out.println("睡眠后...");
        } catch (InterruptedException e) {
            System.out.println("线程被中断了!");
        }
    }
});

sleepingThread.start(); // 启动线程

// 等待一段时间后尝试中断线程
try {
    Thread.sleep(2000); // 等待2秒钟
    sleepingThread.interrupt(); // 中断线程
} catch (InterruptedException e) {
    e.printStackTrace();
}

2.5 Thread 常见方法

方法名

static

功能说明

注意

start()

启动一个新线程,在新的线程运行 run 方法的代码

start 方法只是让线程进入就绪,里面代码

run()

新线程启动后会调用的方法

如果在构造 Thread 对象时传递了 Runnable 参数,则线程启动后会调用 Runnable 中的 run 方法,否则默认不执行任何操作。

2.6 线程的生命周期

3. 线程安全与死锁

3.1 一个问题

有如下代码,这里开1个线程对 sharedList 添加100个元素,同时开1个线程删除100个元素:

package com.werun.werunjuly.common.thread;

import java.util.ArrayList;
import java.util.List;

public class LocalVariablesThreadSafeDemo {

    private final List<Integer> sharedList = new ArrayList<>(); // 共享的 ArrayList

    // 模拟线程不安全的添加操作
    public void unsafeAdd(int value) {
        sharedList.add(value); // 多线程同时访问时,可能引发并发问题
    }

    // 模拟线程不安全的删除操作
    public void unsafeRemove(int index) {
        sharedList.remove(index); // 多线程同时访问时,可能引发 IndexOutOfBoundsException
    }

    public static void main(String[] args) {
        LocalVariablesThreadSafeDemo demo = new LocalVariablesThreadSafeDemo();

        // 创建多个线程同时操作共享资源
        Runnable addTask = () -> {
            for (int i = 0; i < 100; i++) {
                demo.unsafeAdd(i);
            }
        };

        Runnable removeTask = () -> {
            for (int i = 0; i < 100; i++) {
                if (!demo.sharedList.isEmpty()) {
                    demo.unsafeRemove(0); // 尝试移除第一个元素
                }
            }
        };

        Thread thread1 = new Thread(addTask, "AddThread");
        Thread thread2 = new Thread(removeTask, "RemoveThread");

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程完成
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 打印最终列表
        System.out.println("Final list size: " + demo.sharedList.size());
    }
}

大概率会报如下异常:

Exception in thread "Thread_1745" java.lang.IndexOutOfBoundsException: Index: 0, Size: 0
	at java.util.ArrayList.rangeCheck(ArrayList.java:659)
	at java.util.ArrayList.remove(ArrayList.java:498)
	at com.werun.werunjuly.common.thread.LocalVariablesThreadSafeDemo.method3(LocalVariablesThreadSafeDemo.java:24)
	at com.werun.werunjuly.common.thread.LocalVariablesThreadSafeDemo.method1(LocalVariablesThreadSafeDemo.java:13)
	at com.werun.werunjuly.common.thread.LocalVariablesThreadSafeDemo.lambda$main$0(LocalVariablesThreadSafeDemo.java:34)
	at java.lang.Thread.run(Thread.java:750)

在这段代码中,两个线程(AddThreadRemoveThread)并发操作共享的 sharedList。主要问题在于 addremove 方法没有同步机制,可能会导致以下问题:

  1. IndexOutOfBoundsException:在 remove 方法尝试从空列表中删除元素时,会抛出异常。

  2. 数据不一致:由于两个线程可以同时访问并修改 sharedList,可能会导致数据损坏。

3.2 同步代码块

  • 同步代码块格式:

    synchronized (/* some object */) { 
      // 线程同步的代码块
    }
  • 示例

      @Override
      public void run() {
        synchronized (this.data) {
          for (int i = 0; i < 100; i++) {
            System.out.println(Thread.currentThread().getName() + ": " + this.data++);
          }
        }
      }
  • 同步的好处和弊端

    • 好处:解决了多线程的数据安全问题

    • 弊端:当线程很多时,因为每个线程都会去判断同步上的锁,这是很耗费资源的,无形中会降低程序的运行效率

  • 同步方法的格式

    同步方法:就是把 synchronized 关键字加到方法上

    修饰符 synchronized 返回值类型 方法名(方法参数) { 
      方法体
    }

    同步方法的锁对象 this

  • 静态同步方法

    同步静态方法:就是把 synchronized 关键字加到静态方法上

    修饰符 static synchronized 返回值类型 方法名(方法参数) { 
      方法体;
    }

    同步静态方法的锁对象是 类名.class

为了防止3.1的这些问题,我们需要对共享资源的访问进行同步。这可以通过 synchronized 关键字来实现,确保只有一个线程可以访问共享资源。同步方法是最简单的同步方式。通过在方法声明中使用 synchronized 关键字,Java 会确保同一时间只有一个线程能执行该方法。下面是同步后 unsafeAddunsafeRemove 方法的修改:

public synchronized void safeAdd(int value) {
    sharedList.add(value); // 保证同一时间只有一个线程能添加元素
}

public synchronized void safeRemove(int index) {
    if (!sharedList.isEmpty()) {
        sharedList.remove(index); // 保证同一时间只有一个线程能删除元素
    }
}

在上面的代码中,synchronized 关键字确保了 safeAddsafeRemove 方法的线程安全性,防止多个线程同时执行这两个方法,从而避免数据损坏或异常。

在某些情况下,可能只需要同步方法的一部分,而不是整个方法。这时可以使用同步块。同步块能在有限的代码范围内加锁,这样可以减少同步开销。

public void safeAdd(int value) {
    synchronized (sharedList) {
        sharedList.add(value);
    }
}

public void safeRemove(int index) {
    synchronized (sharedList) {
        if (!sharedList.isEmpty()) {
            sharedList.remove(index);
        }
    }
}

在这段代码中,synchronized 块确保了对 sharedList 的操作是线程安全的,但不会对整个方法加锁,减少了锁的粒度。

synchronized 关键字会锁定括号中的对象,这里是 sharedList。这意味着同一时间只有一个线程能够持有 sharedList 的锁,其他线程必须等待锁释放才能访问同步代码块。

3.3 死锁

  • 概述

    线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行

  • 什么情况下会产生死锁

    1. 资源有限

    2. 同步嵌套

public class Main {
    public static void main(String[] args) {
        Object objA = new Object();
        Object objB = new Object();

        new Thread(() -> {
            while (true) {
                synchronized (objA) {
                    //线程一
                    synchronized (objB) {
                        System.out.println("小康同学正在走路");
                    }
                }
            }
        })
        .start();

        new Thread(() -> {
            while (true) {
                synchronized (objB) {
                    //线程二
                    synchronized (objA) {
                        System.out.println("小薇同学正在走路");
                    }
                }
            }
        })
        .start();
    }
}

可以使用 jstack 来查看死锁:

  1. 定位Java进程: 使用jps命令来查找Java进程的进程ID(PID)。jps命令会列出当前运行的所有Java进程及其主类的名称。例如:

    jps

    这将输出类似以下内容,其中PID是你关心的Java进程的进程ID:

    1234 MyApplication
    5678 Jps
  2. 获取线程堆栈信息: 使用jstack命令加上-l选项和进程ID来获取指定Java进程的线程堆栈信息。-l选项会输出关于锁的附加信息,这对于死锁分析非常有用。例如:

    jstack -l PID
Found one Java-level deadlock:
=============================
"t2":
  waiting to lock monitor 0x000001e69125af98 (object 0x000000072d92fca8, a java.lang.Object),
  which is held by "t1"
"t1":
  waiting to lock monitor 0x000001e69125d2a8 (object 0x000000072d92fcb8, a java.lang.Object),
  which is held by "t2"

Java stack information for the threads listed above:
===================================================
"t2":
        at com.werun.werunjuly.common.thread.DeadLockDemo.lambda$main$1(DeadLockDemo.java:41)
        - waiting to lock <0x000000072d92fca8> (a java.lang.Object)
        - locked <0x000000072d92fcb8> (a java.lang.Object)
        at com.werun.werunjuly.common.thread.DeadLockDemo$$Lambda$2/1129670968.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:750)
"t1":
        at com.werun.werunjuly.common.thread.DeadLockDemo.lambda$main$0(DeadLockDemo.java:24)
        - waiting to lock <0x000000072d92fcb8> (a java.lang.Object)
        - locked <0x000000072d92fca8> (a java.lang.Object)
        at com.werun.werunjuly.common.thread.DeadLockDemo$$Lambda$1/662441761.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:750)

Found 1 deadlock.

参考

  1. 黑马程序员深入学习Java并发编程,JUC并发编程全套教程 https://www.bilibili.com/video/BV16J411h7Rd

  2. 为什么main方法在Java中代表主线程?https://blog.csdn.net/qq_72758246/article/details/136588776

  3. 牟志鹏同学的技术分享

  4. 陈子涵同学的技术分享