Java-并发-Java并发编程的基础

1 进程和线程

1.1 进程

  简单地说,一个正在运行的程序,就对应一个进程,进程是系统进行资源分配的基本单位,每一个进程都有它自己的内存空间和系统资源,是一个实体。
  在Java 中,当我们启动main 函数时其实就启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。

1.1.1 多进程的意义

  单进程计算机只能做一件事情。而我们现在的计算机都可以一边玩游戏(游戏进程),一边听音乐(音乐进程),所以我们常见的操作系统都是多进程操作系统。比如:Windows,Mac和Linux等,能在同一个时间段内执行多个任务。

1.1.2 CPU

  中央处理器,整个电脑的核心。
  特点:计算机都是单CPU的,在具体的某一个时刻,CPU只能完成某个一个工作
  计算机在某个一个时刻可以同时的运行多个软件,感觉起来同时运行一样,为什么呢?
  其实是不同的应用程序在抢夺CPU的执行权(在等待CPU时间片的到来),等到,就执行该应用程序。执行完成后,会立刻释放cpu的执行权,cpu去执行其他的应用程序。这个切换不同程序的时间是纳秒级别,所以cpu的切换速度非常快,我们根本察觉不到这么短的时间的延迟,所以感觉像是同时运行多个程序。现在的多核心处理器,每个核心都能独立运行一个应用程序,提升了多线程的能力。
  多进程的作用不是提高执行速度,而是提高CPU的使用率。

1.2 线程

  线程是进程当中的一个执行单元,真正的负责程序的执行。有时被称为轻量进程(Lightweight Process,LWP),是程序执行流的最小单元,是程序执行、CPU调度的最小单位。CPU 一般是使用时间片轮转方式让线程轮询占用的。

1.2.1 进程与线程的关系

  一个进程当中至少有一个线程,这个应用程序称之为单线程的应用程序;如果一个进程当中有两个或者是多个线程,这个应用程序就是一个多线程的应用程序。
  Java对操作系统提供的功能进行封装,包括进程和线程;运行一个程序会产生一个进程,进程包含至少一个线程;每个进程对应一个JVM实例,多个线程共享JVM里的堆;Java采用单线程编程模型,程序会自动创建主线程;主线程可以创建子线程,原则上要后于子线程完成执行。

1.2.2 多线程的意义

  多线程的作用同样不是提高执行速度,而是为了提高应用程序的使用率。而多线程却给了我们一个错觉:让我们认为多个线程是并发执行的。其实不是。
  因为多个线程共享同一个进程的资源(堆内存和方法区),但是栈内存是独立的,一个线程一个栈。所以他们仍然是在抢CPU的资源执行。一个时间点上只有能有一个线程执行。而且谁抢到,这个不一定,所以,造成了线程运行的随机性。

2 基本概念

2.1 并发(Conncurrency)和并行(Parallelism)

  如果一个应用程序当中,多个线程微观上是走走停停,宏观上都在同时运行。这种现象叫并发,但不是绝对意义上的同时发生。实则操作系统里面“同一时刻”只有一个线程在执行,但是处理速率快,效果上是并发运行。
  那么,我们能不能实现真正意义上的并发呢,即同时发生呢?是可以的,多个CPU或者多核就可以实现,不过你得知道如何调度和控制它们。
  应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行。并发和并行的区别是:并发是交替执行多个任务,并行是真正的同时执行多个任务。

2.2 同步(Synchronous)和异步(Asynchronous)

  同步和异步通常用来形容一次方法调用。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回, 调用者就可以继续后续的操作。而异步方法通常会在另外一个线程中“ 真实” 地执行。整个过程,不会阻碍调用者的工作。
  对于调用者来说, 异步调用似乎是一瞬间就完成的。如果异步调用需要返回结果,那么当这个异步调用真实完成时,则会通知调用者。

2.3 临界区

  临界区用来表示一种公共资源或者说是共享数据, 可以被多个线程使用。 但是每一次, 只能有一个线程使用它,一旦临界区资源被占用,其他线程要想使用这个资源,就必须等待。
  比如,在一个办公室里有一台打印机。打印机一次只能执行一个任务。如果小王和小明同时需要打印文件,很显然,如果小王先下发了打印任务,打印机就开始打印小王的文件。 小明的任务就只能等待小王打印结束后才能打印。这里的打印机就是一个临界区的例子。
  在并行程序中,临界区资源是保护的对象,如果意外出现打印机同时执行两个打印任务,那么最可能的结果就是打印出来的文件就会是损坏的文件。它既不是小王想要的,也不是小明想要的。

