第二十三章 Java线程与操作系统关系


线程是比进程更轻量级的调度执行单位,线程可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址、文件I/O等),又可以独立调度。并发不一定依赖于线程,PHP中是多进程并发。但Java并发大多数情况下与线程有关。线程是CPU调度和分配的基本单位,也就是说开辟线程需要系统内核来完成。

Java线程的实现方式

主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类实例就代表了一个线程。我们注意到Thread类与大部分的Java API有明显的区别,它的所有关键方法都是声明为Native的,即会通过JNI(java native interface)调用Java底层实现。

线程的实现

内核线程实现

内核线程通过操作系统将内核函数委托给独立的进程实现。内核通过时间片轮转算法操纵调度器对线程切换和调度,并负责将线程的任务映射到某个处理器的某个核上(上面说的线程是CPU调度和分配的基本单位就是这个意思)。整个CPU能同时处理多少线程,这个由CPU芯片决定的。比如Inter i9 7980XE芯片,支持18核心/36线程。就有18个逻辑核心,能同时运行36个线程。这样对于网站这种IO密集型的作业,多线程运行能让CPU不需要等待IO,进行线程切换后可执行其他线程作业。

程序线程一般通过内核线程的高级接口-轻量级进程(LWP)实现,由于每个轻量级进程都由一个内核线程(暂且可理解为几核几线程中的线程,因为内核线程是OS调度到CPU中处理的基本单位)支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。如下所示。其中P为进程(Process),一个进程可以支持多个轻量级进程,KLT为内核线程(Kernel Thread,KLT)。

img

从图中可知,每个用户进程包含多个轻量级进程,轻量级进程与内核线程是1对1的关系。所有进程的任务通过线程调度器(Thread Scheduler)操作轻量级进程接口将任务分发给指定的CPU来执行。即使某个轻量级进程挂掉,比如,JVM实例的某个线程挂掉,也不会影响该JVM实例,JVM中其他线程可以继续工作。某个内核线程因等待IO阻塞,其链路上的轻量级进程,用户线程都将被阻塞。但轻量级进程(可直接理解为线程就可以)有它的局限性。

  1. 它是基于内核线程实现。各种线程创建、同步等都需要进行系统调用,需要在系统用户态和内核态来回切换。这些是需要消耗系统CPU和内存等资源的。
  2. 每个轻量级线程需要系统内核线程支持,但系统内核线程(CPU能同时并发的线程数)的数量是有限的。
用户线程实现

部分高性能数据库中的多线程就是由用户线程实现的。因为需要对线程的调度、切换很复杂,现在用户线程的程序越来越少。这里不再讲解。

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

用户线程与轻量级进行的N:M的关系正式多对多的线程模型。许多UNIX系统的操作系统中都提供了N:M的线程模型。比如Linux下的NGPT(Next-Generation POSIX Threads)线程库本打算实现该线程模型,但是并没有完全实现预期功能,NGPT算是Red Hat研发失败的一个产品吧。

img

Java线程的实现

对于Sun JDK来说,它的Windows版和Linux版都是使用一对一的线程模型实现的。一条Java线程就映射到一条轻量级进程中。Window通过纤程包(Fiber Package)实现,Linux 2.6版本中有NPTL实现一对一的线程模型。这个线程模型在Linux相关章节再做进一步分析。

Java线程调度

Java主要的调度方式有两种:分别是协同式线程调度和抢占式线程调度。协同式线程调度需要线程把自己的事情干完后才会进行线程切换,相当于串行执行。Python、Lua等解释语言中的"协同例程"就是采用协同时线程调度。Java使用的是抢占式线程调度。通过优先级控制抢占的强度,所以Java有控制线程优先级的相关方法。

线程状态转换

在这里插入图片描述

以下强调等待状态的两个类型。

无限期等待状态:处于等待阻塞状态的线程不会被分配CPU执行时间,它们要等待被其他线程显示地唤醒。以下方法会让线程陷入无限期等待状态:

  1. 没有设置Timeout参数的Object.wait()方法。
  2. 没有设置Timeout参数的Thread.join()方法。
  3. LockSupport.part()方法。

有限期等待状态:又叫超时等待,即过一定时间由系统自动唤醒。调用以下方法进入超时等待状态。

  1. LockSupport.parkNonos()方法。
  2. LockSupport.parkUtil()方法。
  3. Thread.sleep()方法。
  4. 设置Timeout参数的Object.wait()方法。
  5. 设置Timeout参数的Thread.join()方法。

线程安全

线程安全分类

按照线程操作共享数据的"安全程度"可以分为:不可变,绝对安全,相对安全,线程兼容和线程对立。

  1. 不可变

