毁三观!打破你对Java并发的认知!

大多数人对 Java 并发的理解可能只是 Thread.class 类,或者还有 synchronized、volatile 关键字等等,或者,再多一些,JUC、AQS 等等……

当然,本着不断学习的精神,也是对前面一段时间的知识做一个总结,笔者打算把 Java 并发的知识做一个整理

因为并发的知识不简单,因为它往往不局限于 Java 语言本身:
它和 JavaVM、操作系统、硬件组成都有一定的联系。

什么是线程

这个问题看起来很简单,不过实际上,它涉及到的知识点还是很多的。

首先,一般一个普通的 Java 程序员会回答:Thread。

这没错,而且,一般我们也会回答道,创建线程的方式有很多,比如:
通过实现 Runnable 接口、或者 Callable 接口、或者还有线程池等等。

不过,这些方式的本质,都是 Thread.start(),线程在执行 run 方法。

不过,这就结束了吗?
这样未免太简单了点。

因为,这么回答的话,其实并没有很好的解释什么是线程。
为什么这么说?
因为,Thread.class 只是 Java 语言编写出的一个类,而我们平时 new Thread 也仅仅只是创建了一个对象。
我们平时也会写很多很多的类,以及创建很多很多的对象。
但是,为什么,我们写的类,就不能够成为一个线程的对象???

所以,线程不仅仅只是一个 Thread.class 的定义,它其实封装了很多更复杂的事情。

首先,要明白什么是线程,得先明白,什么是进程!
进程:是操作系统分配资源的基本单位。
而线程:则是执行调度的基本单位。

这么说有些抽象,我来详细地解释一下:
首先,在以前,是没有线程这个概念的,进程既充当现在的进程、又充当现在的线程。
也就是说,以前,只有进程这么一种东西,去独自承担程序的资源和执行。

那么,为什么需要有进程这么一种东西?
我们可以先思考,假设,没有进程。那么,我们写完一个程序,要怎样去执行?
有些人会说,代码都有了,CPU 直接跑不就好了。

但其实不然,因为对于目前的计算机而言,都是多程序并发执行,所以,就需要对不同的执行进行管理。
所以,进程不仅仅只包含需要执行的代码,它还包含了很多内容,如:
打开的文件、挂起的信号、内核内部数据、处理器状态、具有内存映射的地址空间等等,
于是,就给进程定义了一个描述符,去记录和表示一个进程的信息。

那么,有了进程,为什么又要有线程?
比如,我们运行一个 qq 程序,我们可以一边聊天,一边语音,一边还在传文件,
也就是说,一个 qq 进程,它也可以同时执行多个任务。

而传统的进程,它既是分配资源的唯一单位,也是执行的唯一单位,那么,一个程序,就不能够并发地执行多个任务。
而引入线程的概念之后,一个进程,可以创建多个线程,那么就可以并发执行多个任务。

那么,有人可能会问,单进程无法并发执行多任务,那么不是可以采用多进程的方式吗?
确实是可以。
不过,这就体现不出多线程的好处了。

引入了线程之后,线程就成为了执行调度的最小单位,不过,系统分配资源的基本单位还是进程。
这就意味着,我们不用为每一个线程去分配资源,那么,线程就会更轻量级。
这样,同一个进程的线程,它们就可以共享进程的资源,因此,线程之间的通信就可以无须系统的干预。
以及,系统对同一个进程的多个线程进行调度,就不用切换进程的运行环境,开销就会降低。

不过,刚刚我所说的线程,都属于操作系统所有线程。
而实际上,其实在用户空间,也可以自己实现线程,
也就是所谓的纤程、协程。

那为什么会出现用户级线程这样的东西呢?
其实这很容易想明白。
对于系统级的线程,线程的调度、切换,就需要操作系统内核的干预。
因此,线程的调度切换就必须在内核态下才能完成。

而对于用户级线程,则是应用程序自己编写逻辑代码,实现对多个用户级线程的调度,
因此不需要操作系统的介入,从而线程调度就不需要进入内核态。
并且,由于用户级线程的实现更加轻量,所以效率也会更高。

那么,看到这,对于 Java 来说,创建的 Thread,是操作系统的线程,还是 Java 虚拟机自己实现的线程呢?
很多人可能会因此开始怀疑。
于是,我们可以查看源码,看一下程序的调用链路,是否调用了操作系统的函数来产生线程,那么就可以确定,Java 的线程和操作系统的线程是不是一一对应了。

首先,我们点开 Thread 类,点开 start() 方法:

public synchronized void start() {
	......
	
    try {
        start0();
        started = true;
    } finally {
        ......
    }
}

我们可以发现,调用了 native 方法,start0()。
这样的话,我们就无法在 Java 的代码中继续深入找到线程的启动方法了。

于是,我们可以查看 Hotspot 源码:
源码很长,我不可能全部放上来,有需要的可以自己去查看,这里,我只放一小段重点,查看调用的方法

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    ......
};

这时,我们就可以查看到,start0 方法,就对应着 JVM_StartThread,所以我们就继续找:

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_StartThread");
  JavaThread *native_thread = NULL;
  ......
  {
  	  ......
      native_thread = new JavaThread(&thread_entry, sz);
      ......
  }
  ......
  Thread::start(native_thread);
JVM_END

我们可以发现,在 JavaVM 启动线程的时候,在这里就是 new 了一个 JavaThread,
所以,我们点进去,找到 JavaThread 的构造方法:

JavaThread::JavaThread(ThreadFunction entry_point, size_t stack_sz) :
  Thread()
  ......
  os::create_thread(this, thr_type, stack_sz);
}

然后,我们就会发现,这里又是调用了 os:create_thread
看名称,我们也可以大致猜到,这个应该就是操作系统线程创建的方法。

在这篇文章中,涉及操作系统,都是默认指 Linux。
别问我为什么,因为其它操作系统我不会。。。

其它代码我就省略了,我们继续看线程的创建是如何创建的:

bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
	......
	
	pthread_t tid;
    int ret = pthread_create(&tid, &attr, (void* (*)(void*)) java_start, thread);
    	
	......
}

这里可以看到,用 pthread_create 创建了线程,而这个函数,就是一般在 Linux 中创建线程的一个函数。
所以,在这里,已经可以证明,Java 在创建 Thread 的过程,就是调用 Linux 的函数来创建线程。

所以,到这里,已经足以证明 Java 的线程,和操作系统的线程是一一对应的。
那么,要明白 Java 的线程是什么,就要去弄明白,操作系统创建的线程是什么!

