JUC并发编程入门

306 阅读17分钟

一、什么是JUC

JUC是java.util.concurrent包的缩写,包结构如下,说白了就是并发场景多线程编程的工具类。 image.png JUC就是在并发场景下,怎么让程序尽量通过有限的硬件,高效的处理请求,并且保证程序“线程安全”。

二、进程、线程、协程

2.1、进程

在操作系统中,进程是基本的资源分配单位,操作系统通过进程来管理计算机的资源,如CPU、内存、磁盘等,每一个进程都有唯一的进程标识符(PID)。用于区分不同的进程。 image.png

2.2、线程

底层角度:线程是操作系统中的基本执行单元(能够直接执行的最小代码块),它是进程中的一个实体,是CPU调度和分派的基本单位。一个进程可以包含多个线程,每个线程都可以独立执行不同的任务,但它们共享进程的资源。 同一时刻,一个CPU核心只能运行一个线程,也就是CPU内核和同时运行的线程数是1:1的关系,也就是说8核CPU同时可以执行8个线程的代码。 image.png

2.3、纤程(协程)

1、纤程可以在一个线程内部创建多个纤程,这些纤程之间可以共享同一个线程的资源 2、纤程是在同一个进程内部运行的,不需要操作系统的介入,可以在用户空间内实现协作式多任务处理。因此纤程的创建和销毁开销很小,可以更高效地利用系统资源。 Java19才支持虚拟线程(纤程)。

总结:

1、先有进程,然后进程可以创建线程,线程是依附在进程里面的,线程里面可以包含多个协程 2、进程之间不共享全局变量,线程之间共享全局变量,但是要注意资源竞争的问题

三、并发、并行、串行

3.1、并发

底层:在操作系统中,安装了多个程序,并发的是同一时间段内 宏观上有多个程序同时运行,这在单CPU系统中,每一时刻只能有一道程序执行,即微观上这些程序是分时的交替运行,只不过是给人的感觉是 同时运行,那是因为分时交替运行的时间是非常短的。 image.png

3.2、并行

底层:在多核CPU 系统中,这些同一时刻的程序可以分配到多个处理器上(CPU),实现多任务并行执行,即利用每个处理器来处理一个可以并发执行的程序,这样多个程序便可以同时执行。目前电脑市场上说的多核 CPU,便是多核处理器,核越多,并行处理的程序越多,能大大的提高电脑运行的效率。 image.png

3.3、串行

底层:如单核CPU,同一时刻只能运行一个程序,如果存在多个程序,需要按照先后顺序执行。我打开qq后,不能再同时打开微信,只能等qq执行完成(关闭) 后才能打开微信,线程的串行亦是如此,一次只能执行一个线程代码指令,其他线程需要排队等待。 image.png

总结:

综合来说:并发Concurrent:指应用能够交替执行不同的任务,比如单CPU核心下执行多线程并非是同时执行多个任务,如果你开两个线程执行,就是在你几乎不可能察觉到的速度不断去切换这两个任务,已达到“同时执行效果”,其实并不是的,只是计算机的速度太快,我们无法察觉到而已。并行Parallel:指应用能够同时执行不同的任务,例:吃饭的时候可以边吃饭边打电话,这两件事情可以同时执行。 两者区别:一个是交替执行一个是同时执行

四、CPU的核心数和线程数的关系

目前主流CPU都是多核的,线程是CPU调度的最小单位。同一时刻,一个CPU核心只能运行一个线程,也就是CPU内核和同时运行的线程数是1:1的关系,也就是说8核CPU同时可以执行8个线程的代码。但 lntel引入超线程技术后,产生了逻辑处理器的概念,使核心数与线程数形成1:2的关系。在我们前面的Windows任务管理器贴图就能看出来,内核数是6而逻辑处理器数是12,在Java中提供了Runtime.getRuntime0.availableProcessors0,可以让我们获取当前的CPU核心数,注意这个核心数指的是逻辑处理器数 获得当前的CPU核心数在并发编程中很重要,并发编程下的性能优化往往和CPU核心数密切相关。

五、上下文切换

由于现在大多计算机都是多核CPU,多线程往往会比单线程更快,更能够提高并发,但提高并发并不意味着启动更多的线程来执行。更多的线程意味着线程创建销毁开销加大、上下文非常频繁,你的程序反而不能支持更高的TPS。

多任务系统往往需要同时执行多道作业。作业数往往大于机器的CPU数,然而一颗CPU同时只能执行一项任务,如何让用户感觉这些任务正在同时进行呢?操作系统的设计者 巧妙地利用了时间片轮转的方式。 时间片是CPU分配给各个任务(线程)的时间!

