从本篇文章开始,我们一起来认识一下Java并发编程的相关细节。如果让你写一个多线程程序,相信绝大多数开发人员都能很轻松的实现。但是对于Java并发编程的一些细节,比如线程多个状态的流转、线程安全保证的原理、线程池的实现等等,综合考虑起来还是有很多知识的,我现在也不能完全搞明白,刚好在并发编程这个系列中,整理一下Java并发编程的一些细节,希望对理解Java并发编程带来一些帮助。
1. Java并发编程的基本概念
1.1 进程和线程
本节介绍一下进程和线程的基本概念,这里借鉴一下前几天读的一本书《码农翻身》的内容,感觉可以更好的帮助大家理解进程和线程产生的原因及为什么要使用多线程等基本概念。
- 批处理
很久之前,计算机还是单道批处理系统的时候,一个计算机内只有一个cpu,所有程序要执行,都要等待cpu资源。获取资源之后,程序才能运行,直到程序运行结束,会一直占用cpu资源。假如有两个程序要执行,必须等其中一个程序完全执行结束,另一个程序才可能被执行。所以会存在两个问题,假如某些程序运行时间过长,会导致其他程序长时间不能得到执行;另外,假如其中一个程序有大量的io操作,这时候cpu资源相当于在空等,对cpu来讲也是一种浪费。
- 进程
上面讲到,当一个程序在进行io操作时,cpu资源还被这个线程所占用,导致cpu利用率过低,实际上这段时间内cpu资源完全可以交给另外一个程序使用。
当然可以这么做的前提是保护好程序的执行现场: 具体执行到那一行程序指令了, 函数调用到什么层次了,每个函数调用都有什么样的参数,CPU寄存器中的值….. 等等一系列东西 。如果不把程序的执行现场给保存下来,等到程序的数据从磁盘读完了,就没法回到中断处来继续执行了。
这个执行现场,再加上运行的代码,就是一个执行中的程序,被称为“进程” 。可以发现原来单道批处理系统中的程序,在这里有了另外的名字——进程。
所有的运行程序都是进程,并且操作系统规定进程不能长时间占据CPU, 只能在CPU上执行一小会儿,然后马上切换到别的进程去执行。在操作系统看来各个程序(进程)的执行情况如下图所示:
但是由于CPU运行速度特别快,进程之间虽然在不断地切换运行,在人类看来,多个进程好像是同时在执行一样(人类时间和机器时间概念的对比请参考让CPU告诉你硬盘和网络到底有多慢),这就是并发。
- 线程
在操作系统引入进程之后,在cpu利用率上确实有了显著提升,但是又出现了新的问题。比如当进程一有界面,同时也可以访问网络,当这个进程访问网络的时候,cpu资源会分配给另外一个进程二,但是此时进程一由于已经失去了cpu资源,界面是无法操作的,即使进程一又重新获取了cpu资源,如果网络访问还没有结束,那么界面还是无法操作的,这对于使用者而言,相当于进程死掉了,肯定是无法接受的。为了解决这种问题,操作系统为一个进程引入了多个处理流程——线程。比如为进程一添加两个线程,一个线程用来处理界面请求,另外一个线程用来处理网络请求,由cpu去调度各个线程,这样就不会造成由于长时间网络IO,导致界面没有响应了。
引入线程之后,线程便成为cpu调度的基本单位,进程作为资源分配的基本单位,进程中各个线程共享进程的资源。
1.2 线程安全
线程安全是编程中的术语,指某个函数、函数库在并发环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
并发环境就是上面讲的操作系统引入线程之后,线程作为cpu调的的基本单位,线程被调度执行跟之前讲的进程的运行方式是一样的,也是并发执行的。单个线程不会执行过久的时间,时间片用完之后,就要让出cpu资源,切换到其他线程执行。
多个线程就是指在并发环境中,在一段时间内,同个进程的多个线程都可以得到执行,其实就是所说的多线程编程。
共享变量,指的是多个线程都可以操作的变量。在Java中,指的是成员变量(静态成员变量 + 普通成员变量)。由于多个线程是并发执行的,那么就存在对于共享变量的读写产生紊乱,也就是所说的线程安全。
1.3 Java并发编程的特性
从我们开始接触并发编程,就知道Java并发编程如果要保证线程安全,需要有三个特性:
- 可见性:当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
- 原子性:在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行
- 有序性:即程序执行的顺序按照代码的先后顺序执行
其实可见性、有序性、原子性问题是抽象定义的,上面每个特性都对应于一个计算机系统现实存在的问题,即缓存一致性、处理器优化和指令重排、竟态条件问题,下面分别来讲一下这三个问题。
1.3.1 缓存一致性问题
计算机在执行程序的时候,每条指令都是在cpu中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在物理內存的。随着cpu技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,导致cpu每次操作内存都要耗费很多等待时间,内存成为了cpu执行的瓶颈。
所以,人们想出来了一个好的办法,就是在cpu和内存之间增加高速缓存。缓存的特点是速度快,内存小,并且昂贵。
引入缓存后,程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
随着cpu能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与cpu结合的紧密程度,cpu缓存可以分为一级缓存(L1),二级缓存(L3),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。
在有了多级缓存之后,程序的执行就变成了,当cpu要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。
单核cpu只含有一套L1,L2,L3缓存;如果cpu含有多个核心,即多核cpu,则每个核心都含有一套L1(甚至和L2)缓存,而共享L3(或者和L2)缓存。
下面分析一下,在单线程、多线程单核CPU、多线程多核CPU中场景下的缓存一致性问题。
- 单线程:cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题
- 多线程单核CPU:进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突
- 多线程多核CPU:每个核都至少有一个L1缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,出现缓存不一致的现象
在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。
1.3.2 处理器优化和指令重排问题
一般情况下,为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入指令进行乱序执行处理,这就是处理器优化。除此之外很多编译器为了提升程序执行的效率,也会按照一定的规则允许进行指令重排,比如Java虚拟机的即时编译器(JIT)就会做指令重排。
在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
1.3.3 竞态条件问题
代码中很多类似于自增(++)操作,这类操作在JVM底层是通过多个指令实现的。比如自增操作就包含“读取 – 修改 – 回写”三个操作序列,并发场景下,可能导致多个线程同时读取到相同的值,并基于读取的值做操作,回写数据,这时候就会导致某些线程的修改丢失的现象产生。
在这种场景下,由于不恰当的执行时序而出现不正确的结果的现象就是竟态条件。而究其本质,就是因为对共享变量的操作不是原子的。
上面讲了并发编程下的三种常见问题,其实到这,我们可以发现:缓存一致性问题就是对应的可见性问题,处理器优化和指令重排问题就是对应的有序性问题,竞态条件问题就是对应的原子性问题。
1.4 Java内存模型
前面讲到了并发编程的三个问题,缓存一致性问题、处理器器优化的指令重排问题、竞态条件问题,会导致并发编程无法保证可见性、有序性、原子性。那么有什么机有什么机制可以很好的解决上面的这些问题呢?
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
计算机内存模型是解决多线程场景下并发问题的一个重要规范,在不同的编程语言中,在实现上可能有所不同。
Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。
JMM就作用于工作内存和主存之间数据同步过程,它规定了如何做数据同步以及什么时候做数据同步,从而解决上述缓存一致性、处理器优化和指令重排及静态条件问题。
1.5 Java内存模型实现
Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现,供开发者直接使用,保证并发编程的可见性、有序性和原子性。
1.5.1 可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。
1.5.2 有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
1.5.3 原子性
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit,Java中关键字synchronized就是对上述两个字节码指令的封装,也就是说synchronized可以保证原子性,至于synchronized用法和底层实现,后面的文章会详细讲解。
1.6 挂起、休眠、阻塞与非阻塞
- 挂起(Suspend):当线程被挂起的时候,线程会失去CPU的使用时间,直到被其他线程(用户线程或调度线程)唤醒
- 休眠(sleep):同样是会失去CPU的使用时间,但是在过了指定的休眠时间之后,它会自动激活,无需唤醒(整个唤醒表面看是自动的,但实际上也得有守护线程去唤醒,只是不需编程者手动干预)
- 阻塞(block):在线程执行时,所需要的资源不能得到,则线程被挂起,直到满足可操作的条件
- 非阻塞(none-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时指定的时间已到。
1.7 内核态和用户态
有一些系统级的调用,比如:清除时钟、创建进程等这些系统指令,如果这些底层系统级指令能够被应用程序任意访问的话,那么后果是危险的,系统随时可能崩溃,所以CPU会将所执行的指令设置为多个特权级别,在硬件执行每条指令时都会校验指令的特权,比如:Intel x86架构的CPU将特权分为0-3四个特权级,0级的权限最高,3权限最低。
而操作系统根据这系统调用的安全性分为两种:内核态和用户态。内核态执行的指令的特权是0,用户态执行的指令的特权是3。
1. 当一个任务(进程)执行系统调用而进入内核指令执行时,进程处于内核运行态(或简称为内核态)
2. 当任务(进程)执行自己的代码时,进程就处于用户态
在执行系统级调用时,需要将变量传递进去、可能要拷贝、计数、保存一些上下文信息,然后内核态执行完成之后需要再将参数传递到用户进程中去,这个切换的代价相对来说是比较大的,所以应该是 尽量避免频繁地在内核态和用户态之间切换。在之前的文章让CPU告诉你硬盘和网络到底有多慢中提到,一次上下文切换,大概需要1500ns,把CPU执行一个指令的时间换算成人类的1S,那么一次上下文切换大概相当于人类时间的60分钟,对于CPU而言,一次上下文切换,代价还是比较大的。
那操作系统的这两种形态和我们的线程主题有什么关系呢?这里是关键。Java并没有自己的线程模型,而是使用了操作系统的原生线程!
如果要实现自己的线程模型,那么有些问题就特别复杂,难以解决,比如:如何处理阻塞、如何在多CPU之间合理地分配线程、如何锁定,包括创建、销毁线程这些,都需要Java自己来做,在JDK1.2之前Java曾经使用过自己实现的线程模型,后来放弃了,转向使用操作系统的线程模型,因此创建、销毁、调度、阻塞等这些事都交由操作系统来做,而 线程方面的事在操作系统来说属于系统级的调用,需要在内核态完成,所以如果频繁地执行线程挂起、调度,就会频繁造成在内核态和用户态之间切换,影响效率。
前面讲到,Java内存模型的实现,synchronized可以保证可见性、有序性、原子性,就是因为当一个线程拥有锁对象之后,其他线程再来访问synchronized修饰的代码会被阻塞,这时候就会发生挂起,并且需要等待重新调度,出现内核态和用户态的切换,这也是为什么synchronized效率低下的原因。synchronized在Java5及以前都是使用这种阻塞的方式,所以效率不高,但是Java6对synchronized进行了很多优化,增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略,大大提高了synchronized的效率,关于synchronized,之后会有专门的文章来介绍,这里只要明白一点,不要谈”synchronized”色变就行了,现在synchronized的效率其实也算是可控的。
2. 线程的优势及风险
2.1 线程的优势
2.1.1 发挥多处理器的强大能力
通过提高时钟频率来提升性能亿变得越来越困难,处理器厂商都开始在单个芯片上防止多个处理器内核,即使在低端的服务器和桌面系统中,也会采用多个处理器。
由于线程是调度的基本单位,因此如果程序中只有一个线程,那么最多同时只能在一个处理器上运行,在双核处理器上,单线程程序只能使用一半的CPU资源,而在拥有100个处理器的系统上,将有99%的资源无法使用。另一方面,多线程程序可以同时在多个处理器上执行。如果设计正确,多线程可以通过提高处理器资源的利用率来提升系统吞吐量。
使用多个线程还有助于在但处理器系统上获取更高的吞吐量。如果程序是单线程的,那么当程序等待某个同步IO操作完成时,处理器将处于空闲状态。而在多线程程序中,如果一个线程在等待IO操作完成,另一个线程可以继续运行,是程序能够在IO阻塞期间继续运行,继而提升系统吞吐量。
2.1.2 建模的简单性
显示生活中,只需要执行一种类型的任务比执行多种类型的任务在时间管理方面要简单。当只有一种类型的任务需要完成时,只需埋头工作,知道所有的任务都完成即可,不需要花费额外的精力去琢磨下一步做什么。而如果需要完成多种类型的任务时,那么需要管理不同任务之间的优先级和执行时间,并且在任务之间切换。
对于软件来说也是如此:如果程序中只包含一种类型的任务,那么比包含多种不同类型的任务的程序要更易于编写,错误更少,也更容易测试。如果为模型中每种类型的任务都分配一个专门的线程,那么可以形成一种串行执行的假象,并将程序的执行逻辑与调度细节,交替执行的操作,异步IO及资源等待问题分离开来。通过使用线程,可以将复杂并且异步的工作流进一步分解为一组简单并且同步的工作流,每个工作流在一个单独的线程中运行,并在特定的同步位置进行交互。
可以通过一些现有的框架来实现上述目标,例如servlet。框架负责解决一些细节问题,比如请求管理、线程创建、负载均衡,并在正确的时刻将请求分发给正确的应用程序组件。编写sertlet的开发人员不需要了解有多少个请求在同一时刻要被处理,也不需要了解套接字的输入流输出流是否被阻塞。当调用sevlet的service方法来响应web请求时,可以以同步方式来处理这个请求,就好像它是一个单线程程序。这种方式可以简化组件的开发,缩短掌握框架的学习时间。
2.1.3 简化异步事件处理
服务器应用程序在接受来自多个远程客户端的套接字连接请求时,如果为每个连接都分配各自的线程并且使用同步IO,那么就会降低这类程序的开发难度。
如果某个应用程序对套接字执行读操作而此时还没有数据到来,那么这个读操作将一直阻塞,知道数据到来。在单线程应用程序中,这不禁意味着在处理请求的过程中要停顿,而且还意味着这个线程被阻塞期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务必须使用非阻塞IO,这种IO的复杂度要远远高于同步IO,并且很容易出错。然而如果每个请求都有自己的处理线程,那么处理某个请求时发生阻塞,将不影响其他请求的处理。
2.1.4 程序响应更快速
2.1.2节讲到,一个线程处理多种类型的任务,会导致建模更加复杂,同时也会导致程序响应速度变慢。比如一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。那么如果一个请求需要占用大量的时间来处理,在这段时间内新的客户端就无法发送请求给服务端。只有服务器在监听的时候,请求才能被接收。
另一种设计是,监听线程把请求传递给工作者线程池,然后立刻返回去监听。而工作者线程则能够处理这个请求并发送一个回复给客户端。
2.2 线程的风险
2.2.1 线程安全问题
上面在讲并发编程的特性时讲到,变量在多个线程之间共享,如果无法保证可见性、有序性、原子性,那么由于线程之间的推进顺序,那么共享变量的访问将变得不可信,也就出现了线程安全问题。单线程环境下,由于只有一个线程访问,所以不需要考虑共享变量的安全问题,一旦引入多线程,线程安全问题就是在编程中必须考虑的一个问题。
2.2.2 活跃性问题
安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就出现了活跃性问题。在单线程程序中,活跃性问题的形式之一就是无意中造成无限循环,从而使循环之后的代码无法得到执行。而多线程则带来其他一些活跃性问题。例如,线程A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么此线程A将永远等待下去。在多线程程序中,具体表现为死锁、饥饿及活锁等。
2.2.3 性能问题
与活跃性问题密切相关的是性能问题。活跃性意味着某件正确的事情最终会发生,但却不一定足够好,因为我们通常希望正确的事情尽快发生。性能问题包括多个方面,例如服务时间过长,相应不灵敏,吞吐量过低,资源消耗过高,或者可伸缩性较低等。与安全性和活跃性问题一样,在多线程程序中不仅存在与单线程程序中相同的问题,而且还存在由于使用多线程而引入的其他性能问题。
在良好的并发应用程序中,多线程能提升程序的性能,但无论如何,多线程总会带来某种程度的运行时开销。在多线程程序中,在线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换,也就是内核态到用户态的切换,上面也讲过,这种开销是非常大的。另外,为了保证多线程程序中共享变量的安全,会使用同步机制,而这些机制往往会抑制某些编译器和处理器优化,是内存缓存区的数据无效,以及增加共享内存总线的同步流量。这些开销都是单线程程序中无序考虑,也就是讲,多线程并不是万能药,也有可能会降低程序性能。
3. Java线程的使用
3.1 Java线程的创建
3.1.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。
3.1.2 实现Runnable接口
Java中另一种创建线程的方式是实现Runnable接口,并覆盖run方法。Runnable对象可以用于Thread实例初始化,调用thread的start方法后,run方法的逻辑会被执行,如下:
public class MyRunnable implements Runnable {
public void run(){
System.out.println("MyRunnable running");
}
}
通过Runnable对象实例化Thread对象,如下:
Thread thread = new Thread(new MyRunnable());
thread.start();
当线程运行时,它将会调用实现了Runnable接口的run方法。上例中将会打印出”MyRunnable running”。
3.1.3 实现Callable接口
Java5后,Java提供了一种新的线程创建方式,实现Callable接口,并覆盖call方法。Callable对象可以用于实例化FutureTask实例,FutureTask实现了Runnable、Callable接口,可以用FutureTask对象实例化Thread对象。如下所示:
public class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
System.out.println("MyCallable running");
int sum = 0;
for(int i=0;i<100;i++)
sum += i;
return sum;
}
}
通过FutureTask对象实例化Thread对象:
MyCallable task = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
Thread thread = new Thread(futureTask);
thread.start()
当调用thread的start方法调用后,call方法的逻辑就会被执行。另外如果通过线程池submit方法使用Callable对象,还可以获取一个Future对象,通过Future对象,可以用来判断任务是否执行结束、中断任务以及获取任务执行结果。至于具体的使用,会在接下来的文章详细介绍。
3.2 如何选择创建线程的方式
上面讲到,Java中可以通过继承Thread类、实现Runnable接口或实现Callable接口来创建线程,那到底是通过哪种方式来创建?其实并没有一个标准的答案。就我的理解而言,我觉得使用Runnable接口和Callable接口比较好,因为这两种方式可以通过线程池来管理,在高并发场景下,我们不可能无限创建线程来处理任务。
3.3 Main线程与子线程关系
- Main线程是个非守护线程,不能设置成守护线程
Main线程是由Java虚拟机在启动的时候创建的。main方法开始执行的时候,主线程已经创建好并在运行了。对于运行中的线程,调用Thread.setDaemon()会抛出异常Exception in thread “main” java.lang.IllegalThreadStateException。
- Main线程结束,其他线程一样可以正常运行
主线程,只是个普通的非守护线程,用来启动应用程序,不能设置成守护线程;除此之外,它跟其他非守护线程没有什么不同。主线程执行结束,其他线程一样可以正常执行。
这样其实是很合理的,按照操作系统的理论,进程是资源分配的基本单位,线程是CPU调度的基本单位。对于CPU来说,其实并不存在java的主线程和子线程之分,都只是个普通的线程。进程的资源是线程共享的,只要进程还在,线程就可以正常执行,换句话说线程是强依赖于进程的。也就是说:
线程其实并不存在互相依赖的关系,一个线程的死亡从理论上来说,不会对其他线程有什么影响。
- Main线程结束,其他线程也可以立刻结束,当且仅当这些子线程都是守护线程
Java虚拟机(相当于进程)退出的时机是:虚拟机中所有存活的线程都是守护线程。只要还有存活的非守护线程虚拟机就不会退出,而是等待非守护线程执行完毕;反之,如果虚拟机中的线程都是守护线程,那么不管这些线程的死活java虚拟机都会退出。
以上就是本篇文章的主要内容,简单讲了一些Java并发编程的一些基础概念,下面一片文章着重讲一下Java中多线程的使用方法。
参考链接
1. 《Java编程的逻辑》