对于共享数据是基本类型,通过定义该基本变量为final就可以保证它不可变,这种不可变性是在编译时确定的。在编译时,对于final变量,是必须初始化值的,不然编译器会报错,并且Java编译器将会将使用final变量的地方进行值替换。这样能提高运行时效率。

如果共享数据是一个对象,需要保证这个对象的方法不对其变量进行重新赋值引用。java.lang.String就是一个典型的不可变对象,它的substring()、replace()和concat()这些方法都不会影响它原来的值,而是返回一个new String()的新对象。还有如枚举类、Number的部分子类(如BigDecimal)。

  1. 绝对线程安全

如Java中的Vector类,尽管它的get()、remove()、size()方法都采用了synchronized,但是在多线程环境下,还是需要调用方法做额外的同步措施才能保证绝对安全。

  1. 相对线程安全

相对的线程安全就是我们通常意义上讲的线程安全。如Vector、HashTable等类。

  1. 线程兼容

线程兼容是指对象本身不是线程安全的。这种情况也就是我们平时说的线程不安全。可以通过调用端正确地使用同步手段实现对象在并发环境下正确访问。

  1. 线程对立

这种情况应该尽量避免。如废弃的Thread类的suspend()和resume()方法。

线程安全的实现方式

多线程对共享资源安全访问主要有3种实现方式。分别是互斥访问、非同步阻塞和无同步方案。

互斥同步

互斥同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用,即串行访问共享资源,是一种悲观的并发策略,无论共享数据是否会真的出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁,如通过偏向锁、自旋等方式)、用户态和核心态转换,维护锁计数器和检查是否有被阻塞的线程需要唤醒。它最主要的问题就是进行线程阻塞和唤醒所带来的性能问题。临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

在Java中最基本的互斥同步是synchronized关键字。反编译后会在同步块前后生成monitorenter和monitorexit这两个字节码指令。这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果指明了对象参数,就用这个对象的reference;如没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。更加详细讲解在第24章。

synchronized同步块对同一个线程来说是可重入的,不会出现自己把自己锁死的问题。同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。Java中采用的是轻量级进程支持多线程,所以要阻塞或者唤醒一个线程都需要涉及到用户态和核心态的切换。所以,这是个重量级的锁。虚拟机本身为了避免频繁地切入到核心态,在通知操作系统阻塞线程之前加入一段自旋等待锁释放的过程,如果锁及时释放,则抢占锁。

除了synchronized之外,我们还可以使用java.util.concurrent中的重入锁ReentrantLock来实现同步。ReentrantLock表现在API层的互斥锁(lock和unlock方法配合try/finally语句块来完成)。synchronized表现为原生语法字节码层的实现。之前听某些讲师认为ReentrantLock因为使用了自旋锁,全是Java级别调用,是用户态的操作。而synchronized关键字调用了c语言代码,涉及到了内核态和用户态的切换,所以,synchronized关键字是重量级锁。这种说法是错误的。是否涉及到内核态和用户态的操作是由是否调用操作系统申请和释放线程资源。如ReentrantLock需要挂起当前线程,调用native park方法,就是涉及到操作系统的资源管理,需要调用操作系统的os::Linux::safe_cond_timedwait方法就涉及到了内核调用。ReentrantLock更加高效的真正原因是通过自旋锁化解了很多不必要线程调度。而优化后的synchronized在虚拟机层面也是通过自旋的方式,性能与ReentrantLock不再相差很远。以下是park方法,其作用是挂起当前线程。其中_counter可理解为AQS中的state,大于0即该线程拥有许可。