2.4 阻塞(Blocking)和非阻塞C Non-Blocking)

  阻塞和非阻塞通常用来形容多线程间的相互影响。比如一个线程占用了临界区资源,那么其他所有需要这个资源的线程就必须在这个临界区中进行等待。等待会导致线程挂起, 这种情况就是阻塞。此时,如果占用资源的线程一直不愿意释放资源,那么其他所有阻塞在这个临界 区上的线程都不能工作。
  非阻塞的意思与之相反,它强调没有一个线程可以妨碍其他线程执行。所有的线程都会尝试不断前向执行。

2.5 并发级别

  根据控制并发的策略,可以把并发的级别进行分类,大致上可以分为:阻塞(Blocking)、无饥饿(Starvation-Free)、无障碍(Obstruction-Free)、无锁(Lock-Free)、无等待(Lock-Free)。
1. 阻塞
  使用synchronized关键字或重入锁等时,会阻塞其他线程获取临界区资源

2. 无饥饿
  使用公平锁时,先到先得,不会产生饥饿;非公平锁,由于竞争激烈,或者某些线程优先级高导致低优先级的线程有可能产生饥饿

3. 无障碍
  是一种最弱的非阻塞调度。两个线程如果是无障碍的执行,那么他们不会因为临界区的问题导致一方被挂起。但是一旦检测到冲突,就应该进行回滚,确保数据安全。冲突严重时所有线程会不停回滚从而造成系统无法正常工作.
  一般会配合"一致性标记"一起使用,操作前读取一致性标记,修改后再次读取此标记,若不一致则表示数据不安全。

4. 无锁
  无锁的并行都是无障碍的,在无锁的情况下,所有的线程都能尝试对临界区进行访问,但是不同的是,无锁的迸发保证必然有一个线程能够在有限步内完成操作离开临界区。
  一般都会包含无穷循环,且可能产生饥饿

while(!atomicVar.compareAndSet(localVar,localVar+1)){
    localVar = atomicVar.get();
}

5. 无等待
  要求所有的线程都必须在有限步内完成,这样就不会引起饥饿问题。一种典型的无等待结构就是RCU(Read-Copy-Update)。它的基本思想是,对数据的读可以不加控制。因此,所有的读线程都是无等待的。但是在写数据的时候,先取得原始数据的副本,接着值修改副本数据,修改完成后,在合适的时机回写数据。

2.6 并行相关的两个重要定律

2.6.1 Amdah1定律

  Amdah1定律定义了串行系统并行化后的加速比的计算公式和理论上限
  加速比定义 = 优化前系统耗时 / 优化后的系统耗时
  加速比越高,表明优化效果越好。
在这里插入图片描述
  其中n表示处理器个数, T表示时间, Tl表示优化前耗时(也就是只有1个处理器时的耗时),Tn表示使用n个处理器优化后的耗时。F是程序中只能串行执行的比例。
  当处理器个数趋向与无穷大时,加速比和串行比例成反比。根据这个公式,如果CPU处理器数量趋千无穷,那么加速比与系统的串行化率成反比,如果系统中必须有50%的代码串行执行,那么系统的最大加速比为2。CPU数量越多,串行比列越小,则优化效果越好。
  由此可见,为了提高系统的速度,仅增加 CPU 处理器的数量并不一定能起到有效的作用。需要从根本上修改程序的串行行为,提高系统内可并行化的模块比重,在此基础上,合理增加并行处理器数量,才能以最小的投入,得到最大的加速比 。

2.6.2 Gustafson定律

  Gustafson定律也试图说明处理器个数、串行比例和加速比之间的关系,但是Gustafson定律和Amdahl定律的角度不同。同样,加速比都定义为优化前的系统耗时除以优化后的系统耗时。
在这里插入图片描述
  如果可被并行化的代码所占比重够多,则加速比就能和处理器个数成线性增长。

2.7 上下文切换

  单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
  CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
  因此多线程不比单线程快,因为创建线程和上下文切换会占用一定的时间。
  线程上下文切换时机有:当前线程的CPU 时间片使用完处于就绪状态时-,当前线程被其他线程中断时。

2.7.1 如何减少上下文切换

  减少上下文切换的方法有 无锁并发编程、CAS算法、使用最少线程 和 使用协程。

  1. 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  2. CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  3. 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  4. 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

3 线程底层的实现

  线程的实现方式有三种:使用内核线程实现、使用用户线程库实现、使用用户线程加轻量级进程混合实现。

