Java多线程讲解,超详细!可获取相关笔记

15 篇文章 7 订阅
10 篇文章 0 订阅

高频面经汇总:https://blog.csdn.net/qq_40262372/article/details/116075528

七、多线程

7.1什么是线程和进程?

7.1.1进程

进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的(很多次执行)。系统运行一个程序是一个进程创建、运行到消亡的过程。

在JAVA中,当我们启动Main函数其实就是启动了一个JVM的进程,而Main函数所在的线程就是这个进程中的一个线程,也称为主线程。

在Windows中,我们可以通过任务管理器看我们电脑运行着这那些进程。

7.1.2线程

 线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的堆方法区资源,但每个线程有自己的程序计数器本地方法栈虚拟机栈。所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

 Java程序天生就是多线程程序我们可以通过JMX来看一下一个普通的Java程序有那些线程,代码如下。

输出下面的多个线程。

从上面的输出内容可以看出:一个Java程序的运行是main线程和多个其他线程同时运行。

7.2请简要描述线程和进程的关系,区别及优缺点?

7.2.1 图解线程和进程的关系

我从JVM的角度说明线程和进程的关系吧。

在一个JVM进程中,有五块内容:堆,方法区,程序计数器,本地方法栈,虚拟机栈。其中一个JVM进程中包含着多个线程。其中程序计数器、本地方法栈、虚拟机栈是单个线程私有的。堆和方法区是公有区域。

 每当运行一个Java程序的时候,就会启动一个JVM进程。不同程序的JVM进程是互不干扰的。每个JVM进程中的线程可以共享该进程中的堆和方法区内存。因此,进程和线程最大的区别在于,进程互不干扰,同一个进程中的线程极有可能互相影响。所以线程开销小(有共享内存区),但不利于资源的管理和保护;进程则相反。

7.2.2 程序计数器为什么是私有的?

  问某个东西为什么要这样的时候。肯定要从它发挥什么作用谈起,它有什么用决定着它的属性操作。

  程序计数器的作用:

  1.指示程序运行的标志,字节解释器通过程序计数器来一次读取程序命令,从而实现代码的流程控制。

  2.在多线程下,保存当前线程的状态,因为当某个线程运作的时候,可能时间片用完了,程序计数器就保留了运行到了某行,下次到了该线程的时候直接从这个行开始执行程序。

所以需要线程私有,否则无法保存当前线程状态的。

7.2.3为什么虚拟机栈和本地方法栈是私有的?

  继续老规矩,既然问为什么就要知道其中的作用。

  虚拟机栈:每个Java方法被执行的时候都会创建一个栈帧,里面包括局部变量表、操作数栈、常量池引用。从方法调用到完成的过程,对应着栈帧在虚拟机栈中的入栈和出栈过程。

举个例子:看一段代码看栈帧如何操作的。

这里的所有操作都会经过操作数栈,方法栈帧里的操作全在操作数栈里完成。所有值压入操作数栈,然后通过操作数栈出栈保存到局部变量表。然后要取的话也是从局部变量表中取出到操作数栈,然后弹出后CPU去计算,计算完成后,然后把结果保存到操作数栈,然后在出栈保存到局部变量表中。

本地方法栈:和虚拟机栈非常相似。区别:虚拟机栈是为虚拟机的方法进行操作,本地方法栈是为本地方法服务。在HotSpot虚拟机中和Java虚拟机合二为一。

7.2.4一句话简单了解堆和方法区

堆和方法区是所有线程共享的资源,其中堆是最大的一块内存,主要存放新创建的对象;方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

7.3并发和并行的区别?

并发:同一个时间段,多个任务都在执行(交替执行)。

并行:单位时间内,多个任务同时执行。

7.4为什么要用多线程

总体上来说:

  从计算机底层来说:(线程描述)线程是轻量级的进程,是程序执行的最小单位,(优点)线程间的切换成本和调度的成本远远小于进程。另外,多核CPU时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

  从当代互联网发展趋势来说:现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

计算机底层来说:

  单核时代:在单核时代多线程主要是为了提高CPU和IO设备的综合利用率。举个例子:当只有一个线程的时候会导致CPU计算的时候,IO设备会空闲;IO设备操作的时候,CPU计算会空闲。所以这边实际利用率只有50%,如果有两个线程的话,我们就可以用一个CPU计算,一个用IO操作。这样操作的话就可以利用率就可以是100%

  多核时代:多核时代主要为了提高CPU利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU只会一个CPU核心被利用到,而创建多个线程的话,那么每个线程都可以用到单独的CPU狠心,提交CPU的利用率。

7.5使用多线程可能带来哪些问题?

并发编程的目的就是为了能提高程序的执行效率和提高程序运行速度,但是并发编程并不总是能提高程序运行速度,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁

