☆啃碎并发(一):Java线程总述与概念

1 前言

在JDK5之前,Java多线程以及其性能一直是个软肋,只有synchronized、Thread.sleep()、Object.wait/notify这样有限的方法,而synchronized的效率还特别地低,开销比较大。

在JDK5之后,相对于前面版本有了重大改进,不仅在Java语法上有了很多改进,包括:泛型、装箱、for循环、变参等,在多线程上也有了彻底提高,其引进了并发编程大师Doug Lea的java.util.concurrent包(后面简称J.U.C),支持了现代CPU的CAS原语,不仅在性能上有了很大提升,在自由度上也有了更多的选择,此时 J.U.C的效率在高并发环境下的效率远优于synchronized

在JDK6(Mustang 野马)中,对synchronized的内在机制做了大量显著的优化,加入了CAS的概念以及偏向锁、轻量级锁,使得synchronized的效率与J.U.C不相上下,并且官方说后面该关键字还有继续优化的空间,所以在现 在JDK7时代,synchronized已经成为一般情况下的首选,在某些特殊场景:可中断的锁、条件锁、等待获得锁一段时间如果失败则停止,J.U.C是适用的,所以对于 多线程研究来说,了解其原理以及各自的适用场景是必要的

2 基本概念

2.1 线程

线程是依附于进程的,进程是分配资源的最小单位,一个进程可以生成多个线程,这些线程拥有共享的进程资源。就每个线程而言,只有很少的独有资源,如:控制线程运行的线程控制块,保留局部变量和少数参数的栈空间等。线程有就绪、阻塞和运行三种状态,并可以在这之间切换。也正因为多个线程会共享进程资源,所以当它们对同一个共享变量/对象进行操作的时候,线程的冲突和不一致性就产生了。

多线程并发环境下,本质上要解决地是这两个问题:

  1. 线程之间如何通信;
  2. 线程之间如何同步;

概括起来说就是:线程之间如何正确地通信。虽然说的是在Java层面如何保证,但会涉及到 Java虚拟机、Java内存模型,以及Java这样的高级语言最终是要映射到CPU来执行(关键原因:如今的CPU有缓存、并且是多核的),虽然有些难懂,但对于深刻把握多线程是至关重要的,所以需要多花一些时间。

2.2 锁

当多个线程对同一个共享变量/对象进行操作,即使是最简单的操作,如:i++,在处理上实际也涉及到读取、自增、赋值这三个操作,也就是说 这中间存在时间差,导致多个线程没有按照如程序编写者所设想的去顺序执行,出现错位,从而导致最终结果与预期不一致

Java中的多线程同步是通过锁的概念来体现。锁不是一个对象、不是一个具体的东西,而是一种机制的名称。锁机制需要保证如下两种特性:

  1. 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程中的协调机制,这样在同一时间只有一个线程对需同步的代码块(复合操作)进行访问。互斥性我们也往往称为操作的原子性
  2. 可见性:必须确保在锁被释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作从而引起不一致;

2.3 挂起、休眠、阻塞与非阻塞

挂起(Suspend):当线程被挂起的时候,其会失去CPU的使用时间,直到被其他线程(用户线程或调度线程)唤醒。

休眠(Sleep):同样是会失去CPU的使用时间,但是在过了指定的休眠时间之后,它会自动激活,无需唤醒(整个唤醒表面看是自动的,但实际上也得有守护线程去唤醒,只是不需编程者手动干预)。

阻塞(Block):在线程执行时,所需要的资源不能得到,则线程被挂起,直到满足可操作的条件。

非阻塞(Block):在线程执行时,所需要的资源不能得到,则线程不是被挂起等待,而是继续执行其余事情,待条件满足了之后,收到了通知(同样是守护线程去做)再执行

挂起和休眠是独立的操作系统的概念,而阻塞与非阻塞则是在资源不能得到时的两种处理方式,不限于操作系统,当资源申请不到时,要么挂起线程等待、要么继续执行其他操作,资源被满足后再通知该线程重新请求。显然非阻塞的效率要高于阻塞,相应的实现的复杂度也要高一些。

在Java中显式的挂起之前是通过Thread的suspend方法来体现,现在此概念已经消失,原因是suspend/resume方法已经被废弃,它们容易产生死锁,在suspend方法的注释里有这么一段话:当suspend的线程持有某个对象锁,而resume它的线程又正好需要使用此锁的时候,死锁就产生了

