Java 并发编程(多线程)

  • 线程和进程相关概念
  • 创建线程的方式
  • 线程的生命周期
  • 线程之间如何通讯
  • 线程调度策略
  • 线程安全解决方案
    • synchronized和Lock的区别
  • 死锁和解决方案
  • 线程常用的方法
    • wait()和 sleep()方法有什么区别
  • 线程池
    • 创建方式
    • 工作流程
    • 状态流转
    • 线程池优点:
    • 线程池核心参数
    • 线程池大小设置
    • submit和execute的区别
    • 关闭线程池
  • 常用的并发工具类
  • ThreadLocal
    • ThreadLocal原理
    • InheritableThreadLocal
  • Volatile
  • Synchonized
    • Synchonized 实现原理
    • Synchonized锁信息在对象中的存储位置
    • 锁的分类与锁升级
    • 偏向锁的原理
    • 偏向锁撤销的原理
    • 轻量级锁的加锁和解锁过程
    • 锁的比较
    • 自旋锁
    • 对于同步方法,处理器如何实现原子操作

线程和进程相关概念

进程:代码在数据集合上的一次运行活动, 是系统进行资源分配和调度的基本单位。
线程:线程是比进程更小的执行单位。一个进程执行过程可以产生多个线程。

创建线程的方式

1. 继承Thread类。
2. 实现Runnable接口。
3. 使用CallableFuture 实现有返回结果的多线程。
4. 通过线程池创建。

线程的生命周期

Java 并发编程(多线程)_第1张图片