3.1 使用内核线程实现

  内核线程(Kernel-Level Thread,KLT)是直接由操作系统内核(Kernel,下面简称内核)支持的,这种线程由内核来完成线程的切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样一个内核就可以处理多个线程,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。
  程序一般不会直接使用内核线程,而是通过内核线程的一个高级接口-轻量级进程(Light Weight Process,LWP)使用,轻量级进程就是我们通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能支持轻量级进程。这种轻量级进程和内核线程之间1:1的关系称为一对一的内核模型。如果下图所示:
在这里插入图片描述
  由于内核线程的支持,每一个轻量级进程都是一个独立的调度单位,各个轻量级进程之间互不影响。由于是直接操作单个内核线程,轻量级进程的线程操作,析构以及同步,都需要系统调用,而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)之间来回切换。其次,由于一条轻量级进程对应一条内核线程,因此,一个系统支持轻量级进程的数量是有限的。

3.2 使用用户线程实现

  广义上讲,一个线程如果不是内核线程,就都是用户线程(User Thread,UT)。从这个角度来说,轻量级进程也是用户线程,但轻量级进程始终是由内核线程实现的,效率会受到限制;狭义上讲,用户线程是完全建立在用户空间的线程库上,系统内核完全感受不到线程存在的实现。如果程序实现得当,这种线程不需要切换到内核态,且效率更高,资源消耗更小,能支持的并发数更高,部分高性能的数据库中的多线程就是通过用户线程来实现的,这种进程与用户线程之间1:N的关系称为一对多的线程模型。如图:
在这里插入图片描述
  使用用户线程的优势在于不需要使用内核线程,但相对的,由于所有的线程操作都需要用户程序来处理,实现起来非常复杂。线程的创建,切换和调度都需要考虑,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”,“多处理器系统中如何将线程映射到其他处理器中”这类问题解决起来将会异常困难,甚至不能完成。因而除了在不支持多线程的操作系统(如DOS)的多线程程序和少数特殊的程序外,现在使用用户线程实现多线程的程序越来越少了,Java、Ruby都曾经在使用过用户线程后最终又放弃了它。

3.3 使用用户线程加轻量级进程混合实现

  通过在用户线程中调用轻量级进程来调度内核线程,弥补了用户线程的缺陷。这种是多对多的线程模型。如图:
在这里插入图片描述

3.4 Java线程的实现

  在JDK1.2之前,线程是基于“绿色线程”(Green Threads)的用户线程实现的,1.2之后线程模型替换为基于操作系统原生线程模型实现。因此,在目前的版本中,很大程度上决定于java虚拟机的线程是怎样映射的,虚拟机规范中也未规定这一部分要通过那种方式实现。Sun JDK的windows版和linux版都是使用一对一的线程模型实习的。而在Solaris平台中,由于操作系统同时支持一对一和多对多的线程模型,因此,Solaris的JDK提供了-XX:+UseLPWSynchronization(默认)和-XX:+UseBoundThreads两个参数来决定。

4 Java线程调度与优先级

  线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads-Scheduling)。

4.1 协同式线程调度

  协同式线程调度是一条线程在执行完毕以后,主动通知系统切换到另一条线程上,实现简单,不存在线程同步问题。但是有一个弊端,当由于某些原因导致单条线程阻塞,那有可能导致系统崩溃。

4.2 抢占式线程调度

  抢占式线程调度是每个线程的执行时间由系统来控制,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的),这样线程之间不会相互影响进度。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度。

4.3 线程优先级

  java可以通过设置线程优先级来决定哪些线程可以优先获取CPU使用权,Java语言一共设置了10个级别的线程优先级(Thread.MIN_N_PRIORITY至Thread.MAX_X_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
  在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级的范围从1~10。
  但是这样的设置也不是一定有效的,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统,取决于java中的优先级与系统的优先级的匹配度。虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应。如果操作系统的优先级比java的优先级少,那一定存在java的某些优先级是在系统中处于一个级别。下表显示了Java线程优先级与Windows线程优先级之间的对应关系,Windows平台的JDK中使用了除  THREAD_PRIORITY_IDLE之外的其余6种线程优先级:
在这里插入图片描述

5 参考

《深入理解Java虚拟机》
《实战Java高并发程序设计》
《Java并发编程之美》

发布了29 篇原创文章 · 获赞 47 · 访问量 8204

猜你喜欢

转载自blog.csdn.net/weixin_43767015/article/details/104806736
今日推荐