进程、线程、协程 面试中的基础与关键 Synchronized与Reentranlock

29 篇文章 0 订阅
本文详细介绍了Java中的进程、线程和协程的概念,包括线程状态、进程间通信、线程同步机制如synchronized和ReentrantLock,以及JNI在Java与操作系统交互中的作用。同时探讨了线程上下文切换的损耗及其原因,指出多线程和多进程在不同场景下的适用性。
摘要由CSDN通过智能技术生成

目录

前言

摘要

JNI

进程、线程、协程

为什么线程上下文切换损耗大?

为什么进程上下文切换损耗比线程切换要大?

多进程和多线程

进程间通信方式

线程间通信方式(或者说线程之间的协作与同步互斥)

1、Synchronized

2、ReentrantLock

ReentrantLock和synchronized使用分析与对比

3、 join()

4、 wait() notify() notifyAll()

5、 await() signal() signalAll()

守护线程、僵尸线程、孤儿进程


前言

对于Java开发者来说,进程和线程的概念,既基础,又关键。我们需要了解JMM模型,也需要了解JVM运行时数据区,知道合适创建线程和进程,怎么样分配空间才是最友好的,最高效的也是十分重要的。

同时,作为操作系统中的重要概念,我们在求职过程中面试官也会单独考察同一概念在不同背景下的细节,同时更会考察彼此之间的联系,比如Java线程和操作系统线程有什么区别,Java代码和内核是怎么交互的? Java线程有几种状态,分别是什么意义? 这种问题是很常见的。

之前还遇到过一个极为硬核的面试问题:Java程序,或者说JVM内的内存是如何与操作系统的虚拟内存交互的?

回答:

Java运行时环境(JRE)是Java程序运行的基础,它是由Java虚拟机(JVM)和Java类库组成的。JVM是Java程序运行的核心,它是一个虚拟的计算机,负责将Java字节码解释成机器码并执行程序。JVM在运行时会使用系统的虚拟内存。

在JVM运行时,JVM会向操作系统申请一块内存,这块内存被称为堆(heap)。堆是JVM中存储对象的地方,所有的Java对象都在堆中分配空间。在JVM中,堆被分成两部分:新生代和老年代。新生代用于存储新创建的对象,老年代则用于存储存活时间较长的对象。当堆中的内存不足时,JVM会启动垃圾回收器进行垃圾回收,释放无用对象占用的内存空间。

JVM还有一些非堆内存,包括方法区和本地方法栈。方法区用于存储类信息、常量、静态变量等,本地方法栈用于支持本地方法的调用。

操作系统中的虚拟内存是由操作系统管理的,它将物理内存分成若干个大小相等的页面,每个页面都有一个对应的虚拟地址。当程序访问一个虚拟地址时,操作系统会将其映射到一个物理地址上。如果虚拟地址对应的物理地址不在内存中,操作系统会将物理内存中不常用的页面换出到硬盘上,从而腾出空间给虚拟内存使用。

JVM和操作系统之间没有共用的内存空间,它们之间的数据交互和传输是通过Java Native Interface (JNI)实现的。JNI是Java提供的一种机制,用于在Java程序中调用C或C++等本地语言编写的代码。通过JNI,Java程序可以调用操作系统提供的API,实现与操作系统的交互。

在Java程序中,如果需要使用操作系统提供的功能,可以通过JNI调用本地方法。本地方法是由C或C++等语言编写的,它们可以直接访问操作系统提供的API。当Java程序调用本地方法时,JNI会将Java对象转换成本地对象,以便本地方法调用操作系统API。本地方法执行完毕后,JNI会将结果转换成Java对象,返回给Java程序。

综上所述,JVM和操作系统之间的数据交互和传输是通过JNI实现的,它们之间没有共用的内存空间。JVM使用堆和非堆内存存储Java程序中的数据,操作系统使用虚拟内存管理物理内存。在Java程序中,如果需要使用操作系统提供的功能,可以通过JNI调用本地方法,实现与操作系统的交互。

总之,我们日常对这三个重要概念有一定的回顾,梳理是十分重要的。


摘要