通过上面的呈现,已经确定了,Java 采用的线程,是和操作系统线程一一对应的,
在 Linux 操作系统中,就是通过 pthread_create,来创建一个线程,并且在 JavaVM 与之一一对应上。
那么,在 Linux 操作系统中,又是如何实现线程的呢?

可能有些人要疑惑了,线程的知识,在上文不是描述了?

确实,对于线程的定义,其实现一般大体如此:
通过创建较重的进程,分配好相关的资源,然后只需要给进程,在创建更轻量的线程即可,
而线程,仅仅只需要一些寄存器、CPU 的资源。

不过,你要是去和面试官说,Linux 的线程是这样的,那多半就直接让回去等通知了。
因为,在 Linux 中,是没有专门的线程的!!!

Linux 实现线程的机制非常独特,从内核的角度来说,它并没有线程这个概念,
因为 Linux 把所有的线程都当做进程来实现,而线程仅仅被视为一个与其它进程共享某些资源的进程。

在操作系统的定义中,每一个进程要有一个 PCB,用于描述一个进程,从而对进程进行管理。
在 Linux 操作系统中,进程通常也叫做任务(task),为了管理进程,于是定义了一个进程描述符,来记录进程的各种信息,进程描述符的类型为 task_struct。
在进程描述符中能完整地描述一个正在执行的程序,包括:它打开的文件、进程的地址空间、挂起的信号、进程的状态等等……

在进程描述符中,有一个 pid,Linux 则内核通过这一个唯一的进程标示值(PID)来标识每个进程,
PID 的类型是一个叫 pid_t 的隐含类型,实际上就是一个 int 类型。
而 PID 的最大默认值就是 32768,所以,假设你的 Java 程序报了一个 error,
OutOfMemoryError:unable to create new native thread,
那就意味着,你的操作系统所能启动的线程已经到达了最大值,无法再进行更多地创建了,
那么,你就需要调整程序,降低线程数,或者,修改线程所能达到的最大数量。

知道了 Linux 的线程是什么,那么,我们就可以来研究,Linux 是如何创建一个线程(也就是进程)的:
首先,Linux 的进程创建很特别,一般的操作系统,都是通过:
在新的地址空间创建进程,然后读入可执行文件,最后开始执行。

而 Linux,则是分为两个步骤:
fork() 和 exec()。
首先,通过 fork() 拷贝当前进程创建一个子进程;
然后,exec() 函数会读取可执行文件并将其载入地址空间开始运行。

不过,Linux 的 fork() 函数有一个很大的特点,就是写时复制
因为,进程的创建是通过 fork 拷贝出来的,那么,按照传统的方式,新创建的进程,就会拷贝一份父进程的数据,假设父进程的数据有很多很多,那么拷贝数据,就会消耗大量的时间、CPU 资源、以及内存资源,那么,这是绝对不合理的。
所以,Linux 采用写时复制机制,因此调用 fork() 的实际开销就只是复制父进程的页表以及创建唯一的进程描述符,而其它的很多资源,都是父子进程共享的,只有到任意一方进行修改数据,才会将该数据拷贝,父子进程看到的数据才会不同。
而一般,创建进程不是为了共享大量的数据,而是作为一个全新的进程的去使用,因此,假设在 fork() 调用后,就复制一大堆数据,这显然是完全浪费的,因为子进程根本不打算用这些数据。
因此,当创建的函数返回,内核会有意选择让子进程先执行,这样,子进程马上调用 exec() 函数,就能成为一个新的进程,不再作为父进程的副本,因此,也就不会再发生复制了。
不过,有趣的是,虽然内核有意让子进程先执行,但是并不能保证如此。

实际上,在 Linux 中对 fork() 的实现实际上就是 clone(),然后 clone() 函数又去调用 do_fork(),在 do_fork() 中又调用了 copy_process() 函数,然后让进程开始运行。
而整个 cpoy_process() 函数则做了很多事,包括:
为新进程创建内核栈、task_truct,然后初始化 tsask_struct 部分成员;
然后状态值要设置为不可被打断(TASK_UNINTERRUPTIBLE),因为此刻该进程还未创建完全,不可以被调度执行;
之后,则调用 copy_flags() 更新 flags 成员;调用 alloc_pid() 为新进程分配 PID;copy_process() 拷贝或共享打开的资源;
最后,返回一个指向子进程的指针。

对于一个普通的进程来说创建便是如此,假设现在不是创建一个普通进程,而是要实现线程,
那么,实际上,区别则很小:
线程的创建和普通进程的创建类似,上文我也提过,Linux 对线程的实现,不过是和其它进程共享某些资源,
所以,Linux 对线程的创建,仅仅是在调用 clone() 函数的时候,传入一些参数标志来指明需要共享的资源。
这样,一个意味着线程的进程就被创建了,
同时,也意味着,我们对应的 Java 线程被创建了,这样,只要做一下初始化工作,我们的 Java 线程,就会投入运行了。

搞明白了线程的创建,那么,线程的结束呢?
实际上,进程的结束,也不是想的那么简单。

我们一般使用 Java 语言时,一般都会给 Thread 运行 run 方法,然后运行完毕,就认为线程已经死亡了。
其实不然,实际上,线程执行完 run 方法后,还会执行一段 exit() 方法进行退出。
如果,我们对 Java 代码进行 debug,就可以发现,线程在执行完 run 方法之后,又接着执行了 exit() 方法。
而且,在执行时,我们也可以通过 debug 观察到此时的线程状态还是 RUNNABLE。

// Java Thread 在运行完run方法,会接着执行exit()方法,释放相关的资源
private void exit() {
    ......
}

那么,执行完这段方法线程就死亡了吗?

其实也不是,因为我们知道,在 Java 中的线程,是和操作系统的线程一一对应的。
在之前,我们也看到了,在 JavaVM 中创建了线程,然后让线程执行 run 方法而已。
所以,这里,仅仅只是在 Java 语言层面,线程的任务已经结束了,
不过,线程的退出,最终还是得由操作系统接管。

其实通过上面的描述,我们很容易就能想明白,进程不可能运行完就自动结束。
主要原因还是,进程还占据着许多系统资源,并且还处于运行队列或可运行队列,也还管理着它的子进程。
所以,必须对进程进行资源释放,并且移除出队列,以及给它的子进程一个交代。

所以,进程的最后,会调用一个 exit() 系统调用,来结束自己的一生,
其中包括:将自己设置为退出状态、释放没有被共享的地址空间、离开等待调度队列、告知父进程、以及给子进程重新找养父,
最终调用 schedule() 函数切换新的进程。