void Parker::park(bool isAbsolute, jlong time) {
  //原子交换,如果_counter > 0,则将_counter置为0,直接返回,否则_counter为0
  if (Atomic::xchg(0, &_counter) > 0) return;
  //获取当前线程
  Thread* thread = Thread::current();
  assert(thread->is_Java_thread(), "Must be JavaThread");
  //下转型为java线程,可理解为C语言线程是对java线程的封装,这里是获取java线程引用
  JavaThread *jt = (JavaThread *)thread;
 
  //如果当前线程设置了中断标志,调用park则直接返回,所以如果在park之前调用了
  //interrupt就会直接返回
  if (Thread::is_interrupted(thread, false)) {
    return;
  }
 
  // 高精度绝对时间变量
  timespec absTime;
  //如果time小于0,或者isAbsolute是true并且time等于0则直接返回
  if (time < 0 || (isAbsolute && time == 0) ) { // don't wait at all
    return;
  }
  //如果time大于0,则根据是否是高精度定时计算到需要挂起的时间
  if (time > 0) {
    unpackTime(&absTime, isAbsolute, time);
  }
 
  //进入安全点避免死锁
  ThreadBlockInVM tbivm(jt);
 
  //如果当前线程设置了中断标志,或者获取mutex互斥锁失败则直接返回
  //由于Parker是每个线程都有的,所以_counter cond mutex都是每个线程都有的,
  //不是所有线程共享的所以加锁失败只有两种情况,第一unpark已经加锁这时只需要返回即可,
  //第二调用调用pthread_mutex_trylock出错。对于第一种情况就类似是unpark先调用的情况,所以
  //直接返回。
  if (Thread::is_interrupted(thread, false) || pthread_mutex_trylock(_mutex) != 0) {
    return;
  }
 
  int status ;
   //staus用于判断是否获取锁
  //如果_counter大于0,说明unpark已经调用完成了将_counter置为了1,
  //现在只需将_counter置0,解锁,返回
  if (_counter > 0)  { // no wait needed
    _counter = 0;
    status = pthread_mutex_unlock(_mutex);
    assert (status == 0, "invariant");
    OrderAccess::fence();
    return;
  }
 
 
  OSThreadWaitState osts(thread->osthread(), false /* not Object.wait() */);
  jt->set_suspend_equivalent();
  // cleared by handle_special_suspend_equivalent_condition() or java_suspend_self()
 
  assert(_cur_index == -1, "invariant");
  //如果time等于0,说明是相对时间也就是isAbsolute是fasle(否则前面就直接返回了),则直接挂起
  if (time == 0) {
    _cur_index = REL_INDEX; // arbitrary choice when not timed
    status = pthread_cond_wait (&_cond[_cur_index], _mutex) ;//挂起当前线程
  } else { //如果time非0
    //判断isAbsolute是false还是true,false的话使用_cond[0],否则用_cond[1]
    _cur_index = isAbsolute ? ABS_INDEX : REL_INDEX;
    //使用条件变量使得当前线程挂起。
    status = os::Linux::safe_cond_timedwait (&_cond[_cur_index], _mutex, &absTime) ;
    //如果挂起失败则销毁当前的条件变量重新初始化。
    if (status != 0 && WorkAroundNPTLTimedWaitHang) {
      pthread_cond_destroy (&_cond[_cur_index]) ;
      pthread_cond_init    (&_cond[_cur_index], isAbsolute ? NULL : os::Linux::condAttr());
    }
  }
 
  //如果pthread_cond_wait成功则以下代码都是线程被唤醒后执行的。
  _cur_index = -1;
  assert_status(status == 0 || status == EINTR ||
                status == ETIME || status == ETIMEDOUT,
                status, "cond_timedwait");
 
#ifdef ASSERT
  pthread_sigmask(SIG_SETMASK, &oldsigs, NULL);
#endif
  //将_counter变量重新置为1
  _counter = 0 ;
  //解锁
  status = pthread_mutex_unlock(_mutex) ;
  assert_status(status == 0, status, "invariant") ;
  // 使用内存屏障使_counter对其它线程可见
  OrderAccess::fence();
 
  // 如果在park线程挂起的时候调用了stop或者suspend则还需要将线程挂起不能返回
  if (jt->handle_special_suspend_equivalent_condition()) {
    jt->java_suspend_self();
  }
}

所以park和unpark和核心就是counter、cur_index、mutex、cond,通过使用条件变量对counter进行操作,在调用park时,如果counter是0则会去执行挂起的流程,否则返回,在挂起恢复后再将counter置为0。在unpark的时候如果counter是0则会执行唤醒的流程,否则不执行唤醒流程,并且不管什么情况始终将counter置为1。

而ReentrantLock增加了一些高级特性。如等待可中断、可实现公平锁,以及锁可以绑定多个条件。这些内容将在第30章进行讲解。

  1. 等待可中断:等待的线程可选择放弃等待或改做其他处理。等待可中断可提高CPU的利用率。当时阿里面试时,有问到一个问题。前提条件是:已经开启了多个线程组,怎样减少某个线程组中线程的等待时间,最大程度利用CPU。先不考虑CAS乐观并发模式。只考虑互斥同步的方式。首先编程时要控制竞争资源的锁拥有的时间不能太长,其次,当竞争不是很明显时,可以用轻量级锁、自旋等方式减少线程用户态和核心态之间的切换。如果竞争比较明显,可以用等待可中断属性,先让出CPU资源。线程池中的线程可以先干其他操作,好比先把简单的事情做了,最后再来处理这个任务。

  2. 公平锁是指按申请锁的时间顺序获取锁。synchronized中的锁是非公平的,ReentrantLock默认情况下也是非公平的,但是可以通过带布尔值的构造函数要求使用公平锁。

  3. 锁绑定多个条件。是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象通过wait()和notify()或notifyAll()方法可以实现绑定一个隐含的条件。但要多个条件又得多加一个锁。