Java中的进程、线程和协程都是并发编程的重要概念。

  • 进程是操作系统中的基本单位,每个进程都有自己独立的内存空间和系统资源,可以独立运行。Java中可以通过Runtime和ProcessBuilder等类创建和控制进程。
  • 线程是进程中的执行单元,可以共享进程的内存空间和系统资源。Java中的线程有这几种状态:新建(New)、可运行(Runnable)、阻塞(Blocking)、无限期等待(Waiting)、限期等待(Timed Waiting)和终止(Terminated)。线程之间可以通过共享变量和锁进行通信和同步。
  • 协程是一种用户空间线程,可以在代码中显式调度。与线程不同,协程的切换不需要进行系统调用,因此切换非常快速。Java中没有原生的协程支持,但可以通过第三方库实现。

在Java中,线程与操作系统线程的区别在于,操作系统线程由操作系统调度,而Java线程是由JVM调度。Java代码与内核交互主要是通过JNI实现的。JVM内存与操作系统虚拟内存的交互,是通过JVM的内存管理和垃圾回收机制实现的。JVM分为堆和栈两个区域,其中堆是用于存储对象的,栈是用于存储方法调用和局部变量的。JVM会根据应用程序的需求动态调整堆的大小,以保证应用程序的性能。

总之,进程、线程和协程是并发编程中非常重要的概念,掌握它们的功能、实现方式以及与操作系统的交互方式,对于Java开发者来说是必不可少的。

概念解释:

JNI

Java Native Interface(JNI)是Java平台的一个重要特性,它允许Java代码与本地代码(如C或C++代码)进行交互。JNI提供了一种机制,使得Java代码可以调用本地代码中的函数和方法,从而实现了Java与本地代码的无缝集成。

JNI的底层实现是通过Java虚拟机(JVM)来完成的。JVM负责将Java代码编译成字节码,并将字节码加载到内存中执行。在执行字节码时,JVM会调用本地代码中的函数和方法,并将结果返回给Java代码。

JNI的主要作用是实现Java与本地代码之间的无缝集成。通过JNI,Java代码可以调用本地代码中的函数和方法,从而实现了Java与本地代码的无缝集成。这种集成可以提高程序的性能和可移植性,因为本地代码可以直接与Java代码进行交互,而不需要通过中间层的转换。

如果没有JNI,Java代码将无法与本地代码进行交互。


进程、线程、协程

(1)进程是资源分配的基本单位,它是程序执行时的一个实例,在程序运行时创建。进程拥有代码和打开的文件资源、数据资源、独立的内存空间。一个程序至少有一个进程,一个进程至少有一线程。

进程是资源竞争的基本单位。

(2)线程从属于进程,是程序执行的最小单位,是进程的一个执行流,一个进程由多个线程组成的。线程共享进程资源。一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉

线程是程序执行的最小单位。

  • 线程状态。线程可以处于以下状态之一:
    • NEW尚未启动的线程处于此状态。
    • RUNNABLE在Java虚拟机中执行的线程处于此状态。
    • BLOCKED被阻塞等待监视器锁定的线程处于此状态。
    • WAITING无限期等待另一个线程执行特定操作的线程处于此状态。
    • TIMED_WAITING正在等待另一个线程执行最多指定等待时间的操作的线程处于此状态。
    • TERMINATED已退出的线程处于此状态。

以Java为例,线程切换状态机如下图所示:

(3)协程是一种比线程更加轻量级的存在,正如一个进程可以拥有多个线程一样,一个线程可以拥有多个协程。协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。

协程不是进程也不是线程,而是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处继续运行。所以说,协程与进程、线程相比并不是一个维度的概念。一个线程内协程却绝对是串行的,无论CPU有多少个核

上下文切换:进程和线程切换者是操作系统,协程的切换者是用户

线程常用的方法

方法名说明
setName设置线程名称,使之与参数名相同
getName返回线程名
start开启线程,JVM调用该线程start0方法
run调用线程对象run方法
setPriority更改线程优先级
getPriority获取该线程优先级
sleep指定毫秒数让当前正在执行的线程休眠
interruput中断线程
  • yield方法,礼让但不一定成功;
  • join方法,插队,一旦插队成功必先执行完所有插队任务再回到调用线程

线程优先级的概念了解吗?

在Java中,线程的优先级是指线程在执行时的执行顺序。Java中的线程优先级是一个整数,用于表示线程的优先级。Java中的线程优先级分为以下几种:

  1. 公共优先级:0
  2. 私有优先级:1
  3. 保护优先级:2
  4. 受保护优先级:3

