凌月风的个人博客

记录精彩人生

Open Source, Open Mind,
Open Sight, Open Future!
  menu

Java笔记系列——05-并发编程(1)

0 浏览

科普

多级存储体系

  • 多级存储体系是指将多级存储器结合起来的一种方式。在一个计算机系统中,对存储器的容量、速度和价格这三个基本性能指标都有一定的要求。
    • 存储容量应确保各种应用的需要;
    • 存储器速度应尽量与CPU的速度相匹配并支持I/O操作;
    • 存储器的价格应比较合理。
  • 这三者经常是互相矛盾的。例如存储器的速度越快,则每位的价格就越高;存储器的容量越大,则存储器的速度就越慢。
  • 按照现有的技术水平,仅仅采用一种技术组成单一的存储器是不可能同时满足这些要求的。只有采用由多级存储器组成的存储体系,把几种存储技术结合起来,才能较好地解决存储器大容量、高速度和低成本这三者之间的矛盾。
  • 存储墙(Memory wall):指的是通信带宽和延迟,是提高系统性能的最大障碍。image-20220605213931400

多道程序设计

  • 引入多道程序设计技术的根本目的是为了提高CPU的利用率。

  • 多道程序设计指的是允许多个程序同时进入一个计算机系统的主存储器并启动进行计算的方法。也就是说计算机内存中可以同时存放多道(两个以上相互独立的)程序,它们都处于开始和结束之间。

  • 计算机的早期,多任务被称作多道程序。多道程序是指CPU一次读取多个程序放入内存,先运行第一个程序直到它出现了IO操作。因为IO操作慢,CPU需要等待。为了提高CPU利用率,此时运行第二个程序。

    • 第n+1个程序得以执行的条件是第n个程序进行IO操作或已经运行完毕。
    • 这种方式每个程序的时间分配是不均等的,很可能第一个程序运行了几个小时而不出现IO操作,故第二个程序没有运行。
    • 在当初,这种情况是令人接受的。人们一次指定运行多个程序,过几个小时或一天后来看运行结果或拿走打印出来的文件。人们不需要实时获得每个程序的运行情况,只关心运行结果。
  • 对于一个单核CPU系统来说,多道程序同时处于运行状态只是一种宏观上的概念,他们虽然都已经开始运行,但就微观而言,任意时刻,CPU上运行的程序只有一个。

  • 多道程序_百度百科 (baidu.com)

分时操作系统

  • 分时操作系统是使一台计算机时间片轮转的方式同时为几个、几十个甚至几百个用户服务的一种操作系统。
  • 分时操作系统将系统CPU时间与内存空间按一定的时间间隔,轮流地切换给各终端用户的程序使用。由于时间间隔很短,每个用户的感觉就像独占计算机一样。
  • 分时操作系统的特点是可有效增加资源的使用率。例如UNIX系统就采用剥夺式动态优先的CPU调度,有力地支持分时操作。
  • 分时操作系统与多道程序设计区别(个人理解):分时操作系统采用了多道程序设计,但是针对于多道程序的切换进行了调整,可以通过时间片来切换执行的程序,而不是等待出现IO操作或者运行完毕之后才切换程序。区别在于任务切换的时机不同。

并发与并行

  • 并发(Concurrent),在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。

    • 对于单核CPU的计算机来说,在CPU中,同一时间是只能干一件事儿的。
    • 分时操作系统为了看起来像是""同时干多件事”,把CPU的时间划分成长短基本相同的时间区间,即”时间片”,通过操作系统的管理,把这些时间片依次轮流地分配给各个应用使用。
    • 这样,给用户的感觉是他在同时的进行听歌和打游戏,实际上,在操作系统中,CPU是在游戏进程和音乐播放器进程之间来回切换执行的。
    • 操作系统时间片的使用是有规则的:某个作业在时间片结束之前,整个任务还没有完成,那么该作业就被暂停下来,放弃CPU资源,等待下一轮循环再继续做。此时CPU资源又分配给另一个作业去使用。
    • 由于计算机的处理速度很快,只要时间片的间隔取得适当,那么一个用户作业从用完分配给它的一个时间片到获得下一个CPU时间片,中间虽然有所”停顿”,但用户察觉不出来。所以,在单核CPU的计算机中,我们看起来“同时干多件事”,其实是通过CPU时间片技术,并发完成的。
    • 就像前面提到的操作系统的时间片分时调度。打游戏和听音乐两件事情在同一个时间段内都是在同一台电脑上完成了从开始到结束的动作。那么,就可以说听音乐和打游戏是并发的。
    • 当有多个线程在操作时,如果系统只有一个单核CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间 段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发(Concurrent)
  • 并行(Parallel),当系统有一个以上CPU核心时,当一个CPU核心执行一个进程时,另一个CPU核心可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

    系统要有多个CPU或者一个CPU多个核心才会出现并行。在有多个CPU或一个CPU多核心的情况下,才会出现真正意义上的『同时进行』。

  • 并发是指在一段时间内宏观上多个程序同时运行。并行指的是同一个时刻,多个任务确实真的在同时运行。