由于已经处于退出状态(EXIT_ZOMBIE),所以进程永远不会再被调度执行,因此,这是进程所执行的最后一段代码,do_exit() 函数永不返回。

线程状态和调度

了解了什么是线程之后,基础打牢,那么,才能逐渐对并发的知识理解的深刻。

那么,接下来,我们就要去探究一个让无数程序员头疼的问题:
程序的异步性

首先,进程的状态有那么几种:

  • 创建态
  • 就绪态
  • 运行态
  • 阻塞态
  • 终止态

这里,只涉及到一些基本的,所以那些挂起什么的就不去探讨了。
我们于是,就可以以此联想到我们 Java 线程的几种对应的状态。

  • New:尚未启动的线程的线程状态
  • Runnable:可运行的线程状态,等待 CPU 调度。
  • Blocked:线程阻塞等待监视器锁定的线程状态。
  • Waiting:等待线程的线程状态。下列不带超时的方式:
  • Object.wait、Thread.join、LockSupport.park
  • Timed Waiting:具有指定等待时间的等待线程的线程状态。下列带超时的方式:
  • Thread.sleep、Object.wait、Thread.join、LockSupport.parkNanos、LockSupport.parkUntil
  • Terminated:线程终止的状态。线程正常完成执行或者出现异常。

我们便能很自然的与操作系统的进程状态对应起来:

  • NEW:创建态
  • Runnable:就绪态和运行态
  • Blocked、Waiting、Timed Waiting:阻塞态
  • Terminated:终止态

所以,其实,WAITING 和 BLOCKED 状态对应于操作系统的状态是一样的,
有些人把 WAITING 和 BLOCKED 分成两种状态,不过心里得留个概念,就是对于操作系统来说,是没有什么区别的。

那么,既然了解了这些,之后,我们就可以继续探讨,线程的异步性。

因为线程的执行具有异步性,所以往往,我们无法判断,哪个线程会先执行,哪个会后执行,什么时候又会发生线程的上下文切换,所以程序的执行,往往结果是多种多样,不确定性很大。
再加上,不同的操作系统,底层执行的逻辑也可能不一样。

因此,就会有这么几个特点:

  • 所见非所得;
  • 无法用肉眼检测程序的准确性;
  • 不同的运行平台有不同的表现;
  • 错误很难重现。

比如一个 DCL 单例,可能有 9999W 次运行是正确的,但是,有一次它就出现错误了,
由于错误又无法重现,因此你很难找到它的问题。

当然,产生问题的原因有很多,不要想着一口气吃成一个胖子,我们先来看,产生异步性的原因:
线程的调度原理。

当然,在 Linux 上得称为进程调度,毕竟 Linux 上准确的说没有额外定义线程。

首先,假设没有进程调度,那么一个计算机就不能像我们现在这样,可以一边听歌,一边玩游戏,一边 qq 聊天,一边传送文件等等,同时做着各种各样的事情,
计算机必须把一个程序执行完了,才能接着,去执行另一个事情。

这明显不是我们期待的样子,我们希望我们可以用一台计算机,同时去做很多很多的任务。
因此,为了实现并发,才有了进程调度。

假设,此时,你的计算机是只有一个 CPU 核心,那么,同一时间,那就只有一个任务在被执行;
虽然看起来是有很多任务在处理,但实际上,所有的任务,只是被分成了一小片一小片,不断地执行各个任务;
因此,从宏观上看起来,就像是在执行很多任务一样。
不过,假设,你的计算机上,有不止一个 CPU,那么,就可以做到,同一时刻,在做着多个任务。

这些一般大家都知道,不过我还是简单提一下,
多任务系统可以划分为两类:

  • 抢占式多任务系统
  • 非抢占式多任务系统

Linux 则提供了抢占式的多任务系统,在这个模式下,会由调度程序来决定什么时候停止一个进程的运行,以便其它进程能够得到执行机会,这个强制进程停止运行的动作就叫抢占。
而进程在被抢占前能够运行的时间是被预先设置好的,而且有一个专门的名字,叫进程的时间片。

而非抢占式多任务模式下,除非进程自己主动停止运行,否则它会一直执行。
进程主动停止自己让其它线程执行的动作就叫做让步。
这样,进程自己做出让步,好让每个任务获得足够的处理时间,
但是,这有着极大的问题:
由于调度程序无法对每个进程该执行多长时间做出统一规定,所以进程独占处理器的时间可能超出预料;
而且,假设一个进程决不让步,那么,就会使得系统崩溃。

所以,毫无疑问,大部分操作系统都采用了抢占式的多任务模型,包括 Linux。

在 Linux 的 2.5 版本,对调度程序进行了改进,采用了一种叫做 O(1) 调度程序的新调度程序。
它在数十个处理器的环境下可以表现出不错的性能和可扩展性,
不过,它对于响应时间敏感的程序,也就是一般的交互程序,会有很多不足。

所以,在 2.6 的一个版本中,则将 O(1) 调度程序完全替换,它被成为 “完全公平调度算法” ,或者简称 CFS。

那么,为了和 CFS 的特点突出出来,我们可以先看一下传统 Unix 的进程调度。

首先,现代进程调度器有两个通用的概念:进程优先级、时间片。
时间片是指每次进程得到处理器时可以执行的时间;
对于优先级,一般有更高优先级的进程可以运行得更频繁,通常也会被赋予更多的时间片。

传统 Unix 系统进程调度的第一个问题,就是 nice 值到时间片的映射。
若要将 nice 值映射到时间片,就需要将 nice 单位值对应到处理器的绝对时间,但是,这样将导致进程切换无法最优化进行。
举个例子,假设一个进程有默认 nice 值,也就是 0,对应一个 100 ms 的时间片;
然后,给另一个进程分配最高 nice 值(20),对应 5 ms 的时间片。
那么,默认优先级的进程将会获得 20/21 的处理器时间,而最高优先级的线程,将只有 1/21 的处理器时间。
假设,此时两个进程的优先级都是 20,都处于低优先级,说明我们是希望两个线程的个获得相同的一半处理器的时间,那么每个进程的时间片都是 5 ms,那么每隔 10 ms,就会有两次上下文切换。
假设,此时两个进程的优先级都是 0,都处于默认优先级,那么两个线程同样都是获得相同的一半处理器的时间,然而这时每个进程的时间片都是 100 ms,每隔 100 ms 才会有一次上下文切换。

明显,这个分配方式不是很理想。
我们往往希望,前台交互进程拥有高优先级(低 nice 值),而后台进程,拥有低优先级(高 nice 值),
而这种时间片分配是难以符合的。