结合源码:

  /**
     * 最低级别
     */
    public final static int MIN_PRIORITY = 1;

   /**
     * 默认级别
     */
    public final static int NORM_PRIORITY = 5;

    /**
     * 最高级别
     */
    public final static int MAX_PRIORITY = 10;

获取线程优先级:

 public static void main(String[] args) {
    new Thread(() -> {
        System.out.println("default level: {}" + Thread.currentThread().getPriority());
    }).start();
}

设置线程的优先级:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("default level: {}" + Thread.currentThread().getPriority());
    });
    t.start();
    t.setPriority(10);
}

线程优先级的概念在Java中非常重要,通常来讲,高级别的优先级往往会更高几率的执行,注意这里是概率性问题。当多个线程同时访问共享资源时,线程优先级高的线程可以先执行,而线程优先级低的线程则需要等待。

一个小点如果一个线程被设置为了守护线程(setDaemon(true)),那么即使其被我们主动设置了高优先级数值,其实际执行的优先级仍然是最低的。


为什么线程上下文切换损耗大?

1、线程在内核态,每次线程切换都要切换到内核,由操作系统调度,而从用户态切换到内核是时间开销比较大的

2、上下文切换会把当前任务状态保存下来,当前运行任务转为就绪(或者挂起、删除)状态,另一个被选定的就绪任务成为当前任务。之后CPU可以回过头再处理之前被挂起任务。这个过程中需要CPU在寄存器和运行队列之间来回奔波,资源的保存和恢复浪费时间。

为什么进程上下文切换损耗比线程切换要大?

进程的一些信息保存在PCB也就是进程控制块里面

进程切换分两步

1.从页表查询来看,因为进程切换务必导致页表也要换,切换页目录以使用新的地址空间

2.切换内核栈和寄存器的切换

对于linux来说,线程和进程的最大区别就在于地址空间,对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。

切换的性能消耗:

1、线程上下文切换和进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

2、另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。

