并发编程-02深入理解Java线程

发布于:2024-06-26 ⋅ 阅读:(44) ⋅ 点赞:(0)

一 线程基础知识
在这里插入图片描述

1.1 理解线程和进程

进程和线程的概念:

进程:

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO的 。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360安全卫士等)。
  • 操作系统会以进程为单位,分配系统资源(CPU时间片、内存等资源),进程是资源分配的最小单位。

线程:

  • 线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。
  • 线程,有时被称为轻量级进程(Lightweight Process,LWP),是操作系统调度 (CPU调度)执行的最小单位。
    进行和线程的区别:
  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享。
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPC(Inter-process communication)
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议, 例如 HTTP。
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一 个共享变量。
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低。

进程间通信的方式:

1.管道(pipe)及有名管道(named pipe):管道可用于具有亲缘关系的父子进程间的通信,有名管道除了具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。

2.信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一致的。

3.消息队列(message queue):消息队列是消息的链接表,它克服了上两种通信方式中信号量有限的缺点,具有写权限的进程可以按照一定得规则向消息队列中添加新信息; 对消息队列有读权限的进程则可以从消息队列中读取信息。

4.共享内存(shared memory):可以说这是最有用的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。

5.信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间得同步和互斥手段。

6.套接字(socket):这是一种更为一般的进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。

1.2 线程的同步互斥

线程同步是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。

线程互斥是指对于共享的进程系统资源,在各单个线程访问时的排它性。当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。线程互斥可以看成是一种特殊的线程同步。

如何显示线程互斥效果呢?主要有以下方式:

1.临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。(在一段时间内只允许一个线程访问的资源就称为临界资源)。

2.互斥量:为协调共同对一个共享资源的单独访问而设计的。

3.信号量:为控制一个具有有限数量用户资源而设计。

4.事件:用来通知线程有一些事件已发生,从而启动后继任务的开始。

1.3 上下文切换

因为同一时候一个处理器只能处理一个线程,线程执行任务是以处理器的时间片为单位,上下文切换是指CPU(中央处理单元)从一个进程或线程到另一个进程或线程的切换。

进程是程序的一个执行实例。在Linux中,线程是轻量级进程,可以并行运行,并与父进程(即创建线程的 进程)共享一个地址空间和其他资源。

上下文是CPU寄存器和程序计数器在任何时间点的内容。