第二个问题就是,nice 值的绝对,引发了优先级的相对问题。
假设,一个进程 nice 值为 0,一个进程 nice 值为 1,它们的时间分配比则会是 100 : 95,
可以说,几乎是没有什么差别的;
而,一个进程的 nice 值为 19,一个进程的 nice 值为 20,它们的时间分配比则会是 10 : 5,
也就是一个进程的时间片是另一个时间片的两倍!
所以,很明显,nice 值由于采用绝对值会使得相对的变化受到很大的影响,这很大程度上取决于 nice 的初始值,
所以,这是不够理想的。

第三个问题,就是如果执行 nice 值到时间片的映射,那这个绝对时间片,则必须能在内核的测试范围之内。
所以,就要求时间片必须是定时器节拍的整数倍,
所以,最小时间片,就可能至少要 1ms 或者 10ms,这取决于时钟节拍的时间。
而且,时间片还会随着定时器节拍而改变。
所以,这也是引入 CFS 的原因。

第四个问题,则是基于优先级的调度器优化交互任务的问题。
因为,可能在交互系统中,为了让进程能更快地投入运行,而去对新要唤醒的进程提升优先级,即便它们的时间片已经用尽了。
虽然这可以提升交互性能,而这却会打破进程公平原则。

虽然说,这些问题,可以通过修改来改善,不过,这终究没有解决实际问题:
分配绝对的时间片引发的固定切换频率,给公平性造成了很大变数。
所以,CFS 完全摒弃了时间片的概念,而是给进程分配处理器使用的比重。
于是,CFS 的出发点给予一个简单的理念:进程调度的效果应该如同系统具备一个理想中完美多处理器。
也就是,在任意时刻,所有的进程都是在一起运行,而它们只是各自使用了处理器 1/n 的性能。

当然,CFS 的理想并不是现实,一个处理器同一时刻始终无法运行多个进程。
而让每个进程运行时间过于小,来显得同时运行也是不合理的,因为这样会导致频繁抢占带来过大的开销。
所以,CFS 考虑到了这一点,它并没有让进程的运行时间过于短暂。

CFS 的做法是:
允许每个进程运行一段时间,不断循环,不过,每次都选择运行最少的进程作为下一个执行的进程。
而且,运行的分配也不是按照绝对时间片了,而是把 nice 值,作为每个进程的权重。
这样,每个进程的执行时间,都是按照它们各自的权重占比来执行。

假设,现在有两个任务,且权重都相同,一个执行周期为 20 ms,那么每个进程就会执行 10 ms;
假设,有 4 个进程,那么每个进程就会占用 5 ms 的执行时间;
可见,取消了绝对时间片,进程的执行时间分配显得更合理了。

不过,假设,进程增加到 20 个,那么,每个进程就只能执行到 1ms,
假设进程数目持续增多,那么每个进程执行的时间,就会趋向于 0,那么几乎所有的开销,都要花在上下文的切换上了,
所以,CFS 设置了一个最小阈值,每一个进程的执行时间,都不会让其小于 1 ms。
这样,即使进程再多,也能保证进程切换的开销被限制在一定范围之内。

那么,CFS 调度是如何实现的呢?

首先,所有的调度器都必须对进程运行做时间记账。
CFS 虽然不再有时间片的概念,但是,它也必须要维护每个进程执行的时间记账,因为它也要保证每个进程只在公平分配给它的处理器时间内运行。
因此,用了一个 vruntime 变量来存放进程的虚拟运行时间,并且和定时器节拍无关。

记录了时间之后,CFS 就试图利用一个简单的规则去均衡进程的虚拟运行时间:
挑一个 vruntime 最小的进程投入执行。
这就是 CFS 算法的核心。

于是,CFS 使用红黑树来组织可运行进程队列,并利用其迅速找到最小 vruntime 值的进程。
红黑树在 Linux 中则被称为 rbtree,树上的进程则会按照 vruntime 作为键值来进行排列,
于是,每次调度,都只要从根节点,不断往左找,找到最后一个叶子结点,就是下一次投入运行的进程。

于是,进程调度,就会从入口函数 schedule(),来选择进程投入执行。

除了 CFS,其它操作系统,也是各有各的调度算法,
所以,由于底层调度的不确定性,程序的执行时机很难被保证。

因此,线程安全的问题是会经常存在的,
由此,才需要程序员有敏锐的直觉和洞察力,去判断程序的可能结果,去对临界资源进行加锁保护。

线程池

既然我们可以很轻易地创建线程,并且赋予它指定的任务加以执行,
可是,为什么会有线程池这种东西?

一方面,是因为线程需要进行良好的管理,不然,要是漏掉了一些线程在疯狂做别的事情,但是我们还不知道,这是非常影响一个系统稳定性的。
此外,线程来自于哪,要做什么,都进行分类,命名,统一管理,这样,即使出现了问题,也易于排查错误。

管理是一方面,另一方面,就是性能的考虑了。
很多人都知道,线程的创建和销毁都是非常耗费系统资源的,大量创建销毁,会十分影响系统的性能,
因此,可以采用池化技术,将线程集中创建,重复利用,而不是用一次创建一次销毁一次,
这样,可以省去了系统对线程的频繁创建的开销,一定程度上提高性能。

如果,你不了解线程的话,那么,你可能就只能像一般人一样,回答出这么几句话,仅仅只提到了消耗资源。
不过,既然你已经看了上面那以小节对线程的描述,想必,你应该很了解操作系统所定义的线程,以及 Linux 对线程的实现了,
那么,为什么消耗资源,想必你也能够理解:
因为创建和销毁线程的时候,操作系统做了那么多的事情。

不过除了这些,还有一个原因:
像进程的管理和控制,这样的程序运行的管控,一般来说是必须牢牢把握在操作系统的手中的,而不能任由用户控制和管理,这样,就很可能对计算机的其它进程进行攻击,或者恶意捣毁我们的系统。

所以,对于计算机操作系统来说,是分为用户态内核态的:
一些核心的指令只能由内核态运行,一些普通的指令,则可以由用户执行。
这样,用户没有权限去执行一些高危操作,也就无法破坏整个计算机的安全了。

不过,如果只这样的话,那么用户空间的程序就做不了什么事了,因为很多关键的指令都被限制在了内核态才能执行的范围;
所以,比如我们要创建文件,访问网页,那就无法做到了。

因此,为了能让用户态的程序去能做到这些功能,操作系统则开放了一个小的入口,叫做:
系统调用

