读书笔记(二):并发

本文围绕Java并发编程展开,介绍了线程的基本概念、启动与终止方式、线程间通信机制,如volatile和synchronized关键字的使用。还阐述了Java中的锁,包括Lock接口、队列同步器的实现,以及重入锁、读写锁等的特点和应用场景,为Java并发编程提供了全面的知识基础。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Java并发编程基础:

Java从诞生开始就明智的选择了内置对多线程的支持,这就使得Java语言相比同一时期的其他语言具有明显的优势。线程作为操作系统调度的最小单元,多个线程能够同时执行,这将显著的提升程序的性能,在多核环境中表现更加明显。但是,过多的创建线程和对线程的不当管理也容易造成问题。

线程简介:


现代操作系统在运行一个程序时,会为其创建一个进程。例如,启动一个Java程序,操作系统就会创建一个Java进程。现代操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。一个Java程序从main()方法开始执行,然后按照既定的代码逻辑执行,看似没有其他线程参与,但实际上Java程序天生就是多线程程序,因为执行main方法的是一个名称为main的线程。但其实是main线程和多个其他线程同时运行。

为什么要使用多线程?

  1. 更多的处理器核心:线程是大多数操作系统调度的基本单元,一个程序作为一个进程来运行,程序运行中能够创建多个线程,而一个线程在一个时刻只能运行在一个处理器核心上。试想一下,一个单线程程序在运行时只能使用一个处理器核心,那么再多的处理器核心的加入也无法显著提升该程序的执行效率。相反,如果该程序使用多线程技术,将计算逻辑分配到多个处理器核心上,就会显著减少程序的处理时间,并且随着更多的处理器核心的加入而变得更加有效率。
  2. 更快的响应时间:使用多线程技术,将数据一致性不强的操作派发给其他线程处理,这样做的好处是响应用户请求的线程能够尽可能快的处理完成,缩短了响应时间,提升了用户体验。
  3. 更好的编程模型:Java为多线程编程提供了良好,考究并且一致的编程模型,使开发人员能够更加专注于问题的解决,即为所遇到的问题建立合适的模型,而不是绞尽脑汁的考虑如何将其多线程化。

线程的优先级:

现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并且等待下次分配。在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1-10,在线程构建的时候可以通过setPriority方法来修改优先级,默认的优先级为5,优先级高的线程分配的时间片的数量要多于优先级低的线程。在设置线程优先级时,针对频繁阻塞的线程需要设置较高的优先级;而偏重计算的线程则设置较低的优先级,确保处理器不会被独占。线程优先级不能作为程序正确性的依赖,因为操作系统可以完全不用理会Java线程对于优先级的设定。

线程的状态:

Java在运行的生命周期有六种不同的状态,在给定的时刻,线程只能处于其中的一个状态:

  1. NEW:初始状态,线程被构建,但是还没有调用start()方法。
  2. RUNNABLE:运行状态,Java线程将操作系统中的就绪和运行两种状态笼统的称作"运行中"。
  3. BLOCKED:阻塞状态,表示线程阻塞于锁。
  4. WAITING:等待状态,表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作
  5. TIME_WAITING:超时等待状态,该状态不同于WAITING,它是可以在指定的时间自行返回的。
  6. TERMINATED:终止状态,表示当前线程已经执行完毕。