7.6说说线程的生命周期和状态

  线程6个状态:创建、运行(抱着锁运行)、阻塞(抢锁)、等待(wait)、超时等待、结束

  创建:创建一个线程,但是还没有调用start方法

  运行:运行状态,Java线程将操作系统中的就绪和运行两种状态笼统地称为“运行”

  阻塞:线程阻塞于锁,等待其他线程释放锁,然后去抢锁。

  等待:处于wait状态,等待其他线程来唤醒notify或者中断。

  超时等待:等得不耐烦了,就自己返回了。有些线程脾气躁,不喜欢等人

  终止:表示线程已经完成。

7.7什么是上下文切换?

多线程编程中一般线程的个数大于CPU核心的个数(问题所在),而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU采用的策略是为每个线程分配时间片轮转形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换

  概括来说就是:当前任务在执行完CPU时间片切换到另一个任务之前会保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一个上下文切换。

  上下文切换都需要纳秒级的时间,相对于系统来说意味着消耗着大量的CPU时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux相比其他系统有很多优点,其中一点就是上下文切换时间消耗小。

7.8什么是线程死锁?如何避免死锁?

7.8.1认识线程死锁

  线程死锁的情况是指:多个线程同时被阻塞,他们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此此程序不可能正常终止。

  如下图所示,有两个线程A,B,两个资源1,2,线程A持有的资源2,线程B持有资源1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

产生死锁的四个条件:

 1.互斥条件:该资源任何一个时刻只由一个线程占有。

 2.请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

 3.不剥夺条件:线程已获得的资源在未使用之前不能被其他线程强行剥夺,只能自己使用完后才释放资源。

 4.循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

自私,不让,不准抢,必须轮流

7.8.2如何避免死锁?

 上面说了死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:

  1.破坏互斥条件:这个条件无法破坏,因为我们用锁本来就是让他们单独拥有

  2.破坏请求与保持条件:一次性把所有进程需要的资源全部拿走。这样就不会在运行的途中进行再去申请资源了。

3.破坏不剥夺条件:以退为进。当某个线程申请不到资源的时候,把自己拥有的资源都释放

4.破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。

一个死锁例子和破坏后正常执行:

线程1:先拿资源1,然后拿资源2

线程2:先拿资源2,然后拿资源1    这样循环等待初始

然后把线程2换为这样就可正常执行

现在线程2:先拿资源1,然后拿资源2.  因为线程1先把资源1拿了,所以线程2一直在阻塞了,等到线程1拿完资源1和2,然后释放再进行线程2的操作。

7.9说说sleep()和wait()方法区别和共同点?

1.两者主要区别在于:sleep()方法没有释放锁,而wait()方法释放了锁。

2.两者都可以暂停线程的执行

3.Wait常用于线程之间的交互/通信,sleep常用于线程的停止

4.Sleep()方法必须设置苏醒时间,wait可以不用设置,等待唤醒。

7.10 为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用run()方法?

  这是线程运行前需要调用的方法,当我们new一个Thread,线程进入新建模式。调用start()方法的时候才会让线程进入就绪状态,当分配到时间片就可以进行运行了。Start()方法会执行线程的准备工作,然后自动去执行run方法,这是真正的多线程工作。如果直接调用run(),只会把run方法当做Main线程中的普通方法调用,而不会进入某个线程调用,所以这并不是多线程工作。

总结:调用start()会让线程进入就绪状态并执行,直接调用run()不是以多线程工作

7.11说一说自己对synchronized关键字的了解

7.11.1 锁的原理(对象头)

  在Java中每个对象都拥有一把锁,锁放在对象头中,表示被那个线程占用。

7.11.2 对象、对象头结构

对象大小是8bit的倍数,所以需要填空字节

对象头比较小,包含Mark word与Class Point(指针指向当前对象类型所在方法区中的类型数据)

Mark word:32位JVM

通过锁标志位来标识这个对象是否上锁。

7.11.3 Sychronized 关键字

  

Synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

 另外,在Java早期版本中,synchronized属于重量级锁,效率低下。

为什么呢?

因为监视器锁是依赖于底层的操作系统的MutexLock 来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态切换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

 庆幸的是Java6之后Java官方从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁清除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

  所以现在无论是开源框架还是JDK源码都大量使用synchronized关键字。

锁只能升级不能降级。

7.11.4 无锁(CAS是原子操作)

其他线程一直失败重试,等到操作成功。这就是CAS精髓,CAS在系统中通过一条指令实现,所以能保证原子性。 无锁只要是通过CAS实现的。

有些场景我们只需要读取数据,用悲观锁的话,每个读操作之间都会产生线程切换操作(操作系统的内核和用户态的切换)。

CAS

在上面的线程先抢到资源讲女神的牌子0(空闲状态)置为1(已经有约),下面的线程会自旋期待女神空闲的时候再去约她,但是自己也有耐心,不会一直等待下去。