高并发

  • 并发数(concurrency),计算机网络术语,是指同时访问服务器站点的连接数。可以理解为系统能在一定时间内处理的请求数量。

  • 高并发(High Concurrency),通常是指通过设计保证系统能够同时并行处理很多请求。可以理解为系统能够承载很大的并发数量。

    • 高并发的基本表现为单位时间内系统能够同时处理大量的请求数
    • 实现高并发的核心是对CPU资源的有效压榨。
  • 高并发的相关指标

    • QPS( Queries Per Second),意思是“每秒查询率”,是一台服务器每秒能够响应的查询次数,是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准。

      • TPS(Transactions Per Second),每秒处理的事务数目。

        如果对于一个页面的一次访问,形成一个TPS;但一次页面请求,可能产生多次对服务器的请求,这些请求,就可计入“QPS”之中。一个TPS可能由1个或多个QPS组成

      • PV(Page View):即页面浏览量,通常是衡量一个网络新闻频道或网站上一条网络新闻的主要指标。用户每一次对网站中的每个页面访问均被记录 1 次。用户对同一页面的多次刷新,访问量累计。

      • DAU(Daily Active User):日活跃用户

      • MAU(Monthly Active User):月活跃用户

      • RT(Response Time):响应时间,是从系统接收请求开始到返回响应之间的时间跨度。在讨论一个系统的响应时间时,人们通常是指该系统所有功能的平均时间或者所有功能中的最大响应时间。

  • 高并发的硬件支撑:硬件是基础决定了高并发的上线

    • CPU:使用更高的核心数,提高任务的并行执行数
    • 内存:使用更大的内存,增加内存使用场景,提高访问效率
    • 磁盘:使用高性能的SSD减少IO时间
    • 网络:千兆或万兆网卡,提高带宽、减少网络延迟
  • 高并发的软件支撑:软件是对于硬件的合理使用

    • 通过使用多线程,提高任务的并发数量
    • 通过优化数据存储结构、使用缓存、异步操作等方法减少IO交互次数,提高IO操作效率
    • 通过分布式架构解决单系统硬件带来的性能瓶颈
  • 高并发的目标:提高系统承载量,比如TPS由100提高为10000。

同步与异步

  • 同步和异步关注的是消息通信机制

    • 同步:调用者在调用发出之后,需要等待这个调用返回结果才能继续执行后续操作。
    • 异步:调用者在调用发出之后,不需要等待调用的返回结果,可以继续执行后续操作。
  • 异步调用者想得知调用的返回结果,一般通过状态、通知以及回调三种方法

    • 状态:调用者即监听被调用者的状态(轮询),每隔一定时间检查一次被调用者是否完成,效率会很低。
    • 通知:当被调用者执行完成后,发出通知告知调用者,无需消耗太多性能。
    • 回调:与通知类似,当被调用者执行完成后,会调用调用者提供的回调函数。

阻塞和非阻塞

  • 阻塞和非阻塞强调的是程序(或线程)在等待调用结果(消息,返回值)时的状态.
    • 阻塞调用:是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
    • 非阻塞调用:指在不能立刻得到结果之前,该调用不会阻塞当前线程。
  • 对于同步调用来说,很多时候当前线程还是激活的状态,只是从逻辑上当前函数没有返回而已,即同步等待时什么都不干,白白占用着资源。

