【并发篇】Java并发基础小结

Java并发基础小结

线程和进程的区别

什么是进程?

进程是系统运行程序的基本单位,我们计算机启动的每一个应用程序都是一个进程。如下图所示,在 Windows 中这一个个 exe 文件,都是一个进程。而在 JVM 下,每一个启动的 Main 方法都可以看作一个进程。

在这里插入图片描述

什么是线程?

线程是一个比进程更小的执行单位,是 CPU 调度的基本单位。一个进程在其执行的过程中可以产生多个线程。所以在进行线程切换时的开销会远远小于进程,线程也常常被称为轻量级进程

与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈

区别

  1. 线程是轻量级的执行单元,而进程是重量级的执行单元。在 Java 中,线程由 Java 虚拟机来创建和管理,一个进程可以包含多个线程
  2. 线程共享进程的内存空间和资源,可以通过共享内存来进行通信和同步。进程拥有自己的内存空间和资源,需要通过进程间通信(IPC)来进行通信和同步
  3. 线程之间的切换开销比进程小,因为线程共享进程的资源,不需要切换进程的内存空间和资源。线程之间的切换只需要切换线程的执行上下文即可。
  4. 线程之间的同步和通信比进程更容易,因为线程之间共享进程的内存空间,可以直接共享数据和对象。而进程之间需要通过 IPC 机制来进行通信和同步,开销较大。
  5. 线程的生命周期受到进程的影响,一个进程退出时,它包含的所有线程都会被强制退出。而进程的生命周期不受其他进程的影响,一个进程可以独立于其他进程运行。

堆和方法区了解吗?

堆和方法区是所有线程共享的资源。

  • 其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),
  • 方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

并发与并行的区别

  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。

最关键的点是:是否是 同时 执行。

同步和异步的区别

  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。

为什么要使用多线程?

主要是为了提高程序的性能和并发能力。

  • **从计算机底层来说:**线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
  • **从当代互联网发展趋势来说:**现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

说说线程的生命周期和状态?

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:

  1. NEW: 初始状态,线程被创建出来但没有被调用 start()
  2. RUNNABLE: 运行状态,线程被调用了 start() 等待运行的状态。
  3. BLOCKED:阻塞状态,需要等待锁释放。
  4. WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
  5. TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
  6. TERMINATED:终止状态,表示该线程已经运行完毕。

线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。

具体来说:

  1. 线程创建之后它将处于 **NEW(新建)**状态,

  2. 调用 start() 方法后开始运行,线程这时候处于 **READY(可运行)**状态。

  3. 可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 **RUNNING(运行)**状态。

    • TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,比如通过 sleep(long millis)方法或 wait(long millis)方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
    • 当线程进入 synchronized 方法/块或者调用 wait 后(被 notify)重新进入 synchronized 方法/块,但是锁被其它线程占有,这个时候线程就会进入 BLOCKED(阻塞) 状态。
  4. 线程在执行完了 run()方法之后将会进入到 TERMINATED(终止) 状态。

如何创建一个线程?

常见的有 5 种方式:

  1. 继承 Thread 类: 这是一种比较传统的创建线程的方式。你可以创建一个类,继承自 Thread 类,并重写 run 方法来定义线程的执行逻辑。
class MyThread extends Thread {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
}

// 创建并启动线程
MyThread thread = new MyThread();
thread.start();
  1. 实现 Runnable 接口:这种方式更常用,它避免了 Java 的单继承限制,你可以实现 Runnable 接口,然后将其实例作为参数传递给 Thread 构造函数。
class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
}

// 创建并启动线程
Thread thread = new Thread(new MyRunnable());
thread.start();
  1. 使用匿名内部类:你可以在创建线程时使用匿名内部类,实现 Runnable 接口的 run 方法。
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 线程的执行逻辑
    }
});
thread.start();
  1. 使用 Java 8 的 Lambda 表达式:如果 Runnable 接口只有一个抽象方法,你可以使用 Lambda 表达式简化代码。
Thread thread = new Thread(() -> {
    // 线程的执行逻辑
});
thread.start();
  1. 实现 Callable 接口: Callable 接口允许线程返回结果或抛出异常。需要通过 ExecutorService 来执行。
class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 线程的执行逻辑
        return "Hello from Callable";
    }
}

ExecutorService executor = Executors.newFixedThreadPool(1);
Future<String> future = executor.submit(new MyCallable());
String result = future.get(); // 获取线程执行结果

什么是线程上下文切换?

线程上下文切换是指:CPU 从一个线程中断执行转而执行另一个线程的过程

在多线程编程中,线程上下文切换是非常常见的操作。

这个过程需要耗费一定的时间和资源,因此线程上下文切换的频繁发生会导致系统的性能下降。

上下文切换时机

1、什么是上下文

线程在执行过程中会有自己的运行条件和状态(也称上下文)。

比如程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。(上下文切换通常发生在以下几种情况)

  • 主动让出 CPU,比如调用了 sleep(), wait() 等。
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
  • 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
  • 被终止或结束运行

2、为了减少线程上下文切换带来的性能损失,可以采取以下措施:

  • 减少线程数,避免无谓的上下文切换;
  • 采用线程池技术,避免线程的频繁创建和销毁;
  • 使用非阻塞式 I/O,避免线程等待 I/O 完成时的上下文切换;
  • 优化线程的调度算法,减少线程上下文切换的次数。

sleep() 和 wait() 方法对比

共同点

两者都可以暂停线程的执行。

区别

  1. sleep() 方法没有释放锁,而 wait() 方法释放了锁
  2. wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
  3. wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  4. sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。

为什么 wait() 方法不定义在 Thread 中?