但是这里也有些问题,比较和赋值没有成为一个原子操作,

所幸的是,各种不同架构的CPU都提供了指令级别的CAS原子操作,比如X86架构下,通过cmpxchg指令支持cas;在ARM架构下,通过LL/SC来实现CS。也就是说,不需要操作系统的同步原语(mutex),CPU已经支持CAS的原子性。

乐观锁本质就是无锁,不会采用借用操作系统的mutex帮忙加锁,而是CPU直接支持CAS的原子性。

7.11.5 偏向锁(单个线程,避免CAS)

偏向锁是为了避免CAS或 线程切换(mutexlock)的资源耗费。当线程处理对象的时候,如果是偏向锁,然后线程id刚好是我进来的这个ID,那么就执行执行 不需要进行CAS操作之类。If true 线程Id,直接调用对象(资源)

偏爱有偏向锁的线程,只要该线程处理这个对象,马上把锁给他。如果多个线程来的话,那偏向锁就会升级为轻量级锁。

7.11.6 轻量级锁(两个线程完)

不用线程id,而是用前30bit的字段。当一个线程想要获得某个对象的锁时,假如看到锁标志位为00那么就知道它是轻量级锁,

1.线程会在自己的虚拟机栈中开辟一块Lock Record的空间。

2.Lock Record存着:①Mark word的副本 ②指向对象的指针

3.Mark Word前30个字符会生成一个指针,指向Lock Record。  这样就形成线程和对象的锁定。线程来了就可以操作了。

如果另外线程来了,那么这个线程会进行自旋。

自旋:线程自己在不断地循环尝试看对象目标是否被释放。如果释放了那么就进行获取3步相互绑定。如果没有释放就进行下一轮循环。   

区别于操作系统挂起阻塞:如果对象的锁很快就被会释放的话,自旋就不需要进行系统中断和现场恢复。所以效率更高。

自旋相当于CPU在空转,长时间自旋浪费COU资源, 出现适应性自旋

适应性自旋:简单来说,锁的自旋时间不再固定。而是取决于两个条件:①上一次在同一个锁的自旋时间②锁状态 。比如刚刚自旋等待的线程获得过锁,但是锁现在是被其他线程占有着,那么这个线程再次进行自旋的时间就会增加。

如果等待自旋的线程超过一个,那么轻量级锁会升级到重量级锁

7.11.7 重量级锁(三线程及以上)

7.14跟操作系统打交道了,所以就有用户态和内核态的切换,监视器monitorenter和monitorexit都是依赖操作系统的mutexlock实现。所以耗时啊。

7.12说说自己是怎么使用synchronized关键字

Synchronized关键字最主要的三种使用方式:

1.修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

2.修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,进入同步代码前获得当前class的锁。因为静态成员不属于任何一个实例对象,是类成员。所以,当我们线程A调用一个实例对象的非静态synchronized方法,线程B仍然可以调用该实例对象所属于类的静态synchronized方法。因为访问静态synchronized方法是占用的当前类的锁,而访问非静态synchronized方法占用的是当前实例对象的锁。

3.修饰代码块:指定加锁对象,对给定对象/类加锁。Synchronized(thisobject)表示进入同步代码库前要获得给定对象的锁。Synchronized(类.class)表示进入同步代码前要获得当前class的锁。

总结:

1.synchronized关键字加到static静态方法和synchronized(class)代码块上都是给Class类上锁

2.synchronized关键字加到非静态方法上是给实例对象加锁

3.尽量不要使用synchronized(String a)因为JVM中,字符串常量池具有缓存效果!

下面我们用一个常见面试题进行讲解synchronized关键字的具体作用。

7.12.1 双重校验锁实现对象单例(给重量级实例用的,线程池等,只会存在一个)

1.因为只有一个实例所以先写一个private static成员,保证一直都有

2.然后来一个外部接口在使用的时候创建实例。如果为Null后才会创建实例

这样的话,可以在主函数任意new,肯定不行。所以要把公开的构造函数屏蔽掉。

通过静态方法构造,这样new就不能构造。

单线程以上就够,但是多线程就会有问题了

运行结果:

这样看着没问题,

因为太快了,所以停一会(实际开发)。为什么太快了会一样的。比如线程A都创建完了,然后线程B才进入,所以线程B就发现这个静态对应已经存在了,所以是一样的对象。

结果就是两个实例了

直接在getinstance方法加锁,只能一个线程走进来

这样有没有必要锁这个方法。锁静态方法 实际上锁的是类。这样的锁粒度太大,我们每次创建的时候,都要锁类,这个操作太重。  

所以放入if块,双重检验。如果单层会出现这样的现象

都进入了锁之前的地方,所以第一个线程创建完后,第二个线程跟进去继续又创建。所以就会有两个因此我们要在锁代码块内部还要添加一个 是否创立过。