3、还有一个显著的区别是当改变虚拟内存空间的时候,处理的页表缓冲(processor's Translation Lookaside Buffer (TLB))将清空,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

多进程和多线程

多线程模型适用于I/O密集型场景,因为I/O密集型场景因为I/O阻塞导致频繁切换,线程只占用栈,程序计数器,一组寄存器等少量资源,切换效率高,单机多核分布式;

多进程模型适用于需要频繁的计算场景,多机分布式

进程间通信方式

1、无名管道( pipe )

管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

2、高级管道(popen)

将另一个程序当做一个新的进程在当前程序进程中启动,则它算是当前程序的子进程,这种方式我们成为高级管道方式。

3、有名管道 (named pipe)

 有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

4、消息队列( message queue )

消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。

5、信号量( semophore )

 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

6、信号 ( sinal )

 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

7、共享内存( shared memory )

共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。

8、套接字( socket )

套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。

线程间通信方式(或者说线程之间的协作与同步互斥)

1、Synchronized

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

需要注意的点:Synchronized在编译时是如何实现锁机制的?

答:

Synchronized进过编译,会在同步块的前后分别形成monitorenter和monitorexit这个两个字节码指令。在执行monitorenter指令时,首先要尝试获取对象锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象锁,把锁的计算器加1,相应的,在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。如果获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。

2、ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。


ReentrantLock和synchronized使用分析与对比

  • ReentrantLock是Lock的实现类,是一个互斥的同步器,在多线程高竞争条件下,ReentrantLock比synchronized有更加优异的性能表现。
  • 1、用法比较
    • Lock使用起来比较灵活,但是必须有释放锁的配合动作
    • Lock必须手动获取与释放锁,而synchronized不需要手动释放和开启锁
    • Lock只适用于代码块锁,而synchronized可用于修饰方法、代码块等
  • 2、特性比较
    • ReentrantLock的优势体现在:
      • 具备尝试非阻塞地获取锁的特性:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁
      • 能被中断地获取锁的特性:与synchronized不同,获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
      • 超时获取锁的特性:在指定的时间范围内获取锁;如果截止时间到了仍然无法获取锁,则返回
  • 3、注意事项
    • 在使用ReentrantLock类的时,一定要注意三点:
      • 在finally中释放锁,目的是保证在获取锁之后,最终能够被释放
      • 不要将获取锁的过程写在try块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放。
      • ReentrantLock提供了一个newCondition的方法,以便用户在同一锁的情况下可以根据不同的情况执行等待或唤醒的动作。
特性SynchronizedReentrantLock
实现方式监视器模式    依赖AQS
获取锁的方式显式获取,隐式获取显式获取,隐式获取
可响应中断可以响应中断不可以响应中断
锁类型非公平锁公平锁、非公平锁
条件队列关联一个条件队列可关联多个条件队列
释放形式自动释放监视器必须显示调用unlock()释放锁
可重入性        可重入可重入

用代码的方式进行两种锁的功能演示:

// **************************Synchronized的使用方式**************************
// 1.用于代码块
synchronized (this) {}
// 2.用于对象
synchronized (object) {}
// 3.用于方法
public synchronized void test () {}
// 4.可重入
for (int i = 0; i < 100; i++) {
	synchronized (this) {}
}
// **************************ReentrantLock的使用方式**************************
public void test () throw Exception {
	// 1.初始化选择公平锁、非公平锁
	ReentrantLock lock = new ReentrantLock(true);
	// 2.可用于代码块
	lock.lock();
	try {
		try {
			// 3.支持多种加锁方式,比较灵活; 具有可重入特性
			if(lock.tryLock(100, TimeUnit.MILLISECONDS)){ }
		} finally {
			// 4.手动释放锁
			lock.unlock()
		}
	} finally {
		lock.unlock();
	}
}

对于AQS的介绍,可简单通过下图了解,具体解释将在另外的文章中说明

  • 上图中有颜色的为Method,无颜色的为Attribution。

  • 总的来说,AQS框架共分为五层,自上而下由浅入深,从AQS对外暴露的API到底层基础数据。

  • 当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的API进入AQS内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层。


3、 join()

在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。

对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。

public class JoinExample {

    private class A extends Thread {
        @Override
        public void run() {
            System.out.println("A");
        }
    }

    private class B extends Thread {

        private A a;

        B(A a) {
            this.a = a;
        }

        @Override
        public void run() {
            try {
                a.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B");
        }
    }

    public void test() {
        A a = new A();
        B b = new B(a);
        b.start();
        a.start();
    }
}


public static void main(String[] args) {
    JoinExample example = new JoinExample();
    example.test();
}

输出:

A

4、 wait() notify() notifyAll()

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。

wait() 和 sleep() 的区别

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;

  • wait() 会释放锁,sleep() 不会。

5、 await() signal() signalAll()

守护线程、僵尸线程、孤儿进程

  • 守护线程是一种特殊的线程,就和它的名字一样,它是系统的守护者,在后台默默完成一些系统性的服务,比如垃圾回收线程,JIT线程就可以理解为守护线程。 如果用户线程全部结束,则意味着这个程序无事可做。守护线程要守护的对象已经不存在了,那么整个应用程序就应该结束。因此,当一个Java应用内只有守护线程时,Java虚拟机自然退出。

main() 属于非守护线程。

使用 setDaemon() 方法将一个线程设置为守护线程。

public static void main(String[] args) {
    Thread thread = new Thread(new MyRunnable());
    thread.setDaemon(true);
}
  • 僵尸进程当前进程运行结束后,其父进程仍在运行或仍未结束并且父进程没有调用wait来清理已结束的子进程,或者一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。

    如果进程不调用wait / waitpid的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。

  • 孤儿进程

    一个父进程结束运行。而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。

    孤儿进程将被init进程(进程号为1)所收养,并由内核init进程对它们完成状态收集工作,而init进程会循环地wait()它的已经退出的子进程。因此孤儿进程并不会有什么危害。

参考资料:

进程间通信的方式有哪几种 • Worktile社区

Java 并发 - 线程基础 (yuque.com)

Synchronize和ReentrantLock区别 - 掘金 (juejin.cn)

Java并发常见面试题总结(中) | JavaGuide(Java面试+学习指南)

从ReentrantLock的实现看AQS的原理及应用 - 美团技术团队 (meituan.com)

java线程基础知识_牛客网 (nowcoder.com)

面试官: 有了解过线程组和线程优先级吗 - 掘金 (juejin.cn)

https://www.dre.vanderbilt.edu/~schmidt/cs891s/2020-PDFs/13.2.2-thread-lifecycle-pt2-state-machine.pdf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值