Java并行程序基础总结

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并行程序基础总结相关的知识,希望对你有一定的参考价值。

1-1 线程概念

1. 同步(Synchronous)和异步(Asynchronous

2. 并发和并行

3. 临界区

4. 阻塞与非阻塞

5. 死锁、饥饿、活锁

6. 并发级别:阻塞、无饥饿、无阻碍、无锁、无等待

7. 并发的两个重要定律:AmdahlGustafson定律

2-1.线程的基本操作

2.1 创建线程

创建线程的第一种方式:继承Thread类。

步骤:

1,定义类继承Thread

2,复写Thread类中的run方法。

目的:将自定义代码存储在run方法。让线程运行。

3,调用线程的start方法,

该方法两个作用:启动线程,调用run方法。

 

多线程的一个特性:

随机性。谁抢到谁执行,至于执行多长,cpu说的算。

 

线程都有自己默认的名称Thread-编号 该编号从0开始

 

创建线程的第二种方式:实现Runable接口

步骤:

1,定义类实现Runnable接口

2,覆盖Runnable接口中的run方法。

将线程要运行的代码存放在该run方法中。

3,通过Thread类建立线程对象。

4,将Runnable接口的子类对象作为实际参数传递给Thread类的构造函数。

为什么要将Runnable接口的子类对象传递给Thread的构造函数。因为,自定义的run方法所属的对象是Runnable接口的子类对象。所以要让线程去指定指定对象的run方法。就必须明确该run方法所属对象。

5,调用Thread类的start方法开启线程并调用Runnable接口子类的run方法。

 

创建线程的第种方式:通过 Callable Future 创建线程

 

创建线程的第种方式:通过线程池创建线程

2.1.1 Java创建线程后直接调用start()run()的区别

01)调用run()方法跟普通方法一样按序执行,可以重复多次调用。

02)调用start()方法就是创建一个线程和主线程交替执行,不能多次启动一个线程。

用户线程和守护线程

 

Start()方法用来启动一个线程,真正实现多线程,无需等待run方法代码的执行完毕,通过thread类的start()方法来启动一个线程,这时线程处于就绪状态,并没有执行,而是通过执行thread类的run()方法,执行线程体。

 

1.start()方法来启动线程,并在新线程中运行 run()方法,真正实现了多线程运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码;通过调用 Thread 类的 start()方法来启动一个线程,这时此线程是处于就绪状态,并没有运行,然后通过此 Thread 类调用方法 run()来完成其运行操作,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,run()方法运行结束,此线程终止。然后 CPU 再调度其它线程。

2.直接调用 run()方法的话,会把 run()方法当作普通方法来调用,会在当前线程中执行 run()方法,而不会启动新线程来运行 run()方法。程序还是要顺序执行,要等待 run 方法体执行完毕后,才可继续执行下面的代码; 程

序中只有主线程——这一个线程, 其程序执行路径还是只有一条, 这样就没有达到多线程的目的。

 

2.2 终止线程

Stop():当一个线程对文件上锁,进行写操作,当stop()后,文件锁立即被释放,但操作执行了一半才,另外一个线程进来就会有问题。

线程中断

建议使用”异常法”来终止线程的继续运行。在想要被中断执行的线程中,调用 interrupted()方法,该方法用来检验当前线程是否已经被中断,即该线程是否被打上了中断的标记,并不会使得线程立即停止运行,如果返回 true,则抛出异常,停止线程的运行。在线程外,调用 interrupt()方法,使得该线程打上中断的标记。

Thread.interrupt():是一个实例方法,通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断。

Thread.isInterrupted():实例方法,判断当前线程是否被中断(通过检查中断标志位)

Thread.interrupted():判断当前中断标志位,但也同时会清除当前线程的中断标志位状态

 

注:Thread.sleep()会让当前线程休眠一段时间,会抛出InterruptedException中断异常。InterruptedException不是运行时异常,也就是说程序必须捕获并且处理它,当线程在sleep休眠时,如果被中断,就会产生。

2.3 等待(wait)和通知(notify)

Object.wait()方法不是可以随便调用,必须包含在synchronized语句中,无论是wait或者notify都需要先获得目标对象的一个监视器。

技术分享图片

2.4 挂起(suspend)和继续执行(resume)线程