在用户需要的时候,系统切换到内核态,由内核来代表应用程序在内核空间执行系统调用。
比如用户调用 print() 函数,就会调用 c 语言库中 printf() 函数,然后再是调用 c 库中的 write() 函数,最后则由内核来执行 write() 系统调用。

所以,我们可以明白,像创建线程这样的操作,必须是交由操作系统内核来执行的。

但是,为什么系统调用效率就会低呢?

我们时长听说,用户态内核态切换,会导致效率不高,但是从来没有听谁解释过为什么会这样,

首先,我们需要知道,系统调用,是通过中断来实现的,
因为,中断是唯一能使用户态转变为内核态的方式!!!

所以,要理解系统调用效率低的原因,首先,要先理解,什么是中断:

计算机指令的执行,都是由 PC(程序计数器)来指定的,PC 中记录了指令存放的地址,因此,计算机执行指令的时候,就会去 PC 所指的地址,去取出程序执行,
然后,执行下一跳指令的话,就只要把 PC 的值 +1,改成下一跳指令的地址即可。

所以,要执行中断处理程序的话,也就只要把 PC 的值,改成中断程序的地址,这样,就可以从该地址,读出中断服务程序,从而执行。

所以,看起来,似乎要中断程序,没什么特殊的,就像普通的程序一样,可以执行。

不过,其实不然。
首先,在一条程序执行结束后,先去检查是否有中断信号,
假设有,那么就把 PC 的地址改为中断服务程序的地址,从而去执行中断,
然后,继续回来执行的原来的程序的话,就会产生一个问题,原来的程序执行到哪了???

那么,假设你来想,这应该怎么办?

如果你觉得,可以放在程序执行结束后,就保存一下 PC,
那么,每次执行一次程序,都会花费这样一个操作,这肯定是十分浪费效率的;

假设,那么在中断处理程序中保存呢?
你就会发现,这时候,由于已经切换在中断处理执行了,所以 PC 记录的,已经是中断服务程序的地址了,所以,之前的 PC 的值,已经丢失了,这时去存已经来不及了。

所以,你会发现,用程序来实现,是一个非常不友好的行为。
所以,在切换到中断服务程序的时候,会由硬件来实现 PC 的保存,这个操作就叫做中断隐指令

所以,在切换到中断处理程序的时候,由硬件实现了中断隐指令操作:

  • 一个很重要的就是关中断
    因为,我们这里要执行的任务是保存中断现场,而这个操作是不能被中断的,
    因为,如果这个操作如果被中断,加杂入其它中断操作,就会导致这个中断现场没有被保存,
    那么,程序之后就无法恢复到原样,那么应用程序就会直接崩溃。
  • 既然有关中断的操作了,那么就可以把断点,也就是 PC 的值,安全的保存起来,以便中断程序结束后,原程序可以接着向后执行。
  • 第三个,就是引出中断服务程序了,可以执行中断的处理。

这时,中断服务程序可以开始执行了。
于是,程序的第一步,就是把之前通用寄存器和状态寄存器的内容,保存起来;
这个操作就不用中断隐指令来做了,因为中断程序的执行,第一步就是修改 PC,所以 PC 的值,就没法放到中断服务程序之中去做;
而这些寄存器的数据,则中断服务程序启动时都还在,于是就可以在中断服务程序中,再来进行保存。

于是,接着就可以执行主体的中断服务程序的逻辑。
执行完了之后,就要返回之前的用户程序了,于是,就要把之前打乱的场地恢复,也就是所谓的恢复现场,把 PC、各个寄存器的值全都给按照原来的样子放回去。
当然,最后不要忘了,要把关掉的中断再打开,程序就可以继续响应新的中断了。

那么,这就是一个中断。

不过,刚刚也提到了,系统调用,就是用的中断,
那么,系统调用又是怎么做到的呢?

其实很简单,这里也就不费太多笔墨了,
首先要做的第一步就是,调用 movl 指令,将系统调用的参数,存入到寄存器当中,
一般,在 x86-32 系统上,ebx、ecx、edx、esi、edi 按照顺序存放前五个参数(6 个或以上不多见),

然后,执行陷入指令,比如大家都常听说的 int 0x80,80 中断,
后来,x86 处理器又添加了一条叫做 sysenter 的指令,这条指令相比 int 指令,可以更快速、更专业地陷入内核执行系统调用,
这时,CPU 收到中断信号,就会触发中断服务程序,也就会执行我们系统调用的函数,

最后,返回值给用户,放在寄存器中。
比如 x86 系统,就是放在 eax 寄存器中。

实际上,要注意的一点是,由于系统调用,是由内核空间代为执行的,所以关乎系统的安全与稳定,因此,是不能对随意使用用户传递的参数的,
所以,系统调用,有很重要的一部分内容,就是检查用户参数的合法性!

比如,与文件 I/O 有关的系统调用,就必须检查每一个文件描述符是否有效;
与进程相关的系统调用,就必须检查提供的 PID 是否有效;
所以每一个参数,都必须保证全部合法、并且有效、正确。

并且,对于指针,也必须严格检查:
指针指向的区域必须属于用户空间,因为用户决不能哄骗内核去读取内核空间的数据;
指针指向的区域也必须属于进程的地址空间中,因为进程决不能去哄骗内核读其它进程的数据;
此外,如果内存被标记为可读,才能去读,可写,才能去写,可执行,才能去执行,因为进程绝对不能绕过内存的访问限制。

所以,我们到这里可以发现,在一次系统调用的过程之中,需要做这么多一系列复杂的事情,
因此,频繁进行系统调用,效率是不高的。

所以,很多时候,我们不应该过度使用系统调用,而是合理地去利用。
比如,线程池就是很充分地利用了操作系统的线程资源,而不是用一个丢一个;
同样的,为了合理利用网络资源,也有了连接池,来避免不断 TCP 连接带来的资源消耗。

还有,为什么会有用户态线程,也就是纤程、协程的出现?
其实,说白了也就是因为内核中线程的开销比较大。

本来,系统中只有进程,每个进程都保存着大量的信息,每次切换都要置换掉一大堆状态;
于是,后来引入了线程,每次切换的话,进程空间不用动,不用切换页表,只要把寄存器让出来,CPU 让出来,给另一个线程就行了;
不过,由于线程还是由内核管理,每次调度,都需要内核来执行,并且线程的占有资源,还是相对比较多的,也大约至少要 1M 大小。
而协程,或者说纤程,则 4K 就足够,切换调度也不需要经过操作系统内核。

所以,一般的计算机,启上千个线程,基本上所有的开销都要花在线程的调度切换上了,计算机真正用于执行任务反而会变得很少;
而采用协程的话,就可以启动很多,10W+也可以达到。