所以,现在的JDK版本中,挂起是JVM的系统行为,程序员无需干涉。休眠的过程中也不会释放锁,但它一定会在某个时间后被唤醒,所以不会死锁。现在我们所说的挂起,往往并非指编写者的程序里主动挂起,而是由操作系统的线程调度器去控制

所以,我们常常说的“线程在申请锁失败后会被挂起、然后等待调度”这样有一定歧义,因为这里的“挂起”是操作系统级别的挂起,其实是在申请资源失败时的阻塞,和Java中的线程的挂起(可能已经获得锁,也可能没有锁,总之和锁无关)不是一个概念,很容易混淆,所以在后文中说的挂起,一般指的是操作系统的操作,而不是Thread中的suspend()。

相应地有必要提下java.lang.Object的wait/notify,这两个方法同样是等待/通知,但它们的前提是已经获得了锁,且在wait(等待)期间会释放锁。在wait方法的注释里明确提到:线程要调用wait方法,必须先获得该对象的锁,在调用wait之后,当前线程释放该对象锁并进入休眠(这里到底是进入休眠还是挂起?文档没有细说,从该方法能指定等待时间来看,更可能是休眠,没有指定等待时间的,则可能是挂起,不管如何,在休眠/挂起之前,JVM都会从当前线程中把该对象锁释放掉),只有以下几种情况下会被唤醒:其他线程调用了该对象的notify或notifyAll、当前线程被中断、调用wait时指定的时间已到

2.4 内核态与用户态

这是两个操作系统的概念,但理解它们对我们理解Java的线程机制有着一定帮助。

有一些系统级的调用,比如:清除时钟、创建进程等这些系统指令,如果这些底层系统级指令能够被应用程序任意访问的话,那么后果是危险的,系统随时可能崩溃,所以 CPU将所执行的指令设置为多个特权级别,在硬件执行每条指令时都会校验指令的特权,比如:Intel x86架构的CPU将特权分为0-3四个特权级,0级的权限最高,3权限最低。

而操作系统根据这系统调用的安全性分为两种:内核态和用户态内核态执行的指令的特权是0,用户态执行的指令的特权是3

  1. 当一个任务(进程)执行系统调用而进入内核指令执行时,进程处于内核运行态(或简称为内核态);
  2. 当任务(进程)执行自己的代码时,进程就处于用户态;

明白了内核态和用户态的概念之后,那么在这两种状态之间切换会造成什么样的效率影响?

在执行系统级调用时,需要将变量传递进去、可能要拷贝、计数、保存一些上下文信息,然后内核态执行完成之后需要再将参数传递到用户进程中去,这个切换的代价相对来说是比较大的,所以应该是 尽量避免频繁地在内核态和用户态之间切换

那操作系统的这两种形态和我们的线程主题有什么关系呢?这里是关键。Java并没有自己的线程模型,而是使用了操作系统的原生线程

如果要实现自己的线程模型,那么有些问题就特别复杂,难以解决,比如:如何处理阻塞、如何在多CPU之间合理地分配线程、如何锁定,包括创建、销毁线程这些,都需要Java自己来做,在JDK1.2之前Java曾经使用过自己实现的线程模型,后来放弃了,转向使用操作系统的线程模型,因此创建、销毁、调度、阻塞等这些事都交由操作系统来做,而 线程方面的事在操作系统来说属于系统级的调用,需要在内核态完成,所以如果频繁地执行线程挂起、调度,就会频繁造成在内核态和用户态之间切换,影响效率(当然,操作系统的线程操作是不允许外界(包括Java虚拟机)直接访问的,而是开放了叫“轻量级进程”的接口供外界使用,其与内核线程在Window和Linux上是一对一的关系,这里不多叙述)。

前面说JDK5之前的synchronized效率低下,是 因为在阻塞时线程就会被挂起、然后等待重新调度,而线程操作属于内核态,这频繁的挂起、调度使得操作系统频繁处于内核态和用户态的转换,造成频繁的变量传递、上下文保存等,从而性能较低

3 线程优势

尽管面临很多挑战,多线程有一些优点使得它一直被使用。这些优点是:

  1. 资源利用率更好;
  2. 程序设计在某些情况下更简单;
  3. 程序响应更快速;

3.1 资源利用率更好