CPU的缓存一致性


  • CPU 数据操作

    • 读取数据的时候,先从缓存中查找数据,如果没找到从内存加载到缓存再进行读取。
    • CPU操作写数据的时候是需要将数据写回内存的,如果采用写直达机制会频繁操作主存,影响性能,因此采用写回机制。
  • 写直达(Write Through):写入前会先判断数据是否已经在 CPU Cache 里面了

    • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
    • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。
  • 写回(Write Back):当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

    写回机制下,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。

    这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。


  • 缓存一致性:既然CPU引入了高速缓存,而且每个CPU核心的L1和L2缓存是独占的,必然引发缓存一致性问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

    • 缓存一致性问题的现象
      image-20220608153721749
      1. 假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。
      2. 这时如果 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 1 的执行结果写入到 Cache 中,然后把 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。
      3. 如果这时B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。

  • 要解决缓存一致性问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

    • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation);

      写传播很容易理解,当某个核心在 Cache 更新了数据,就需要通知到其他核心的 Cache 里

    • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)。

      关于事务的串形化,我们举个例子来理解它

      1. 假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。
      2. 如果C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。
      3. 如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。
      • 所以,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。

  • CPU中写传播和事务串形化具体实现。

    • 通过总线锁来实现
      • CPU与其他设备的交互都是通过总线完成的。
        image-20220607140110801
        image-20220609164218580
      • 在要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。
      • 但是总线锁会加重总线负担,降低处理性能,不推荐,还是老老实实的解决写传播和事务串形化吧
        image-20220725154035929

    • 总线嗅探(Bus Snooping):写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。总线嗅探就能很好的实现

      • 当 A 号 CPU 核心修改了Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的Cache 里面,如果 B 号 CPU 核心的 Cache 中有该数据,那么也需要把该数据更新到自己的Cache。
        image-20220725154213396
      • 可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
      • 另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化。于是,有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。

    • MESI 协议

      • MESI 基于总线嗅探机制实现了事务串形化,其实是 4 个状态单词的开头字母缩写,这四个状态来标记 Cache Line 四个不同的状态。分别是:
        • M(Modified):已修改,就是我们前面提到的脏标记,代表该 Cache 上的数据已经被更新过,但还没有写到内存中里。
        • E(Exclusive):独占,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据
        • S(Shared):共享,状态代表着相同的数据在多个 CPU 核心的 Cache 里都有
        • I(Invalidated):已失效,表示的是这个 Cache里的数据已经失效了,不可以读取该状态的数据。
      • 四种状态的理解
        1. 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。差别在于
          • 「独占」状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
          • 在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
        2. 「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
      • 状态转换
        • 具体查看动画演示VivioJS MESI (tcd.ie)
        • 我们举个具体的例子来看看这四个状态的转换(以动画演示为主):
          1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
          2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
          3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「独占」状态,此时 Cache 中的数据就与内存不一致了。
          4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line「独占 」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
          5. 如果 B 号 CPU 核心要读取Cache变量的值,会发消息给其他CPU核心,因为A号CPU核心发现自己的 Cache 里的 i 变量还是「独占」。A号CPU核心就会将自己的 Cache Line 为「共享」状态,将自己 Cache Line中的数据写入到内存中。同时返回给B号CPU核心,这样B号CPU核心Cache中Cache Line 的状态就会变成「共享」。并且其 Cache 中的数据与内存也是一致的;

  • Store BufferInvalidate Queue
    • MESI引入之后,发现有些地方有瑕疵
      1. 当如果A号CPU核心修改变量,向其他核心发送通知,如果采用同步方式,那么A号CPU核心会一直等待其他核心修改完成并通知给自己。那会有点不太聪明的样子
      2. 如果CPU核心接收到其他核心的通知时,如果马上处理通知,感觉太把别人当回事了。
    • 因此大聪明引入了Store BufferInvalidate Queue来进行优化
      1. 当A号CPU核心向其他核心发送通知时,将自己的修改值存入自己的Store Buffer中,同时通知给其他核心。通知发完之后不等待其他核心的返回结果,自己继续执行其他指令
        image-20220607183230822
      2. 当核心收到其他核心的通知时,将这个通知放到一个失效队列(Invalidate Queue)中,然后马上返回给发送核心说我收到了,然后继续执行自己的逻辑。失效队列通过异步处理更改缓存行标志

  • 由于Store Buffer和Invalidate Queue的出现,处理器的执行变成了异步操作。速度虽然提高了,但是产生了CPU层面的指令重排

    • 举个例子:使用A代表 A号CPU核心,B代表 B号CPU核心
      • 假如A和B各自的缓存中都有变量I=0,根据MESI协议,再各自的缓存中状态都是共享
      • 假如现在A更新自己的变量将变量存储到Store Buffer中,还没有写入到自己的Cache中,缓存行的状态还是共享,然后发通知给B。
      • B接收到A的通知,将该通知丢到Invalidate Queue中,在Invalidate Queue没有修改之前,B的Cache中的缓存行还是共享状态
      • 正常情况下如果我想通过MESI保证缓存一致性,必须A的Cache Line变为独占状态,B的Cache Line变为失效状态。如果Store Buffer和Invalidate Queue中的内容还没有被同步到各自的Cache,就会出现问题
  • 既然CPU的优化导致了这一问题,但是因为CPU只关心不断压榨自己的性能,CPU本身不知道业务上何时不需要这个优化,所以提供了内存屏障来供开发人员调用,来解决这一问题

  • 其实从头看到尾就会发现,一个技术点的出现往往是为了填补另一个的坑。

    • 为了解决处理器与主内存之间的速度鸿沟,引入了高速缓存,却又导致了缓存一致性问题
    • 为了解决缓存一致性问题,引入了如MESI等技术,又导致了处理器同步等待问题
    • 为了解决处理器等待问题,引入了写缓冲(Store Buffer)和无效化队列(Invalidate Queue),又导致了重排序和可见性问题
    • 为了解决重排序和可见性问题,引入了内存屏障,舒坦。。。