这样还不够完美。因为涉及到指令重排。

Javap 看反汇编看具体指令执行。 New的操作会执行这三部操作

编译器 JIT cpu 可能会让我们指令重排

成下面这样

比如T1进去后执行到2(把内存引用赋值给变量),然后这个时候T3线程进来发现实例引用不为null,然后就直接返回。但是这次返回的是没有被初始化的实例。

怎么解决呢  在这个instance的变量加上volatile就不会重排序了

7.13构造方法可以使用synchronized关键字修饰么?

  构造方法不能使用synchronized关键字修饰

  构造方法本事就属于线程安全的,不存在同步的构造方法一说

  构造方法是存在于JVM的栈区,本就是线程私有的。

7.14.讲一下synchronized关键字的底层原理

  Synchronized关键字底层原理属于JVM层面。

7.14.1 synchronized同步语句块的情况

通过JDK自带的javap命令查看SynchronizedDemo类的相关字节码信息:首先切换到类的对应目录执行javac SynchronizedDemo.java 命令生成编译后的.class文件,然后执行 javap -c -s -v -l ynchronizedDemo.class

从上面我们可以看出:

Synchronized同步语句块的实现使用的是monitorenter和monitorexit指令,其中monitorenter指令执行同步代码块的开始位置,monitorexit指令则致命同步代码块的结束位置。

当执行monitorenter指令时,线程试图获得锁也就是获得对象监视器monitor的持有权。

在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出非法监视状态异常。

在执行monitorenter时,会尝试获得对象的锁,如果锁的计数器为0则表示锁可以被获取,获取后将锁计算器设为1也就是加1。

在执行monitorexit指令后,,锁的计数器会减1,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外个线程释放为止。

7.14.2 synchronized修饰方法

Synchronized修饰的方法并没有monitorenter和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,该标识指明这个方法是同步方法。JVM可以通过该标识来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

7.14.3 总结

Synchronized锁代码块的时候,是用monitor对象监视器,当进入的时候判断锁计数器是否为0,为零就可以执行monitorenter指令,计数器加1,否则一直等待其他线程释放锁。执行完同步代码块后,执行monitorexit指令,锁计数器-1。

Synchronized锁方法的时候,会加上ACC_SYNCHRONIZED标识,让JVM识别出该方法是同步方法,直接作为同步方法处理。

7.15为什么要弄一个CPU高速缓存呢?

  CPU缓存的是内存数据,用于解决CPU处理速度与内存不匹配的问题。内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

如果两个线程一起获取i=1,进行i++,最后是i为2,但是应该是3的,为了避免这种问题我们可以通过制定一些缓存一致协议或者其他手段解决。

缓存一致性协议:当CPU写数据的时候,发现变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将变量的缓存设置为无效状态。因此在其他CPU读取这个变量的时候,发现是无效状态,会重新去主存这个变量。

7.16一下JMM(Java内存模型)

 JDK1.2之前,Java的内存模型是直接从主存里面去变量,这种方式没有什么需要注意的。但是现在的内存模型是,线程可以把变量保存到本地内存(高速缓存),而不是直接从主存读写。这个可能操作一个线程修改了一个变量的值,而另一个线程还在用自己本地内存的变量。造成数据不一致

  要解决这个问题,我们需要把变量设置为volatile,使得线程之间的变量可见。也就是volatile其中的一个重要性质保证变量的可见性

7.17为什么有MESI(缓存一致)协议,还需要volatile关键词来保证可见性?

两点:

1.多核情况下,MESI协议是弱一致性协议,不能保证一个线程修改变量后,其他线程能立马可见,可能存在这一的情况,当前线程修改了但是还没有刷新回主存,它又去做其他事了,其他线程重新去主存取的时候还是以前的值。但是volatile可以保证可见性,修改操作和刷回主存操作是一个原子操作。

2.正确情况下,系统操作并不会进行缓存一致性的校验,只有变量被volatile修饰了,该变量所在的缓存行才被赋予一致性的校验功能。

7.18说说synchronized关键字和volatile关键字的区别

  Synchronized关键字和volatile关键字是两个互补的存在,而不是对立的存在!

  1.Volatile关键字是线程同步的轻量级实现。所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只用于变量synchronized关键字可以修饰方法以及代码块

  2.Volatile关键字主要用于解决多个线程之间的可见性,而synchronized关键字解决的是多个线程之间访问资源的同步性

7.19ThreadLocal了解么?

通常情况下,我们创建的变量可以被任何一个线程访问修饰的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?JKD中提供的ThreadLocal类正是为了解决这样的问题。ThreadLocal类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据

比如有两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子就不会出现这样的情况。如果把这两个人比作线程的话,那么ThreadLocal就是用来避免这两个线程竞争的。  