都是废弃线程,因为当挂起意外的在suspend前执行,那么挂起的线程就可能很那有机会被继续执行,而且被占用的锁不会被释放,可能导致整个系统工作不正常。

 

2.5 等待线程结束(join)和谦让(yield)

Join:会让其他线程等待此线程执行完毕再执行

Yield:会让出当前CPU,然后重新去竞争

 

2-2 synchronied和同步问题

01)同步问题出现的原因:当多条语句在操作同一个 线程共享数据 时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行。导致共享数据的错误。

02)同步的方法:

同步代码块(锁是Object对象或其子类)、同步函数(函数需要被对象调用。那么函数都有一个所属对象引用。就是this。所以同步函数使用的锁是this(当前对象))、静态的同步方法,使用的锁是该方法所在类的字节码文件对象:类名.class

03)synchronizedstatic synchronized的区别

正确的加锁程序:(注意main函数,是同步一个对象,里面synchronized同步一个实例方法,所以它或得本类对象为锁)

技术分享图片 

下面是一个有问题的同步程序:(主要看main方法中的对象,对象不同,synchronized不能对不同实例对象做到同步,所以可以用static synchronized

 

技术分享图片技术分享图片

2-3 volatile关键字的理解和使用JMM

JMM(Java内存模型)

    原子性:指一个操作不可中断,一个线程开始执行,不会被其他线程干扰。

    可见性;是指当一个线程修改了某一个共享变量的值,其他线程可以立即知道这个修改。

    有序性:并发时,程序的执行顺序可能出现乱序(比如:写在前面的代码,会在后面执行),是因为程序在执行时,可能会进行指令重排。(volatile修饰的变量不会发生指令重排)

 

volatile 变量提供了线程的可见性,并不能保证线程安全性和原子性。

什么是线程的可见性:

锁提供了两种主要特性:互斥(mutual exclusion 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

具体看volatile的语义:

1)保证了不同线程对这个变量进行读取时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。(volatile 解决了线程间共享变量的可见性问题)

第一:使用 volatile 关键字会强制将修改的值立即写入主存;

第二:使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU L1或者 L2 缓存中对应的缓存行无效);

第三:由于线程 1 的工作内存中缓存变量 stop 的缓存行无效,所以线程 1再次读取变量 stop 的值时会去主存读取。

2)禁止进行指令重排序,阻止编译器对代码的优化。

volatile 关键字禁止指令重排序有两层意思:

I)当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

II)在进行指令优化时,不能把 volatile 变量前面的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

2-4 Atomic

Atomic类的作用

· 使得让对单一数据的操作,实现了原子化

· 使用Atomic类构建复杂的,无需阻塞的代码

访问对2个或2个以上的atomic变量(或者对单个atomic变量进行2次或2次以上的操作)通常认为是需要同步的,以达到让这些操作能被作为一个原子单元。

 

无锁定且无等待算法

基于 CAS compare and swap)的并发算法称为 无锁定算法,因为线程不必再等待锁定(有时称为互斥或关键部分,这取决于线程平台的术语)。无论 CAS 操作成功还是失败,在任何一种情况中,它都在可预知的时间内完成。如果 CAS 失败,调用者可以重试 CAS 操作或采取其他适合的操作。


3-1 jdk并发包-ReentrantLock

1.1 什么是reentrantlock

java.util.concurrent.lock 中的 Lock 框架是锁定的一个抽象,它允许把锁定的实现作为 Java 类,而不是作为语言的特性来实现。这就为 Lock 的多种实现留下了空间,各种实现可能有不同的调度算法、性能特性或者锁定语义。 ReentrantLock 类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。(换句话说,当许多线程都想访问共享资源时,JVM 可以花更少的时候来调度线程,把更多时间用在执行线程上。)

1.2 ReentrantLock与synchronized的比较

相同:ReentrantLock提供了synchronized类似的功能和内存语义。

不同:

(1)ReentrantLock功能性方面更全面,比如时间锁等候,可中断锁等候,锁投票等,因此更有扩展性。在多个条件变量和高度竞争锁的地方,用ReentrantLock更合适,ReentrantLock还提供了Condition,对线程的等待和唤醒等操作更加灵活,一个ReentrantLock可以有多个Condition实例,所以更有扩展性。

(2)ReentrantLock 的性能比synchronized会好点。