什么是锁

对于并发中的线程,我们现在已经理解了;
而且,对于线程的调度,由于存在异步性,所以会在多线程代码中产生各种各样的可能结果。

因此,对于很多共享资源,为了保证数据的安全,我们才需要,对资源进行加锁。

附:很多小白会不理解加锁是对线程加锁,还是对对象加锁。
其实,不难理解,线程就像一个在做事情的人,它要保护一些资源(对象、变量……),于是,它把房间门上了一把锁(把一个锁对象挂在房门上)。
比如 synchronized(object),这个 object 就作为了一把锁;
lock.lock(),这个 lock 对象就作为了一把锁。

那么,对于我们 Java 程序员来说,一般用到的最频繁的就是 synchronized 关键字,或者 Lock 接口,来实现锁的机制。

如果你比较了解 JavaVM 的 synchronized 锁的话,你应该知道,自 1.6 版本以来,synchronized 关键字做了很多的优化,比如:
偏向锁、轻量级锁、重量级锁、锁粗化、锁消除……

如果你还不知道 synchronized,可以去看我之前的博客(文末有链接)

如果是 Lock 接口,则提供了 ReentrantLock、ReentrantReadWriteLock 等等实现。
在 synchronized 还没有优化之前,JUC 包下的 ReentrantLock 则会拥有更好的效率;
同时,Lock 接口也提供了更丰富的 API,可以执行更个性的加解锁操作。

不过,这些并发实现类,都是通过 AQS 类来实现的,
不仅仅 Lock,也包括 CountDownLatch、CyclicBarrier、Semaphore 等等。

所以,要理解 Java 提供的并发工具类,就得去理解 AQS 的实现原理。
如果,你还不会 AQS,可以去看我之前的博客(文末有链接)

那么,我们现在可以思考一个问题,为什么有的锁,效率高,有的锁,却效率低?
synchronized 当初被吐槽效率低,再加上 ReentrantLock 出现,所以后来就做了优化,来提高锁的性能。

其实,因为开始时,synchronized 是纯重量级锁,也就是无论如何都会调用操作系统的锁来实现一把锁。
而 ReentrantLock,再调用操作系统锁之前,在 Java 层面就已经尝试过直接加锁,也就是,在非竞争条件下,是可以不去操作系统申请锁的。
而 synchronized 优化之后,由于有了偏向锁,在单线程的情况下,都可以省略加锁操作,性能极高的,即便是出现了竞争,还有一个轻量级锁过度,同样不用去申请操作系统的重量级锁,因此,可以保证高效。

那么,为什么,向操作系统申请重量级锁,性能就会低?

因为,重量级锁,意味着,在抢不到的锁情况下,会阻塞线程,
而线程的阻塞与唤醒,这个代价是比较高的,
比如在 Linux 下:
首先,进程需要把自己添加到等待队列,并且从可执行红黑树中移出;
然后调用 prepare_to_wait() 把进程的状态修改为休眠状态;
由于存在伪唤醒的可能,所以还需要用循环来判断是否条件真的成立;
并且,这些操作可能存在竞争情况,因此也还需要加锁。

只有当条件满足,进程被唤醒,那么此时检查条件确实为真,才会退出循环,
然后调用 finish_wait() 方法把自己移除等待队列。

所以,就可以有如下类似伪代码:

DEFINE_WAIT(wait);

add_wait_queue(q, &wait);
while(!condition) {
    prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
    if(signal_pending(current))
        处理信号
    schedule();
}
finish_wait(&q, &wait);

不过,有人还说,ReentrantLock 由于采用了 park() 阻塞,和 synchronized 不同,所以效率更高;
还有人说,park 导致线程 WAITING,而 synchronized 阻塞则是 BLOCKED,所以效率不同。

不过,我在上文已经提过了,WAITING、BLOCKED 都是对应操作系统同一种状态,阻塞态,所以不存在什么不同。
不过,由于具体细节没有看到,所以大家仍然会猜测,park() 的效率是否和 synchronized 阻塞有所不同。

于是,我特地翻了一下源码,给大家看一下调用过程:

public static void park() {
    UNSAFE.park(false, 0L);
}

可以发现,实际上就是调用了 Unsafe 的 park() 方法。
Unsafe 类大家应该是很熟悉了,很多原子操作,都是可以通过 Unsafe 来调用了,
比如阻塞、唤醒、还有 CAS 等等。

我们继续看 Unsafe 的 park() 方法:

public native void park(boolean isAbsolute, long time);

这时,我们就会发现,已经是 native 方法了,所以我们要查看 JVM 是如何实现的,
我们一点点点进去:

{CC"park",               CC"(ZJ)V",                  FN_PTR(Unsafe_Park)},
UNSAFE_ENTRY(void, Unsafe_Park(JNIEnv *env, jobject unsafe, jboolean isAbsolute, jlong time))
  UnsafeWrapper("Unsafe_Park");
  EventThreadPark event;

......

  JavaThreadParkedState jtps(thread, time != 0);
  thread->parker()->park(isAbsolute != 0, time);
  
......

UNSAFE_END

我们可以发现,这里面就是调用了 thread 的 parker() 方法,然后调用返回的 parker 的 park() 方法,
所以,我们就可以点进去确认一下:

public:
  Parker*     parker() { return _parker; }

可以看到,parker() 确实是返回了一个 Parker
我们再看 park() 方法:

void Parker::park(bool isAbsolute, jlong time) {
  ......
  
  int status ;
  if (_counter > 0)  { // no wait needed
    _counter = 0;
    status = pthread_mutex_unlock(_mutex);
    ......
  }
  ......
}

我们可以发现,实际上 park,本质上就是调用了 Linux 底层提供的 pthread_mutex_unlock 函数,来实现阻塞。

那我们接下来看一下 synchronized 的重量级锁是怎么做的。
不过,由于 synchronized 有很多逻辑,偏向、轻量、批量重偏向,等等复杂的逻辑,我就不一一贴代码了。
我们直接看重量级锁是调用了什么方法:

void ATTR ObjectMonitor::enter(TRAPS) {
    ......
    
    for (;;) {
      jt->set_suspend_equivalent();
      // cleared by handle_special_suspend_equivalent_condition()
      // or java_suspend_self()

      EnterI (THREAD) ;

      if (!ExitSuspendEquivalent(jt)) break ;

      ......
    }
    ......
}

发现,是在 EnterI 方法中进了阻塞。
继续点进去:

void ATTR ObjectMonitor::EnterI (TRAPS) {
    ......
    
    Self->_ParkEvent->park() ;
    
    ......
}