线程

  • 线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程中包含多个线程。

  • 线程的特征

    • 并行,依赖于CPU核数,提高同时刻的任务运行数量
    • 异步:调用方不会立即得到结果,而是在调用发出后继续执行后续操作。
  • 线程的使用

    • 继承Thread类
    • 实现Runnable接口
    • Future/Callable
  • 线程原理

    • 当调用start()方法后,会通过C语言提供的方法调用OS创建线程
      • 线程启动之后会通过CPU调度算法,等待获取执行时间片

      • 线程获取时间片之后,最终回调Java编写的run()方法

      • CPU调度算法:比如先来先服务调度算法(FIFO)、最短优先(就是对短作业的优先调度)、时 间片轮转调度等。

        image-20220606152121427


  • 线程的状态

    • NEW:初始状态,线程被构建,但是还没有调用start()方法
    • RUNNABLED:运行状态,JAVA线程把操作系统中的就绪和运行两种状态统一称为“运行中”
    • BLOCKED:阻塞状态
      • 一个处于 BLOCKED状态的线程正在等待一个监视器锁以进入一个同步的块或方法。
      • 即使用synchronized关键字修饰的方法或代码块在抢占锁没有成功时,会进入到Monitor对象的队列中,这时处于BLOCKED状态,当抢占到锁之后就恢复为运行状态。
    • WAITING: 等待状态
      • 调用Object.wait()Thread.join()LockSupport.park()进入等待状态
      • 调用Object.notify()Object.notifyAll()LockSupport.unpark(Thread)恢复运行状态
    • TIME_WAITING:超时等待状态,
      • 调用Thread.sleep(long)Object.wait(long)Thread.join(long)LockSupport.parkNanos()LockSupport.parkUntil()会进入超时等待,超时结束以后自动恢复为运行状态
      • 调用Object.notify()Object.notifyAll()LockSupport.unpark(Thread)或者执行interrupt()操作也可以唤醒尝试等待状态的线程,进入到运行状态
    • TERMINATED:终止状态,表示当前线程执行完毕
  • 查询线程的状态

    1. 打开终端命令,使用jps显示当前所有Java进程pid
    2. 根据获取到的 pid, 使用jstack pid 打印指定Java进程ID的堆栈信息 通过堆栈信息,可以看到线程的运行状态

  • 线程运行的生命周期:当start()调用用时线程开始,当run()方法 中代码执行完毕之后线程结束
    image-20220606154428443

  • 线程终止

    • 正常情况下,线程run()方法中的代码执行完毕之后线程会自动停止。

    • 存在无法自动结束的情况,比如while(true)循环、wait()、sleep()这些不受控制的情况,如果想要主动停止线程

      • stop():强行终止。这种方式比较暴力,相当于Linux系统的kill -9
      • interrupt():给线程一个中断信号,由线程自己决定终止
    • 线程是否执行完毕,只有run()方法中的程序知道。如果要优雅的终止线程,就要将线程的结束权交给线程本身去决定。


  • interrupt()方法剖析

    • 使用代码示例

      public class InterruptExample implements Runnable{
          public static void main(String[] args) throws InterruptedException {
              Thread t1=new Thread(new InterruptExample());
              t1.start();
              Thread.sleep(2000);   
              t1.interrupt();//Main线程来发送一个中断信号, 中断标记变为true      
          }
          @Override
          public void run() {
              //如果让线程友好结束,只有当前run方法中的程序知道.
              //Thread.currentThread().isInterrupted()获取中断状态
              whlie(!Thread.currentThread().isInterrupted()){
                  System.out.println(Thread.currentThread().getName()+"--");
              }
          }
      }
      
    • interrupt()方法主要有两个作用:将中断标志改为true,唤醒阻塞状态下的线程

    • 如果线程是阻塞状态,线程的中断就没有意义,所以执行interrupt()会唤醒阻塞方法。

      • Java会通过InterruptedException异常,来通知阻塞的线程收到了中断信号。当InterruptedException异常抛出之后,会将中断标记复位,即将值改为false

      • InterruptedException异常的抛出并不意味着线程必须终止,而是提醒当前线程有中断的操作发 生,至于接下来怎么处理取决于线程本身,还是把中断的控制权交给了Run()方法的线程本身。

      • 比如sleep()、join()、wait()产生阻塞时会抛出InterruptedException异常,捕获异常后此时线程不会停止。如下的例子如果要停止需要在方法中调用Thread.currentThread().interrupt();才会终止。

        @Override   
        public void run() {
             whlie(!Thread.currentThread().isInterrupted()){
                 try {
                     Thread.sleep(1000);
                 } catch (InterruptedException e) { 
                     //中断标记变为false
                     e.printStackTrace();
                     //TODO 临时保存一些数据.
                     // 把中断标记修改为true 才会终止
        
                     //  Thread.currentThread().interrupt();
                 }
                 System.out.println(Thread.currentThread().getName()+"--");
             }
        }
        
    • 调用方法Thread.interrupted()也会让当前线程的中断的状态复位


  • 问题排查

    • CPU占用率不高,响应很慢。可能存在死锁

      1. 打开终端命令,使用jps显示当前所有Java进程pid
      2. 根据获取到的pid, 使用jstack pid 打印指定Java进程ID的堆栈信息 通过堆栈信息,可以看到线程的运行状态
      3. 根据信息查看是否有死锁信息,定位到问题所在,进行处理
    • CPU占用率很高,响应很慢。可能存在线程死循环

      1. 通过 top -c 动态显示进程及占用资源的排行榜,从而找到占用CPU最高的进程PID,例如:得到进程 PID=80972
      2. 然后再定位到对应的线程, top -H -p 80972 查找到该进程中最消耗CPU的线程,例如:得到线程 PID=81122
      3. 通过 printf "0x%x\n" 81122 命令,把对应的线程PID转化为16进制: 0x13ce2
      4. 执行命令 jstack 80972 | grep -A 20 0x13ce2 查看线程Dump日志,其中-A 20表示 展示20行, 80972表示进程ID, 0x13ce2表示线程ID
      5. 然后通过日志定位到具体的问题代码行进行处理