7.20ThreadLocal原理

  从Thread类源码入手:

从上面Thread类源代码可以看出Thread类中有一个threadLocals和一个inheritableThreadLocals变量,它们都是ThreadLocalMap类型的变量,我们可以把ThreadLocalMap理解为ThreadLocal类实现的定制化的HashMap。默认情况下这两个变量都是null,只有当前线程调用ThreadLocal类的set或get方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的get()、set()方法。

通过上面这些内容,我们足以通过猜测得出结论:最终的变量是放在了当前线程的ThreadLocalMap中,并不是存在ThreadLocal上,ThreadLocal可以理解为只是ThreadLocalMap的封装,传递了变量值。ThreadLocal类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

ThreadLocal内部维护的是一个类似Map的ThreadLocalMap数据结构,key为当前对象的Thread对象,值为Object对象。

比如我们在同一个线程中声明了两个ThreadLocal对象的话,会使用Thread内部都是使用仅有那个ThreadLocalMap存放数据的,ThreadLocalMap的key就是ThreadLocal对象,value就是ThreadLocal对象调用set方法设置的值。

7.21 ThreadLocal内存泄漏问题了解不?

ThreadLocalMap中使用的key为ThreadLocal的弱引用,而value是强引用。所以,如果ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉。这样一来,ThreadLocalMap中就会出现key为null的Entry。加入我们不做任何措施的话,value无法被GC回收,这个时候就可能会产生内存泄漏。ThreadLocalMap实现中已经考虑了这种情况,在调用set()、get()、remove()方法的时候,会清理掉key为null的记录。使用完ThreadLocal方法后最好手动调用remove()方法。

弱引用:如果一个对象是弱引用,那就类似可有可无的商品。弱引用只要在垃圾回收线程扫描到它的时候,无论内存够不够,它都不会被收回。但是垃圾回收器是优先级比较低的线程所以不一定会被很快发现那些弱引用的对象。

弱引用会把和一个引用队列联合起来用,如果弱引用的对象被垃圾回收器回收后,Java虚拟机会把这个弱引用加入到这个关联的队列中去。

7.22 线程池

7.22.1 为什么要使用线程池

  线程池提供一种限制和管理资源。每个线程池还护卫一些基本统计信息。例如已完成任务的数量。

  使用线程池的好处:

1.降低资源的消耗。通过重复利用已经创建的线程降低线程创建和销毁造成的消耗。

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

3.提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

7.22.2 实现Runnable接口和Callable接口的区别

在ThreadPoolExecutor类中,执行线程池有两种方法:submit()、execute()

从关系图中,我们从下往上看是实现类到接口一步一步往内走。

在ExecutorService中的submit方法中,输入参数有callable与runnable。Runnable接口不会返回任何结果或抛出异常,但是Callable接口可以。

Runnable实现主要核心功能,其他辅助逻辑还是交给Thread来实现

7.22.3 执行execute()方法和submit()方法的区别是什么?

1.execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否。

2.submit()方法用于提交需要返回值的任务。线程池会返回一个Future类型的对象,通过这个Future对象可以判断任务是否执行成功,并且可以通过Future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成。

7.22.4如何创建线程池

《阿里巴巴Java开发手册》中强制线程池不允许是用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程的运行规则,规避资源耗尽的风险。

因为其中FixedThreadPool和SingleThreadPool中,运行请求的队列长度是21亿,这样会堆积大量的请求,导致OOM

其中cachedThreadPool和ScheduledThreadPool中,允许创建的线程数量为21亿,这样会创建大量的线程,导致OOM。

因此就有两种方式创建线程池:Executors和ThreadPoolExecutor。

Executors的三个方法是静态的,直接Executors.方法名;

ThreadPoolExecutor里是普通方法,所以需要new ThreadPoolExecutor;

其中Exectors框架的工具类可以创建三种ThreadPollExecutor,分别是单线程,指定线程数,可收缩的线程池。

7.22.5 ThreadPoolExecutor类分析

 里面主要是七大参数,四大拒绝策略

七大参数:

1.核心线程数:一般情况下工作的线程数

2.最大线程数:最大可以开的线程数

3.阻塞队列:最大线程数都满了,来的线程就要放入阻塞队列

4.存活时间:如何核心线程空闲一定时间后就关闭线程池

5.存活时间的单位:存活时间的单位

6.线程工厂:创建线程的工厂类

7.拒绝策略:阻塞队列都满了,还来线程怎么处理呢?4种拒绝策略

四大拒绝策略:

1.中断抛出异常

2.默默丢弃任务,不进行任何通知

3.丢弃掉在队列中存在时间最久的任务

4.让提交任务的线程去执行任务。

7.22.6线程池原理分析

看的顺序:核心线程->阻塞队列->最大线程数   就比如银行开柜台可以要消耗资源哦。肯定人不多你们先等着,先去队列。