高并发,低耗时的情况,建议少线程。 低并发,高耗时的情况,建议多线程。 高并发高耗时,要分析任务类型、增加排排队、加大线程数。

六、Java创建线程

1、继承Thread 2、实现Runnable 3、Callable接口 可以返回线程执行结果 同过FutureTask中间类创建执行

Java中,类仅支持单继承,如果一个类继承了Thread类,就无法再继承其它类,因此,如果一个类既要继承其它的类,又必须创建为一个线程,就可以使用实现Runable接口的方式。使用实现Callable接口的方式创建的线程,可以获取到线程执行的返回值、是否执行完成等信息。

七、线程的五种状态

image.png 线程状态之间转换

八、线程池

8.1、使用线程池的好处

我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?在Java中可以通过线程池来达到这样的效果。 线程池:其实就是一个容纳多个线程的容器,其中的线程可以反复使用省去了频繁创建线程对象的操作,无需反复创建线程而消耗过多资源。线程是稀缺资源,不能无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

8.2、Java内置线程池使用

了解Java几种内置线程池的使用和关闭。 execute方法和submit方法区别: 1.参数 execute Runnable.run submit callable 2.返回值 execute void submit Future 3.异常 execute 会在子线程中抛出异常,但在主线程捕捉不到 submit 不会立马地出异常,而是会将异常暂时存起来,等Future.get()方法的时候,才会地出,可以在主线程捕捉, 处理异常更方便。

8.3、线程池参数&原理

image.png corePoolSize:核心线程池数量 maximunPoolSize:最大线程数量 keepAliveTime:非核心线程的空闲状态的存活时间 unit::时间单位 workQueue:工作队列(阻塞队列) threadFactory:线程工厂 handler:拒绝策略 image.png 创建:核心-->阻塞-->最大 执行:核心-->最大-->阻塞

8.4、源码

了解源码 没有核心线程,淘汰剩下的线程就是核心线程 线程执行异常 ,淘汰当前线程,重新创建一个线程 线程池关闭,会把在执行中的任务执行完再关闭

九、线程安全问题

并发导致线程不安全

9.1、原子性

原子性操作是指单一不可分割的操作,一系列操作要么全部成功执行,要么全部执行失败。

9.2、可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值CPU在执行代码的时候,为了减少变量访问的时间消耗可能将代码中访问的变量的值缓存到该CPU缓存区中,因此,相应的代码再次访问该变量的时候,相应的值可能从CPU缓存中而不是主内存中读取的。同样的,代码对这些被缓存过的变量的值的修改也可能仅是被写入CPU缓存区,而没有写入主内存。由于每个CPU都有自己的缓存区,因此一个CPU缓存区中的内容对于其他CPU而言是不可见的。 image.png

9.3、有序性

有序性最终表述的现象是CPU是否按照既定代码顺序执行依次执行指令。编译器和CPU为了提高指令的执行效率可能会进行指令重排序,这使得代码的实际执行方式可能不是按照我们所认为的方式进行。

指令重排两大原则

重排序会遵循as-if-serial与happens-before原则: as-if-serial语义的意思是: 不管怎么重排序 (编译器和处理器为了提高并行度), (单线程)程序的执行结果不能被改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。 了解happens-before原则 image.png

9.4、THreadLocal

ThreadLocal也就是线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了线程安全问题。

InheritableThreadLocal

9.5、volatile

volatile 关键字具备两个特性,一是可见性,一是禁止指令重排。

缓存一致性协议(MESI)

多个cpu从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。

缓存加锁

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32和Intel 64处理器使用MESI实现缓存一致性协议。

volatile缓存可见性实现原理

底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定),并回写到主内存。 IA-32和Intel 64架构软件开发者手册对lock指令的解释: 1、会将当前处理器缓存行的数据立即写回到系统内存。 2、这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)。 3、提供内存屏障功能,使lock前后指令不能重排序。

指令重排与内存屏障

image.png

9.6、原子类

java.util.concurrent.atomic AtomicInteger、AtoimcArray、引用类型原子类、Adder,Accumulator累加器....

9.7、各种锁

悲观锁

认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。

乐观锁

乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作。乐观锁在Java中是通过使用无锁编程来实现,最常采用的是CAS算法,Java原子类中的递增操作就通过CAS自旋实现的。适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。比较和替换。

CAS(Compare-and-Swap,即比较并替换)

如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

自旋锁

