Java并发编程
什么是线程和进程
进程
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是动态的。系统运行一个进程就是一个进程从创建、运行到消亡的过程。
在Java中,当我们启动main函数时时就是启动了一个JVM的进程,而main函数所在的线程就是这个进程中的一个线程,也称著线程。
线程
线程是一个比进程更小的执行单位。
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
关系
线程是进程划分成的更小的运行单位。线程和进程最大的不同在于各进程基本上是独立的;而各线程则不一定,同一进程中的线程很有可能会相互影响。
线程执行开销小,但不利于资源的管理和保护;而进程则相反。
程序计数器为什么是私有的
程序计数器用于记录当前线程执行的位置。所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置
虚拟机栈和本地方法栈为什么是私有的
每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象。方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据「据说方法区的目的是与Java堆区分开」。
以变量为例子:
- 局部变量存在栈内存中。当方法结束系统会释放方法栈,其对应在方法中声明的变量随着栈的销毁而结束,这就是局部变量只能在方法中有效的原因。
- 成员变量存储在堆内存中。因为对象实例存储在堆内存中,所以成员变量不会随着某个方法执行结束而销毁
- 类中的静态变量「被static关键字修饰」存放在Java内存区域的方法区
并发与并行
- 并发:两个及以上的作业在同一时间段执行
- 并行:两个及以上的作业在同一时刻执行
最关键的点是:是否是同时执行
同步和异步
- 同步:发出一个调用后,在没有得到结果后,该调用就不可以反悔,一直等待
- 异步:调用在发出之后,不用等待返回结果,该调用直接返回
Java线程的生命周期和状态
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
- NEW: 初始状态,线程被创建出来但没有被调用
start()
。 - RUNNABLE: 运行状态,线程被调用了
start()
等待运行的状态。 - BLOCKED :阻塞状态,需要等待锁释放。
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
- TERMINATED:终止状态,表示该线程已经运行完毕。
什么是上下文切换
当出现如下情况的时候,线程会从占用 CPU 状态中退出并发生线程切换。
- 主动让出CPU,比如调用了
sleep()
、wait()
等 - 时间片用完
- 调用了阻塞类型的系统中断,比如请求IO,线程被阻塞
线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换。
什么是死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
产生死锁有四个必要条件:
- 互斥:该资源任意一个时刻只由一个线程占用
- 请求与保持条件:一个线程因请求资源而堵塞时,对已获得的资源保持不放
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源
- 循环等待:若干线程之间形成一种头尾相接的循环等待资源关系
预防、避免死锁
预防死锁即破坏死锁产生的必要条件。
- 破坏请求与保持条件:一次性申请所有的资源
- 破坏不剥夺条件:若申请其他资源申请不到,主动释放它占有的资源
- 破坏循环等待条件:按某一顺序申请资源时,按照反序来释放资源
避免死锁则是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
sleep()方法和wait()方法
共同点:两者都可以暂停线程的执行。
-
sleep()方法没有释放锁,而wait()方法释放了锁
-
wait()
通常被用于线程间交互 / 通信,sleep()
通常被用于暂停执行。 -
wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。 -
sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法。
Thread类的run方法
若直接执行run()
方法,会把run()
方法当成一个main线程下的普通方法去执行,并不会在某个线程中执行它,这并不是多线程工作。
start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。
总结: 调用 start()
方法方可启动线程并使线程进入就绪状态,直接执行 run()
方法的话不会以多线程的方式执行。
volatile关键字
被volatile修饰的共享变量,就具有了以下两点特性:
- 保证了不同线程对该变量操作的内存可见性
- 禁止指令重排序
关于内存可见性
Java 内存模型是通过变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,将主内存作为传递媒介。可举例说明内存可见性的过程。
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
无论普通变量还是 volatile 变量都是如此,只不过 volatile 变量保证新值能够立马同步到主内存,使用时也立即从主内存刷新,保证了多线程操作时变量的可见性。而普通变量不能够保证。
指令重排
CPU 和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。但代码逻辑之间是存在一定的先后顺序,并发执行时按照不同的执行逻辑会得到不同的结果。
而 volatile 关键词修饰的变量,会禁止指令重排的操作,从而在一定程度上避免了多线程中的问题。
原子性
volatile
关键字能够从保证变量的可见性,但不能保证对变量的操作是原子性的。
原因是它只是对单个volatile变量的读/写具有原子性,但是对于inc++这样的复合操作是无法保证的。
实际上,inc++
包括三步:
- 读取inc的值
- 对inc加1
- 将inc的值写回内存
悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题 (比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。
也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
像 Java 中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现。
悲观锁通常多用于写多比较多的情况下(多写场景),避免频繁失败和重试影响性能。
乐观锁
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了。
版本号机制
加上一个version
字段表示数据被修改的次数。当数据被修改时,version
值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version
值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version
值相等时才更新,否则重试更新操作,直到更新成功。
CAS算法「常见」
CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS涉及到三个操作数:
- V:要更新的变量值
- E:预期值
- N:拟写入的新值
举个例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)
- i与1比较,若相等,则说明没被其他线程修改,可以被设置为6
- 若不相等,说明被其他线程修改,操作失败
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
乐观锁的问题
ABA问题是乐观锁最常见的问题。
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候仍然说A值,我们显然不能说它的值不能被其他线程修改过。因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。
ABA问题的解决思路是在变量前面加上版本号或者时间戳。判断的时候还要判断时间戳或者版本号是否相等。
synchronized关键字
synchronized
是Java中的一个关键字,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。
以上文volatile的例子,可以使用synchronized
改进
1 | public synchronized void increase() { |
synchronized
关键字的使用方式主要有下面 3 种:
- 修饰实例方法「锁当前对象实例」
给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。
1 | synchronized void method() { |
- 修饰静态方法「锁当前类」
给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前class的锁
1 | synchronized static void method() { |
- 修饰代码块「锁指定对象/类」
synchronized(object)
表示进入同步代码库前要获得 给定对象的锁。synchronized(类.class)
表示进入同步代码前要获得 给定 Class 的锁
1 | synchronized(this) { |
和volatile的区别
synchronized
关键字和 volatile
关键字是两个互补的存在,而不是对立的存在!
volatile
关键字是线程同步的轻量级实现,所以volatile
性能肯定比synchronized
关键字要好 。但是volatile
关键字只能用于变量而synchronized
关键字可以修饰方法以及代码块 。volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证。volatile
关键字主要用于解决变量在多个线程之间的可见性,而synchronized
关键字解决的是多个线程之间访问资源的同步性
ReetrantLock
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
ReentrantLock底层由AQS实现,默认使用非公平锁,也可以通过构造器来显式地指定使用公平锁。
以上文的volatile的例子,可以使用ReentrantLock
改进。
1 | Lock lock = new Reentrnatlock(); |
和synchronized的区别
- 两者都是可重入锁
这指的是线程可以再次获取自己的内部锁。比如一个线程获取了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的。
- 依赖方式不同
synchronized
是依赖于JVM实现的,JDK1.6对该关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。
ReentrantLock
是JDK层面实现的(也就是 API 层面,需要 lock () 和 unlock () 方法配合 try/finally 语句块来完成)。
- ReentrantLock增加了一些功能
- 等待可中断,正在等待的线程可以选择放弃等待,改为处理其他事情。而
synchronized
只能等到拿到锁以后才能进行其他的逻辑处理 - 可实现公平锁。
ReentrantLock
可以指定是公平锁还是非公平锁,而synchronized
只能是非公平锁。
AQS
AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制。
用int变量state
表示同步状态,由volatile修饰,用于展示当前临界资源的情况。并且,用final
修饰,子类无法重写方法。
以 ReentrantLock
为例,state
初始值为 0,表示未锁定状态。A 线程 lock()
时,会调用 tryAcquire()
独占该锁并将 state+1
。此后,其他线程再 tryAcquire()
时就会失败,直到 A 线程 unlock()
到 state=
0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state
会累加),这就是可重入的概念。但要注意,获取多少次就要释放多少次,这样才能保证 state 是能回到零态的。
再以 CountDownLatch
以例,任务分为 N 个子线程去执行,state
也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown()
一次,state 会 CAS (Compare and Swap) 减 1。等到所有子线程都执行完后 (即 state=0
),会 unpark()
主调用线程,然后主调用线程就会从 await()
函数返回,继续后余动作。
Semaphore信号量
synchronized和ReentrantLock都是一次只允许一个线程访问某个资源。而Semaphore可以多个。
Semaphore 的使用简单,我们这里假设有 N (N>5) 个线程来获取 Semaphore
中的共享资源,下面的代码表示同一时刻 N 个线程中只有 5 个线程能获取到共享资源,其他线程都会阻塞。
1 | // 初始共享资源数量 |
CountDownLatch
CountDownLatch允许count个线程阻塞在一个地方,直至所有线程的任务都执行完毕。
默认构造AQS的state
值为count
。当调用await()
方法时,如果state
不为0,那就证明任务还没有执行完毕,await()
方法就会一直阻塞。
一种典型用法是某一线程在开始运行前等待 n 个线程执行完毕,具体应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
ThreadLocal
通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?
ThreadLocal类正是为了解决这样的问题,**它可以让每个线程绑定自己的值。**如果创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的本地副本。它们可以使用get()
和set()
方法来获取默认值或将其值更改为当前线程所存的副本的值。
ThreadLocal有3个常用方法:
1 | void set(Object value) //设置当前线程的线程局部变量的值。 |
实际上,get()
和set()
方法调用的是ThreadLocalMap
类对应的get()
和set()
方法。可以把ThreadLocalMap
理解为ThreadLocal
类实现的定制化的HashMap
,最终的变量是放在了当前线程的ThreadLocalMap
中,并不是存在ThreadLocal
上。ThreadLocal
可以理解为只是 ThreadLocalMap
的封装,传递了变量值。
内存泄漏
使用完ThreadLocal方法后,最好手动调用remove()方法,否则可能导致内存泄漏。因为key为ThreadLocal的弱引用,如果value是强引用,这样一来就会出现key为null的value,垃圾回收时将无法清理掉。
场景:全局存储用户信息
当用户登录后,会将用户信息存入 Token 中返回前端,当用户调用需要授权的接口时,需要在 header 中携带 Token,然后拦截器中解析 Token,获取用户信息,调用自定义的类 (AuthNHolder) 存入 ThreadLocal 中,当请求结束的时候,将 ThreadLocal 存储数据清空。
中间的过程无需在关注如何获取用户信息,只需要使用工具类的 get 方法即可。
1 | public class AuthNHolder { |
场景:解决线程安全问题
在 Spring 的 Web 项目中,我们通常会将业务分为 Controller 层,Service 层,Dao 层, 我们都知道 @Autowired 注解默认使用单例模式,那么不同请求线程进来之后,由于 Dao 层使用单例,那么负责数据库连接的 Connection 也只有一个, 如果每个请求线程都去连接数据库,那么就会造成线程不安全的问题,Spring 是如何解决这个问题的呢?
在 Spring 项目中 Dao 层中装配的 Connection 肯定是线程安全的,其解决方案就是采用 ThreadLocal 方法,当每个请求线程使用 Connection 的时候, 都会从 ThreadLocal 获取一次,如果为 null,说明没有进行过数据库连接,连接后存入 ThreadLocal 中,如此一来,每一个请求线程都保存有一份 自己的 Connection。于是便解决了线程安全问题
ThreadLocal 在设计之初就是为解决并发问题而提供一种方案,每个线程维护一份自己的数据,达到线程隔离的效果。
什么是线程池
线程池是管理一系列线程的资源池。当有任务要处理时,直接从线程池中获取线程来处理,处理完之后线程并不会立即被销毁,而是等待下一个任务。
《Java并发编程的艺术》中提到线程池的好处:
- 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
- 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
- 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
ThreadPoolExecutor创建线程池「推荐」
1 | public ThreadPoolExecutor( |
一共有7个参数:
- corePoolSize:核心线程数,任务队列未达到队列容量时,线程池中始终存活的线程数
- maximumPoolSize:任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数
- workQueue:新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在阻塞队列中。
- LinkedBlockingQueue:一个由链表结构组成的无界阻塞队列
- SynchronousQueue:不存储元素,直接提交给线程不处理它们
- DelayedWorkQueue:内部采用堆,按照延迟的时间长短堆任务进行排序(小根堆)。添加元素满了之后会自动扩容原来容量的一半。
- keepAliveTime:线程池中的线程数量大于
corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁 - unit:
keepAliveTime
参数的时间单位 - threadFactory:线程工厂,主要用来创建线程,默认为正常优先级、非守护线程
- handler:饱和策略,拒绝处理任务时的策略
- AbortPolicy「默认」:拒绝并抛出异常
- CallerRunsPolicy:使用当前调用的线程来执行此任务
- DiscardOldestPolicy:抛弃队列头部的一个任务,并执行当前任务
- DiscardPolicy:忽略并抛弃当前任务
Executor框架创建线程池
1 | ExecutorService threadPool = Executors.newFixedThreadPool(5); |
有多种类型的线程池:
FixedThreadPool
:返回一个固定线程数量的线程池。有新任务时,若有空闲线程则立即执行;否则暂存在一个任务队列中。SingleThreadExecutor
:返回只有一个线程的线程池CachedThreadPool
:返回一个可根据实际情况调整线程数量的线程池。
线程池处理任务的流程
- 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
- 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程会调用饱和策略「默认是拒绝并抛出异常」
如何设定线程池的大小
有一个简单并且适用面比较广的公式:
- CPU密集型任务「N+1」:这种任务消耗的主要是CPU资源,比CPU核心数多出来的一个线程是为了防止线程偶发的问题导致任务暂停所带来的影响。
- IO密集型任务「2N」: 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。
什么是Future类
这是异步思想的典型应用。 当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future
类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
这其实就是多线程中经典的Future模式,可以看作是一种设计模式,核心思想是异步调用。
在Java中,Future只是一个泛型接口,定义了5个方法,主要包括4个功能:
- 取消任务
- 判断任务是否被取消
- 判断任务是否已经执行完成
- 判断任务执行结果
1 | // V 代表了Future执行的任务返回值的类型 |