7.23 介绍一下Atomic原子类

Atomic翻译成中文是原子的意思。在化学上,我们知道原子是构成一般物质的最小单位,在化学反应中是不可翻个的。在我们这里Atomic是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始就不会被其他线程干扰。

所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

并发包JUC的原子类都存放在JUC.atomic下,如图:

7.24 JUC包中的原子类是那4类?

7.24.1基本类型

7.24.2 数组类型

7.24.3 引用类型

7.24.4 对象的属性修改类型

7.25 介绍下ReentrantLock

  ReentantLock基于AQS,在并发编程中它可以实现公平锁(不允许插队)非公平锁(允许插队)来对共享资源进行同步,同时,和synchronized一样,ReentrantLock支持可重入,除此之外,ReentrantLock在调度上更灵活,支持更多丰富的功能。

其中ReentrantLock是实现了Lock接口,源码说的意思如下:

意义:Lock的意义在于提供了区别于synchronized的另一种具有广泛操作的同步方法,他们支持复杂的结构,并且能关联多个Condition对象。

 Lock主要就6个方法,分别:

Lock:用于获取锁,假如当前锁被其他线程占用,一直会等待,直到获得锁

lockInterruptibly:获取锁,与Lock区别: 假如当前线程在等待锁的过程被中断,会退出等待抛出异常。

Trylock无参:尝试获得锁,并立即返回,返回布尔,看是否获得锁

Trylock有参:在一段时间内尝试获得锁,在该阶段被中断会抛出异常

Unlock:释放锁

newCodition:表示一个等待状态,线程要等待这个条件触发线程才可以执行,Condition还提供了限时、中断相关的功能,丰富了线程的调度策略。强大之处在于多个线程可以建立不同的Condition,可以指定唤醒,但是synchronized会把所有的阻塞队列的线程都唤醒。

ReentrantLock:

ReentrantLock里面只有一个属性sync,其中还是一个final,不可修改的。

三个内部类:Sync、NonfairSync、FairSync

Sync的构造方法

Sync是继承了AQS,所以AQS的方法Sync都可以使用。NonfairSync、FairSync是Sync的唯二子类。

Sync类中除了lock(公平与不公平实现不一样)和readObject(用于反序列化)方法,其他的方法都是final修饰,不可子类修改。为什么要被final修饰?第一反应这些方法应该是对AQS的一些方法封装,并且本身实现已经完整,不想被外部修改。

NonfairTryAcquire

看名字是非公平的尝试获得锁,步骤:

1.获取state,如果为0,锁状态为空闲,并可以一次CAS原子更改state,将当前线程设为独占线程,并返回true,否则返回false。

2.判断当前线程是否为独占线程,这就是现实可重入性,可重入性指的是单个线程重新进入同一个子程序,仍是线程安全的。比如A线程在某上下文中获取了锁,当A线程又想获得该锁,不会因为自己占有该锁而一直等待,假如A线程既获得了锁,又在等待自己释放锁,那就死锁了。简单来说,一个线程可以不用释放可以获得锁n次,但是释放也要n次。

判断state的次数,因为Int为32位,只要超过了就会溢出成为负数,所以重入的次数就是无符号的最大值。

TryRelease

 释放锁,返回值是布尔。代表的是是否全部释放完成,不是释放了锁就返回true。一开始我看到名字的时候我以为释放锁就返回true,结果仔细看是要完全释放锁才可以返回true,这也给我们提了一个醒,自己写代码的时候一定要在含糊的名字上加上注释,要对别人调用api负责。

其余方法

newCondition:新建一个Condition对象

getOwner:获取正在占用锁的那个线程对象

getHoldCount:获取state的数值

isLock:判断锁是否空闲

NonfairSync

  非公平锁:不按照请求的顺序分配,可能线程永远得不到锁,但是性能比公平锁好。可能有违常识,在现实生活中,排队肯定比哄抢的效率高,但是为什么锁却不一样了?因为在唤醒一个线程的时候,线程切换之间会产生短暂的延时,非公平锁可以利用这点时间去完成操作,非公平锁在一些情况下比公平锁更好。

Lock的实现:

上来就是一个CAS操作获取锁,无论前面是否有没有线程在等待,它都直接搞了,但是只有一次机会。假如一次没有获得成功,那么就会调用AQS的acquire方法。

首先会调用一次tryAcquire,如果失败,那么就会进入FIFO队列,变成公平锁。

TryAcquire的实现:

  直接调用了父类sync的nonfairTryAcquire,从这个方法可以看出是非公平的,这个非公平尝试获得应该放在NonfairSync中,为什么会放在Sync中呢?

总结来说

1.进行了两次非公平的获取锁,如果这两次获取锁都失败了,那么就乖乖进FIFO队列直到获得锁。