线程安全

  • 多线程执行过程中,如果同时对同一资源进行修改操作,会出现安全性问题

  • 线程安全问题的出现涉及到原子性、可见性、有序性,当这几个方面的问题得到解决之后,多线程就是安全的

  • 原子性问题

    • 现象描述:声明变量count=0,两个线程同时循环1000次执行:count++。最终得到count的值不是2000。
    • 原因剖析:
      • count++的命令不是原子的。count++是属于Java高级语言中的编程指令,而这些指令最终可能会有多条CPU指 令来组成,而count++经过编译后最终会生成3条指令,指令详情可以通过javap -v xxx.class查看。3条指令如下含义如下
        1. 将count=0 加载到寄存器
        2. 执行+1操作
        3. 将修改后的值写入内存
      • 当多线程之间产生线程切换时就会产生问题
        image-20220606172747936
    • 解决方案:使用Synchronized关键字、Atomic相关的类、使用Lock

  • 可见性

    • 现象说明:在代码示例中,正常情况下我们希望main线程改变stop的值,然后线程 t1 感知到变化然后停止循环。实际运行发现线程并不会终止,这就是说对于stop值的修改,线程t1并不时时可见 。线程终止两个线程对于同一个变量的修改,一个线程的修改结果对于另一个线程来说并不是时时可见

      public class VolatileExample {
          public  static boolean stop=false;
          public static void main(String[] args) throws InterruptedException {
              Thread t1=new Thread(()->{
                  int i=0;
                  while(!stop){ 
                      i++;
                  }
              });
              t1.start();
              System.out.println("begin start thread");
              Thread.sleep(1000);
              stop=true; //store
              //storeload()
          }
      }
      
    • 原理剖析

      1. CPU使用了高速缓存,在不断的性能优化过程中,采用了Store Buffer 和 Invalidate Queue。导致一个CPU核心的修改操作在没有写入主存之前,对另一个核心来说是可见的。
      2. 指令重排序,因为CPU或者 JVM 的一些性能优化机制导致的。实例中的现象就是因为 JVM 做了优化,将!stop优化为了 true
    • 解决方案:使用Synchronized关键字、使用volatile关键字,底层使用的内存屏障


  • 有序性

    • 有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序

      • 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序
      • 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源
      • 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题
    • 有序性会带来可见性问题,所以可以通过内存屏障指令来禁止特定类型的处理器重排序

    • 解决方案:使用Synchronized关键字、使用volatile关键字