CPU能够在等待IO的时候做一些其他的事情。这个不一定就是磁盘IO。它也可以是网络的IO,或者用户输入。通常情况下,网络和磁盘的IO比CPU和内存的IO慢的多。

3.2 程序设计更简单

在单线程应用程序中,如果你想编写程序手动处理多个IO的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程处理一个文件的读取和处理操作。线程会在等待磁盘读取文件的过程中被阻塞在等待的时候,其他的线程能够使用CPU去处理已经读取完的文件。其结果就是,磁盘总是在繁忙地读取不同的文件到内存中。这会带来磁盘和CPU利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。

3.3 程序响应更快速

将一个单线程应用程序变成多线程应用程序的另一个常见的目的是 实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。

如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。

另一种设计是,监听线程把请求传递给工作者线程池(worker thread pool),然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。

4 线程代价

使用多线程往往可以 获得更大的吞吐率和更短的响应时间,但是,使用多线程不一定就比单线程程序跑的快,这取决于我们程序设计者的能力以及应用场景的不同。不要为了多线程而多线程,而应考虑具体的应用场景和开发实力,使用多线程就是希望能够获得更快的处理速度和利用闲置的处理能力,如果没带来任何好处还带来了复杂性和一些定时炸弹,那就傻逼了?只有在使用多线程给我们带来的好处远大于我们付出的代价时,才考虑使用多线程。有时候可能引入多线程带来的性能提升抵不过多线程而引入的开销,一个没有经过良好并发设计得程序也可能比使用单线程还更慢。

4.1 设计更复杂

多线程程序在访问共享可变数据的时候往往需要我们很小心的处理,否则就会出现难以发现的BUG,一般地,多线程程序往往比单线程程序设计会更加复杂(尽管有些单线程处理程序可能比多线程程序要复杂),而且错误很难重现(因为线程调度的无序性,某些bug的出现依赖于某种特定的线程执行时序)。

4.2 上下文切换开销

当CPU从执行一个线程切换到执行另外一个线程的时候,需要先存储当前线程的本地的数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行。这种切换称为 “上下文切换”(“context switch”)。CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另外一个线程。

上下文切换并不廉价。如果没有必要,应该减少上下文切换的发生。

4.3 增加资源消耗

线程在运行的时候需要从计算机里面得到一些资源。除了CPU,线程还需要一些内存来维持它本地的堆栈。它也需要占用操作系统中一些资源来管理线程。我们可以尝试编写一个程序,让它创建100个线程,这些线程什么事情都不做,只是在等待,然后看看这个程序在运行的时候占用了多少内存。

5 创建运行

编写线程运行时执行的代码有两种方式:一种是创建Thread子类的一个实例并重写run方法,第二种是创建类的时候实现Runnable接口

5.1 创建Thread的子类

创建Thread子类的一个实例并重写run方法,run方法会在调用start()方法之后被执行。例子如下:

public class MyThread extends Thread {
   public void run(){
     System.out.println("MyThread running");
   }
}

可以用如下方式创建并运行上述Thread子类:

MyThread myThread = new MyThread();
myTread.start();

一旦线程启动后start方法就会立即返回,而不会等待到run方法执行完毕才返回。就好像run方法是在另外一个cpu上执行一样。当run方法执行后,将会打印出字符串MyThread running。

5.2 实现Runnable接口

第二种编写线程执行代码的方式是新建一个实现了java.lang.Runnable接口的类的实例,实例中的方法可以被线程调用。下面给出例子:

public class MyRunnable implements Runnable {
   public void run(){
    System.out.println("MyRunnable running");
   }
}

为了使线程能够执行run()方法,需要在Thread类的构造函数中传入 MyRunnable的实例对象。示例如下:

Thread thread = new Thread(new MyRunnable());
thread.start();

当线程运行时,它将会调用实现了Runnable接口的run方法。上例中将会打印出”MyRunnable running”。

5.3 创建子类还是实现Runnable接口?

对于这两种方式哪种好并没有一个确定的答案,它们都能满足要求。就个人意见,更倾向于实现Runnable接口这种方法。因为线程池可以有效的管理实现了Runnable接口的线程,如果线程池满了,新的线程就会排队等候执行,直到线程池空闲出来为止。而如果线程是通过实现Thread子类实现的,这将会复杂一些

有时我们要同时融合实现Runnable接口和Thread子类两种方式。例如,实现了Thread子类的实例可以执行多个实现了Runnable接口的线程。一个典型的应用就是线程池。