寄存器是CPU内部的一小部分非常快的内存(相对于CPU外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。

程序计数器是一种专门的寄存器,它指示CPU在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。

上下文切换可以更详细地描述为内核(即操作系统的核心)对CPU上的进程(包括线程)执行以下活动:

1.暂停一个进程的处理,并将该进程的CPU状态(即上下文)存储在内存中的某个地方

2.从内存中获取下一个进程的上下文,并在CPU的寄存器中恢复它

3.返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程

上下文切换只能在内核模式下发生。内核模式是CPU的特权模式,其中只有内核运行,并提供对所有内存位置和所有其他系统资源的访问。其他程序(包括应用程序)最初在用户模式下运行,但它们可以通过系统调用运行部分内核代码。

内核模式(Kernel Mode)

在内核模式下,执行代码可以完全且不受限制地访问底层硬件。它可以执行任何CPU指令和引用任何内存地址。内核模式通常为操作系统的最低级别、最受信任的功能保留。内核模式下的崩溃是灾难性的;他们会让整个电脑瘫痪。

用户模式(User Mode)

在用户模式下,执行代码不能直接访问硬件或引用内存。在用户模式下运行的代码必须委托给系统api来访问硬件或内存。由于这种隔离提供的保护,用户模式下的崩溃总是可恢复的。在您的计算机上运行的大多数代码将在用户模式下执行。

应用程序一般会在以下几种情况下切换到内核模式:

1.系统调用。

2.异常事件:当发生某些预先不可知的异常时,就会切换到内核态,以执行相关的异常事件。

3.设备中断。在使用外围设备时,如外围设备完成了用户请求,就会向CPU发送一个中断信号,此时,CPU就会暂停执行原本的下一条指 令,转去处理中断事件。此时,如果原来在用户态,则自然就会切换到内核态。

思考:CAS操作是否涉及到用户态到内核态的切换?

当然不涉及,CAS操只是执行CPU指令而已,不涉及用户态内核态切换。在Linux X86架构中,CAS是在用户态下使用Lock前缀指令 + cmpxchg指令来保证线程安全的。

上下文切换是多任务操作系统的一个基本特性。在多任务操作系统中,多个进程似乎同时在一个 CPU上执行,彼此之间互不干扰。这种并发的错觉是通过快速连续发生的上下文切换(每秒数十次或数百次)来实现的。这些上下文切换发生的原因是进程自愿放弃它们在CPU中的时间,或者是调度器在进程耗尽其CPU时间片时进行切换的结果。

上下文切换通常是计算密集型的。就CPU时间而言,上下文切换对系统来说是一个巨大的成本, 实际上,它可能是操作系统上成本最高的操作。因此,操作系统设计中的一个主要焦点是尽可能地避免不必要的上下文切换。与其他操作系统(包括一些其他类unix系统)相比,Linux的众多优势之一是它的上下文切换和模式切换成本极低。

1.4 操作系统层面线程生命周期

操作系统层面的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。

1.初始状态:指的是线程已经被创建,但是还不允许分配 CPU 执行。这个状态属于编程语言特有的,不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有创建。

2.可运行状态:指的是线程可以分配给CPU执行。在这种状态下,真正的操作系统线程已经被成功创建了,所以可以分配给CPU执行。

3.运行状态:当有空闲的CPU时,操作系统会将其分配给一个处于可运行状态的线程,被分配到CPU的线程的状态就转换成了运行状态。

4.休眠状态:运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件 (例如条件变量),那么线程的状态就会转换到休眠状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等待的事件出现了,线程就会从休眠状态转换到可运行状态。

5.终止状态:线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。

这五种状态在不同编程语言里会有简化合并。例如,C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了;Java 语言里则把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而JVM层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理 了。

二 Java线程

2.1 Java创建线程的方式

方式1:使用 Thread类或继承Thread类

        Thread thread = new Thread() {
            @Override
            public void run() {
                System.out.println("方式一:通过Thread创建线程");
            }
        };
        return thread;

方式2:实现 Runnable接口配合Thread

    public static Runnable getThreadByRunnable(){


        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("方式二:通过Runnable创建线程");
            }
        };
        
        return runnable;
    }

方式3:实现Callable接口并搭配FutrueTask和Thread

    public static Callable<Integer> getThreadByCallable(){

        return new MyCallableTask();
        
    }

方式4:通过线程池方式

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4, 8, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
        threadPoolExecutor.submit(CreateThreadTest::getThreadByCallable);
        threadPoolExecutor.submit(CreateThreadTest::getThreadByRunnable);

统一启动线程:

        //方式1:
        Thread thread = CreateThreadTest.getThreadByNewThread();
        thread.start();
        //方式2:
        Thread thread1 = new Thread(CreateThreadTest.getThreadByRunnable());
        thread1.start();
        //方式3:
        FutureTask task = new FutureTask(CreateThreadTest::getThreadByCallable);

        Thread thread2 = new Thread(task);
        thread2.start();
        System.out.println(task.get());

        //方式4:
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(4, 8, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
        threadPoolExecutor.submit(CreateThreadTest::getThreadByCallable);
        threadPoolExecutor.submit(CreateThreadTest::getThreadByRunnable);

可以看出,无论哪种方式创建线程,其实本质上都在通过new Thread()创建线程,调用Thread#start启动线程,然后最终都会调用Thread#run方法

针对通过实现Callable和Runnable接口创建线程方式的区别:

1.Runnable的run方法不能声明检查时异常且没有返回值

2.Callable的call方法可以声明异常且可以有返回值。

@FunctionalInterface
public interface Runnable {

    public abstract void run();
}

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

2.2 Java线程实现原理

通过以上我们指定,当我们创建一个线程的时候,它都会调用Thread类的run

方法,那么它和我们不调用start方法直接调用线程类run方法有什么区别呢?

当我们new Thread创建线程的时候,这个时候操作系统并没有创建线程,此时如果我们直接调用run方法,相当于是当前线程调用了run方法而不是新线程执行run方法,也就意味着我们的方法是同步调用。所以Java线程底层原理就是start方法,我们一起探究下start方法底层做了什么吧。

    public synchronized void start() {
 
        //线程状态不是new则抛出异常
        if (threadStatus != 0)
            throw new IllegalThreadStateException();

		//添加到线程组
        group.add(this);

        boolean started = false;
        try {
            //开启线程
            start0();
            started = true;
        } finally {
            try {
                if (!started) {
                    group.threadStartFailed(this);
                }
            } catch (Throwable ignore) {
                /* do nothing. If start0 threw a Throwable then
                  it will be passed up the call stack */
            }
        }
    }

通过源码就可以知道,我们的start方法只可以调用一次,多次调用就会抛出异常,而我们java线程映射到操作系统的线程的重点是start0方法:

    private native void start0();

它是一个本地方法,这一块需要看jvm的源码,大概流程就是:

1)使用new Thread()创建一个线程,然后调用start()方法进行java层面的线程启动