2.tryAcquire就是一直尝试,的确不想排队。上层就可以写一个循环一直调用这个非公平的调用。

FairSync

  公平锁:按照请求的顺序分配,线程肯定可以得到锁,性能比非公平锁低。

Lock的实现:

  Acquire首先会调用AQS的tryAcquire,没有获取成功则会会进入FIFO队列乖乖直到获得锁

tryAquire的实现:

1.获得锁的状态state

2.如果state为0,说明锁状态为空闲。看阻塞队列中是否有线程,没有线程就去尝试获得锁。如果获取获取失败就返回false。

3.其中还会进行当前线程是否是独占线程,如果是的话,就把加锁次数+1,然后加锁次数小于0了,说明溢出了,那么超过最大加锁次数了。抛出异常。然后更新加锁状态。

ReentrantLock的构造方法:

无参构造是默认非公平加锁

有参构造,看传入的是否为true或false,true就是代表公平加锁

其中构造好了就无法修改加锁状态了。

Lock与LockInterruptibibly的区别:

Lock:是调用的AQS的acquire。如果线程在排队等待锁的过程中(使用LockSupport.park),被调用了中断,那么该线程不会抛出中断异常,而是存储中断状态,等待获得锁后才会抛出中断异常。

LockInterruptibily:是调用了AQS的acquireInterruptibly方法,该方法在线程的排队等待锁中,调用了中断,那么就会放弃等待锁,直接抛出中断异常。

TryLock:

无参数TryLock:无论Sync的实现是公平锁还是非公平锁,tryLock都是非公平的,因此NonfairAcquire方法为什么写到Sync中而不是NonfairSync中的原因。

有参数TryLock:直接调用的AQS的tryAcquireNanos方法。

Unlock和NewCondition方法:

分别调用AQS的release和newcondition方法。

7.25 介绍下AQS

介绍一下存在原因:

因为CPU的晶体管已经快接近物理极限了,所以越厉害的CPU,价格越昂贵,因此将几块稍微差一点的CPU一起去执行,节约成本,提高软件效率。

所以我们就产生了多线程操作。但是多线程操作就会遇到一个问题,多个线程去操作同一块空间的时候,到底谁执行那个线程的代码呢?为了节约时间一般都是采用谁先到就运行谁。为了避免当前线程在运行的时候,其他线程来骚扰,所以就进行加锁操作。加锁的过程步骤挺多的,而且线程调度的内容(获得锁,阻塞,唤醒等)都是相同的,所以就专门用AQS对这些线程调度进行管理。那些锁类就去专注实现自己线程的逻辑即可。

在第二步也可以返回true或者false,但是如果让业务线程一直去轮询看是否空闲,那么占用CPU大量资源。

因此要放入等待序列,依次等待去获取资源

AQS就是一个同步管理框架

AQS的成员变量

1.state

  表示同步状态,其中有独占模式和共享模式,独占模式一个线程,但是共享模式就会有很多个线程,因为要表示线程的数量所以用上了int而不是Boolean。

2.head

3.tail

如果线程想获取资源,但是被别的线程占用,该线程就要进入一个FIFO队列,

AQS的内部类

1.Node

AQS的主要方法

1.tryAcquire(模板模式)

这个是被Protected修饰的,只能被子类调用,里面只写了一行抛出异常,说明必须要被子类重写,不重写就抛出异常,给上层调用开放空间,上层可以override这个方法,实现自己的逻辑。如下自己写的一个Demo,

判断一个线程是否应用能尝试获得锁,首先看传入是否为1,然后看当前是否有独占线程,然后再进行CAS,自己独占

如果获取失败,1.如果不想等待锁,那么可以直接进行相应的处理;2.如果选择等待锁,那么调用acquire进入队列等待锁。这就显示了AQS框架灵活的地方

2.Acquire

  Accquire是final修饰的方法,不允许上层有任何修改,意思是我一定可以获得锁(有点霸道总裁的那味)

If条件里面有两个条件:

                   1、先尝试看看是否能够获得锁,不满足tryAcqire才会进行Aquire

                   2、然后进行acquireQueued排队等待锁

3.addWaiter()

其中调用了addWaiter()方法。

这个方法的作用,将线程封装成Node,加入等待队列操作,返回值为当前的节点。

代码步骤:

1.先建立一个Node对象

2.顺理成章插入尾节点。

其中的细节,if(CAS)是原子性的,但是if里面的内容不是原子性的,但是,就算其他线程来了,pre指针已经更新了,所以也不影响。

3.如果第一次CAS失败或者首节点为空,会进入完成的入队方法enq();

4.enq()

1.自旋,等待加入队列或建立头指针。

2.加入尾节点:1.判断尾节点是否为空,为空直接设置头结点;

                         2.如果不为空,新进的Node前置指针连接到尾节点,进行CAS成功后,才把尾指针的next指针指向新加入的节点。