(1)新建状态(New):当线程对象创建后,即进入了新建状态。
(2)就绪状态(Runnable:当调用线程对象的start()方法(t.start();,线程即进入就绪状态。
(3)运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正的执行,即进入到运行状态。
(4)阻塞状态(Blocked:处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:
	a.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态。
	b.同步阻塞:线程在获取synchronized同步锁失败(因为锁被其他线程所占用),它会进入同步阻塞状态
	c.其他阻塞:通过调用线程的sleep()或join()发出了I/O请求时,线程会进入到阻塞状态,当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入到就绪状态。
(5)死亡状态(Dead):线程执行完了或者因为异常退出了run(),该线程结束生命周期

线程之间如何通讯

1. 使用全局变量(共享变量)。
2. 使用事件对象。
3. 使用消息中间件。

线程调度策略

时间片:cpu正常情况下的调度策略。即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片,即该进程允许运行的时间,使各个程序从表面上看是同时进行的。如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。而不会造成CPU资源浪费。在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
抢占式:高优先级的线程抢占cpu。

线程安全解决方案

1. 同步代码块
2. 同步方法
3. 实现Lock4. 使用分布式锁

synchronized和Lock的区别

1. Lcok是显式锁(需要手动开启和关闭锁),synchronized是隐式锁,除了作用域自动释放。
2. Lock只有代码块锁,synchronized有代码块锁和方法锁。
3. 使用Lcok锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的拓展性(提供更多的子类)。

死锁和解决方案

不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了死锁。死锁的四个条件:

不可剥夺:资源被一个线程占用后,不能被另外一个线程剥夺使用权。
资源互斥:资源在某一时刻只能被一个线程使用。
请求保持:线程持有资源锁的时候,没处理完任务就不会释放锁。
循环等待:多个线程互相循环等待。

解决方案:破坏四个条件中的一个或多个

1. 超时机制。
2. 尽量避免嵌套同步。

线程常用的方法

start() : 启动当前线程, 调用当前线程的run()方法。
run() : 通常需要重写Thread类中的此方法, 将创建的线程要执行的操作声明在此方法中。
currentThread() : 静态方法, 返回当前代码执行的线程。
getName() : 获取当前线程的名字。
setName() : 设置当前线程的名字。
yield() : 释放当前CPU的执行权。
join() : 在线程a中调用线程b的join(), 此时线程a进入阻塞状态, 知道线程b完全执行完以后, 线程a才结束阻塞状态。
stop() : 已过时. 当执行此方法时,强制结束当前线程.
sleep(long militime) : 让线程睡眠指定的毫秒数,在指定时间内,线程是阻塞状态。
isAlive() :判断当前线程是否存活。

wait()和 sleep()方法有什么区别

sleep 方法和 wait 方法都可以用来放弃 CPU 一定的时间,不同点在于如果线程持有某个对象的监视器,sleep 方法不会放弃这个对象的监视器,wait 方法会放弃这个对象的监视器。
wait需要等待唤醒,而sleep睡眠一定时间之后自动苏醒。

线程池

创建方式

使用Java并发包中的Executors创建

Executors.newCachedThreadPool() 创建一个可缓存线程池。可以无限扩展线程数量,适用于短期异步任务。
Executors.newFixedThreadPool(int len) 创建一个定长线程池,可控制线程最大并发数。适用于负载比较重的服务器场景。
Executors.newScheduledThreadPool() 创建一个定长线程池,支持定时及周期性任务执行。
Executors.newSingleThreadExecutor() 创建一个单线程化的线程池,它只会用唯一的工作线程来执行。适用于保证执行顺序的场景。

工作流程

Java 并发编程(多线程)_第2张图片

状态流转

Java 并发编程(多线程)_第3张图片

  • RUNNING:接受新任务并处理排队的任务。
  • SHUTDOWN:不接受新任务,但处理队列中的任务。
  • STOP:不接受新任务,步出力队列中的任务,并中断正在执行中的任务。
  • TIDYING:所有任务已终止,wokercount 为 0 ,线程转换到TIDYING 状态将运行 terminater()钩子方法。
  • TERMINATED:terminated() 已完成。

线程池优点:

1. 重用存在的线程,减少对象创建销毁的开销。
2. 可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。
3. 提供定时执行、定期执行、单线程、并发数控制等功能。

线程池核心参数

corePoolSize:线程池中常驻核心线程数。
maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于1。
keepAliveTime:多余空闲线程的存活时间。当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime时,多余空闲线程会被销毁直到剩下corePoolSize为止。
unit:keepAliveTime的单位。
workQueue:里面放了被提交但是尚未执行的任务。
threadFactory:表示线程池中工作线程的线程工厂,用于创建线程。
handler:拒绝策略,当队列满了并且工作线程大于等于线程池的最大线程数(maximumPoolSize)时,对任务的拒绝方式。
拒绝策略类型:
	AbortPolicy(默认):直接抛出RejectedExecutionException异常阻止系统正常进行。
	CallerRunsPolicy:“调用者运行”一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低新任务的流量。
	DiscardPolicy:抛弃队列中等待最久的任务,然后将当前任务加入队列,然后再次提交任务。
	DiscardOldestPolicy:改策略默默丢弃无法处理的任务,不予任何受理也不抛出异常。如果允许任务丢弃,这是最好的一种策略。
	
通常而言,这四种拒绝策略我们一般都不太适用我们的业务场景,我们一般都会自定义自己的拒绝策略,将线程任务放进kafaka或者mq消息队列中。

线程池大小设置

合理区分任务是CPU计算密集型的还是I/O密集型的。

如果是计算密集型的,那么设置 线程数 = CPU数 + 1.

如果是I/O密集型的,那么设置 线程数 = CPU数 * 2.

在我们日常开发中,我们的任务几乎离不开I/O的,常见的网络I/O(RPC调用),磁盘I/O(数据库操作),并且I/O的等待时长通常会占整个任务处理时长的很大一部分,在这种情况下,开启更多的线程可以让CPU得到更充分的使用。一个较为合理的计算公式如下:
线程数 = CPU数 * CPU利用率 * (任务等待时间/任务计算时间 + 1)

当然,具体我们要结合实际的使用场景来考虑。如果要求比较精确可以使用压测来获取一个合理的值。

submit和execute的区别

submit提交任务,且有返回值Future对象。
execute没有返回值。

关闭线程池

线程池可以通过调用线程池的shutdown或shutdownNow方法来关闭线程池。

它们的原理是遍历线 程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务 可能永远无法终止。

但是它们存在一定的区别,shutdownNow首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线 程。

只要调用了这两个关闭方法中的任意一个,isShutdown方法就会返回true。当所有的任务 都已关闭后,才表示线程池关闭成功,这时调用isTerminaed方法会返回true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用shutdown方法来关闭线程池,如果任务不一定要执行完,则可以调用shutdownNow方法。

常用的并发工具类

CountDownLatchCountDownLatch简单的说就是一个线程等待,直到他所等待的其它线程都执行完成并且调用countDown()方法发出通知后,当前线程才可以继续执行。只能用一次。
CyclicBarrier:是所有线程都进行等待,直到所有线程都准备好进入await()方法之后,所有线程同时开始执行。可以重复使用。
SemaphoreSemaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。
ExchangerExchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。

ThreadLocal

ThreadLocal 是JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

ThreadLocal原理

Thread 类中有一个threadLocals 和一个inheritableThreadLocals , 它们都是ThreadLocalMap 类型的变量, 而ThreadLocalMap 是一个定制化的Hashmap 。

在默认情况下, 每个线程中的这两个变量都为null ,只有当前线程第一次调用ThreadLocal 的set 或者get 方法时才会创建它们。其实每个线程的本地变量不是存放在ThreadLocal 实例里面,而是存放在调用线程的threadLocals 变量里面。也就是说, ThreadLocal 类型的本地变量存放在具体的线程内存空间中。

ThreadLocal 就是一个工具壳,它通过set 方法把value 值放入调用线程的threadLocals 里面并存放起来, 当调用线程调用它的get方法时,再从当前线程的threadLocals 变量里面将其拿出来使用。如果
调用线程一直不终止, 那么这个本地变量会一直存放在调用线程的threadLocals 变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal 变量的remove 方法,从当前线程的threadLocals 里面删除
该本地变量。

ThreadLocal 的key是当前线程对象Java 并发编程(多线程)_第4张图片
同一个ThreadLocal 变量在父线程中被设置值后, 在子线程中是获取不到的。
可以用InheritableThreadLocal 解决此问题。

InheritableThreadLocal

子线程可以访问在父线程中设置的本地变量。

InheritableThreadLocal 类通过重写代码。getMap和 createMap 让本地变量保存到了具体线程的inheritableThreadLocals 变量里面,那么线程在通过InheritableThreadLocal 类实例的set 或者get 方法设置变量时,就会创建当前线程的inheritableThreadLocals 变量。当父线程创建子线程时,构造函数会把父线程中inheritableThreadLocals 变量里面的本地变量复制一份保存到子线程的inheritableThreadLocals 变量里面。

Volatile

volatile 修饰的对象,可以保证修改可见性

有 volatile 修饰的共享变量进行写操作的时候会多出第二行Lock汇编代码,Lock前缀的指令在多核
处理器下会引发了两件事情:

	1)将当前处理器缓存行的数据写回到系统内存。(volatile写的内存语义)
	2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。(volatile写的内存语义)

Synchonized

Synchonized 实现原理

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
代码块同步是使用monitor enter 和monitor exit 指令实现的,而方法同步是使用另外一种方式实现的。

monitor enter指令是在编译后插入到同步代码块的开始位置,而monitor exit是插入到方法结束处和异常处,
JVM要保证每个monitor enter必须有对应的monitor exit与之配对。

任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到
monitor enter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

Synchonized锁信息在对象中的存储位置

存储在对象的对象头(Mark Word)中。

无锁状态下32位JVM 的 Mark Word 的默认存储结构如下:
在这里插入图片描述

有锁状态的 Mark Word 的信息变化如下,并从下图中能够看到锁的信息的确是放到Mark Word中的,并且不同的锁类型,Mark Word中的信息会有变化。
Java 并发编程(多线程)_第5张图片

锁的分类与锁升级

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

偏向锁的原理

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出 同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):

  • 如果没有设置,则 使用CAS竞争锁;
  • 如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁撤销的原理

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。

它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;

如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
Java 并发编程(多线程)_第6张图片
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如 有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。

如果你确定应用程 序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:- UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁的加锁和解锁过程

轻量级锁加锁

  • 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录空间中,官方称为Displaced MarkWord;
  • 然后线程尝试使用 CAS将对象头中的Mark Word替换为指向锁记录的指针。
  • 如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级解锁
会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

Java 并发编程(多线程)_第7张图片

  1. 线程1为锁的持有者,线程2为竞争者。线程2尝试CAS操作将轻量级锁的指针指向自己栈中的锁记录失败后。发起了升级锁的动作。

  2. 线程2会将Mark Word中的锁指针升级为重量级锁指针。自己处于阻塞状态,因为此时
    线程1还没释放锁。

  3. 当线程1执行完同步体后,尝试CAS操作将Displaced Mark Word替换回到对象头时,此时肯定会失败,因为mark word中已经不是原来的轻量级指针了,而是线程2的重量级指针。那么此时线程1很无奈,只能释放锁,并唤醒其他线程进行锁竞争。此时线程2被唤醒了,获取了重量级锁。

锁的比较

Java 并发编程(多线程)_第8张图片
其实偏向锁,本就为一个线程的同步访问的场景。在出现线程竞争非常小的环境下,适合偏向锁。

轻量级锁自旋获取线程,如果同步块执行很快,能减少线程自旋时间,采用轻量级锁很适合。

重量级锁就不用多说了,synchronized就是经典的重量级锁。

自旋锁

JDK 1.5的自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。

自旋次数可以设定,通过-XX:PreBlockSpin=10 自行设置自旋次数,此处举例说明设置为10次。

在JDK 1.6 引入了适应性自旋锁,XX:PreBlockSpin参数也就没有用了。适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时 JVM 还针对当前 CPU 的负荷情况做了较多的优化,如下三点优化非常突出。

  1. 如果平均负载小于 CPUs 则一直自旋
  2. 如果有超过 (CPUs/2) 个线程正在自旋,则后来线程直接阻塞(升级为重量级锁)
  3. 如果正在自旋的线程发现 Owner 发生了变化则延迟自旋时间(自旋计数)或进入阻塞(升级为重量级锁)

对于同步方法,处理器如何实现原子操作

处理器提供总线锁定和缓存锁定两个机制来保证复杂 内存操作的原子性。

总线锁定
如果多个处理器同时对非同步共享变量进行读改写操作 (i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。
对于同步方法操作i++时,部分处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个 LOCK#信号(参见93题的Lock汇编指令),当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该 处理器可以独占共享内存,只不过总线锁定开销很大。

缓存锁定
所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声 言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效(参见93题的主线嗅探技术,即缓存一直性协议。)

你可能感兴趣的:(JAVA,基础,java,jvm,开发语言)