关键字synchronized

  • 使用synchronized关键字加锁,可以通过不同的使用范围控制锁的粒度。

    • 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
    • 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
    • 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
  • synchronized实现锁的原理

    • 多个线程去抢占锁的本质是实现资源的互斥,通过一个多线程共享的标志位来达成抢占的效果。
      • JVM 的堆中一个对象的存储结构包含对象头、实例例数据、对齐填充三个部分。
        image-20220725160844930
      • 对象头中又包含Mark Word。锁的标志就存储在Mark Word中。
      • Java中万物皆对象,当类加载完成之后,已经生成了对应的Class对象。所以使用Synchronized关键字锁定XXX.class的时候也是存在一个对象的。

    • 锁的竞争是Monitor去实现的。每一个对象都有一个Monitor与之关联,JVM中的Monitor是通过C语言的 ObjectMonitor 实现的。
      • 当用 synchronized 修饰代码块时,编译后的字节码会有 monitorentermonitorexit指令,分别对应的是获得锁和解锁。
      • 当用 synchronized 修饰方法时,会给方法加上 ACC_SYNCHRONIZED标记,这样 JVM 就知道这个方法是一个同步方法,于是在进入同步方法的时候就会进行执行竞争锁的操作,只有拿到锁才能继续执行。

    • ObjectMonitor主要成员包括:
      • _owner:指向持有ObjectMonitor对象的线程
      • _WaitSet:存放处于wait状态的线程队列,即调用wait()方法的线程
      • _EntryList:存放处于等待锁block状态的线程队列
      • _count:为_WaitSet_EntryList 的节点数之和
      • _cxq:多个线程争抢锁,会先存入这个单向链表
      • _recursions:记录重入次数

    • ObjectMonitor的基本工作机制:
      1. 当多个线程同时访问一段同步代码时,首先会进入_EntryList 队列中。
      2. 当某个线程获取到对象的Monitor后进入临界区域,并把Monitor中的 _owner 变量设置为当前线程,同时Monitor中的计数器_count 加1。即获得对象锁。
      3. 若持有Monitor的线程调用 wait() 方法,将释放当前持有的Monitor,_owner变量恢复为null,同时该线程进入 _WaitSet 集合中等待被唤醒。
      4. _WaitSet 集合中的线程会被再次放到_EntryList队列中,重新竞争获取锁。
      5. 若当前线程执行完毕也将释放Monitor并复位变量的值,以便其他线程进入获取锁。
        image-20220610112852033

  • 通过ClassLayout打印对象头

    • 引入依赖

      <dependency>
      	<groupId>org.openjdk.jol</groupId>
      	<artifactId>jol-core</artifactId>
      	<version>0.16</version>
      </dependency>
      
    • 编写测试类

      package org.example;
      
      import org.openjdk.jol.info.ClassLayout;
      
      public class Demo {
          Object  object = new Object();
          public static void main(String[] args) {
      //        System.out.println(ClassLayout.parseClass(Demo.class).toPrintable());
              System.out.println(ClassLayout.parseInstance(new Demo()).toPrintable());
          }
      }
      
      
    • 查看结果

      org.example.Demo object internals:
      OFF  SZ               TYPE DESCRIPTION               VALUE
        0   8                    (object header: mark)     0x0000000000000005 (biasable; age: 0)
        8   4                    (object header: class)    0x00067040
       12   4   java.lang.Object Demo.object               (object)
      Instance size: 16 bytes
      Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
      
      

  • synchronized锁的升级

    • jdk1.6之前,synchronized要么是无锁,要么是重量级锁。重量级锁对于性能的影响比较大,主要体现在两方面:
      • 会运用操作系统层面的机制实现,这个过程会涉及用户态和内核态的交互
      • 没有获取锁的线程被阻塞,等获取锁再唤醒
    • 由于重量级锁性能消耗较大,Jdk1.6 对锁的实现引入了大量的优化,来减少重量级锁带来的性能开销。通过优化尽可能的在无锁状态下解决线程并发问 题,其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。
      • 偏向锁:在没有线程获得偏向锁的情况下,当前线程获得偏向锁。意义不大,没有竞争的时候才会获得偏向锁,默认情况下是延迟开启的。
      • 轻量级锁:通过自旋锁来避免线程阻塞。当已经有线程获取了偏向锁,那么后面的抢占线程会对锁进行升级,升级为轻量级锁。抢占线程不会被阻塞,而是通过循环和CAS机制不断的尝试修改对象的锁标志位。如果修改失败会将轻量级锁升级为重量级锁。
    • 无锁状态Mark Wordimage-20220606223439620
    • 偏向锁状态Mark Wordimage-20220606223510919
    • 轻量级或者重量级锁Mark Word。重量级锁的指针指向ObjectMonitor()image-20220606223629571
    • 锁升级流程
      1. 默认情况下偏向锁是开启状态,如果有线程去抢占锁,发现当前是无锁状态,那么这个时候线程会先去抢占偏向锁,也就是把MarkWord的线程ID改为当 前抢占锁的线程ID的过程,偏向锁位变为1
      2. 如果有线程竞争,这个时候会撤销偏向锁,升级到轻量级锁,线程在自己的线程栈帧中会创建一个 LockRecord,用CAS操作把MarkWord设置为指向自己这个线程的 LockRecord的指针,设置成功后表示抢 占到锁。
      3. 出现竞争加剧的情况,会升级到重量级锁,向操作系统申请资源,然后线程被挂起进入到等待队列。
        • 在jdk1.6之前如果线程超过10次自旋(通过-XX:PreBlockSpin参数配置),那么会进行升级
        • jdk1.6开始加入了自适应自旋, JVM会根据上次竞争 的情况来自动控制自旋的时间,以此来控制锁升级