非同步阻塞(CAS)

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略,通俗的讲,就是先执行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,就采取其他补偿措施(最常见的补偿措施为不断重试,直到成功为止)。这种方式不需要把线程挂起。称为非阻塞同步。
在IA64、x86指令集中有cmpxchg指令完成CAS功能。虚拟机只允许Bootstrap ClassLoader对Unsafe类进行加载。且对调用Unsafe类的compareAndSwapInt()几个CAS方法进行了编译处理,直接编译与平台相关的CAS指令。但CAS存在“ABA”问题,JUC中引入“AtomicStampedReference”类,通过控制变量值的版本来保证CAS的正确性。这种方式也用在数据库设计中,用于对业务进行防重。比如,更新时带版本version字段更新,防止商品超卖等现象。

无同步方案

要保证线程安全,并不是一定要进行同步。如果一个资源本来就不涉及多个线程共享,那它就不需要任何同步措施,所以ThreadLocal并不是解决资源共享问题的。

可重入代码。可重入代码有一些共同的特征。例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数传入、不调用非可重入方法。

线程本地存储(Thread Local Storage)。如果一段代码中所需要的数据能保证在同一个线程中执行,我们就可以限制该数据的可见范围在该线程中即可。这样,无须同步也能保证线程之间不出现数据争用的问题。这种情况一个经典的应用实例是Web交互模型中“一个请求对应一个服务器线程”的处理方式。存放在ThreadLocal中的变量都是当前线程本身私有变量。其他线程本身就不能访问,存到ThreadLocal中只是为了方便在程序中同一个线程之间传递这个变量。多个线程在同一时刻访问或修改的并不是同一个对象,从而隔离了多个线程对共享数据的访问,如果是修改,则不会影响其他线程副本。如果一个变量要被多个线程访问,并且修改后要影响其他线程的行为,可以使用volatile关键字。

线程安全问题产生有两个前提,(1)存在数据共享,即多个线程访问同样的数据。(2)共享数据是可变的。多个线程对访问的共享数据做出来修改。显然ThreadLocal没有这样的前提条件,它也不是用来解决并发访问共享数据的。

当使用线程池的时候,由于ThreadLocal的设计原理是将一个ThreadLocalMap的引用作为Thread的一个属性,利用当前ThreadLocal作为key,保存的变量值作为value保存在当前线程的ThreadLocalMap中的。所以ThreadLocalMap是伴随的Thread本身的存在而存在的,只要Thread不被回收,ThreadLocalMap就存在。因此,对于线程池来讲,重复利用一个Thread就等于在重复利用Thread的ThreadLocalMap,所以ThreadLocalMap里面保存的数据可能会被多次使用。

ThreadLocal与同步机制Syncronized

本打算在分析完ThreadLocal和Synchronized两个关键字再来比较两者的关系。但那两节内容太多,大家可以先阅读完那两节内容再回到这里看两者的关系。

  1. synchonzied同步机制是为了实现同步多线程对共享资源的并发访问控制。同步目的是保证多线程间对共享数据的正确访问。synchronzied同步会带来一定的性能开销,所以尽可能保证同步资源的"细粒度"。确保对象中的不同元素使用不同的锁,而不是整个大的共享变量共用一把锁。
  2. 对于多线程资源共享的问题,同步机制(syncronizied)采用了“以时间换空间”。而ThreadLock从采用了“以空间换时间的方式”角度解决多线程的并发问题。ThreadLocal会为每一个线程提供了一个独立的变量副本,从而隔离了多个线程对数据的访问冲突。因为多个线程并发访问无需进行等待,所以使用ThreadLocal会获得更大的性能。因为每一个线程都拥有自己的变量副本,每个线程访问副本即可。编写代码时,可以把不安全的变量封装进ThreadLocal。
  3. ThreadLocal中的对象,通常都是比较小的对象。另外使用ThreadLocal不能使用原子类型,只能使用Object类型。ThreadLocal的使用比synchronized要简单得多。
  4. synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。 ThreadLocal采用了“空间换时间”的方式,而sychronized等同步机制采用”时间换空间“。ThreadLocal为每个线程提供了竞争资源的副本,每个线程仅对副本进行操作,而同步机制只有一份副本,多个线程排队访问。
  5. Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。

Hotspot JVM 后台运行的系统线程分类

虚拟机线程(VM thread)这个线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有: stop-theworld 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。
周期性任务线程这线程负责定时器事件(也就是中断),用来调度周期性操作的执行。
GC 线程这些线程支持 JVM 中不同的垃圾回收活动。
编译器线程这些线程在运行时将字节码动态编译成本地平台相关的机器码。
信号分发线程这个线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值