因为 wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。

  • 这句话指出了 wait() 方法的两个关键作用:等待和释放对象锁。
  • 当一个线程调用了对象的 wait() 方法,它会进入等待状态,等待其他线程通过 notify()notifyAll() 方法唤醒它。
  • 同时,该线程会自动释放它当前占有的对象锁,这使得其他等待这个对象锁的线程有机会获得锁并执行临界区代码。

每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

wait() 方法是针对对象锁进行操作,而不是针对线程本身的操作。

  • 这句话强调了对象锁是和对象绑定的,而不是与线程绑定的。
  • 每个 Java 对象都有一个关联的对象锁(监视器锁),这个锁用于对该对象的同步访问。
  • 当线程调用了某个对象的 wait() 方法,它会让出这个对象的锁,让其他线程有机会进入临界区或执行同步代码。
  • 释放的是对象锁,而不是当前线程的锁。这也是为什么在使用 wait() 时需要明确调用的是哪个对象的锁。

什么是对象锁

对象锁是一种多线程同步机制,它用于保护对象的状态和操作,以确保在多线程环境下对象的数据一致性和线程安全性。

在 Java 中,每个对象都有一个关联的对象锁,也称为监视器锁或内置锁。对象锁的作用是防止多个线程同时访问一个对象的临界区代码,从而避免并发访问造成的数据错误和不一致性。

可用 synchronized 关键字来实现。

为什么 sleep() 方法定义在 Thread 中?

因为 sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用 Thread 类的 run 方法吗?

  1. new 一个 Thread,线程进入了新建状态。
  2. 调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。
  3. start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结:

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

守护线程了解吗?

Java 中的线程分为两类,分别为 daemon 线程(守护线程)和 user 线程(用户线程)。

在 JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程。其实在 JVM 内部同时还启动了很多守护线程,守护线程是用来服务用户线程的线程,比如垃圾回收线程。多用于执行后台任务。

那么守护线程和用户线程有什么区别呢?

区别之一是当最后一个非守护线程束时,JVM 会正常退出,而不管当前是否存在守护线程,也就是说守护线程是否结束并不影响 JVM 退出。换而言之,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出。

简单来说就是用户线程会阻止 JVM 的退出,而守护线程不会。

讲一下 JMM(Java 内存模型)

Java 内存模型(Java Memory Model,JMM)是 Java 虚拟机规范中的一部分,它定义了 Java 程序中各种变量的访问方式和存储方式

JMM 的作用是:解决并发编程中的线程安全问题,确保多线程环境下程序的正确性和稳定性。

主要包括以下几个方面:

  1. 主内存和工作内存:Java 内存模型将内存分为主内存和工作内存两部分。

    • 主内存是所有线程共享的内存区域,而每个线程都有自己的工作内存,工作内存中保存了该线程使用到的变量的副本。

    • 线程不能直接对主内存进行操作,而是需要先将变量的副本从主内存中读取到工作内存中,然后再对变量进行操作,操作完成后再将变量的副本写回到主内存中。

  2. 内存屏障:内存屏障(Memory Barrier)是一种机制,用于确保线程之间的内存可见性和操作的有序性

    • JMM 中定义了四种内存屏障:Load Barrier、Store Barrier、Read Barrier、Write Barrier,分别用于确保变量的读、写和读写操作的顺序和可见性。
  3. happens-before 关系:happens-before 是 Java 内存模型中的一个概念,用于描述变量之间的先后顺序和可见性

    • 如果一个操作 happens-before 另一个操作,那么第一个操作的结果对第二个操作是可见的,而且第一个操作的执行顺序在第二个操作之前。
  4. 原子性、可见性和有序性:JMM 保证了原子性、可见性和有序性的内存操作。

    • 原子性指的是一个操作是不可分割的整体,要么全部执行,要么全部不执行;

    • 可见性指的是一个线程对变量的修改对其他线程是可见的;

    • 有序性指的是指令的执行顺序是有序的,保证了程序的正确性。

AQS

什么是 AQS

AQS,全称为 AbstractQueuedSynchronizer,是 Java 并发编程中的一个重要组件。

它提供了一种灵活的框架,可以用来实现各种同步工具,比如锁、信号量、倒计时门栓等。

AQS 原理了解么?

AQS 的核心思想是使用一个 FIFO 的等待队列来管理线程的获取和释放资源。

AQS 维护一个 state 变量,用来表示同步状态,同时通过一个双向链表来实现等待队列,并提供了 acquire、release、tryAcquire、tryRelease 等方法,允许子类通过重写这些方法来实现特定的同步逻辑。

用过 CountDownLatch 么?什么场景下用的?

概念

CountDownLatch 是 Java 并发编程中的一个同步工具,它允许一个或多个线程等待其他线程完成操作后再执行。

原理

CountDownLatch 的核心思想是:通过一个计数器来实现,计数器初始值为线程数,每个线程完成操作后会将计数器 -1,当计数器减为 0 时,所有等待的线程都会被唤醒。

用法

CountDownLatch 的用法如下

  1. 创建 CountDownLatch 对象,并指定计数器的初始值。
  2. 各个线程执行任务,并在任务完成后调用 CountDownLatch 的 countDown 方法,将计数器 -1。
  3. 主线程调用 CountDownLatch 的 await 方法,等待所有任务完成。

应用场景

CountDownLatch 的应用场景包括:

  1. 主线程等待多个子线程完成任务后再执行,可以使用 CountDownLatch 来实现。
  2. 一些任务需要等待其他任务完成后才能执行,可以使用 CountDownLatch 来实现。
  3. 测试场景中,可以使用 CountDownLatch 来控制测试用例的执行顺序。
  4. 多个线程并发执行,需要等待所有线程完成后再进行合并操作,可以使用 CountDownLatch 来实现。

参考文献

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值