发现同样是 park() 方法

void os::PlatformEvent::park() {       // AKA "down()"
	......
	
	int status = pthread_mutex_lock(_mutex);
	
	......
}

同样调用了底层 pthread_mutex_lock 方法。

可见,实际上,LockSupport 的 park() 方法,和 synchronized 的阻塞,实际上一样的,
所以,也不会有什么性能上的差别。

实际上,我们一般在使用锁的时候,要考虑的,是要用一种什么方式的锁:
比如,
是采用自旋锁?还是阻塞锁?还是可以分段?还是读写锁?或者可以写时复制?或者甚至可以无锁?

我们 Java 中,经常讨论到的就是 CAS(CompareAndSet),也就是一个原子操作。
CAS 这样的操作贴近硬件,所以一般是作为操作系统的原语,
特点是,这样的指令是原子的,指令会一次执行完,而不可能出现竞争条件。
这在计算机底层是通过关中断来实现的,执行指令的时候,就关闭中断,所以在执行中就无法被打断,直到执行完毕,才把中断重新打开。

我们在很多场景下都会使用到这样的原子指令,比如 Atomic 类,就会通过这样的方式,来对数值进行修改操作。
主要是因为,这样的原子指令,不会阻塞线程,而是会直接执行成功,或者由于竞争条件,直接返回失败。
所以,在进行一些短小精悍的指令的时候,就很适合 CAS + 自旋,
因为,这不会阻塞线程,带来的开销就只有原语的执行,和额外自旋少量次数的开销。
而如果采用阻塞的方式,则会使得开销大得多。

那么,所以一般等到一些复杂冗长的代码段需要在加锁期间执行的话,
那么,如果期间自旋,就会使得额外的自旋数量很多,比如上百上千,
那么此时,自旋带来的损耗就会很大了,不像之前简单的指令,自旋几次,就能执行成功。
所以,此时,就更倾向于使用重量级锁,阻塞线程,那么开销就还是会和之前一样多,相比这时的自旋,就会显得很少了。

这是加锁的方式的区别。
不过,其实加锁还有一些可以细究的地方。
比如,我们很多时候,其实可以优化一部分内容,因为其本身不需要被锁定,比如读写锁:
所以,读的时候,如果没有写,本身是不加锁也没问题的,
但是,如果会产生写操作,那就得加锁,
所以,为了保证安全,又得让读不会由于加锁而丧失大量的性能,因此出现了读写锁,
读的时候,加读锁,那就只有 CAS + 自旋的开销,
而加写锁的时候,才会进行阻塞,这样,对性能的影响,只要写不是特别多,就不会特别大。

当然,尽量不加锁的方式,还有写时复制(Copy on write),
也就是不加锁,采用写数据时,将原有数据拷贝的方式,写入数据,
这样,也可以减少锁的开销。
不过,要注意的是,由于复制也有一定的开销,那就要和阻塞的开销进行衡量,
只有在阻塞开销大于复制开销的情况下,才适合用这个方式。

除此以外,还有一种思维就是尽量减少竞争。
分段锁就是这个思想。
比如,数据库的行锁,相比表锁,竞争就会减少;
此外,还有 1.7 版本的 ConcurrentHashMap,就是采用了分段锁;
1.8 的 ConcurrentHashMap 则是变成了数组的每一个槽位就是一把锁,分段更加细粒度;
在 CAS+自旋 方面,还有 LongAdder,也是通过分段的方式,减少了自旋锁的竞争。

当然,锁的知识还有很多,比如 Synchronized 的锁就有那么多的优化,自旋、偏向、阻塞、批量、等等;
比如大名鼎鼎的 ReentrantLock,内部也实现了非常多的机制,来保证其效率;
以及 ReentrantReadWriteLock,读写锁也采取了非常精妙的设计……

篇幅有限,我不可能对其中细节一一道来,大家可以尝试去阅读源码,
或者,到文末链接,查看笔者其它的文章。

volatile

对于并发而言,除去上面所列的知识,还有一个很重要的点。
相信学过 Java 的同学也都知道 volatile 这个关键字,它的意义在于保证可见性,和禁止指令重排序。

那么,要理解这些,就需要先了解,为什么会出现指令重排序和不可见的情况。

首先,我们的 Java 语言编译成 class 的时候,是不会把指令重新排序的。
不过,在 CPU 执行的时候,就可能会根据执行的情况,去对指令做动态的排序调整,
并且,JIT 编译热点代码的时候,也会做出一定的优化。

那为什么指令会重排序呢?
这里简单举个例子,比如,有这么两条指令:
x = a;
y = b;

可能 CPU 在执行的时候,就会发现,a 的值,还在主存,而 b 的值,还在 Cache 中,
那么,这时候,要去内存中把数据读出来,缓存都可以读好几个了,
所以,CPU 就可能在 a 的值还没读到的时候,就把 b 的值读到了,
所以,y = b 这条指令,反而比上一条指令先执行完。

不过,排序也不是能随便乱排的,否则,运行结果错乱了怎么整?

所以,在指令重排的过程,要遵循 as-if-serial 语义,
英文翻译一下就是,好像就是串行的,
as-if-serial:不管按照什么顺序排序,单线程执行的结果不变,看上去像是在顺序执行一样。

所以,程序执行的时候,只是单线程,就不会因为重排序产生错误。

为了保证不会出错,处理器在执行的时候,就不会对有上下文关系的指令进行重排,比如:

a = 1;
b = a;

这样的话,因为 b 的赋值,要依赖于 a,所以这里就不能够进行重排序,
否则,b 的值要从哪里取?

不过,假设是多线程,就会出现有意思的情况,
假设此时,我们的 a 和 b 的变量,都是 0,然后分两个线程,分别执行下面的代码:
(左边是一个线程,右边时一个线程)

a = 1;              b = 1;
x = b;              y = a;

如果没有重排序的话,那么最终,结果应该是 x 和 y 至少有一个 1;
但是,实际上,这个程序,大约每运行 6000多次,就会因为重排序,而产生 x 和 y 都为 0 的结果,
你也可以回去试一下。

因为,在每个独立的线程中,两条指令,都没有上下依赖的关系,所以就可能产生重排序,
然而,再两个线程的程序之中,却有变量相互关联影响。
而这时,我们的 CPU 是不知情的,它还是会按照自己的逻辑,去重排序我们的指令。

所以,我们在多线程的代码中,就需要额外注意,要对不安全的数据,进行禁止指令重排!