自旋锁(spinlock): 是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取直到获取到锁才会退出循环。 自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。 对于互斥锁,会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。但是自旋锁不会引起调用者堵塞,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。 自旋锁的实现基础是CAS算法机制。CAS自旋锁属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

ABA问题(了解)

假设这样一种场景,当第一个线程执行CAS(V,E,U)操作。在获取到当前变量V,准备修改为新值U前,另外两个线程已连续修改了两次变量V的值,使得该值又恢复为旧值,这样的话,我们就无法正确判断这个变量是否已被修改过,如下图: image.png

synchronized锁升级

在JDK 1.6之前,Java内置锁还是一个重量级锁,是一个效率比较低下的锁,会由jvm用户态切换到操作系统的管程来实现互斥。 image.png image.png Monitor结构如下: image.png 在JDK 1.6之后,JVM为了提高锁的获取与释放效率,对synchronized的实现进行了优化,引入了偏向锁和轻量级锁,从此以后Java内置锁的状态就有了4种(无锁、偏向锁、轻量级锁和重量级锁),并且4种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别)。 由低到高:无锁-》偏向锁-》轻量级锁-》重量级锁

重入锁

可重入锁是指同一个线程可以多次获得同一把锁,ReentrantLock和关键字Synchronized都是可重入锁。

ReentrantLock

lock、unlock

tryLock()

嗅探拿锁,如果当前线程发现锁被其它线程持有了,则返回false,程序继续执行后面的代码,而不是呈阻塞等待锁的状态。 可以传入时间参数,等待获取锁的时间。

打断等待线程

对于synchronized关键字,如果一个线程在等待获取锁,最终只有2种结果: 1、要么获取到锁然后继续后面的操作 2、要么一直等待,直到其他线程释放锁为止 而ReentrantLock提供了另外一种可能,就是在等待获取锁的过程中(发起获取锁请求到还未获取到锁这段时间内)是可以被中断的,也就是说在等待锁的过程中,程序可以根据需要取消获取锁的请求。有些使用这个操作是非常有必要的。比如:你和好朋友越好一起去打球,如果你等了半小时朋友还没到,突然你接到一个电话,朋友由于突发状况,不能来了,那么你一定达到回府。中断操作正是提供了一套类似的机制,如果一个线程正在等待获取锁,那么它依然可以收到个通知,被告知无需等待,可以停止工作了。

公平锁和非公平锁

在大多数情况下,锁的申请都是非公平的,也就是说,线程1首先请求锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1可获得锁还是线程2可获得锁呢?这是不一定的,系统只是会从这个锁的等待队列中随机挑选一个,因此不能保证其公平性。这就好比买票不排队,大家都围在售票窗口前,售票员忙的焦头烂额,也顾及不上谁先谁后,随便找个人出票就完事了,最终导致的结果是,有些人可能一直买不到票。而公平锁,则不是这样,它会按照到达的先后顺序获得资源。公平锁的一大特点是: 它不会产生饥饿现象,只要你排队,最终还是可以等到资源的;synchronized关键字默认是有jvm内部实现控制的,是非公平锁。而ReentrantLock运行开发者自己设置锁的公平性。

排他锁和共享锁

排他锁:排它锁又称独占锁,获得了以后既能读又能写,其他没有获得锁的线程不能读也不能写,典型的synchronized就是排它锁。 共享锁:共享锁又称读锁,获得了共享锁以后可以查看但无法修改和删除数据,其他线程也能获得共享锁,也可以查看但不能修改和删除数据。在没有读写锁之前,我们虽然保证了线程安全,但是也浪费了一定的资源,因为多个读操作同时进行并没有线程安全问题。 ReentrantReadWriteLock中 读锁就是共享锁,写锁是排它锁,在读的地方使用读锁,在写的地方使田写锁。灵活控制,如果不这样,读是无限阻塞的,这样提高了程序的执行效率。

synchronized与Lock的区别

1、首先synchronized是java内置关键字,在jvm层面,Lock是个java类; 2、synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁; 3、synchronized会自动释放锁(a 线程执行完同步代码会释放锁,b线程执行过程中发生异常会释放锁),Lock需在finally中手工释放锁(unlock0方法释放锁),否则容易造成线程死锁; 4、用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了; 5、synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可)。

AQS

那么AQS的全称是什么呢?AbstractQueuedsynchronizer,抽象队列同步器。给大家画一个图先,看一下ReentrantLock和AQS之间的关系。 image.png 我们来看上面的图。说白了,ReentrantLock内部包含了一个AOS对象,也就是AbstractQueuedSvnchronizer类型的对象。这个AQS对象就是ReentrantLock可以实现加锁和释放锁的关键性的核心组件。