2)线程类的start方法会调用本地方法start0(),去调用jvm中的JVM_StartThread方法进行线程创建和启动

3)调用new JavaThread(&thread entry, sz)进行虚拟机层面线程的创建,并根据不同的操作系统平台调用对应的os:create thread方法进行操作系统层面的线程创建

4)这个时候新创建的线程状态为Initialized,这里循环判断线程的状态是否是Initialized状态,如果是则调用sync->wait()方法进行阻塞等待,等到被唤醒才继续执行thread->run().

5)调用Thread::start(native thread)方法进行线程启动,此时将线程状态设置为RUNNABLE,接着调用os.start_thread(thread),根据不同的操作系统选择不同的线程启动方式,

6)线程启动之后状态设置为RUNNABLE, 并唤醒第4步中等待的线程,接着执行thread->run()的方法

7)JavaThread::run()方法会回调第1步new Thread中复写的run()方法

整体上就是java线程-jvm线程-os线程

java线程调用start方法-jvm创建线程-os创建线程-线程阻塞-修改线程状态为runnable-唤醒等待的线程-调用jvm的run方法-调用java线程的run方法

注意:Java线程属于内核级线程

内核级线程(Kernel Level Thread ,KLT):它们是依赖于内核的,即无论是用户进程中的线程,还是系统进程中的线程,它们的创建、撤消、切换都由内核实现。

用户级线程(User Level Thread,ULT):操作系统内核不知道应用线程的存在(协程的实现)。

2.3 协程

协程,英文Coroutines, 是一种基于线程之上,但又比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),具有对内核来说不可见的特性。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用 了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。而协程的调用和子程序不同。协程在子程序内部是可中断的,然后转而执行别的子程序,在适当的时候再返回来接着执行。

def A():
	print '1'
	print '2'
	print '3'
def B():
	print 'x'
	print 'y'
	print 'z'	

假设由协程执行,在执行A的过程中,可以随时中断,去执行B,B也可能在执行过程中中断再去执行A,结果可能是:1 2 x y 3 z。

协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

  • 线程的切换由操作系统调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率。
  • 线程的默认stack大小是1M,而协程更轻量,接近1k。因此可以在相同的内存中开启更多的协程。
  • 不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

注意: 协程适用于被阻塞的,且需要大量并发的场景(网络io)。不适合大量计算的场景。

2.4 线程的调度机制

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度。

  • 协同式线程调度:线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没啥线程同步问题。坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。
  • 抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java中, Thread.yield()可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会有一个线程导致整个进程阻塞。

那我们Java中线程的调度机制是什么呢?

Java线程调度采用的是抢占式调度,希望系统能给某些线程多分配一些时间,给一些线程少分配一些时间,可以通过设置线程优先级来完成。Java语言一共10个级别的线程优先级(Thread.MIN_PRIORITY至 Thread.MAX_PRIORITY),在两线程同时处于ready状态时,优先级越高的线程越容易被系统选择执行。但优先级并不是很靠谱,因为Java线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。


网站公告

今日签到

点亮在社区的每一天
去签到