(3)ReentrantLock提供了可轮询的锁请求,他可以尝试的去取得锁,如果取得成功则继续处理,取得不成功,可以等下次运行的时候处理,所以不容易产生死锁,而synchronized则一旦进入锁请求要么成功,要么一直阻塞,所以更容易产生死锁。

 

可重入锁:

ReentrantLock 和 synchronized 都是可重入锁。

如果当前线程已经获得了某个监视器对象所持有的锁,那么该线程在该方法中调用另外一个同步方法也同样持有该锁。

1.3 ReentrantLock扩展的功能(三个建锁方式)

Lock.lock();lock.lock();可以写多次:可重入

1.3.1 实现可轮询的锁请求 tryLock

Lock.lock();lock.lock();可以写多次:可重入

在内部锁中,死锁是致命的——唯一的恢复方法是重新启动程序,唯一的预防方法是在构建程序时不要出错。而可轮询的锁获取模式具有更完善的错误恢复机制,可以规避死锁的发生。 
如果你不能获得所有需要的锁,那么使用可轮询的获取方式使你能够重新拿到控制权,它会释放你已经获得的这些锁,然后再重新尝试。可轮询的锁获取模式,由tryLock()方法实现。此方法仅在调用时锁为空闲状态才获取该锁。如果锁可用,则获取锁,并立即返回值true。如果锁不可用,则此方法将立即返回值false。此方法的典型使用语句如下: 

[java] view plain copy

1. Lock lock = ...;   

2. if (lock.tryLock()) {   

3. try {   

4. // manipulate protected state   

5. finally {   

6. lock.unlock();   

7. }   

8. else {   

9. // perform alternative actions   

10. }   

 

1.3.2 实现可定时的锁请求 tryLock(long, TimeUnit)

 

当使用内部锁时,一旦开始请求,锁就不能停止了,所以内部锁给实现具有时限的活动带来了风险。为了解决这一问题,可以使用定时锁。当具有时限的活动调用了阻塞方法,定时锁能够在时间预算内设定相应的超时。如果活动在期待的时间内没能获得结果,定时锁能使程序提前返回。可定时的锁获取模式,由tryLock(long, TimeUnit)方法实现。 

 

1.3.3 实现可中断的锁获取请求 lockInterruptibly

可中断的锁获取操作允许在可取消的活动中使用。lockInterruptibly()方法能够使你获得锁的时候响应中断。

1.3.4 公平锁reentrantLock(boolean fair)

Public reentrantLock(boolean fair):默认是非公平,如果是公平锁,要求系统维护一个有序队列,因为公平锁维护成本高,性能低下。

1.4 ReentrantLock不好与需要注意的地方

(1) lock 必须在 finally 块中释放。否则,如果受保护的代码将抛出异常,锁就有可能永远得不到释放!这一点区别看起来可能没什么,但是实际上,它极为重要。忘记在 finally 块中释放锁,可能会在程序中留下一个×××,当有一天×××爆炸时,您要花费很大力气才有找到源头在哪。而使用同步,JVM 将确保锁会获得自动释放

(2) 当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象。

1.5 条件变量Condition

条件变量很大一个程度上是为了解决Object.wait/notify/notifyAll难以使用的问题。

条件(也称为条件队列 或条件变量)为线程提供了一个含义,以便在某个状态条件现在可能为 true 的另一个线程通知它之前,一直挂起该线程(即让其“等待”)。因为访问此共享状态信息发生在不同的线程中,所以它必须受保护,因此要将某种形式的锁与该条件相关联。等待提供一个条件的主要属性是:以原子方式 释放相关的锁,并挂起当前线程,就像 Object.wait 做的那样。

每一个Lock可以有任意数据的Condition对象,Condition是与Lock绑定的,所以就有Lock的公平性特性:如果是公平锁,线程为按照FIFO的顺序从Condition.await中释放,如果是非公平锁,那么后续的锁竞争就不保证FIFO顺序了。 

2.1 await* 操作

上一节中说过多次ReentrantLock是独占锁,一个线程拿到锁后如果不释放,那么另外一个线程肯定是拿不到锁,所以在lock.lock()和lock.unlock()之间可能有一次释放锁的操作(同样也必然还有一次获取锁的操作)。我们再回头看代码,不管take()还是put(),在进入lock.lock()后唯一可能释放锁的操作就是await()了。也就是说await()操作实际上就是释放锁,然后挂起线程,一旦条件满足就被唤醒,再次获取锁!

这里再回头介绍Condition的数据结构。我们知道一个Condition可以在多个地方被await*(),那么就需要一个FIFO的结构将这些Condition串联起来,然后根据需要唤醒一个或者多个(通常是所有)。所以在Condition内部就需要一个FIFO的队列。

2.2 signal/signalAll 操作

await*()清楚了,现在再来看signal/signalAll就容易多了。按照signal/signalAll的需求,就是要将Condition.await*()FIFO队列中第一个Node唤醒(或者全部Node)唤醒。尽管所有Node可能都被唤醒,但是要知道的是仍然只有一个线程能够拿到锁,其它没有拿到锁的线程仍然需要自旋等待,就上上面提到的第4步(acquireQueued)。

 

1.6 允许多个线程同时访问:信号量(semaphore)

使用信号量可以允许多个线程同时访问某一个资源(需要指定线程数量)

1.7 readwritelock

读写分离锁,可以有效减少锁竞争,提升系统性能。

1.8 倒计时器:countdownlatch

,可以用来控制线程等待,控制线程等待到倒计时结束。

 

1.9循环栅栏(增强倒计时器)

1.10 线程阻塞工具类locksupport

可以在线程内任意位置让线程阻塞,比起Thread.suspend(),弥补了由于resume()在前发生的危险;和wait相比,不需要先获得某个对象的锁,也不会抛出interruptedException

3-2 常用的线程池模式以及不同线程池的使用场景

提高性能,控制响应数量。

Httpservice这里

01)定义:线程池根据系统自身的环境情况,有效的限制执行线程的数量,使运行效果达到最佳。

02)优点:线程池为线程生命周期的开销问题和资源不足提供了解决方案。并且线程池具有一种能够以低开销有效的处理这些任务的机制以及一些资源管理和定时可见性的措施。