CAS

  • 在synchronized锁升级的过程中需要修改指针指向和锁标志,这两个操作都是需要三步完成:读取原有信息,修改信息,将信息写入内存。要保证这三步是一个原子操作才有意义,不然就陷入了原子性的循环

  • CAS(Compare and swap)就是比较并交换的意思。它可以保证在多线程环境下对于一个变量修改的原子性。CAS操作会有一个返回值,如果替换失败返回false,替换成功返回true

    // 可以这么理解CAS的作用
    int i = 0
        // 先判断再修改i的值,这个操作是非原子的
        if(i==0){
            i=1;
        }
    //改为了cas之后,底层的C语言将先判断再修改这个过程变成了原子操作
    cas(i,0,1)
    
  • CAS是用C语言实现的。

    • 在C语言中依赖的方法是 :Atomic::cmpxchg,方法的实现中用到了LOCK指令(缓存锁或者总线锁)

    • CAS操作有3个参数:CAS(old,exp,upd)

      • 第一个参数old代表当前内存中的值,第二个参数exp代表预期原来的值,第三个参数upd代表期待更新的值。
      • JAVA自带的unsafe类中提供的native方法CAS(obj,offset,exp,update)。前两个参数obj,offset组合起来就代表当前内存中的值。
  • CAS 的 ABA问题

    • 在CAS过程中,线程1、线程2分别从内存中拿到了当前值为A,同时线程2把当前值A改为B,随后又把B改回来变为A,此后线程1检查到当前值仍为A而导致执行CAS成功,但这个过程却发生了ABA问题,现场资源可能和当初不一样了

      如果不关心过程,只关心最终结果,那么这个问题对我们不产生影响。

      举个例子:小A想看看公共资金还有多少,小B挪用了公共资金,然后又还回来了。对小A来说如果只关心结果,那么公共资金是没有变化的。但是这期间确实发生了小B的违规行为。

    • 解决方法:采用版本号机制,利用版本号标记线程1拿到的‘当前值’的版本,若线程2进行了A->B->A操作,则版本号会改变,那线程1再次拿到的‘当前值’的版本和第一次的肯定是不同的,从而判定CAS失败;

内存屏障

  • CPU在性能优化道路上导致的了指令重排问题,在CPU层面无法被解决。原因是CPU只是一个运算工具,它只知道接收指令并且执行指令,充分压榨自己性能,提高运行 效率。并不清楚当前执行的整个逻辑中是否需要优化,也就是说 硬件层面也无法优化解决这种顺序一致性带来的可见性问题。
  • CPU层面提供了写屏障、读屏障、全屏障这样的指令来供开发者调用来解决问题。不同架构下指令内容不一样
    • x86架构中,这三种指令分别是 SFENCELFENCEMFENCE指令,
      • sfence:也就是save fence,写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作 前完成。
      • lfence:也就是load fence,读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作 前完成。
      • mfence:也就是modify/mix,混合屏障指令,在mfence前得读写操作必须在mfence指令后的读 写操作前完成。
    • 在不同的操作系统中,也都会实现封装一个内存屏障的实现
      • 在Linux系统中,将这三种指令分别封装成了, smp_wmb-写屏障smp_rmb-读屏障smp_mb-读写屏障三 个方法

JMM模型规范

  • 在不同的CPU架构中,都提供了不同的内存屏障指令。同时,在不同的操作系统中,也都会实现对于内存屏障的封装。
  • 为了屏蔽操作系统和硬件的差异,让编写的Java程序能够一套代码在不同平台下都 能达到线程安全的访问目的。制定了JMM规范。
    image-20220607222010046

  • JMM(Java Memory Model )规范:
    • 规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中 保存了这个线程中用到的变量的主内存副本拷贝
    • 线程对变量的所有操作都必须在工作内存中进行,而 不能直接读写主内存。
    • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主 存之间进行数据同步进行
      image-20220607222105763
  • JVM对于JMM的实现:在HotSpot JVM运行时数据区中,每个线程的执行在本地方法栈中,属于线程私有的,这个就相当于是本地内存的概念。

  • 开发者需要使用volatile关键字调用底层的内存屏障,JVM根据JMM规范针对于不同的CPU架构和操作系统的内存屏障做了封装,相当于采用策略模式,不同的操作系统对应不同的策略实现,封装的屏障类型如下

    image-20220608205513874

关键字volatile

  • Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。
  • volatile实际上是开发者通过Java语言,依据 JMM 模型调用内存屏障的关键字,使用volatile可以在汇编语言层面上使用内存屏障禁止指令重排序,从而解决并发编程下由于指令重排导致的顺序性以及可见性问题
  • 使用volatile关键字的重排序规则image-20220608210354041

happens-before

  • 并不是所有的程序指令都会存在可见性或者指令重排序问题。happens-before模型本质上描述的是可见性规则,A happens-before B。那么A的操作的执行结果对B的操作是可见的,规则分类:

    • 程序顺序规则:不管怎么重排序,单线程的程序的执行结果不能改变。

    • 传递性规则:A happens-before B。 B happens-before C。 A happens-before C。

    • volatile变量规则:image-20220608210846635

    • 监视器锁规则:如果线程1获得锁并且执行完毕释放锁,那么线程2获取锁之后读取的数据一定是线程1修改后的结果

      // 假设x的初始值是10,线程A执行完代码块后,x的值会变成12,执行完成之后会释放锁。 
      // 线程B进入代码块时,能够看到线程A对x的写操作,也就是B线程能够看到x=12。
      int x=10;
      synchronized (this) { // 此处自动加锁
          // x 是共享变量, 初始值 =10
          if (this.x < 12) {
              this.x = 12;
          }
      } // 此处自动解锁
      
    • start规则:如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中 的任意操作。

    • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于 线程A从ThreadB.join()操作成功的返回。

    public class Example implements Runnable{
        static int x = 100;
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x = 200;
        }
        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(new Example());
            thread.start();
            thread.join();
            System.out.println(x==200);// true
        }
    }
    