为什么会在addWaiter会进行快速入队,而不是直接进行完整入队,因为完整入队在自旋,判空操作是耗性能的。

5.acquireQueued()

1.定义了failed,初始值为true,只有执行抛出了异常,最后进入finally,然后执行取消请求的方法cancelAquire,将Node的waitStatus值为CANCEL,再进行一些清理工作。

2. 出现一个interrupated变量,

3.方法主体是自旋操作,当前节点是头结点并且尝试获得锁成功了,  但是当前节点是输入节点的前置节点(因为FIFO队列第一个节点是Head,但是线程队列第一个Node是第二个节点,所以我们看的是输入节点的前置节点是否为头结点),判断当前节点是否有资格拿到锁没。  先执行是否能挂起操作shouldParkAfter()方法,然后在进行真正的挂起parkAndCheckInterrupt()方法。

4.如果不是当前没资格拿锁,那么就会进入挂起状态,之后再适合的时间唤醒。

中断:

A线程在运行的时候进行中断,只会先保存下中断状态

B线程在等待的时候进行中断,会直接抛出中断异常。

6.ShouldParkAfterFailedAcquire()

如果是ws=SIGNAL 那么直接返回true可挂起,如果前ws大于0,说明是CALCEL状态,删除前面所有CANCEL状态的Node。如果wiatstate是其他状态,既然当前节点压入,所以前置节点就应该做好准备来等待锁,通过CAS将前置节点的wa设置为SINGAL。

7.ParkAndCheckInterrupt()

这里才是真正被挂起,通过native方法来调用操作系统的原语来将当前线程挂起,

进队列挂起总结:

对AcquireQueue分析,

先回进行判断当前节点的前置节点是否为Head,如果是head会自旋一直等待锁,直到拿锁成功。否则会进行判断是否需要挂起。

判断是否需要挂起,条件是这样,当前节点是否有除了Head节点的其他节点,再看其他节点的state,如果是SINGNAL就挂起,如果大于0,是CANNEL状态,就会删除,其他状态会被置成SINGNAL,为以下轮询的线程方便挂起。  这样就可以避免大量的自旋浪费CPU一直挂着,肯定要进行释放,不是就一堆线程挂起。

8.tryReleaseRelease

tryRelease也是给上层开放空间的源码。

Release,里面unparkSuccessor(h)是关键,

1.先把首节点的状态置为0,防止它干扰其他状态的判断

2.然后从尾节点开始搜索,找到除了head节点之外最靠前的(非head)且state<0的节点,然后进行唤醒,起来工作。

3.唤醒的线程会继续执行acquireQueue方法,拿锁。然后就可以在这里阻塞跳出,然后进行任务执行。

9.Interrupted

  其中调用wait,sleep方法,其中其他线程把该线程中断,该线程会直接抛出中断异常,但是通过LockSupport.park进行挂起,是不会直接抛出中断异常,会记录在interrupted里面表示中断的状态值,外部调用了中断,就会成true。当执行完acquireQueue方法后,就会把这个中断状态带出外部,就会走到acquire的selfInterrupt方法,然后进行线程中断。

 

7.26 ReentrantLock和Sychronized的区别?

相似点:1.加锁方式同步,而且都是阻塞式的同步,也就是说当一个线程获得了对象锁,进入了同步快,其他访问该同步块的线程都必须阻塞在同步块外面等待,而进行线程阻塞和唤醒的代价是比较高的(操作系统在用户态和内核态之间来回切换,代价很高,不过可以通过对锁进行优化进行改善)

功能区别:1.Sychronized,它是java语言的关键字,是原生语法层面的互斥,需要Jvm实现。而ReentrantLock是JDK1.5之后提供的API层面的互斥锁,需要lock和unlock方法配置try/finally语句来完成。

便利性:Sychronized的使用比较方便,并且由编译器去保证锁的加锁和释放,,而ReentrantLock需要手工去加锁和释放锁,为了避免忘记手工释放造成死锁,所以最好在finnaly中声明释放锁。

锁的细粒度和灵活度:很明显ReentrantLock优于Synchronized

性能的区别:在Sychronized优化以前,它的性能比ReentrentLock性能差很多的,但是自从Synchronized引入偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况,官方建议使用Synchronized,其实synchronized的优化我感觉就是借鉴了ReentrantLock的CAS技术。

 

更多相关视频

B站视频讲解如何三个月学习JAVA拿到实习Offer:

https://www.bilibili.com/video/BV1dV411t71K

 

相关Java基础知识面试讲解视频请关注B站万小猿

欢迎加入QQ群:725936761

一起打卡讨论学习

互相分享面试经验

关注微信公众号:万小猿 获取更多学习笔记资源

回复“多线程”即可获取原文PDF文件

               

  • 5
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

万小猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值