1.通过重复利用已创建的线程,减少在创建和销毁线程上所花的时间以及系统资源的开销。

2.提高响应速度。当任务到达时,任务可以不需要等到线程创建就可以立即执行。

3.提高线程的可管理性。使用线程池可以对线程进行统一的分配和监控。

4.如果不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存

 

03)风险:同步错误、死锁、线程泄露

线程池的注意事项

虽然线程池是构建多线程应用程序的强大机制,但使用它并不是没有风险的。

(1)线程池的大小。多线程应用并非线程越多越好,需要根据系统运行的软硬件环境以及应用本身的特点决定线程池的大小。一般来说,如果代码结构合理的话,线程数目与 CPU数量相适合即可。如果线程运行时可能出现阻塞现象,可相应增加池的大小;如有必要可采用自适应算法来动态调整线程池的大小,以提高 CPU 的有效利用率和系统的整体性能。

(2)并发错误。多线程应用要特别注意并发错误,要从逻辑上保证程序的正确性,注意避免死锁现象的发生。

(3)线程泄漏。这是线程池应用中一个严重的问题,当任务执行完毕而线程没能返回池中就会发生线程泄漏现象。 书151

 

04)分类:Java通过Executors提供四种线程池

newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

newSingleThreadScheduledExecutor():

 

05)线程池常用的接口和类