5.4 常见错误:调用run()方法而非start()方法

创建并运行一个线程所犯的常见错误是调用线程的run()方法而非start()方法,如下所示:

Thread newThread = new Thread(MyRunnable());
newThread.run();  //should be start();

起初你并不会感觉到有什么不妥,因为run()方法的确如你所愿的被调用了。但是,事实上,run()方法并非是由刚创建的新线程所执行的,而是被创建新线程的当前线程所执行了。也就是被执行上面两行代码的线程所执行的。想要让创建的新线程执行run()方法,必须调用新线程的start()方法

5.5 线程名

当创建一个线程的时候,可以给线程起一个名字。它有助于我们区分不同的线程。例如:如果有多个线程写入System.out,我们就能够通过线程名容易的找出是哪个线程正在输出。例子如下:

MyRunnable runnable = new MyRunnable();
Thread thread = new Thread(runnable, "New Thread");
thread.start();
System.out.println(thread.getName());

需要注意的是,因为MyRunnable并非Thread的子类,所以MyRunnable类并没有getName()方法。可以通过以下方式得到当前线程的引用:

Thread.currentThread();

因此,通过如下代码可以得到当前线程的名字:

String threadName = Thread.currentThread().getName();

首先输出执行main()方法线程名字。这个线程JVM分配的。然后开启10个线程,命名为1~10。每个线程输出自己的名字后就退出。

public class ThreadExample {
  public static void main(String[] args){
     System.out.println(Thread.currentThread().getName());
      for(int i=0; i<10; i++){
         new Thread("" + i){
            public void run(){
             System.out.println("Thread: " + getName() + "running");
            }
         }.start();
      }
  }
}

需要注意的是,尽管启动线程的顺序是有序的,但是执行的顺序并非是有序的。也就是说,1号线程并不一定是第一个将自己名字输出到控制台的线程。这是因为线程是并行执行而非顺序的。JVM和操作系统一起决定了线程的执行顺序,他和线程的启动顺序并非一定是一致的

5.6 Main线程与子线程关系

  1. Main线程是个非守护线程,不能设置成守护线程

    这是因为,Main线程是由Java虚拟机在启动的时候创建的。main方法开始执行的时候,主线程已经创建好并在运行了。对于运行中的线程,调用Thread.setDaemon()会抛出异常Exception in thread "main" java.lang.IllegalThreadStateException

  2. Main线程结束,其他线程一样可以正常运行

    主线程,只是个普通的非守护线程,用来启动应用程序,不能设置成守护线程;除此之外,它跟其他非守护线程没有什么不同。主线程执行结束,其他线程一样可以正常执行

    这样其实是很合理的,按照操作系统的理论,进程是资源分配的基本单位,线程是CPU调度的基本单位。对于CPU来说,其实并不存在java的主线程和子线程之分,都只是个普通的线程。进程的资源是线程共享的,只要进程还在,线程就可以正常执行,换句话说线程是强依赖于进程的。也就是说:

    线程其实并不存在互相依赖的关系,一个线程的死亡从理论上来说,不会对其他线程有什么影响

  3. Main线程结束,其他线程也可以立刻结束,当且仅当这些子线程都是守护线程

    Java虚拟机(相当于进程)退出的时机是:虚拟机中所有存活的线程都是守护线程。只要还有存活的非守护线程虚拟机就不会退出,而是等待非守护线程执行完毕;反之,如果虚拟机中的线程都是守护线程,那么不管这些线程的死活java虚拟机都会退出

6 再聊并发与并行

并发和并行的区别就是一个处理器同时处理多个任务多个处理器或者是多核的处理器同时处理多个不同的任务前者是逻辑上的同时发生(simultaneous),而后者是物理上的同时发生

并发性(concurrency),又称共行性,是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。

并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。

来个比喻:并发和并行的区别就是一个人同时吃三个馒头和三个人同时吃三个馒头

上图反映了一个包含8个操作的任务在一个有两核心的CPU中创建四个线程运行的情况。假设每个核心有两个线程,那么每个CPU中两个线程会交替并发,两个CPU之间的操作会并行运算。单就一个CPU而言两个线程可以解决线程阻塞造成的不流畅问题,其本身运行效率并没有提高,多CPU的并行运算才真正解决了运行效率问题,这也正是并发和并行的区别

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容