Lock

  • Reentrant Lock 重入锁,是Lock 的实现类。代码结构
    image-20220609211949822

  • 设计猜想

    1. 要满足互斥必须要有共享的标志位来实现,可以设计成0,1
    2. 既然有锁的抢占,需要记录哪个线程抢占到了锁,哪些没抢占到锁
    3. 抢占到锁的线程,执行代码,执行完成之后要执行锁的释放
    4. 锁被释放完成之后,没抢占到锁的线程继续抢占

  • 具体实现原理
    • 线程A尝试抢占锁
      1. 判断AbstractQueuedSynchronizer中的state状态是否为0
      2. 如果为 0 说明锁没有被抢占。使用CAS操作修改类中的state的值,如果修改成功,代表抢占成功
      3. 如果不为 0 说明锁被抢占,判断抢占到锁线程是不是自己。如果是,CAS操作state1,代表重入次数。
      4. 线程A抢占锁成功后,AbstractQueuedSynchronizer中的exclusiveOwnerThread会修改为线程A
    • 线程B尝试抢占锁
      1. 此时线程A已经抢占到锁,经过判断state状态不为0,而且exclusiveOwnerThread的值也不是自己
      2. 线程B会进入到AQS的等待队列(采用的双向链表结构)
      3. 进入到队列之后,线程B会使用自旋再次尝试修改state状态
      4. 如果没有修改成功,那么使用LockSupport.park()阻塞线程B。
      5. 等待队列中的线程有个waitState标志,表示当前等待队列里的线程状态。会尝试抛弃掉等待队列 状态值为取消的线程
    • 线程C尝试抢占锁,同线程B抢占状态
    • 线程A执行完毕释放锁
      1. 会修改state状态减1,修改后如果state状态为0,代表开始减少一次重入。
      2. 线程A释放锁,会唤醒等待队列中head节点的下一个节点
      3. 此时下一个节点中的线程B开始自旋抢占锁

  • 锁的公平性
    • 如果是公平锁,新线程想抢占锁之前会先判断等待队列是否为空,如果为空那么再尝试抢占,如果不为空加入队列
    • 如果是非公平锁,新线程先尝试抢占,抢占不到再加入到队列
      image-20220609114641312

ThreadLocal

  • ThreadLocal依靠的是线程之间的相互隔离,不同线程之间不共享同一变量

  • ThreadLocal使用

    • set()在当前线程范围内,设置一个值存储到ThreadLocal中,这个值仅对当前线程可见。 相当于在当前线程范围内建立了副本。
    • get() 从当前线程范围内取出set方法设置的值.
    • remove() 移除当前线程中存储的值

  • 存储结构

    • 每一个Thread都有一个私有的ThreadLocalMap。每一个ThreadLocalMap定义了一个Entry数组image-20220610195507579

    • Entry中的key 是使用的ThreadLocal的弱引用,而value就是set()的值

      • 强引用与弱引用

        // ------------强引用---------------
        static Object obj = new Object();
        
        public static void main(String[] args) {
            Object strong = obj;
            obj = null;
            System.gc();
            System.out.println(strong == null);// 结果为false
        }
        // -------------弱引用--------------
        static Object obj = new Object();
        
        public static void main(String[] args) {
            WeakReference<Object> weak = new WeakReference<>(obj);
            obj = null;
            System.gc();
            System.out.println(weak.get() == null);// 结果为true
        }
        

  • 原理分析

    • 当调用set()方法时,会根据key进行hash运算获取一个值,以此来决定在数组中的位置下标。

      1. 如果该下标位置没有Entry,则直接插入到当前的下标位置

      2. 如果该下标位置已经存在Entry,则判断Entry存储的key与本次插入的key是否一致

      3. 如果Entry存储的key与本次相同,则直接修改Value值

      4. 如果Entry存储的key与本次不同,代表产生了hash冲突,采用线性探索的方式来解决,继续寻找当前下标的下一个

      5. 如果Entry存储的key为null,会根据当前下标向前查找 、向后查找,然后根据查找结果做不同处理

        存储的key为null的Entry称为脏Entry。

        如果Entry的key与要插入的key相同,则称为可覆盖Entry

      6. 处理逻辑
        image-20220622122157202

    • 内存泄漏问题

      • 因为使用弱引用作为key,所以存在Entry数组中key为null的情况,
      • 如果entry数组中key为null,且不及时清理会发生内存泄漏,调用set()时会采用向前、向后查找并清理脏Entry
      • 使用remove()来保证及时清理,来避免发生内存泄漏
image/svg+xml