Executor 接口(execute(Runnable command)

Executors 类

ExecutorService 接口

AbstractExecutorService 抽象类

ThreadPoolExecutor 类

 

Executor 是一个顶层接口,在它里面只声明了一个方法 execute(Runnable),返回值为 void,参数为 Runnable 类型,从字面意思可以理解,就是用来执行传进去的任务的;

然后 ExecutorService 接口继承了 Executor 接口,并声明了一些方法:submitinvokeAllinvokeAny 以及 shutDown 等;

抽象类 AbstractExecutorService 实现了 ExecutorService 接口,基本实现了ExecutorService 中声明的所有方法;

然后 ThreadPoolExecutor 继承了类 AbstractExecutorService

Executors 类它主要用来创建线程池。

 

3.1 线程池内部实现

内部均使用了ThreadPoolExecutor:这个的构造函数里面有

1. corePoolSize:(线程池中的线程数量)

2. Maximumpoolsize:线程最大数量,

3. 还有一个重要的就是用来存放线程的队列BlockingQueue

常见的队列有:直接提交的队列(SychronousQueue)、有界队列(ArrayBlockingQueue)、无界的任务队列(LinkedBlockingQueue)、优先任务队列(priorityBlockingQueue

4. 线程工厂(用来创建线程)

5. Handler(拒绝策略)

当线程池和等待队列都满了以后,可以使用拒绝策略:abortpolicy(抛出异常)、callerrunspolicy(会直接在调用者线程中,运行当前被丢弃的任务)、丢弃最老请求,也就是即将被执行的任务、丢弃无法处理的任务

当上面四种不满足需求,可以通过rejectExecutionHandler接口自己定义

 

3.2 扩展线程池

ThreadpollExecutor可以扩展,提供了beforeExecutor()、afterExecutor()、terminated()三个接口对线程池进行控制。

3.3优化线程池线程数量

Ncpu = cpu数量

Ucpu = 目标CPU的使用率

W/C = 等待时间与计算时间的比率

 

Nthreads = ncpu * ucpu * (1 + W/C)

 

3.3 fork/join框架

Linux中Fork()用来创建子进程,使得系统进程可以多一个执行分支。

3.3 newFixedThreadPool此种线程池如果线程数达到最大值后会怎么办,底层原理

创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程。在任意点,在大多数 nThreads 线程会处于处理任务的活动状态。如果在所有线程处于活动状态时提交附加任务,则在有可用线程之前,附加任务将在队列中等待。如果在关闭前的执行期间由于失败而导致任何线程终止,那么一个新线程将代替它执行后续的任务(如果需要)。在某个线程被显式地关闭之前,池中的线程将一直存在。

4-1 提高锁性能的建议

1.减少锁持有时间

2.减小锁粒度(concurrenthashmap

3.读写分离锁来替换独占锁(ReadWriteLock

4.锁分离(LinkedBlockingQueue

5.锁粗化:有一连串不断请求的时候,就会合并请求

4-2 Thread local

线程局部变量,就是为每个使用该变量的线程提供一个变量的副本,是Java中的一种较为特殊的线程绑定机制,是每个线程都可以独立改变自己的副本,而不会与其他线程的副本冲突。为线程的并发问题提供了一种隔离机制。

Threadlocal通常用在一个类的成员上,多个线程访问它时,每个线程都有自己的副本,互不干扰:private static ThreadLocal<Connection> t1=new ThreadLocal<Connection>();

内部实现原理:

ThreadLocal内部其实是一个ThreadLocalMap(可以当做map,但是不是map)来保存数据。虽然在使用ThreadLocal时只给出了值,没有给出键,其实内部使用当前线程作为键。

Get方法:

/**

     * Returns the value in the current thread's copy of this

     * thread-local variable.  If the variable has no value for the

     * current thread, it is first initialized to the value returned

     * by an invocation of the {@link #initialValue} method.

     *

     * @return the current thread's value of this thread-local

     */

    public T get() {

        Thread t = Thread.currentThread();

        ThreadLocalMap map = getMap(t);

        if (map != null) {

            ThreadLocalMap.Entry e = map.getEntry(this);

            if (e != null) {

                @SuppressWarnings("unchecked")

                T result = (T)e.value;

                return result;

            }

        }

        return setInitialValue();

    }

当线程退出时,Thread类会进行一些清理工作,包括清理ThreadLocalMap ,由系统执行exit()方法。

 

线程泄露,当使用连接池的时候,里面线程数量固定,将大量的对象设置到ThreadLocal中,但是没有清理,线程就回不去线程池,导致线程泄露。

 

想要及时回收对象,最好使用ThreadLocal.remove()方法。或者JDK允许像释放普通变量释放ThreadLocal。比如直接obj= null.

 

适用场景:如果共享对象对于竞争的处理容易引起性能损失,就可以,考虑使用ThreadLocal为每个线程分配单独的对象。



以上是关于Java并行程序基础总结的主要内容,如果未能解决你的问题,请参考以下文章

Java并发包源码分析

多线程基础知识总结

多线程基础知识总结

Java 基础Java 8 Stream 相关内容的简单总结

java从零基础到项目实战,一线互联网公司面经总结

Java基础 | Stream流原理与用法总结