在 Java 中,那就比较简单,只需要加一个 volatile 关键字即可。
不过,这只是由于 Java 语言,帮我们屏蔽了底层的细节。
假设,我们用 c 语言,或者汇编,都需要手动去添加屏障。

我们再看另一个问题,就是可见性问题,

首先,我们都知道,我们的计算机一般都会在 CPU 和内存之间,还要有一层缓存,
比如,一般我们的电脑,都会有 L1、L2、L3 三级缓存。

每个 CPU 由于自己都有缓存,所以,就存在这样一种情况,
一个 CPU,把 a 的值,读入缓存;
另一个 CPU,把 a 的值,读入它自己的缓存;
然后,一个 CPU 把 a 的值改了,另一个 CPU 缓存中的值,却还是原来的!
那,这样的话,结果肯定是有问题的,所以才需要去对这种情况做一个纠正。

对于缓存的一致性,一个广泛应用的协议就是 MESI 协议,这个的话,了解即可,我们 Java 程序员不必过于深究。
它是对 CPU 中的每个缓存行,用额外两个比特标记,一共有 4 种状态:

  • 修改态(Modified)— 此 cache 行已被修改过(脏行),内容已不同于主存,为此 cache 专有;
  • 专有态(Exclusive)— 此 cache 行内容同于主存,但不出现于其它 cache 中;
  • 共享态(Shared)— 此 cache 行内容同于主存,但也出现于其它 cache 中;
  • 无效态(Invalid)— 此 cache 行内容无效(空行)。

这样,假设两个 CPU 都把同一个数据读入了缓存,那么,此时就是共享态(Shared);
然后,一个 CPU 修改了缓存的值,于是这个 CPU 的缓存行状态,就得修改为 Modified,
并且,要通知其它的 CPU,这时,它们的状态就会改成 Invalid,无效态;
因此,它们要使用该数据时,就会重新从内存中读取,而不会使用缓存中不一致的值。

这样的话,多处理器时,单个 CPU 对缓存中数据进行了改动,需要通知给其他 CPU。
也就是意味着,CPU 处理要控制自己的读写操作,还要监听其他 CPU 发出的通知,从而保证最终一致性。

除了缓存带来的可见性问题之外,还有其它原因,会造成数据之间的不可见,
比如下面这个很经典的小程序:

public class Demo1Visibility {
    int i = 0;
    boolean isRunning = true;

    public static void main(String args[]) throws InterruptedException {
        Demo1Visibility demo = new Demo1Visibility();
        // 新建线程,在true的情况下不断print出i的值
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("子线程开始i++操作");
                while(demo.isRunning){
                    demo.i++;
                }
                // 主线程将变量设置为false时退出循环
                System.out.println("我已退出,当前值为:" + demo.i);
            }
        }).start();
        // 3秒后设置变量为false
        Thread.sleep(3000L);
        demo.isRunning = false;
        System.out.println("改变变量为false,主线程结束...");
    }
}

这个程序,一般在 server VM 下,是不会结束的。
如果你以前没有了解过,那么可能会大跌眼镜,竟然 isRunning 改为 false,但是 while 却不会终止循环???
当然,这个题有很多变种,我在这里就不一一列出来了,想要了解的读者可以从文末链接前往查看

这就既涉及到了可见性,又涉及到了重排序优化的问题:
因为 while 循环被执行了很多很多次,所以被认为是热点代码,就会触发 JIT 优化,
于是,我们就会发现代码发生如下变化:

while(isRunning){
    i++;
}
if(isRunning)
	while(true){
	    i++;
	}
}

发现,在编译时,指令就被进行重排序了,因此,代码陷入死循环,将无法结束。
所以,对于 while 循环条件来说,isRunning 的值已经不可见了,所以,也丧失了可见性。

于是,我们给原代码,isRunning 字段添加 volatile 关键字之后,程序就会正常终止,
因为 volatile 要保证可见性,同时也禁止了重排序,因此,JIT 就无法执行这个优化。

下面,了解了什么是 volatile 之后,我们就继续探究。

首先,Java 程序中的变量,添加了 volatile 关键字之后,
我们将其编译,在 class 字节码层面,就会被标记 ACC_VOLATILE,
随后,JavaVM 对这些变量的读写,都会加上内存屏障。

在概念上,屏障分为 4 种:

  • 读读屏障 LoadLoadBarrier
  • 读写屏障 LoadStoreBarrier
  • 写读屏障 StoreLoadBarrier
  • 写写屏障 StoreStoreBarrier

而 JVM,对于 volatile 变量的读写,就会分别加上这些屏障:

  • LoadLoadBarrier
    volatile 读
    LoadStoreBarrier
  • StoreStoreBarrier
    volatile 写
    StoreLoadBarrier

通过加上内存屏障,就可以保证指令不会重排序,同时也可以保证可见性。
因为,屏障设立的目的就是,拦住前后的指令,不让它们出错,比如:

a = 1;
StoreStoreBarrier
b = a;

通过屏障,就是要保证,这个 b 获取 a 的数据,要是准确的,
所以 b 要获取 a 的时候,a 变量就必须已经被准确完好地赋值成功,然后由 b 读取。

而内存屏障的实现,是我们 Java 程序员不用过多关心的,
因为在不同的CPU架构上内存屏障的实现非常不一样,所以,我们更多的,是知道内存屏障的作用即可。

在 Linux 内核中,则提供了 rmb() 读屏障,wmb() 写屏障,以及 mb() 读写屏障;
此外,Linux 还提供了 read_barrier_depends() 这样的依赖读屏障,也就是只屏障住了那些有依赖关系的读操作。

在 Intel x86 上,则提供了 ifence 读屏障、sfence 写屏障、mfence 读写屏障这样的原语;
此外,”lock” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。

文末

阅读到这里,相比你对并发的理解,应该算是更深刻些了吧。

毕竟文章篇幅有限,很多细节,我也没法一一列举出来,不过,笔者认为,对于并发编程的重要知识,也算是大体的罗列了一些。

这里,我偏重于讲解了一些并发的偏基础的一些知识,其实也不仅仅是对于 Java,对于任何一门语言都是如此。

而对于其它要学的知识,不可否认,还有很多,比如很多并发工具类等等,还有很多 Java 的源码。

所以,学习的路还有很远。

最后,给出一些,本文章涉及到知识点的笔者的其它博客,因为篇幅有限,并且也已写过,在这里便没有详细写。

99%的人答不对的并发题(可见性,原子性,synchronized 原理)

AQS互斥锁源码讲解(基于ReentrantLock)

线程池源码分析

synchronized(对象头、批量重偏向、延迟偏向、线程欺骗)

你可能感兴趣的:(班级作业,java)