Java并发编程一直是Java程序员必须懂但又是很难懂的技术内容,这部分的内容我也是反复学习了好几遍才能理解。本篇博客梳理一下最近从《Java 并发编程的艺术》和他人的博客学习Java并发编程的思路,本篇博客只梳理了Java并发整体的框架,以及罗列了重点内容和参考学习资料,由于篇幅问题就不对每个知识点做过多的深入。
一、进程与线程、并发与并行的概念,为什么要使用多线程
程序:一段静态的代码,一组指令的有序集合,它本身没有任何运行的含义,它只是一个静态的实体,是应用软件执行的蓝本。
进程:进程是CPU分配资源的最小单元,是程序的一次动态执行,它对应着从代码加载,执行至执行完毕的一个完整的过程,是一个动态的实体,它有自己的生命周期。它因创建而产生,因调度而运行,因等待资源或事件而被处于等待状态,因完成任务而被撤消。进程是应用程序的执行实例,每个进程是由私有的虚拟地址空间、代码、数据和其它系统资源组成。进程在运行时创建的资源随着进程的终止而死亡。
线程:线程是CPU调度的基本单元,可以理解为进程的多条执行线索,每条线索又对应着各自独立的生命周期。线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发执行。
一个普通的Java SE程序启动后它就是一个进程,进程相当于一个空盒,它只提供资源装载的空间,具体的调度并不是由进程来完成的,而是由线程来完成的。一个java程序从main开始之后,进程启动,为整个程序提供各种资源,而此时将启动一个线程,这个线程就是主线程,它将调度资源,进行具体的操作。Thread、Runnable的开启的线程是主线程下的子线程,是父子关系,此时该java程序即为多线程的,这些线程共同进行资源的调度和执行。
并行:同时进行几个任务;并行是指两个或者多个事件在同一时刻发生
并发:根据虚拟机分配的时间片分时间运行不同的任务,同一时间只有一个任务在进行。并发是指两个或多个事件在同一时间间隔发生。
从图中可以清楚的看出,并发中,一个单核的CPU在同一时间只能执行一个线程中的任务,CPU通过给每个线程分配CPU时间片来实现这个机制的,时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
多个处理器、或集群,才能做到并行。单核单处理器无法并行执行程序,只能并发执行。
上下文切换:CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存再加载的过程就是一次上下文切换。
多线程程序会增加线程创建、上下文切换的开销以及资源调度的时间,在一些特定的环境下,多线程程序并不一定比单线程程序快。
为什么要使用多线程:
使用多线程的理由之一是和进程相比,它是一种非常花销小,切换快,更"节俭"的多任务操作方式。在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而在进程中的同时运行多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。
使用多线程的理由之二是线程间方便的通信机制。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。当然,数据的共享也带来其他一些问题,有的变量不能同时被两个线程所修改,有的子程序中声明为static的数据更有可能给多线程程序带来灾难性的打击,这些正是编写多线程程序时最需要注意的地方。
除了以上所说的优点外,不和进程比较,多线程程序作为一种多任务、并发的工作方式,当然有以下的优点:
1) 提高应用程序响应,充分利用多核CPU,提升CPU利用率。这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作(time consuming)置于一个新的线程,可以避免这种尴尬的情况。
2) 使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
3) 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改,以及程序功能的解耦。
二、JMM/原子性、可见性、有序性
任何语言最终都是运行在处理器上,JVM虚拟机为了给开发者一个一致的编程内存模型,需要制定一套规则,这套规则可以在不同架构的机器上有不同实现,并且向上为程序员提供统一的JMM内存模型。
所以了解JMM内存模型也是了解Java并发原理的一个重点,其中了解指令重排,内存屏障,以及可见性原理尤为重要。
JMM只保证happens-before和as-if-serial规则,所以在多线程并发时,可能出现原子性,可见性以及有序性这三大问题。
-
- 原子性
原子,即一个不可再被分割的颗粒。在Java中原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。 - 有序性
程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序) - 可见性
当多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获取到最新的值。
- 原子性
Java提供了volatile、synchronized、lock等关键字方便程序员解决原子性、可见性、以及有序性等问题。
JMM的详细介绍见我之前的一篇博文:https://www.cnblogs.com/kukri/p/9109639.html
三、线程六大状态。线程启动、中断。
Java中线程的状态分为六种:
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。
- 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
线程状态转换图:
1. 初始状态
实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。
2.1. 运行状态——就绪
就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。
调用线程的start()方法,此线程进入就绪状态。
当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
锁池里的线程拿到对象锁后,进入就绪状态。
2.2. 运行状态——运行中
线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。
3. 阻塞状态
阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。
4. 等待
处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。
5. 超时等待
处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。
6. 终止状态
当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。
在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。
-
线程启动
在运行线程之前首先要构造一个线程对象,线程对象在构造的时候需要提供线程所需要的属性,如线程所属的线程组、线程优先级、是否是Daemon线程等信息。一个新构造的线程对象是由其parent线程来进行空间分配的,而child线程继承了parent是否为Daemon、优先级和家在资源的contextClassLoader以及科技城的ThreadLocal,同时还会分配一个唯一的ID来表示这个child线程。至此,一个能够运行的线程对象就初始化好了,在堆内存中等待着运行。
-
通过Runnable接口创建线程类
(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
(2)创建 Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
(3)调用线程对象的start()方法来启动该线程。用start方法来启动线程,真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的start()方法来启动一个线程,这时此线程处于运行状态中的就绪状态,并没有真正运行,一旦得到cpu时间片,就开始执行run()方法,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,Run方法运行结束,此线程随即终止。
public class RunnableThreadTest implements Runnable { private int i; public void run() { for(i = 0;i <100;i++) { System.out.println(Thread.currentThread().getName()+" "+i); } } public static void main(String[] args) { for(int i = 0;i < 100;i++) { System.out.println(Thread.currentThread().getName()+" "+i); if(i==20) { RunnableThreadTest rtt = new RunnableThreadTest(); new Thread(rtt,"新线程1").start(); new Thread(rtt,"新线程2").start(); } } } }
-
线程中断
中断可以理解为线程的一个标识位属性,它表示一个运行中的线程是否被其他线程进行了中断操作。中断好比其他线程对该线程打了个招呼,其他线程通过调用该线程的interrupt()方法对其进行中断操作。
调用线程的interrupt() 方法不会中断一个正在运行的线程,这个机制只是设置了一个线程中断标志位,如果在程序中你不检测线程中断标志位,那么即使设置了中断标志位为true,线程也一样照常运行。所以说线程是通过检查自身是否被终端来进行响应,通过方法isInterrupted()来进行判断是否被中断,也可以调用静态方法Thread.interrupted()对当前线程的中断标识位进行复位,如果该线程已经处于终结状态,即使该线程被中断过,在调用该线程对象的isInterrupted()时依旧会返回false。
可以通过在线程中设置对线程终端标志位的检查来控制线程的中断
public class InterruptThreadTest2 extends Thread{ public void run() { // 这里调用的是非清除中断标志位的isInterrupted方法 while(!Thread.currentThread().isInterrupted()) { long beginTime = System.currentTimeMillis(); System.out.println(Thread.currentThread().getName() + "is running"); // 当前线程每隔一秒钟检测线程中断标志位是否被置位 while (System.currentTimeMillis() - beginTime < 1000) {} } if (Thread.currentThread().isInterrupted()) { System.out.println(Thread.currentThread().getName() + "is interrupted"); } } public static void main(String[] args) { // TODO Auto-generated method stub InterruptThreadTest2 itt = new InterruptThreadTest2(); itt.start(); try { Thread.sleep(5000); } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } // 设置线程的中断标志位 itt.interrupt(); }
-
线程的唤醒和阻塞带来的资源消耗
java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
-
- 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
- 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
四、线程间通信
线程开始运行,拥有自己的栈空间,就如同一个脚本一样,按照既定的代码一步一步地执行,知道终止,但是每个运行中的线程,如果仅仅是孤立地运行,那么没有一点儿价值,或者说价值很少,如果多个线程能够相互配合完成工作,这将会带来巨大的价值。
1 volatile和synchronized关键字
Java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝(虽然对象以及成员变量分配的内存是共享内存中的,但是每个执行的线程还拥有一份拷贝,这样做的目的是加速程序执行,这是现代多核处理器的一个显著特性),所以程序在执行过程中,一个线程看到的变量并不一定是最新的。
关键字volatile可以用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需从共享内存中获取,而对它的改变必须同步刷新回共享内存。它能保证所有线程对变量访问的可见性。但过多地使用volatile会降低程序执行的效率。
关键字synchronized可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,保证了线程对变量访问的可见性和排他性。
具体volatile和synchronized的原理分析以及使用场景对比会在第六部分具体讲到。
2 等待/通知机制
一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作,整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者。这种模式隔离了“做什么”(what)和“怎么做”(How),在功能层面上实现了解耦,体系结构具备了良好的伸缩性。
等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在所有对象的超类java.lang.Object上。具体的方法有notify()
,notifyAll()
,wait()、wait(long)、wait(long, int)
。
方法名称 | 描述 |
notify() | 通知一个在对象上等待的相乘,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁。 |
notifyAll() | 通知所有等待在该对象上的线程。 |
wait() | 调用该方法的线程由RUNNING进入WAITING状态,只有等待另外线程的通知或被终端才会返回,需要注意,调用wait()方法后,会释放对象的锁。 |
wait(long) | 超时等待一段时间,这里的参数时间是毫秒,也就是等待长达n毫秒,如果没有通知就超时返回。 |
wait(long, int) | 对于超时时间更细粒度的控制,可以达到纳秒。 |
等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B调用了对象O的notify()或者notifyAll()方法,线程A收到了通知后从对象O的wait()方法返回,进而执行后序操作。上述两个线程通过对象O来完成交互,而对象上的wait()和notify/notifyAll()的关系就如同开关信号一样,用来完成等待方和通知方之间的交互工作。
使用wait(),notify()以及notifyAll()需要注意的细节:
1)使用wait()、notify()和notifyAll()时需要先对调用对象加锁。
2)调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程放置到对象的等待队列。
3)notify()或notifyAll()后,等待线程并不会直接从wait()放回,需要等到1.notify()或notifyAll()的线程释放锁之后;2.等待线程获取到锁, 才会从wait()返回。
4)notify()方法将等待队列中的一个等待线程从等待队列中移到都同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移到的状态由WEAITNG变为BLOCKED。
5)等待线程从wait()方法返回的前提时获得了调用对象的锁。
从上述细节可以看到,等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感受到通知线程对变量的修改。
等待/通知的经典范式
范式分为两部分,分别为等待方(消费者)和通知方(生产者)
等待方的原则:
1)获取对象的锁
2)如果条件不满足,那么调用锁的wait()方法,使该线程进入waiting,被通知后依然要检查条件
3)条件满足则执行对应的逻辑
伪代码:
synchronized(对象){ while(条件不满足){ 对象.wait(); } 对应的逻辑处理 }
通知方的原则:
1)获取对象的锁
2)改变条件
3)通知所有等待在该对象上的线程
伪代码:
synchronized(对象){ 改变条件 对象.notifyAll(); }
3 await()/signal()
await()/signal()的用法和功能其实和wait()/notify()比较像,前者是属于Condition接口,后者属于java.lang.Object类。
wait()/notify()一般配合synchronized使用,这些方法都是Object类提供的。synchronized在一个对象上锁(同步),wait()/notify()在这个对象上执行操作,所以一般在一个同步代码块中只能对一个对象进行wait()/notify()的操作。
await()/signal()一般配合Lock对象使用,与wait()/notify()不同的是,wait()/notify()是根据synchronized(object)的object来object.wait()或object.notify(),而await()/signal()是根据锁对象的Condition对象关联的,每一个lock中可以有多个Condition,即一个同步代码块中,可以对多个Condition对象await()/signal(),这样的话大大提升了等待/通知机制的灵活性。在JUC并发包中的阻塞队列,就使用了Condition的await()/signal(),通过notFull和notEmpty两个Condition,合理的控制了程序的流程。具体在第八部分会详细讲到阻塞队列。
Lock lock = new ReentrantLock(); Condition condition = lock.newCondition(); public void conditionWait() throws InterruptedException { lock.lock(); try { condition.await(); } finally { lock.unlock(); } } public void conditionSignal() throws InterruptedException { lock.lock(); try { condition.signal(); } finally { lock.unlock(); } }
一般情况下使用Object提供的3种方法就已经可以很好的实现线程间的协作。当Lock锁使用公平模式的时候,可以使用Condition的signal(),线程会按照FIFO的顺序冲await()中唤醒。当每个锁上有多个等待条件时,可以优先使用Condition,这样可以具体一个Condition控制一个条件等待。
4 Thread.join()
如果一个线程A执行了threadA.join()语句,其含义是:当前线程A等待thread线程终止之后才从threadA.join()返回。线程Thread除了提供join()方法之外,还提供了join(long millis)和join(long milis, int nanos)两个具备超时特性的方法。这两个超时方法表示,如果线程thread在给定的超时时间里没有终止,那么将会从超时方法中返回。
看JDK中的Thread.join()方法的源码发现,join()方法是用wait(0)方法挂起线程的。可以看到这个join()方法还是用2中讲到的等待/通知经典范式的思想来实现的,即:加锁、循环和处理逻辑三个步骤。只不过看源码发现,join()中的wait()似乎不太需要notify()唤醒,因为join()中的wait(timeout)的timeout参数一般是0或delay,所以会自动返回。
public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay <= 0) { break; } wait(delay); now = System.currentTimeMillis() - base; } } }
5 ThreadLocal
ThreadLocal是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。
ThreadLocal是用来维护本线程的变量的,并不能解决共享变量的并发问题。ThreadLocal是各线程将值存入该线程的map中,以ThreadLocal自身作为key,需要用时获得的是该线程之前存入的值。如果存入的是共享变量,那取出的也是共享变量,并发问题还是存在的。
ThreadLocal的主要用途是为了保持线程自身对象和避免参数传递,主要适用场景是按线程多实例(每个线程对应一个实例)的对象的访问,并且这个对象很多地方都要用到。
ThreadLocal的详细内容查看:
https://www.jianshu.com/p/98b68c97df9b
https://blog.csdn.net/qq_36632687/article/details/79551828
https://mp.weixin.qq.com/s/aM03vvSpDpvwOdaJ8u3Zgw
五、常见锁的概念
1 乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用场景,因为这种场景冲突比较小,不使用锁可以减少上下文切换的开销等,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中 java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。CAS算法就是典型的乐观锁,具体的原理会在第六部分介绍。
简而言之乐观锁就是每次操作不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止,不会造成线程阻塞。
2 悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞block直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。悲观锁更适用于多写的应用场景,这种场景下冲突较多。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现。
简而言之悲观锁就是每次操作都会加锁,会造成线程阻塞。
java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,如ReentrantLock。
3 重入锁
也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在JAVA中 ReentrantLock 和synchronized 都是可重入锁。重入锁可以有效的避免死锁。
4 自旋锁
采用让当前线程不停的在循环体内执行实现,当循环的条件被其它线程改变时才能进入临界区
public void lock() { Thread current = Thread.currentThread(); //compareAndSet(V expect, V update) 如果当前值 == 预期值,则以原子方式将该值设置为给定的更新值。 while (!sign.compareAndSet(null, current)) { } } public void unlock() { Thread current = Thread.currentThread(); sign.compareAndSet(current, null); }
由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。
互斥锁相对于自旋锁的缺点是 其他未获取到锁的线程在等待获取锁时会进入block状态,获取到锁时再切换回来,这样会造成CPU很大的时间开销,如果互斥量仅仅被锁住很短的一段时间, 用来使线程休眠和唤醒线程的时间会比该线程睡眠的时间还长, 甚至有可能比不断在自旋锁上轮训的时间还长。自旋锁的问题是, 如果自旋锁被持有的时间过长, 其它尝试获取自旋锁的线程会一直轮训自旋锁的状态, 这将非常浪费CPU的执行时间, 这时候该线程睡眠会是一个更好的选择。
自旋锁和互斥锁(排他锁,独占锁)是相对的概念,具体区别可参考:http://ifeve.com/practice-of-using-spinlock-instead-of-mutex/
5 读写锁
之前提到的锁(如synchronized和ReentrantLock)基本都是排他锁,这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁的机制: "读-读"不互斥 、"读-写"互斥 、"写-写"互斥 。JUC包中有专门的读写锁的实现:ReentrantReadWriteLock。
读写锁代码实例见:https://blog.csdn.net/liyantianmin/article/details/42829233
其实在mysql中读写锁有时候也分为两部分:排他锁(写锁)和共享锁(读锁),在java中倒是没有对这两个概念进行细分。
6 公平锁
公平锁的公平性与否是在于:锁的获取顺序是否符合请求的绝对时间顺序,也就是FIFO。
JUC包中的ReetrantLock就可以实现公平锁,具体底层实现机制是通过AQS(同步队列)判断当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取锁并释放之后才能继续获取锁,有关AQS的原理会在第七部分具体讲到。
ReetrantLock具体实现公平锁的代码可参考:https://blog.csdn.net/qyp199312/article/details/70598480
7 偏向锁
Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。 偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
偏向锁通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。synchronized的实现就用到了偏向锁。
8 死锁
把死锁放在这略微有点萌QAQ,因为它并不是一种锁的类型,而是指错误使用锁而出现的现象。死锁出现的原因就是多个线程涉及到了多个锁,这些锁出现了交叉或闭环,如下面这个例子:
创建了两个字符串a和b,再创建两个线程A和B,让每个线程都用synchronized锁住字符串(A先锁a,再去锁b;B先锁b,再锁a),如果A锁住a,B锁住b,A就没办法锁住b,B也没办法锁住a,这时就陷入了死锁。
public class DeadLock { public static String obj1 = "obj1"; public static String obj2 = "obj2"; public static void main(String[] args){ Thread a = new Thread(new Lock1()); Thread b = new Thread(new Lock2()); a.start(); b.start(); } } class Lock1 implements Runnable{ @Override public void run(){ try{ System.out.println("Lock1 running"); while(true){ synchronized(DeadLock.obj1){ System.out.println("Lock1 lock obj1"); Thread.sleep(3000);//获取obj1后先等一会儿,让Lock2有足够的时间锁住obj2 synchronized(DeadLock.obj2){ System.out.println("Lock1 lock obj2"); } } } }catch(Exception e){ e.printStackTrace(); } } } class Lock2 implements Runnable{ @Override public void run(){ try{ System.out.println("Lock2 running"); while(true){ synchronized(DeadLock.obj2){ System.out.println("Lock2 lock obj2"); Thread.sleep(3000); synchronized(DeadLock.obj1){ System.out.println("Lock2 lock obj1"); } } } }catch(Exception e){ e.printStackTrace(); } } }
六、CAS、volatile、synchronized
有人称CAS、volatile是Java并发的基石,再加上第八章介绍的lock接口和AQS、重入锁以及JUC中的好多实用的类都是用他们实现的。为了理解阻塞队列,ConcurrentHashmap和线程池等成熟的并发包的实现原理(第八章)需先掌握第六章和第七章的基本同步组件的原理和应用。
1 volatile
java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。注意这里的字段只能是Java中的8大基础变量,如果是引用型变量,可能无法保证内存可见性。
valitate是轻量级的synchronized,不会引起线程上下文的切换和调度,执行开销更小。
volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置位无效。线程接下来将从主内存中读取共享变量。(强制从主内存读取共享变量,把本地内存与主内存的共享变量的值变成一致)。
为了实现volatile的内存语义,JMM对编译器和处理器的重排序做了限制。具体JMM是如何实现volatile的内存语义的可以参见我之前的博客:https://www.cnblogs.com/kukri/p/9109639.html
volatile可以保证三大特性中的
可见性:多线程操作的时候,一个线程修改了一个变量的值 ,其他线程能立即看到修改后的值
有序性:即程序的执行顺序按照代码的顺序执行(处理器为了提高代码的执行效率可能会对代码进行重排序)
但不能保证原子性,例如说x++这种复合操作,例如说,x初始值为3,x=x+1会首先从主内存中取x的值到工作内存中,此时取的值是3,+1以后的值为4,但此时还没来得及将4赋值给x,CPU时间片就轮到了其他线程,另一个线程将x的变量值改为了10,然后该线程重新获取到了时间片后,继续执行之前的操作,x被赋为4,这样就出现了同步问题。
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1)对变量的写操作不依赖于当前值
2)该变量没有包含在具有其他变量的不变式中
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,我的理解就是因为volatile本身不具备原子性,因此上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。
2 synchronized
跟volatile相比,synchronized更“重量级”,带来的内存 CPU的开销也更大一些,带来的好处就是能确保线程互斥的访问同步代码,更安全。
synchronized 是JVM实现的一种锁,其中锁的获取和释放分别是monitorenter 和 monitorexit 指令,该锁在实现上分为了偏向锁、轻量级锁和重量级锁,其中偏向锁在 java1.6 是默认开启的,轻量级锁在多线程竞争的情况下会膨胀成重量级锁,有关锁的数据都保存在对象头中。
synchronized用法:
- 修饰普通方法:同步对象是实例对象
- 修饰静态方法:同步对象是类本身
- 修饰代码块:可以自己设置同步对象
synchronized的缺点:会让没有得到锁的资源进入Block状态,争夺到资源之后又转为Running状态,这个过程涉及到上下文切换和调度延时以及操作系统用户态和内核态的切换,代价比较高。Java1.6为 synchronized 做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。
三种锁的差别如下图:
关于synchronized的锁优化问题可进一步参考:https://www.cnblogs.com/barrywxx/p/8678698.html
3 CAS
Java并发应用中通常指CompareAndSwap或CompareAndSet,即比较并交换。JVM中的CAS操作是利用了处理器提供的CMPXCHG指令实现的;CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,因此CAS是一个原子操作,但仅能保证一个共享变量的原子性。
CAS(V,A,B)操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。
像刚刚讲到的volatile解决不了的x++问题,JUC包中的原子操作类java.util.concurrent.atomic可以解决,其底层其实是用自旋+CAS来实现的。
例如AtomicInteger:
public final int getAndIncrement() { for(;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return current; } } public final boolean compareAndSet(int except, int update) { return unsafe.compareAndSwapInt(this, valueOffset, except, update); }
CAS的优点:能有效的减少上下文的切换的开销,提升性能。并提供了单变量的原子性。
CAS存在的问题(缺点):
- ABA问题:
什么是ABA问题?比如有一个 int 类型的值 N 是 1
此时有三个线程想要去改变它:
线程A :希望给 N 赋值为 2
线程B: 希望给 N 赋值为 2
线程C: 希望给 N 赋值为 1
此时线程A和线程B同时获取到N的值1,线程A率先得到系统资源,将 N 赋值为 2,线程 B 由于某种原因被阻塞住,线程C在线程A执行完后得到 N 的当前值2
此时的线程状态
线程A成功给 N 赋值为2
线程B获取到 N 的当前值 1 希望给他赋值为 2,处于阻塞状态
线程C获取当好 N 的当前值 2 希望给他赋值为1
然后线程C成功给N赋值为1
最后线程B得到了系统资源,又重新恢复了运行状态,在阻塞之前线程B获取到的N的值是1,执行compare操作发现当前N的值与获取到的值相同(均为1),成功将N赋值为了2。
在这个过程中线程B获取到N的值是一个旧值,虽然和当前N的值相等,但是实际上N的值已经经历了一次 1到2到1的改变
上面这个例子就是典型的ABA问题
怎样去解决ABA问题
给变量加一个版本号即可,在比较的时候不仅要比较当前变量的值 还需要比较当前变量的版本号。Java中AtomicStampedReference 就解决了这个问题
- 循环时间长开销大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
- 只能保证一个共享变量的原子操作。
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
七、JUC包:Lock接口、AQS(队列同步器)、重入锁、读写锁
本章介绍JUC包(java.util.Concurrent)中与锁相关的API和组件,以及这些API和组件的使用方式和实现原理。
1 Lock接口
synchronized的缺点:
1)不能响应中断;
2)同一时刻不管是读还是写都只能有一个线程对共享资源操作,其他线程只能等待
3)锁的释放由虚拟机来完成,不用人工干预,不过此即使缺点也是优点,优点是不用担心会造成死锁,缺点是由可能获取到锁的线程阻塞之后其他线程会一直等待,性能不高。
锁时用来控制多个线程访问共享资源的方式,一般来说,一个锁能防止多个线程同时对共享资源(读写锁例外,可以允许多个线程并发访问共享资源)。在Lock接口出现之前,Java是靠synchronized关键字实现锁功能的。但是在Java SE 5之后,并发包中新增了Lock接口以及其相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显示地获取和释放锁;虽然它缺少了通过synchronized块提供的隐式获取释放锁的便携性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等更灵活的特性。
Lock lock = new ReentrantLock(); lock.lock(); try { } finally { lock.unlock(); }
Lock接口有6个方法如下,本篇就不详细介绍这几个API的使用方法了,可参考https://www.cnblogs.com/dolphin0520/p/3923167.html:
public interface Lock { void lock(); void lockInterruptibly() throws InterruptedException; boolean tryLock(); boolean tryLock(long time, TimeUnit unit) throws InterruptedException; void unlock(); Condition newCondition(); }
2 (AQS)队列同步器
队列同步器AbstractQueuedSynchronizer,是用来构建锁或者而其他同步组件的基础框架,它使用了一个int成员变量表示同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
AQS的主要使用方式是集成,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了对同步状态进行更改,这时就需要使用同步器提供的三个方法(getState()、setState(int new State)和compareAndSetState(int expect, int update))来进行操作,因为它们能够保证状态的改变是安全的。子类推荐被定义为自定义同步组件的静态内部类,同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch等)。
Lock接口的实现基本上都是通过聚合了一个同步器的子类来完成线程访问控制的。同步器是实现锁(也可以是任意同步组件)的关键,在锁的实现中聚合同步器,利用同步器实现锁的语义。
锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;
同步器面向的是锁的实现者,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。锁和同步器很好地隔离了使用者和实现者所需要关注的领域。
AQS的实现原理我是看方腾飞的那本《Java 并发编程的艺术》学习的,篇幅过多就不在此罗列了。或参考链接https://www.cnblogs.com/chengxiao/archive/2017/07/24/7141160.html#a1-2
AQS是JUC中很多同步组件的构建基础,简单来讲,它内部实现主要是状态变量state和一个FIFO队列来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点(或共享式或独占式)加入到同步队列尾部(采用自旋CAS来保证此操作的线程安全),随后线程会阻塞;释放时唤醒头结点的后继结点,使其加入对同步状态的争夺中。
AQS为我们定义好了顶层的处理实现逻辑,我们在使用AQS构建符合我们需求的同步组件时,只需重写tryAcquire,tryAcquireShared,tryRelease,tryReleaseShared几个方法,来决定同步状态的释放和获取即可,至于背后复杂的线程排队,线程阻塞/唤醒,如何保证线程安全,都由AQS为我们完成了,这也是非常典型的模板方法的应用。AQS定义好*逻辑的骨架,并提取出公用的线程入队列/出队列,阻塞/唤醒等一系列复杂逻辑的实现,将部分简单的可由使用者决定的操作逻辑延迟到子类中去实现。
基于AQS实现的同步组件,包括下面要介绍的重入锁和读写锁相比于传统的synchronized比除性能外还有以下优势:
1.可响应中断、锁申请等待限时等;
2.公平锁与非公平锁(ReentrantLock);
3.根据AQS的独占式和共享式两种获取同步状态的方法可以实现排他锁与共享锁(ReentrantReadWriteLock);
4.另外可以结合Condition来使用await()/signal() 提供更灵活的等待/通知机制;
5.锁的释放获取可以由程序员自己控制,更加灵活。
3 重入锁
重入锁ReentrantLock支持重进入,表示该锁能够支持一个线程对资源重复加锁,除此之外,比较重要的是该锁还支持获取锁时的公平性和非公平性选择。ReentrantLock是通过组合自定义AQS来实现锁的获取和释放。具体原理可查看《Java 并发编程的艺术》相关部分。
本篇只分析一下ReentrantLock的重点部分,就是如何实现公平锁的:
static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; final void lock() { acquire(1); } protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } }
重点是在于公平获取锁的判断条件相对于非公平锁,加入了!hasQueuedPredecessors()的判断:
public final boolean hasQueuedPredecessors() { Node t = tail; Node h = head; Node s; //head没有next ----> false //head有next,next持有的线程不是当前线程 ----> true //head有next,next持有的线程是当前线程 ----> false return h != t && ((s = h.next) == null || s.thread != Thread.currentThread()); }
即加入了同步队列中当前节点是否有前驱节点,如果返回true,则表示有线程比当前线程更早地请求获取锁,因此要等待前驱线程获取锁并释放锁才能继续获取锁,对应的就是把该线程加入同步队列中等待。
即 tryAcquire return false,取非以后 第一部分return true,然后进入第二部分,通过addWaiter把当前线程存为节点并加入到同步队列的尾部。
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
然后acquireQueued又是一个自旋,等待同步队列中的线程(节点)出队列的过程,且保证了队列的FIFO的特性。
final boolean acquireQueued(final Node node, int arg) { boolean failed = true;//标记是否成功拿到资源 try { boolean interrupted = false;//标记等待过程中是否被中断过 //又是一个“自旋”! for (;;) { final Node p = node.predecessor();//拿到前驱 //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。 if (p == head && tryAcquire(arg)) { setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。 p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了! failed = false; return interrupted;//返回等待过程中是否被中断过 } //如果自己可以休息了,就进入waiting状态,直到被unpark() if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true } } finally { if (failed) cancelAcquire(node); } }
第一次看这块的时候有疑问,就感觉AQS自带的acquire方法中的acquireQueued已经保证了FIFO,ReentrantLock集成AQS实现还怎么保证非公平和公平两种特性了。
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
后来仔细分析程序结构发现,acquire中的if判断,是先会去判断tryAcquire是否成功,根据&&(与)运算的特性,当且仅当tryAcquire失败(false),也就是if语句中的第一个判断条件为真时,才会去判断第二个条件,也就是加入同步队列自旋,这个同步队列是会保证FIFO也就保证了公平性的。但如果一个新来的线程,恰好在tryAcquire的CAS原子操作中成功获取了同步状态,那么他将“插队”,也就是说越过了同步队列中的所有节点,直接执行,这样就失去了公平性。所以虽然AQS机制自带了同步队列保证了一部分“公平性”,但tryAcquire中却没有保证公平性,所以ReentrantLock是否保证公平性是体现在tryAcquire的方法中的。
言归正传,公平锁虽然能保证线程锁的公平获取,但经过试验发现,会造成上下文的切换次数增加,造成更大的开销。非公平锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。所以ReentrantLock默认设置为非公平锁。
在JDK5.0版本之前,重入锁的性能远远好于synchronized关键字,JDK6.0版本之后synchronized 得到了大量的优化,二者性能也不分伯仲,但是重入锁是可以完全替代synchronized关键字的。除此之外,重入锁又自带一系列高逼格操作:可中断响应、锁申请等待限时、公平锁、另外可以结合Condition来使用await()/signal() 提供更灵活的等待/通知机制,另外锁的释放获取也更加灵活。
4 读写锁
之前提到的锁基本上都是排他锁(独占锁),在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。ReentrantReadWriteLock维护了一堆锁,读锁和写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
八、阻塞队列、ConcurrentHashMap、线程池
掌握了第六部分第七部分所讲的核心组件的原理后,就可以了解一下在实际生产环境中使用更多的API了。
1 阻塞队列
阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。
1)支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
2)支持阻塞的移除方法:意思是当队列为空时,获取元素的线程会等待队列变为非空。
阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取出元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。
JDK7提供了7个阻塞队列。分别是
- ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
- PriorityBlockingQueue :一个支持优先级排序的*阻塞队列。
- DelayQueue:一个使用优先级队列实现的*阻塞队列。
- SynchronousQueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的*阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
阻塞队列的方法对比非阻塞队列的方法
1.非阻塞队列中的几个主要方法:
add(E e):将元素e插入到队列末尾,如果插入成功,则返回true;如果插入失败(即队列已满),则会抛出异常;
remove():移除队首元素,若移除成功,则返回true;如果移除失败(队列为空),则会抛出异常;
offer(E e):将元素e插入到队列末尾,如果插入成功,则返回true;如果插入失败(即队列已满),则返回false;
poll():移除并获取队首元素,若成功,则返回队首元素;否则返回null;
peek():获取队首元素,若成功,则返回队首元素;否则返回null
对于非阻塞队列,一般情况下建议使用offer、poll和peek三个方法,不建议使用add和remove方法。因为使用offer、poll和peek三个方法可以通过返回值判断操作成功与否,而使用add和remove方法却不能达到这样的效果。注意,非阻塞队列中的方法都没有进行同步措施。
2.阻塞队列中的几个主要方法:
阻塞队列包括了非阻塞队列中的大部分方法,上面列举的5个方法在阻塞队列中都存在,但是要注意这5个方法在阻塞队列中都进行了同步措施。除此之外,阻塞队列提供了另外4个非常有用的方法:
put(E e):put方法用来向队尾存入元素,如果队列满,则等待;
take():take方法用来从队首取元素,如果队列为空,则等待;
offer(E e,long timeout, TimeUnit unit):offer方法用来向队尾存入元素,如果队列满,则等待一定的时间,当时间期限达到时,如果还没有插入成功,则返回false;否则返回true;
poll(long timeout, TimeUnit unit):poll方法用来从队首取元素,如果队列空,则等待一定的时间,当时间期限达到时,如果取到,则返回null;否则返回取得的元素;
阻塞队列的实现原理
JDK是使用通知模式实现的阻塞队列。所谓通知模式,就是当生产者往队列满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。通过查看JDK源码发现ArrayBlockingQueue使用了Condition接口以及(await()/signal())来实现。
/** Condition for waiting takes */ private final Condition notEmpty; /** Condition for waiting puts */ private final Condition notFull; public ArrayBlockingQueue(int capacity, boolean fair) { if (capacity <= 0) throw new IllegalArgumentException(); this.items = new Object[capacity]; lock = new ReentrantLock(fair); notEmpty = lock.newCondition(); notFull = lock.newCondition(); } public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); } } public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return dequeue(); } finally { lock.unlock(); } } private void enqueue(E x) { // assert lock.getHoldCount() == 1; // assert items[putIndex] == null; final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); } private E dequeue() { // assert lock.getHoldCount() == 1; // assert items[takeIndex] != null; final Object[] items = this.items; @SuppressWarnings("unchecked") E x = (E) items[takeIndex]; items[takeIndex] = null; if (++takeIndex == items.length) takeIndex = 0; count--; if (itrs != null) itrs.elementDequeued(); notFull.signal(); return x; }
生产者/消费者示例代码(基于阻塞队列实现)
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; public class BlockingQueueTest { public static void main(String[] args) { BlockingQueue<Integer> queue = new ArrayBlockingQueue<Integer>(10); new Thread(new Producer(queue)).start(); new Thread(new Consumer(queue)).start(); } } class Consumer implements Runnable{ BlockingQueue<Integer> queue; Consumer (BlockingQueue<Integer> queue){ this.queue = queue; } @Override public void run() { while (true) { try { queue.take(); System.out.println("从队列取走一个元素,队列剩余" + queue.size() + "个元素"); } catch (Exception e) { e.printStackTrace(); } } } } class Producer implements Runnable { BlockingQueue<Integer> queue; Producer (BlockingQueue<Integer> queue){ this.queue = queue; } @Override public void run() { while (true) { try { queue.put(1); System.out.println("向队列插入一个元素,队列剩余空间:" + (10 - queue.size())); } catch (Exception e) { e.printStackTrace(); } } } }
2 ConcurrentHashMap
这部分内容之前在我的一篇博客中详细分析过: https://www.cnblogs.com/kukri/p/9392906.html
3 线程池
基本思想还是一种对象池的思想,开辟一块内存空间,里面存放了众多(未死亡)的线程,池中线程执行调度由池管理器来处理。当有线程任务时,从池中取一个,执行完成后线程对象归池,这样可以避免反复创建线程对象所带来的性能开销,节省了系统的资源。合理地使用线程池能够带来3个好处。
第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
第二:提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
第三:提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
为什么要使用线程池:
多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。
假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。
如果:T1 + T3 远大于 T2,则可以采用线程池,以提高服务器性能。
一个线程池包括以下四个基本组成部分:
1、线程池管理器(ThreadPool):用于创建并管理线程池,包括 创建线程池,销毁线程池,添加新任务;
2、工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
3、任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后的收尾工作,任务的执行状态等;
4、任务队列(taskQueue):用于存放没有处理的任务。提供一种缓冲机制。
线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了。
线程池不仅调整T1,T3产生的时间段,而且它还显著减少了创建线程的数目,看一个例子:
假设一个服务器一天要处理50000个请求,并且每个请求需要一个单独的线程完成。在线程池中,线程数一般是固定的,所以产生线程总数不会超过线程池中线程的数目,而如果服务器不利用线程池来处理这些请求则线程总数为50000。一般线程池大小是远小于50000。所以利用线程池的服务器程序不会为了创建50000而在处理请求时浪费时间,从而提高效率。
诸如 Web 服务器、数据库服务器、文件服务器或邮件服务器之类的许多服务器应用程序都面向处理来自某些远程来源的大量短小的任务。请求以某种方式到达服务器,这种方式可能是通过网络协议(例如 HTTP、FTP 或 POP)、通过 JMS 队列或者可能通过轮询数据库。不管请求如何到达,服务器应用程序中经常出现的情况是:单个任务处理的时间很短而请求的数目却是巨大的。
构建服务器应用程序的一个简单模型是:每当一个请求到达就创建一个新线程,然后在新线程中为请求服务。实际上对于原型开发这种方法工作得很好,但如果试图部署以这种方式运行的服务器应用程序,那么这种方法的严重不足就很明显。每个请求对应一个线程(thread-per-request)方法的不足之一是:为每个请求创建一个新线程的开销很大;为每个请求创建新线程的服务器在创建和销毁线程上花费的时间和消耗的系统资源要比花在处理实际的用户请求的时间和资源更多。
除了创建和销毁线程的开销之外,活动的线程也消耗系统资源。在一个 JVM 里创建太多的线程可能会导致系统由于过度消耗内存而用完内存或“切换过度”。为了防止资源不足,服务器应用程序需要一些办法来限制任何给定时刻处理的请求数目。
线程池为线程生命周期开销问题和资源不足问题提供了解决方案。通过对多个任务重用线程,线程创建的开销被分摊到了多个任务上。其好处是,因为在请求到达时线程已经存在,所以无意中也消除了线程创建所带来的延迟。这样,就可以立即为请求服务,使应用程序响应更快。而且,通过适当地调整线程池中的线程数目,也就是当请求的数目超过某个阈值时,就强制其它任何新到的请求一直等待,直到获得一个线程来处理为止,从而可以防止资源不足。