多线程和并发 GuideLine
作者:互联网
多线程和并发 GuideLine
基本概念
串行和并行
-
串行:指程序按顺序运行,写在前面的代码运行完毕后才会轮到后面的代码,就算现在CPU空着不运行,保证程序的运行和书写的一致。
事实上这只是表面上,写好的程序会经过编译器的优化和处理器的优化,很多值令经过重排序,但是编译器和处理器优化都会考虑结果一致性,所以程序并不会有问题
-
**并行:**并行指多个程序(线程)同时运行。对于单核单线程CPU来说,因为CPU一个,所以并行是通过CPU不断分配时间片给不同线程而造成的假象,虽然同样会提高程序运行效率,例如有些程序进行IO阻塞的时候,另一个线程就可以充分利用CPU而不用等待程序运行完成。多核多线程CPU实现线程就很直接,可以充分利用多核的性能。但是由于同时进行,所以程序的运行顺序是随机的(指两个线程之间),和程序中书写顺序无关,可能会导致数据上的一些问题和资源抢占的问题。
并行的优点
- 充分利用CPU
并行存在的风险
- 死锁
- 数据脏读,不可重复读,幻读等等问题
- 线程饥饿
多线程
JAVA实现多线程的两种方式
- 继承Thread并重写run()方法
- 实现Runnable()接口并传入Thread
new Thread(new Runnable(){...})
Thread常用的几种方法
普通方法
- start()
- setDaemon()
- currentThread()
- getId()
- getName()
- isAlive()
- isDaemon()
- isInterrupted()
- join()
- setPriority
静态方法
- sleep()
- yield()
- interrupted()
线程生命周期图
实现多线程的原则
原子性
- 解释:
- 实现方法:加锁
可见性
- 解释:当某个线程对某个共享变量更新后,要对之后的其他线程可见。不然其他线程可能会使用旧数据(脏数据)造成安全问题。
有序性
-
有序性(Ordering)是指一个处理器上运行的线程执行的内存访问操作在其他处理器运行的其他线程看来是乱序的。
乱序是指内存访问操作的顺序看起来发生了变化。
因为编译器和处理器都会对程序执行进行重排序,但是仅仅针对一个线程内,所以如果两个看似无关的线程在两个处理器中分别进行处理,从一个程序的角度来看,重排序没有问题,但是可能一个程序的重排序会对另一个程序执行的时机造成影响,导致线程安全问题。
Java虚拟机的内存模型
线程同步
线程同步机制简介
线程同步机制是一套用于协调线程之间的数据访问的机制。该机制可以保障线程安全。
Java平台提供的线程同步机制包括:锁,volatile关键字,final关键字,static关键字,以及相关的API例如Object.wait()/Object.notify()方法等
锁概述
简介
锁相关的概念
- 可重入性
- 如果一个线程持有一个锁的时候,该线程还能够继续成功申请该锁,称该锁是可重入的,否则就是不可重入的
- 锁的争用和调度
- Java平台中内部锁属于非公平锁,显示Lock锁既支持公平锁又支持非公平锁
- 锁的粒度
内部锁:synchronized关键字
Java中的每个对象都有一个与之相关的内部锁(Intrinsic lock)。这种锁也称为监视器(Monitor),这种内部锁是一种排他锁,可以保障原子性,可见性和有序性
内部锁是通过synchronized关键字实现的
synchronized可以锁一个对象,也可以修饰一个普通方法。修饰方法时,调用这个方法时,锁住的是这整个方法。修饰静态方法时,锁住的是这个类.class文件(运行时对象)。上述分别称为同步实例方法和同步静态方法
轻量级锁:volatile关键字
volatile简介
synchronized与volatile的比较
- volatile只能修饰变量而synchronized可以修饰变量和方法
- volatile是轻量级锁,效率较高。不过JDK6之后synchronized做了优化,效率也比较高。
- 多线程访问volatile变量不会发生阻塞,而synchronized可能会发生阻塞
- volatile能保证数据的可见性,看十不能保证原子性;而synchronized可以保证原子性,也可以保证可见性
- volatile解决的是变量再多个线程之间的可见性;synchronized解决多个线程之间访问公关资源的同步性
volatile不具备原子性
也就是volatile可以保证写这个对象会被刷新到主存,也就是这个对象的变化对所有线程可见。但是不代表这个对象的加减操作等具有原子性
CAS
- Compare and swap。对于一个volatile变量的修改, 会在赋值前判断当前变量和期望值(我们是根据期望值计算得到新的值的)是否相同,相同的话就更新为新的值,不相同就重复进行操作直到更新完成。其中Compare and swap这个过程使用硬件的方式(多CPU使用汇编添加LOCK前缀,单核没有关系,其实还是排他锁,但是比JAVA层面使用synchronized开销小的多)实现了原子性。我们实验的话就用synchronized(this)模仿。
- 但是CAS都是基于当前值和期望值相同,就认为值没有变化这个假设。但是这个假设不适用于ABA问题,也就是当前值变了,又变回来了,所以认为没有变。但是实际上是变了
- 有些CAS的实现会对volatile修饰的变量增加版本号(时间戳、修订号),因为版本号只能递增,所以不会又ABA问题。
线程间的通信
等待/通知机制
什么是等待通知机制
等待/通知机制的实现
Object类中的wait()方法可以使执行当前代码的线程等待,暂停执行,直到街道通知或者被中断为止。
注意:
- wait()方法只能在同步代码块中由锁对象调用
- 调用wait()方法,当前线程会释放锁
Object类中的notify()可以唤醒线程,该方法也必须子啊同步代码块中由锁对象调用。
如果没有使用锁对象调用wait()/notify(),会抛出IllegalMonitorStateException异常。
notify()会唤醒一个线程,如果有多个线程等待唤醒,无法确定会唤醒哪个一个线程
interrupt()方法会中断wait()
当线程处于wait()状态是,调用该线程的interrupt()方法会中断wait()并曝出InterruptedException异常
notifyAll()方法
和notify()一样,但是会唤醒所有线程
wait(long)
wait()方法的重载,当设定时间还没有被唤醒,那么该线程会自动唤醒
join()
生产者消费者模式
使用管道进行线程间通信
ThreadLocal的使用
ThreadLocal可以把共享变量变为私有变量,也就是相当于一个容器(比如在main中定义),其他的线程调用这个容器,都是私有的,也就是拿到的都是干净的空的容器,仅属于这个线程的
- ThreadLocal设置初始值
- 重写initialValue()方法
ThreadLocal.withInitial(()->new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss"));
显式锁Lock
Lock是一个对象可以实现锁。其中ReentrantLock类是其实现,ReentrantLock中还实现了两个内部类分别是FairSync和NonfairSync
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dgS1vJMA-1607066289161)(C:\Users\J\AppData\Roaming\Typora\typora-user-images\image-20201125212849589.png)]
Lock具有可重入性:
可以在一段程序中重复对这个锁进行加锁操作,但是锁几次就得解锁几次,不然会出问题
ReentrantLock
基本使用:
static Lock lock = new ReentrantLock();
try{
lock.lock();
{...}
}finally{
lock.unlock();
}
关键方法
-
lock()
-
unlock()
-
lockInterruptibly()
可以用于解决死锁问题。
单纯的线程调用interrupt()其实并不会中断,但是如果使用lockInterruptibly()会让线程发现自己被中断后,中断自己。这个时候如果能在finally中保证将自己持有的锁都正常释放,那么就可以解决死锁问题(如果释放未持有的锁就会报错,导致可能带着锁死去,并不会解决中断)
-
isHeldByCurrentThread()判断当前线程是否持有该锁
-
tryLock()
在给定等待时长内锁没有被另外的线程持有,并且当前线程也没有被中断,则获得该锁。时长结束没有获得锁就退出。
如果没有时长参数,就试一次
tryLock()可以避免死锁
-
newCondition()方法
关键字synchronized与wait()/notify()这两个方法一起使用可以实现等待/通知模式.
Lock锁的newCondition()方法返回Condition对象,Condition类也可以实现等待/通知模式
使用notify()通知时,JVM会随机唤醒某个等待的线程.使用Condition类可以进行选择性通知.Condition比较常用的两个方法:
- await()会使当前线程等待,同时会释放锁。当其他线程调用signal()时,线程会重新获得锁并继续实醒
- signal()/signalAll()用于唤醒
- await()/signal()方法调用前,需要线程先持有对应锁
-
getHoldCount()获取当前线程调用lock()的次数
-
getQueneLength()返回估计的等待锁的线程数
-
getWaitQueneLength()返回等待与Condition相关的线程的数量
-
hasQueuedThread(Thread thread) 查询参数指定的线程是否在等地啊获得锁
-
hasQueuedThreads() 查询是否还有线程在等待获得该锁
-
hasWaiters() 查询是否还有线程在等待指定的Condition条件
-
isFair() 判断是否为公平锁
-
isLock() 查询当前锁是否被线程持有
公平锁与非公平锁
大多数情况下,锁的申请都是非公平的。非公平是指系统分配锁时,是随机从阻塞线程中选择一个,不能保证其公平性。
公平锁会按照时间顺序保证先到先得。
公平锁不会出现线程饥饿现象,而非公平锁可能会出现饥饿现象。
ReentrantLock提供构造方法,传递true可以设置该锁为公平锁。
ReentrantReadWriteLock读写锁
前面的synchronized锁或者ReentrantLock都是排他锁。允许一个线程持有
ReentrantReadWriteLock是改进的排他锁,允许多个线程持有进行读,但是只允许一个线程进行写
- 读写锁通过读锁和写锁来完成读写操作。线程在读取共享数据前必须先持有读锁,该读锁可以同时被多个线程持有,即它是共享的
- 线程在修改共享数据前必须先持有写锁,写锁是排他的,一个线程持有写锁时其他线程无法获得相应的锁。
- 读锁只是在读线程之间共享,任何一个线程持有读锁时,其他线程都无法获得写锁。保障读线程在进行读的时候,不会有写线程进行更新,保障了读到的数据是正确数据(不会被修改)
读写锁获得的是一个锁的两个不同角色,而不是两个不同的锁
线程管理
线程组
Thread有几个构造方法可以指定线程组
默认线程组为创建该线程的线程所属的线程组(属于父线程所在线程组)
JVM创建main()线程时会为它指定一个线程组
可以调用getThreadGroup()
方法返回线程组
类为 ThreadGroup()
线程组的基本操作
- activateCount() 返回当前线程组以及子线程组中活动线程的数量
- activateGroupCount()返回当前线程组以及子线程组中活动线程组的数量
- int enumerate(Thread[] list)将当前线程组中的活动线程复制到参数数组中
- getMaxPriority() 返回线程组的最大优先级,默认为10
- getName()返回线程组名称
- getParent()返回父线程组
- interrupt()中断线程组中所有线程
- isDaemon()判断当前线程组是否是守护线程组
- list()将当前线程组中的而活动线程打印出来
- parentOf(ThreadGroup g)判断当前线程组是否时g的父线程组
- setDaemon(boolean daemon)设置线程组为守护线程组
捕获线程的执行异常
在线程的run方法中,如果有受检异常,必须进行驳货处理。如果想要获得run()方法中出现的运行时异常信息,可以通过回调UncaughtExceptionHandler
接口获得哪个线程出现了运行时异常。
在Thread类中有关处理运行时异常的方法:
getDefaultUncaughtExceptionHandler()
静态方法,获得全局的默认的UncaughtExceptionHandler
getUncaughtExceptionHandler()
实例方法,获得当前线程的UncaughtExceptionHandler
setDefaultUncaughtExceptionHandler()
静态方法,设置全局的默认的UncaughtExceptionHandler
setUncaughtExceptionHandler()
实例方法,设置当前线程的UncaughtExceptionHandler
如果线程出现运行时异常,JVM会调用Thread类的dispatchUncaughtException(Throwable e)
方法,该方法会调用getUncaughtExceptionHandler().uncaughtException(this,e);
;因此如果想要获得线程中出现异常的信息,就需要设置线程的UncaughtExceptionHandler方法
注入Hook钩子线程
很多软件包括MySQL,Zookeeper,kafka等都存在Hook线程的校验机制,目的是校验进程是否已启动,防止重复启动程序。
Hoook线程也称为狗子线程,当JVA退出的时候会执行Hook线程。经常在程序启动时创建一个.lock文件,用.lock文件校验程序是否启动。在程序退出时删除该.lock文件。
在Hook线程中除了防止重新启动进程外,还科研做资源释放,尽量避免在Hook线程中进行复杂的操作。
线程池
线程池简介
预先创建一定数量的工作线程,客户端代码直接将任务作为对象提交给线程池,线程池将这些任务缓存在工作队列中,线程池中的工作线程不断被取出,执行。执行完毕后返回线程池。
节省创建和销毁线程的开销
JDK对线程池的支持
JDK提供了一套Executor框架,可以帮助开发人员有效的使用线程池
核心线程池的底层实现
- Executor工具类中返回线程池的方法底层都使用了ThreadPoolExecutor线程池的封装
- workQueue工作队列是指提交未执行的任务队列,它是BlockingQueue接口的对象,仅用于储存Runnable接口。根据队列功能分类,在ThreadPoolExecutor狗崽方法中可以使用以下几种阻塞队列:
- 1)直接提交队列,由SynchronousQueue对象提供,该队列没有容量。提高给线程池的任务不会被真实的保存,总是将新的任务提交给线程执行,如果没有空闲线程则尝试创建新的线程。如果线程数量已经达到maxnumPoolSize规定的最大值,则执行拒绝策略。适合用于执行大量耗时短且提交频繁的任务。
- 2)有界任务队列,由ArrayBlockingQueue实现。指定容量。当由任务需要执行,如果线程池中线程数小于corePoolSize核心线程数,则创建新的线程;如果大于corePoolSize核心线程数则加入等待队列。如果队列已满则无法加入。在线程数小于maxinumPoolSize指定的最大线程数前提下会创建新的线程来执行,如果线程数大于maxniumPoolSize最大线程数,则执行拒绝策略
- 3)无界任务队列,由LinkedBlockingQueue对象实现,与有界相比,除非系统资源耗尽,否则不存在任务入队失败的情况。当有新的任务是,当系统线程数小于核心线程数,则创建新的线程来执行任务。当线程池中线程数量大于核心数量,则加入阻塞队列
- 4)优先任务队列,通过PriorityBlockingQueue实现,带有任务优先级的特殊无界队列
线程池的拒绝策略
ThreadPoolExecutor构造方法的最后一个参数指定了拒绝策略。当提交给线程池的任务量超过设定时,会执行拒绝策略。
- AbortPolicy,抛出异常(默认策略)
- CallerRunsPolicy,只要线程池没有关闭,会在调用者线程中运行当前被丢弃的任务
- DiscardOldestPolicy, 将任务队列中最老的任务丢弃,尝试再次提交新任务
- DiscardPolicy,直接丢弃无法处理的任务
自定义异常处理策略:
new RejectedExecutionHandler(){
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor){
//...具体逻辑
}
}
线程工厂ThreadFacoty
监控线程池
ThreadPoolExecutor提供了一组方法用于监控线程池
int getActiveCount() 获得线程池中当前活动线程的数量
getCompletedTaskCount() 返回线程池完成任务的数量
getCorePoolSize() 线程池中核心线程的数量
getLargestPoolSize() 返回线程池曾经达到的线程的最大数
扩展线程池
ThreadPoolExecutor线程池提供了两个方法,用于对线程池进行扩展
- afterExecute(Runnable r, Throwable t)
- beforeExecute(Runnable r, Throwable t)
在线程池执行某个方法之前会调用before方法,结束(或任务异常退出)会执行after
【源码分析】ThreadPoolExecutor线程池的内部类worker就是线程实例,worker在执行前会执行before,结束会执行after
优化线程池大小
线程池大小对系统性能有一定影响,过大或者过小都会无法发挥最优的系统性能,线程池大小不需要非常精确,只要避免极大或者极小的情况即可。一般来说:
线程池大小 = CPU数量*目标CPU的使用率*(1+等地啊时间与计算时间比)
线程池死锁
任务A执行过程向线程池提交任务B执行,A的结束要等待B,但是B由于请求太多被放在了等待队列中,类似这种情况造成所有工作线程都处于等待任务处理结果,而这些任务都在阻塞队列中,就造成了死锁
适合给线程池提交相互独立的任务,而不是彼此依赖的任务。对于有依赖的任务最好可以提交给不同线程池。
线程池中的异常处理
线程池可能会吃掉程序中的异常
解决方法:
- 方法一:把submit()方法改为execute()。这样就会显示异常信息
- 方法二:自定义线程池类,对ThreadPoolExecutor进行扩展(支持更复杂的异常处理),
- 在自定义线程类中自定义方法对任务进行包装,第一个参数是任务Runnable r,另一个是Exception e。里面完成对异常的处理,返回一个任务。
- 重写submit方法(或者execute方法),调用父类方法的时候,把Runnable进行包装后再传入进去。
ForkJoinPool线程池(JDK7)
系统对ForkJoinPool线程池进行了优化,提交的任务与线程的数量不一定时一对一的关系。在多数情况下,一个物理线程实际上需要处理多个逻辑任务(每个线程有自己的任务队列?)
线程A把自己的任务执行完毕,线程B的任务队列中还有若干的任务等待执行,线程A会从线程B的等待队列中取任务帮助线程B完成。线程A在帮助线程B执行任务时,总是从线程B的等待队列底部开始取任务。
例如使用线程进行归并排序操作,就可能会叫其他线程帮忙一起排序。类似的还有大量数据累加,打印大量数据等待可以拆分且数量巨大的任务。由于任务的分割无法做到都一样,所以还是用了一种“工作窃取”的算法,让已经完成工作空闲的线程可以去帮忙完成其他还没完成的线程的工作。这样可以使用少量的线程完成大量的工作,充分利用了硬件
重要方法:
- submit()提交一个ForkJoinTask任务。ForkJoinTask任务支持fork()分解与join()等待的任务。
两个重要子类:
- RecursiveAction 没有返回值
- RecursiveTask 可以带返回值
保障线程安全的设计技术
从面向对象设计的角度除法介绍几种保障线程安全的设计技术,这些技术可以使得我们在不必借助锁的情况下保障线程安全,避免锁可能导致的问题及开销
Java运行时储存空间
栈空间:由于线程栈是相互独立的,一个线程不能访问另外一个线程的栈空间,因此线程对局部变量以及只能通过当前线程的局部变量才能访问的对象进行的操作具有固定的线程安全性。
堆空间:是在JVM启动时分配的一段可以动态扩容的内存空间。创建对象时,在堆空间中给对象分配储存空间,实例变量就是储存在堆空间中的,堆空间是多个线程之间可以共享的空间,因此实例变量可以被多个线程共享,多个线程同时操作实例变量可能存在线程安全问题。
非堆空间(Non-Heap Space):用于储存常量,类的元数据等,非堆空间也是在JVM启动时分配的一段可以动态扩容的储存空间。类的元数据包括静态变量,类的哪些方法及这些方法的元数据(方法名,参数,返回值等)。非堆空间也是多个线程可以共享的,因此访问非堆空间中的静态变量也可能存在线程安全问题。
无状态对象
对象就是数据及堆数据操作的封装,对象包含的数据称为对象的状态(State),实例变量与静态变量称为状态变量。
如果一个类的同一个实例被多个线程共享并不会使这些线程储存共享的状态,那么该类的实例就称为无状态对象(Stateless Object)。反之如果一个类的实例被多个线程共享会使这些线程存在共享状态,那么该类的实例称为有状态对象。实际上无状态对象就是不包含任何实例变量也不包含任何静态变量的对象。
线程安全问题的前提是多个线程存在共享的数据,实现线程安全的一种办法就是避免在多个线程之间共享数据,使用无状态对象就是这种方法。
不可变对象
不可变对象是指一经创建它的状态就保持不变的对象,不可变对象具有固有的线程安全性。当不可变对象现实实体的状态发生变化时,系统会创建一个新的不可变对象,就如String字符串对象。Integer等包装类。
String,Integer等包装类最好不要用作synchronized锁,因为一旦被改变,这个对象会指向一个新的引用(就是指向新的内存),而你原来锁的信息在原来的那个内存的对象的对象头中,指向Monitor,所以这时候调用wait()/notify()就会报错。
一个不可变对象需要满足以下条件:
1)类本身使用final修饰,防止通过创建子类来改变它的定义
2)所有的字段都是final修饰的,final字段在创建对象时必须显式初始化,并不能被修改
3)如果字段引用了其他状态可变的对象(集合,数组),则这些字段必须时private私有的
不可变对象状态的修改是通过创建新对象的的,所以频繁创建新对象对垃圾回收可能会有影响。但是也会出现年轻代引用年老代对象的情况,又对垃圾回收比较友好
不可变对象主要的应用场景:
1)被建模对象的状态变化不频繁
2)同时对一组相关数据进行写操作,可以应用不可变对象,既可以保障原子性也可以避免锁的使用
3)使用不可变对象作为安全可靠的Map键,HashMap键值对的储存位置与键的hashCode()有关,如果键的内部状态发生了变化会导致键的哈希码不同,可能会影响键值对的储存位置。如果HashMap的键是一个不可变对象,则hashCode()方法的返回值恒定,储存位置时固定的。
线程特有对象
我们可以选择不共享非线程安全的对象,对于非线程安全的对象,每个线程都创建一个该对象的实例,各个线程访问各自创建的实例,不能访问其他线程创建的实例。这样就能保证线程安全,又避免了锁的开销。
例如ThreadLocal<T>类
装饰器模式
装饰器模式可以用来实现线程安全。其基本思想是为非线程安全的对象创建一个相应的线程安全的外包装对象,客户端代码不直接访问非线程安全的对象而是访问它的外包装对象。
外包装对象与非线程安全对象具有相同的接口,即使用方式相同。而外包装对象内部通常会借助锁,以线程安全的方式调用相应的非线程安全对象的方法。
在java.util.Collections工具类中提供了一组synchronizedXXX(xxx)方法,可以把不是线程安全的xxx集合转换为线程安全的集合,它就是采用了这种装饰器模式。这个方法返回值就是指集合的外包装对象。
锁的优化及注意事项
有助于提高锁性能的几点建议
-
减少持有锁的时间
时间太长会加剧竞争
-
减小锁的粒度
一个锁保护的共享数据的数量大小称为锁的粒度。如果一个锁保护的共享数据的数量大,就称该锁的粒度粗,否则称该锁的粒度细。锁的粒度过粗会导致线程在申请锁时需要进行不必要的等待。
-
使用读写分离锁代替独占锁
-
锁分离
将读写锁的思想进一步延申就是锁分离。读写锁时根据读写操作功能上的不同进行了锁分离。根据应用程序功能的特点,也可以对独占锁进行分离。例如
java.util.concurrent.LinkedBlockingQueue
类中take()方法与put()方法分别从队头取数据,队尾加入数据,所以也可以分离。 -
锁粗化
一连串的对同一个锁的加锁和释放操作,在虚拟机中会合并成为一次加锁和释放锁,来增加性能,开发过程中也需要注意这一点。尤其是循环体内,例如:
for(int i=0;i<100;i++){ synchronized(){ ... } }
毕竟加锁解锁的开销也蛮高的
JVM对锁的优化
乐观锁:
-
偏向锁
如果线程以及获得了锁,就进入偏向模式。此线程再次请求时,就无需任何同步操作,可以节省申请时间。
锁偏向在没有锁竞争的场合可以有较好的优化效果,但是对于竞争激烈的情况下不适用
-
轻量级锁
如果锁偏向失败,JVM不会立即挂起线程,还会使用一种称为轻量级锁的优化手段。会将共享对象的头部作为执政,指向持有锁的线程堆栈内部,来判断一个线程是否持有对象所。如果获得成功,进入临界区,如果获取失败,表示其他线程抢到了锁,锁升级为重量级锁(synchronized),当前线程转为阻塞状态。
悲观锁
- synchronized
标签:队列,synchronized,对象,GuideLine,并发,任务,线程,多线程,方法 来源: https://blog.csdn.net/qq_39117858/article/details/110646129