线程在自身的生命周期中,并不是固定的处于某个状态,而是随着代码的执行在不同的状态之间进行切换。线程在创建之后,调用start()方法开始运行。当线程执行wait()方法之后,线程进入等待状态。进入等待状态的线程需要依靠其他线程的通知才能返回到运行状态,而超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将会返回到运行状态。当线程在调用同步方法时,在没有获取到锁的情况下,线程将进入到阻塞状态。线程在执行run()方法之后将会进入到终止状态。(Java将操作系统中的运行和就绪两个状态合并称为运行状态,阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或者代码块时的状态,但是阻塞在Java。concurrent包中的lock接口的线程状态却是等待状态。

Daemon线程:

Daemon线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出。可以通过调用Thread.setDaemon将线程设置为Deamon线程。Daemon线程被用作完成支持性工作,但是在Java虚拟机退出时Daemon线程中的finally块并不一定会执行。所有在构建Daemon线程时,不能依靠finally块中的内容来确保执行关闭或者清理资源的逻辑。

启动和终止线程:

构造线程:

在运行线程之前首先要构造一个线程对象,线程对象在构造的时候需要提供线程所需要的属性,如线程所属的线程组,线程优先级,是否是Daemon线程等信息。一个新构造的线程对象是由其parent线程来进行空间分配的,而child线程继承了parent是否为Daemon,优先级和加载资源的contextclassloader以及可继承的ThreadLocal,同时还会分配一个唯一的ID来标识这个child线程。至此,一个能够运行的线程对象就初始化好了,在堆内存中等待着运行。

启动线程:

线程对象在初始化完成之后,调用start()方法就可以启动这个线程。线程start()方法的含义是:当前线程(即parent线程)同步告知Java虚拟机,只要线程规划器空闲,应该立即启动调用start()方法的线程。启动一个线程前,最好为这个线程设置线程名称。

理解中断:

中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了招呼,其他线程通过调用该线程的interrupt()方法对其进行中断操作。线程也可以检查自身是否被中断来进行响应,线程通过isInterrupted()方法来进行判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位。如果该线程已经处于终结状态,即使该线程被中断过,在调用该对象的isInterrupted()方法时依旧会返回false。

安全的终止线程:

中断状态是线程的一个标识位,而中断操作是一种简便的线程间交互方式,而这种交互方式最适合用来取消或者停止任务。除了中断以外,还可以利用一个boolean变量来控制是否需要停止任务并且终止该线程,这种终止线程的做法显得更加安全和优雅。

线程之间的通信:

线程开始运行,如同一个脚本一样,按照既定的代码一步步的执行,直到终止。但是,每个运行中的线程,如果仅仅是孤立的运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,这将会带来巨大的价值。

volatile和synchronized关键字:

Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝,所以程序在执行过程中,一个线程看到的变量并不一定是最新的。volatile关键字可以用来修饰字段,就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。但是,过多的使用volatile是不必要的,因为它会降低程序执行的效率。synchronized关键字可以修饰方法或者以同步块的形式来进行使用,它主要确保在同一时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。

简述synchronized实现细节:

对同步块的实现使用了monitorenter和monitorexit指令,而同步方法则是依靠方法修饰符上的AA_SYNCHRONIZED来完成的。无论是采用哪种方式,其本质都是对一个对象的监视器进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获得synchronized所保护的监视器。任意一个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取到该对象的监视器才能进入到同步块或者同步方法,而没有获取到的线程将会进入同步队列,进入阻塞状态。当获取锁的线程释放了锁,则该释放操作将唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

等待/通知机制:

一个线程修改一个对象的值,而另一个线程感知到了变化,然后再进行相应的操作,整个过程开始于一个线程,而最终执行的又是一个线程。前者是生产者,后者是消费者,这种模式隔离了做什么和怎么做,在功能层面上实现了解耦,体系结构上具备了良好的伸缩性。简单的办法就是让消费者线程不断的循环检查变量是否符合预期,不符合则一直循环,符合则退出循环

 等待/通知的相关方法被定义在所有对象的父类java.long.object上,它是指一个线程它首先获取了对象的锁,然后调用对象的wait()方法,然后该线程会放弃锁并且进入到对象的等待队列中,从而进入等待状态。由于它放弃了锁,另一个线程随后可以获取锁,并且通过调用对象的notify()方法或者notify()方法(前者是随机一个,或者是所有),将等待队列里面的线程移动到同步队列中,这个时候线程的状态为阻塞状态。等到另一个线程释放了锁,再竞争获取锁并且从wait()方法返回继续执行。所以等待方应该遵循:获取对象的锁;条件不满足,调用wait方法,被通知后仍然要检查条件;条件满足则执行对应逻辑。通知方应该遵循:获取对象的锁;改变条件;通知所有等待在这个对象上的线程。

管道输入/输出流:

管道输入输出流和普通的文件输入输出流或者网络输入输出流的不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。管道输入输出流主要包括4种具体的实现:PipedOutputStream,PipedInputStream,PipedReader,PipedWriter,前面两个是面向字节,后面两个是面向字符。而对于Piped类型的流,必须要先进行绑定,也就是调用connect()方法,如果没有将输入输出流绑定起来,那么对于流的访问将会抛出异常。

Thread.join()和ThreadLocal的使用:

  1. 如果一个线程A执行了线程B.join()方法,那么其含义是:当前线程A等待线程B终止之后才从线程B.join()方法返回。除了有join方法以外,还有具备超时的join方法,这两个超时方法表示如果线程B在给定的时间里没有终止,那么就会从该超时方法中返回。跟等待/通知机制类似。
  2. ThreadLocal,它是线程变量,是一个以ThreadLocal对象为键,任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定到这个线程上的一个值。可以通过set设置这样的一个值,或者通过get来获取之前设置的值。

Java中的锁:

Lock接口:


锁是用来控制多个线程访问共享资源的方式,一般来说一个锁能够防止多个线程同时访问共享资源(但是有些锁可以允许多个线程并发的访问共享资源,比如读写锁)。在Lock接口出现之前,Java程序是靠synchtonized关键字实现锁功能的,而Java SE 5之后,并发包中新增了Lock接口(以及相关实现类)用来实现锁功能,它提供了与synchronized关键字类似的同步功能, 只是在使用时需要显式地获取和释放锁。虽然它缺少了(通过synchronized块或者方法所提供的)隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。


使用synchronized关键字将会隐式地获取锁,但是它将锁的获取和释放固化了,也就是先获取再释放。当然,这种方式简化了同步的管理,可是扩展性没有显式的锁获取和释放来的好在finally块中释放锁,目的是保证在获取到锁之后,最后能够释放锁。不要将获取锁的过程写在try中,因为如果在获取锁时发生了异常,在异常抛出的同时,也会导致锁的无故失效。

队列同步器:


队列同步器AbstractQueuedSynchronizer(以下简称同步器),是用来构建锁或者其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。


同步器的主要使用方式是继承子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()、setState(int newState)和 compareAndSetState(int expect, int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch等)。


同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。可以这样理解二者之间的关系,锁是面向使用者的,它定义了使用者与锁交互的接口(比如可以允许两个线程并行访问),隐藏了实现细节;同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需关注的领域。



同步器的设计是基于模板方法模式的,也就是说使用者需要继承同步器并重写指定的方法,随后将同步器组合在自定义同步组件的实现中,并调用同步器提供的模板方法,而这些模板方法将会调用使用者重写的方法。

重写同步器指定的方法时,需要使用同步器提供如下的三个方法来访问或者修改同步状态:

  • getState:获取当前的同步状态。
  • setStare:设置当前的同步状态。
  • compareAndSetStare:使用CAS来设置当前的状态,该方法能够保证状态设置的原子性。

同步器可重写的方法:

  • tryAcquire:独占式的获取同步状态。
  • tryRelease:独占式的释放同步状态。
  • tryAcquireShared:共享式的获取同步状态。
  • tryReleaseShared:共享式的释放同步状态。
  • isHeldExclusively:当前同步器是否在独占模式下被线程占用。

同步器提供的模板方法基本上分为3类:独占式的获取与释放同步状态;共享式的获取与释放同步状态;查询同步队列中的等待线程的情况。(有acquire(),acquireInterruptibly(),tryAcquireNanos(),acquireShared(),acquireSharedInterruptibly(),tryAcquireSharedNanos(),release(),releaseShared(),getQueuedThreads())。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。

队列同步器的实现分析:


 接下来将从实现角度分析同步器是如何完成线程同步的,主要包括:同步队列、独占式同步状态获取与释放, 共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。


1.同步队列


同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态,同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点。节点是构成同步队列的基础,同步器拥有首节点和尾节点,没有成功获取同步状态的线程将会成为节点加入到该队列的尾部。

同步器包含了两个节点类型的引用,一个指向头节点,而另一个指向尾节点。试想一下,当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列中,而这个加入队列的过程必须要保证线程安全,因此同步器提供了一个基于CAS的设置尾节点的方法:compareAndSetTail (Node expect, Node update),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。首节点的设置是提供获取同步状态成功的线程来完成的,由于只有一个线程能够成功的获取到同步状态,因此设置首节点的方法并不需要使用CAS来保证,它只需要把首节点设置为原来的首节点的后继节点并且断开原来首节点的next引用即可。

2.独占式同步状态获取与释放

通过调用同步器的acquire(int arg)方法可以获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列中,后续对线程进行中断操作时,线程不会从同步队列中移出,其主要逻辑是:首先调用自定义同步器实现的tryAcquire(int arg)方法,该方法保证线程安全的获取同步状态,如果同步状态获取失败,则构造同步节点(独占式Node.EXCLUSIVE,同一时刻只能有一个线程成功获取同步状态)并通过addWaiter(Node node)方法将该节点加入到同步队列的尾部,最后调用acquireQueued(Node node, int arg)方法,使得 该节点以“死循环”的方式获取同步状态。如果获取不到则阻塞节点中的线程,而被阻塞线程的唤醒主要依靠前驱节点为头节点且成功获取了同步状态的出队或阻塞线程被中断来实现。在释放同步状态时,同步器调用tryRelease方法来释放同步状态,然后唤醒头节点的后继节点。

3.共享式同步状态获取与释放:

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态和tryReleaseShared方法必须确保同步状态线程安全释放,一般是通过循环和CAS来保证的,因为释放同步状态的操作会同时来自多个线程。以文件的读写为例,写操作要求对资源的独占式访问,而读操作可以是共享式访问。

4.独占式超时获取同步状态:


通过调用同步器的doAcquireNanos(int arg, long nanosTimeout)方法可以超时获取同步状态,即在指定的时间段内获取同步状态,如果获取到同步状态则返回true,否则,返回false。该方法提供了传统Java同步操作(比如 synchronized关键字)所不具备的特性。在分析该方法的实现前,先介绍一下响应中断的同步状态获取过程。在Java5之前,当一个线程获取不到锁而被阻塞在synchronized之外时,对该线程进行中断操作,此时该线程的中断标志位会被修改但线程依旧会阻塞synchronized上,等待着获取锁。在Java5中,同步器提供了acquireInterruptibly(int arg)方法,这个方法在等待获取同步状态时,如果当前线程被中断,会立刻返回,并抛InterryptedException。
超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,doAcquireNanos(int arg, long nanosTimeout)方法在支持响应中断的基础上,增加了超时获取的特性。针对超时获取,主要需要计算出需要睡眠的时间间隔nanosTimeout.为了防止过早通知,nanasTimeout计算公式为:nanesTimeout=now-lastTime其中now为当前唤醒时间,lastTime为上次唤醒时间,如果nanosTimeout大于0则表示超时时间未到,需要继续睡眠nanosTimeout纳秒,反之,表示已经超时。

重入锁:


重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平性选择。回忆在同步器一节中的示例(Mutex),同时考虑如下场景:当一个线程调用Mutex的lock()方法获取锁之后,如果再次调用lock()方法,则该线程将会被自己所阻塞,原因是Mutex 在实现tryAcquire(int acquires)方法时没有考虑占有锁的线程再次获取锁的场景,而在调用tryAcquire(int acquires)方法时返回了false,导致该线程被阻塞。简单地说,Mutex是个不支持重进入的锁。而 synchronized关键字隐式的支持重进入,比如一个synchronized修饰的递归方法,在方法执行时,执行线程在获取了锁之后仍能连续多次地获得该锁,而不像Mutex由于获取了锁,而在下一次获取锁时出现阻塞自己的情况。
RcentrantLock虽然没能像synchronized关键字一样支持隐式的重进入,但是在调用lock()方法时,已经获取到锁的线程 能够再次调用lock方法获取锁而不被阻塞。 
这里提到一个锁获取的公平性问题,如果在绝对时间上,先对锁进行获取的请求一定先 被满足,那么这个是公平的,反之是不公平的。公平的获取锁,也就是等待时间最长的线程最优先获取锁,也可以说锁获取是顺序的。ReentrantLock提供了一个构造函数,能够控制锁是否是公平的。
事实上,公平的锁机制往往没有非公平的锁效率高,但是,并不是任何场景都是以TPS作为唯一的指标,公平锁能够减少“饥饿”发生的概率,等待越久的请求越是能够得到优先满足。


读写锁:


之前提到锁(如Mutex 和Reentranttock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。


除了保证写操作对读操作的可见性以及并发性的提升之外,读写锁能够简化读写交互场景的编程方式。假设在程序中定义一个共享的用作缓存数据结构,它大部分时间提供读服务(例如查询和搜索),而写操作占有的时间很少,但是写操作完成之后的更新需要对后续的读服务可见。
在没有读写锁支持的(Java 5之前)时候,如果需要完成上述工作就要使用Java的等待通知机制。就是当写操作开始时,所有晚于写操作的读操作均会进入等待状态,只有写操作成并进行通知之后,所有等待的读操作才能继续执行(写操作之间依靠synchronized进行同步),这样做的目的是使读操作能读取到正确的数据,不会出现脏读。改写读写锁实现上述功能,只需要在读操作时获取读锁,写操作时获取写锁即可。当写锁获取到时,后续的读写操作都会被阻塞,写锁释放之后,所有操作继续执行,编程方式相对于使用等待通知机制而言,变得简单明了。

锁降级:

锁降级是指的是写锁降级成为读锁,如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种完成过程不能称为锁降级。锁降级是指当前拥有的写锁,再获取到读锁,随后释放先前拥有的写锁的过程。

LockSupport工具:

当需要阻塞或者唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。LockSuppprt定义了一组以park开头的方法用来阻塞当前线程,以及unpark方法来唤醒一个被阻塞的线程。

Condition接口:

任意一个Java对象,都拥有一组监视器方法,主要包括wait(),notify(),notifyAll(),这些方法与synchronized同步关键字配合,可以实现等待/通知模式。Condition接口也提供了类似object的监视器方法,与lock配合可以实现等待/通知模式,但是两者使用方式和功能特性上还是有差别的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mo@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值