多线程(无代码,纯干货)

23 篇文章 0 订阅
3 篇文章 0 订阅

本文是在JavaGuide面试突击版.pdf以及下面的总结文章上整理得到的,侵删。
多线程总结:https://www.cnblogs.com/toria/p/11234323.html

1、什么是线程和进程?

进程:
在操作系统中能够独立运行,并且作为资源分配的基本单位。它表示运行中的程序。系统运行一个程序就是一个进程从创建、运行到消亡的过程。

线程:
线程是进程划分成的更⼩的运⾏单位,⼀个进程在其执⾏的过程中可以产⽣多个线程。多个线程共享进程的堆和⽅法区资源,但是每个线程有⾃⼰的程序计数器、虚拟机栈 和 本地⽅法栈。系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,因此也被称为轻量级进程。

不同点:
线程和进程最⼤的不同在于基本上各进程是独⽴的,⽽各线程则不⼀定,因为同⼀进程中的线程极有可能会相互影响。线程执⾏开销⼩,但不利于资源的管理和保护;⽽进程正相反。

2. 并发与并行的区别?

并发指的是多个任务交替进行,并行则是指真正意义上的“同时进行”。实际上,如果系统内只有一个CPU,使用多线程时,不能并行,只能通过切换时间片的方式交替进行,从而并发执行任务。真正的并行只能出现在拥有多个CPU的系统中。

3. 为什么要使⽤多线程呢?

线程可以⽐作是轻量级的进程,线程间的切换和调度的成本远远⼩于进程。

多线程机制可以⼤⼤提⾼系统整体的并发能⼒以及性能。

多核时代多线程可以提⾼ CPU 利⽤率。假如我们要计算⼀个复杂的任务,我们只⽤⼀个线程的话,CPU 只会⼀个 CPU 核⼼被利⽤到,⽽创建多个线程就可以让多个 CPU 核⼼被利⽤到,这样就提⾼了 CPU 的利⽤率。

4.使⽤多线程可能带来什么问题?

内存泄漏、上下⽂切换、死锁还有受限于硬件和软件的资源闲置问题。
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。其实说白了就是该内存空间使用完毕之后未回收。

5. 创建线程的几种方式?(重要)

有4种方式:继承Thread类、实现Runnable接口、实现Callable接口、使用Executor框架来创建线程池。

6. 线程的生命周期和状态?(重要!)

Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:初始状态、运行状态、阻塞状态、等待状态、超时等待状态、终止状态。(图源《Java
并发编程艺术》4.1.4 节)
在这里插入图片描述
线程在⽣命周期中并不是固定处于某⼀个状态⽽是随着代码的执⾏在不同状态之间切换。Java 线程状
态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节):

线程创建之后它将处于NEW(新建)状态,调⽤ start() ⽅法后开始运⾏,线程这时候处于 READY(可运⾏) 状态。可运⾏状态的线程获得了 CPU 时间⽚后就处于RUNNING(运⾏) 状态。(Java 系统⼀般将这两个状态统称为 RUNNABLE(运⾏中) 状态 。)

当线程执⾏wait() ⽅法之后,线程进⼊ WAITING(等待) 状态。进⼊等待状态的线程需要依靠其他线程的通知才能够返回到RUNNABLE 状态;

⽽ TIMED_WAITING(超时等待) 状态相当于在等待状态的基础上增加了超时限制,⽐如通过 sleep() ⽅法或 wait() ⽅法可以将 Java线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。

当线程调⽤同步⽅法时,在没有获取到锁的情况下,线程将会进⼊到 BLOCKED(阻塞) 状态。

线程在执⾏run() ⽅法完毕后将会进⼊到 TERMINATED(终⽌) 状态。

7.线程终止的方式(考察1次)

停止一个线程通常意味着在线程处理任务完成之前停掉正在做的操作,也就是放弃当前的操作。

在 Java 中有以下 3 种方法可以终止正在运行的线程:
1. 使用标志位终止线程
在 run() 方法执行完毕后,该线程就终止了。但是在某些特殊的情况下,run() 方法会被一直执行;比如在服务端程序中可能会使用 while(true) { … } 这样的循环结构来不断的接收来自客户端的请求。此时就可以用修改标志位的方式来结束 run() 方法。

    public static void main(String[] args) {
        ServerThread t = new ServerThread();
        t.start();
        ...
        t.exit = true; //修改标志位,退出线程
    }

2. 使用 stop() 终止线程
虽然 stop() 方法确实可以停止一个正在运行的线程,但是这个方法是不安全的,而且该方法已被弃用,最好不要使用它。

为什么弃用stop:
a.调用 stop() 方法会立刻停止 run() 方法中剩余的全部工作,包括 catch 或 finally 语句,并抛出ThreadDeath异常,因此可能会导致一些清理性的工作的得不到完成,如文件,数据库等的关闭。
b. 调用 stop() 方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,出现数据不一致的问题。

3. 使用 interrupt() 中断线程
interrupt() 方法并不像在 for 循环语句中使用 break 语句那样干脆,马上就停止循环。调用 interrupt() 方法仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。
也就是说,线程中断并不会立即终止线程,而是通知目标线程,有人希望你终止。至于目标线程收到通知后会如何处理,则完全由目标线程自行决定。这一点很重要,如果中断后,线程立即无条件退出,那么又会遇到 stop() 方法的老问题。

8. 什么是上下⽂切换?

多线程编程中⼀般线程的个数都⼤于 CPU 核⼼的个数,⽽⼀个 CPU 核⼼在任意时刻只能被⼀个线程使⽤,为了让这些线程都能得到有效执⾏,CPU 采取的策略是为每个线程分配时间⽚轮转的形式。当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是⼀次上下⽂切换。
上下⽂切换对系统来说意味着消耗⼤量的 CPU 时间,且会影响多线程的执行速度。
Linux 相⽐与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有⼀项就是,其上下⽂切换和模式切换的时间消耗⾮常少。

9. 线程死锁?产生条件?如何避免?

线程死锁描述的是这样⼀种情况:多个线程同时被阻塞,它们中的⼀个或者全部都在等待某个资源被释放。由于线程被⽆限期地阻塞,因此程序不可能正常终⽌。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对⽅的资源,所以这两个线程就会互相等待⽽进⼊死锁状态。
在这里插入图片描述
产⽣死锁必须具备以下四个条件:

  1. 互斥条件:该资源任意⼀个时刻只由⼀个线程占⽤。
  2. 请求与保持条件:⼀个进程因请求资源⽽阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源。
  4. 循环等待条件:若⼲进程之间形成⼀种头尾相接的循环等待资源关系。

如何避免线程死锁?

  1. 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的。
  2. 破坏请求与保持条件 :⼀次性申请所有的资源。
  3. 破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放
    它占有的资源。
  4. 破坏循环等待条件 :靠按序申请资源来预防。按某⼀顺序申请资源,释放资源则反序释放。破坏循环等待条件。

10. 临界资源、临界区

**临界资源:**各线程采取互斥的方式,实现共享的资源称作临界资源。属于临界资源的硬件有打印机、磁带机等,软件有消息缓冲队列、变量、数组、缓冲区等。
临界区: 每个线程中访问临界资源的那段代码称为临界区。如果能保证每个线程互斥地进入自己的临界区,便可以实现各个线程对临界资源的互斥访问。为此,每个线程在进入临界区之前,应先对想要访问的临界资源进行检查,看它是否正被访问。如果未被访问,则该线程便可进入临界区对该资源进行访问,并设置它正被访问的标志; 如果此刻该临界资源正被某线程访问,则本线程不能进入临界区,进入等待状态。
需要合理的分配临界区以达到多线程的同步和互斥关系,如果协调不好,就容易使系统处于不安全状态,甚至出现死锁现象。

11. start() ⽅法和 run() ⽅法

new ⼀个 Thread,线程进⼊了新建状态;调⽤ start() ⽅法,会启动⼀个线程并使线程进⼊了就绪状态,当分配到时间⽚后就可以开始运⾏了。 start() 会执⾏线程的相应准备⼯作,然后⾃动执⾏run() ⽅法的内容,这是真正的多线程⼯作。
⽽直接执⾏ run() ⽅法,会把 run ⽅法当成⼀个 main线程下的普通⽅法去执⾏,并不会在某个线程中执⾏它,所以这并不是多线程⼯作。
总结: 调⽤ start ⽅法才可以启动线程并使线程进⼊就绪状态,⽽ run ⽅法只是 thread 的⼀个普通⽅法调⽤,还是在主线程⾥执⾏。

12 synchronized 关键字

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

synchronized关键字最主要的三种使⽤⽅式
修饰实例⽅法: 给当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
修饰静态⽅法: 给当前类加锁,进入同步代码前要获得当前类对象的锁 。
(如果⼀个线程A调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程B需要调⽤这个实例对象所属类的静态synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。)
修饰代码块: 指定加锁对象,对给定对象加锁,锁是synchronized括号里配置的对象,进入同步代码块前要获得给定对象的锁。

相关面试题:“单例模式了解吗?来给我⼿写⼀下!给我解释⼀下双重检验锁⽅式实现单例模式的原理!”
(https://blog.csdn.net/qq_30476717/article/details/107822919)

在 Java 早期版本中,synchronized属于重量级锁,效率低下,Java 的线程是映射到操作系统的原⽣线程之上的。如果要挂起或者唤醒⼀个线程,都需要操作系统帮忙完成,⽽操作系统实现线程之间的切换时需要从⽤户态转换到内核态,这个状态之间的转换需要相对⽐较⻓的时间,时间成本相对较⾼,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 JDK1.6之后 Java 官⽅对从 JVM 层⾯对synchronized较⼤优化,如⾃旋锁、适应性⾃旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

synchronized 关键字的底层原理

synchronized 关键字底层原理属于 JVM 层⾯。

Java对象头与Monitor:
在JVM中,实例对象在内存中的布局分为三块区域:对象头、实例变量和对齐填充。
Java对象头,它实现synchronized的锁对象的基础,由Mark Word 和 Class Metadata Address 组成。
(synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)。
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。)
在这里插入图片描述
其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等。(以下是32位JVM的Mark Word默认存储结构)
在这里插入图片描述
考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间。(32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构)
在这里插入图片描述
这里我们主要分析重量级锁,也就是通常说synchronized的对象锁,锁标识位为10。
① synchronized 同步语句块时

public class SynchronizedDemo {
  public void method() {
    synchronized (this) {
      System.out.println("synchronized 代码块");
    }
  }
}

此时,synchronized 同步语句块的实现使⽤的是 monitorenter 和 monitorexit 指令,其中 monitorenter指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执⾏monitorenter 指令时,线程试图获取锁也就是获取 monitor的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。
(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。)

② synchronized 修饰⽅法的的情况

public class SynchronizedDemo2 {
  public synchronized void method() {
    System.out.println("synchronized ⽅法");
  }
}

synchronized 修饰的⽅法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是ACC_SYNCHRONIZED 标识,该标识指明了该⽅法是⼀个同步⽅法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。

synchronized的可重入性
重入锁,即一个线程再次请求自己持有对象锁的临界资源,synchronized是基于原子性的内部锁机制,是可重入的,由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

一种特殊情况:当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。

等待唤醒机制(notify/notifyAll和wait方法)与synchronized
在使用notify/notifyAll和wait方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor。

13. JVM对synchronized的优化

JDK1.6 对锁的实现引⼊了⼤量的优化,如偏向锁、轻量级锁、⾃旋锁、适应性⾃旋锁、锁消除、锁粗化等技术来减少锁操作的开销。

锁主要存在四种状态,依次是:⽆锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈⽽逐渐升级。注意锁可以升级不可降级,这种策略是为了提⾼获得锁和释放锁的效率。

⽆锁状态和重量级锁状态前面已经介绍,下面介绍偏向锁状态、轻量级锁状态。
偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段。因为在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁的代价而引入偏向锁(会涉及到一些CAS操作,耗时)。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构(锁标志位为01),当这个线程再次请求锁时,无需再做任何同步操作,这样就省去了大量有关锁申请的操作。
所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,但是需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁
倘若偏向锁失败,虚拟机并不会立即将其升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(这个也是JDK1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构(锁标志位为00)。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,即轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

虚拟机的其他锁优化:
自旋锁
轻量级锁失败后,虚拟机为了避免线程在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,因此自旋锁会假设在不久的将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是被称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除
锁消除是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。

14. sleep() ⽅法和 wait() ⽅法

相同点:两者都可以暂停线程的执⾏。
区别:
wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠但并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。

15. synchronized和ReentrantLock 的区别

① 两者都是可重⼊锁
“可重⼊锁”概念是:⾃⼰可以再次获取⾃⼰的内部锁。⽐如⼀个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重⼊的话,就会造成死锁。同⼀个线程每次获取锁,锁的计数器都⾃增1,所以要等到锁的计数器
下降为0时才能释放锁。
② synchronized 依赖于 JVM ⽽ ReentrantLock 依赖于 API
synchronized 是依赖于 JVM 实现的,虚拟机团队在 JDK1.6 为 synchronized 关键字进⾏了很多优化,但是这些优化都是在虚拟机层⾯实现的,并没有直接暴露给我们。
ReentrantLock 是 JDK 层⾯实现的(也就是 API 层⾯,需要 lock() 和 unlock() ⽅法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。
③ ReentrantLock ⽐ synchronized 增加了⼀些⾼级功能,主要来说主要有三点:a. 等待可中断;b.可实现公平锁;c.可实现选择性通知(锁可以绑定多个条件)
a. 等待可中断:
ReentrantLock提供了⼀种能够中断等待锁的线程机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
b.可实现公平锁:
ReentrantLock可以指定是公平锁还是⾮公平锁。⽽synchronized只能是⾮公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是⾮公平的,可以通过 ReentrantLock类的 ReentrantLock(boolean fair) 构造⽅法来指定是否实现公平锁。
c.可实现选择性通知(锁可以绑定多个条件)
synchronized关键字与wait()和notify()/notifyAll()⽅法相结合可以实现等待/通知机制,synchronized关键字就相当于整个Lock对象中只有⼀个Condition实例,所有的线程都注册在该实例上。如果执⾏notifyAll()⽅法的话就会通知所有处于等待状态的线程,会造成很⼤的效率问题,⽽Condition实例的signalAll()⽅法 只会唤醒注册在该Condition实例中的所有等待线程。
Condition是JDK1.5之后才有的,可以实现多路通知功能,也就是在⼀个Lock对象中可以创建多个Condition实例,线程对象可以注册在指定的Condition中,从⽽可以有选择性的进⾏线程通知,在调度线程上更加灵活。

16. volatile关键字

在当前的 Java 内存模型下,线程可以把变量保存本地内存(⽐如机器的寄存器)中,⽽不是直接在主存中进⾏读写。这就可能造成⼀个线程在主存中修改了⼀个变量的值,⽽另外⼀个线程还继续使⽤它在寄存器中的变量值的拷⻉,造成数据的不⼀致。要解决这个问题,就需要把变量声明为volatile,这就指示 JVM,这个变量是不稳定的,每次使⽤它都到主存中进⾏读取。
说⽩了, volatile 关键字的主要作⽤就是保证变量的可⻅性然后还有⼀个作⽤是防⽌指令重排序。
(可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。把变量声明为volatile,这就指示 JVM每次使用它都到主存中进行读取。)

17.synchronized 关键字和 volatile 关键字的区别

  • volatile关键字是线程同步的轻量级实现,volatile关键字只能⽤于变量,只能保证数据的可⻅性,但不能保证数据的原⼦性,所以多线程访问volatile关键字不会发⽣阻塞。
  • synchronized关键字可以修饰⽅法以及代码块,既能保证可见性,又能保证原子性,即解决的是多个线程之间访问资源的同步性问题,多线程使用时可能会发生阻塞。实际开发中使⽤synchronized 关键字的场景还是更多⼀些。

18. 并发编程的三个重要特性

  1. 原⼦性 : ⼀个操作或者多次操作,要么所有的操作都执⾏,要么都不执⾏。 synchronized 可以保证代码⽚段的原⼦性。
  2. 可⻅性 :当⼀个变量对共享变量进⾏了修改,那么另外的线程都是⽴即可以看到修改后的最新值。 volatile 关键字可以保证共享变量的可⻅性。
  3. 有序性 :代码的执⾏顺序未必就是编写代码时候的顺序。 volatile 关键字可以禁⽌指令进⾏重排序优化。

19 ThreadLocal

1.ThreadLocal简介
如果创建了⼀个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本,在实际多线程操作的时候,每个线程操作的是自己内部的副本变量,从而规避了线程安全问题。

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

3.内存泄露问题
ThreadLocalMap 中使⽤的 key 为 ThreadLocal 的弱引⽤,⽽ value 是强引⽤。所以,如果ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。这样⼀来,假如我们不做任何措施的话,value 永远⽆法被GC 回收,这个时候就可能会产⽣内存泄露。在ThreadLocalMap的实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。另外,使⽤完ThreadLocal ⽅法后最好⼿动调⽤ remove() ⽅法

弱引用:如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:只具有弱引⽤的对象拥有更短暂的⽣命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。

20. 内存泄漏问题

内存泄漏和内存溢出的关系:

内存泄露: 指程序中动态分配内存给一些临时对象,但是对象不会被GC所回收,它始终占用内存。即被分配的对象可达但已无用。
内存溢出: 指程序运行过程中无法申请到足够的内存而导致的一种错误。

从定义上可以看出内存泄露是内存溢出的一种诱因,但是不是唯一因素。

可以使用Runtime.getRuntime().freeMemory()查询当前还有多少空闲内存,在某语句前后使用可以判断该语句是否存在内存泄漏。

System.out.println("free内存:" + Runtime.getRuntime().freeMemory() / 1024 / 1024);
String[] aaa = new String[2000000];
for (int i = 0; i < 2000000; i++) {
      aaa[i] = new String("aaa");
 }
 System.out.println("free内存:" + Runtime.getRuntime().freeMemory() / 1024 / 1024);

此时结果如下所示:
在这里插入图片描述
内存泄漏的例子:

  1. 如果长生命周期的对象持有短生命周期的引用,就很可能会出现内存泄露。
    比如下面的代码,这里的object实例,我们期望它只作用于method1()方法中,但是,当method1()方法执行完成后,object对象所分配的内存不会马上被认为是可以被释放的对象,只有在Simple类创建的对象被释放后才会被释放,严格的说,这就是一种内存泄露。
    解决方案是将使用完毕后将该对象设置为null。
public class Simple {
    Object object;
    public void method1(){
        object = new Object();
        //object = null;//解决内存泄漏的语句
    }
}
  1. 集合里面的内存泄漏
    虽然集合里面的数据都设置成null,但是集合内存还是存在的,集合里面的所有元素都不会进行垃圾回收。
    解决方案就是把list集合变量也变成null。
list = null;
  1. 连接没有关闭会造成内存泄漏
    比如数据库连接,网络连接和io连接,这些链接在使用的时候,除非显式的调用了其close()方法(或类似方法)将其连接关闭,否则是不会自动被GC回收的。其实原因依然是长生命周期对象持有短生命周期对象的引用。所以在连接调用结束的时候要进行调用close()进行关闭,这样可以回收不用的内存对象,增加可用内存。
  2. ThreadLocal 内部的value可能会造成内存泄漏
    ThreadLocal 内部维护的是⼀个类似 Map 的ThreadLocalMap 数据结构, key 为当前对象的 Thread 对象,值为 Object 对象。ThreadLocalMap 中使⽤的 key 为 ThreadLocal 的弱引⽤,⽽ value 是强引⽤。所以,如果ThreadLocal 没有被外部强引⽤的情况下,在垃圾回收的时候,key 会被清理掉,⽽ value 不会被清理掉。假如不做任何措施的话,value 永远⽆法被GC 回收,这个时候就可能会产⽣内存泄露。在ThreadLocalMap的实现中已经考虑了这种情况,在调⽤ set() 、 get() 、 remove() ⽅法的时候,会清理掉 key 为 null 的记录。另外,使⽤完ThreadLocal ⽅法后最好⼿动调⽤ remove() ⽅法。

21. 线程池优点、创建、参数、原理

为什么要使用线程池?
线程池、数据库连接池、Http 连接池等等都是对池化技术思想的应⽤。池化技术的思想主要是为了减少每次获取资源的消耗,提⾼对资源的利⽤率。
使⽤线程池的好处:

  • 降低资源消耗: 通过重复利⽤已创建的线程降低线程创建和销毁造成的消耗。
  • 提⾼响应速度: 当任务到达时,任务可以不需要等到线程创建就能⽴即执⾏。
  • 提⾼线程的可管理性: 线程是稀缺资源,如果⽆限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使⽤线程池可以进⾏统⼀的分配,调优和监控。

如何创建线程池?
⽅式⼀:通过ThreadPoolExecutor 构造⽅法实现
在这里插入图片描述
⽅式⼆:通过Executor 框架的⼯具类Executors来实现,通过此种方式我们可以创建三种类型的ThreadPoolExecutor:

  • FixedThreadPool : 该⽅法返回⼀个固定线程数量的线程池。该线程池中的线程数量始终不变。当有⼀个新的任务提交时,线程池中若有空闲线程,则⽴即执⾏。若没有,则新的任务会被暂存在⼀个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: ⽅法返回⼀个只有⼀个线程的线程池。若多余⼀个任务被提交到该线程池,任务会被保存在⼀个任务队列中,待线程空闲,按先⼊先出的顺序执⾏队列中的任务。
  • CachedThreadPool: 该⽅法返回⼀个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复⽤,则会优先使⽤可复⽤的线程。若所有线程均在⼯作,⼜有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执⾏完毕后,将返回线程池进⾏复⽤。

对应Executors⼯具类中的⽅法如图所示:
在这里插入图片描述
《阿⾥巴巴Java开发⼿册》中强制线程池不允许使⽤ Executors 去创建,⽽是通过ThreadPoolExecutor 的⽅式,这样的处理⽅式让写的同学更加明确线程池的运⾏规则,规避资源耗尽的⻛险。

Executors 返回线程池对象的弊端如下:
FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列⻓度为 Integer.MAX_VALUE,可能堆积⼤量的请求,从⽽导致OOM(Out Of Memory)。
CachedThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE,可能会创建⼤量线程,从⽽导致OOM。

ThreadPoolExecutor 类分析
ThreadPoolExecutor 构造函数重要参数分析
重要的参数:
corePoolSize : 核⼼线程数,线程池的基本大小,默认情况下,在创建了线程池后,线程池中的线程数为0,当有任务来之后,就会创建一个线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中;

maximumPoolSize : 线程池中允许的最大线程数,线程池中的当前线程数目不会超过该值。如果队列中任务已满,并且当前线程个数小于maximumPoolSize,那么会创建新的线程来执行任务。

workQueue:一个阻塞队列,用来存储等待执行的任务,一般来说,这里的阻塞队列有以下几种选择:
ArrayBlockingQueue;
LinkedBlockingQueue;
SynchronousQueue;
PriorityBlockingQueue
  ArrayBlockingQueue和PriorityBlockingQueue使用较少,一般使用LinkedBlockingQueue和SynchronousQueue。

其他常⻅参数:
1.keepAliveTime :当线程池中的线程数量⼤于corePoolSize 的时候,如果这时没有新的任务提交,核⼼线程外的线程不会⽴即销毁,⽽是会等待,直到等待的时间超过了keepAliveTime 才会被回收销毁;

2.unit : keepAliveTime 参数的时间单位。

3.threadFactory :线程工厂,用来创建线程。

4.handler:拒绝策略,当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize,如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:
ThreadPoolExecutor.AbortPolicy:(默认策略)丢弃任务并抛出RejectedExecutionException异常。

ThreadPoolExecutor.DiscardPolicy:也是丢弃任务,但是不抛出异常。

ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复此过程)

ThreadPoolExecutor.CallerRunsPolicy:由调用线程处理该任务

线程池原理分析
在这里插入图片描述
比如在代码中模拟了 10 个任务,我们配置的核⼼线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执⾏,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之⾏完成后,才会之⾏剩下的 5 个任务。

线程池执行流程
 任务被提交到线程池,会先判断当前线程数量是否小于corePoolSize,如果小于则创建线程来执行提交的任务,否则将任务放入workQueue队列,如果workQueue满了,则判断当前线程数量是否小于maximumPoolSize,如果小于则创建线程执行任务,否则就会调用handler,以表示线程池拒绝接收任务。

线程池的关闭
ThreadPoolExecutor提供了两个方法,用于线程池的关闭,分别是shutdown()和shutdownNow(),其中:

shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务;
shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务;

22.常见的线程池及适用场景

通过Executor 框架的⼯具类Executors来实现,通过此种方式我们可以创建三种类型的ThreadPoolExecutor:FixedThreadPool、SingleThreadExecutor、CachedThreadPool。

  • FixedThreadPool:固定线程数的线程池。corePoolSize和maximumPoolSize值是相等的,它使LinkedBlockingQueue队列;

  • SingleThreadExecutor:只会创建一个线程执行任务。corePoolSize和maximumPoolSize都设置为1,也使用LinkedBlockingQueue队列;

  • CachedThreadPool:是一个会根据需要调整线程数量的线程池。corePoolSize设置为0,将maximumPoolSize设置为Integer.MAX_VALUE,使用的SynchronousQueue,也就是说来了任务就创建线程运行,当线程空闲超过60秒,就销毁线程。

  • ScheduledThreadPool:继承自ThreadPoolExecutor。它主要用来定期执行任务。使用DelayQueue作为任务队列。

23. 计算密集型和IO密集型任务的线程池

常见的任务分为两种:CPU密集型任务和IO密集型任务
CPU密集型任务:在一个任务中,主要做计算,CPU持续在运行,CPU利用率高,具有这种特点的任务称为CPU密集型任务。
IO密集型任务:在一个任务中,大部分时间在进行I/O操作,由于I/O速度远远小于CPU,所以任务的大部分时间都在等待IO,CPU利用率低,具有这种特点的任务称为IO密集型任务。

在设计线程池时,应先对执行的任务有个大体分类,然后根据类型进行设置。一般而言,两种任务的线程数设置如下:
CPU密集型任务:线程个数为CPU核数。 这几个线程可以并行执行,不存在线程切换到开销,提高了cpu的利用率的同时也减少了切换线程导致的性能损耗。
IO密集型:线程个数为CPU核数的两倍。 到其中的线程在IO操作的时候,其他线程可以继续用cpu,提高了cpu的利用率。

24. 线程池的工作队列

ArrayBlockingQueue:基于数组的先进先出队列,此队列创建时必须指定大小;

LinkedBlockingQueue:基于链表的先进先出队列,如果创建时没有指定此队列大小,则默认为Integer.MAX_VALUE;

synchronousQueue:这个队列比较特殊,它不会保存提交的任务,而是将直接新建一个线程来执行新来的任务。

PriorityBlockingQueue:一个具有优先级的无限阻塞队列。

25.Runnable接⼝和Callable接⼝的区别

Runnable ⾃Java 1.0以来⼀直存在,但 Callable 仅在Java 1.5中引⼊,⽬的就是为了来处理 Runnable 不⽀持的⽤例。Runnable 接⼝不会返回结果或抛出异常,但是 Callable 接⼝可以。所以,如果任务不需要返回结果或抛出异常推荐使⽤ Runnable 接⼝,这样代码看起来会更加简洁。

26.执⾏execute()⽅法和submit()⽅法的区别是什么呢?

  1. execute() ⽅法⽤于提交不需要返回值的任务,所以⽆法判断任务被线程池执⾏成功与否;
  2. submit() ⽅法⽤于提交需要返回值的任务。线程池会返回⼀个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执⾏成功,并且可以通过 Future 的 get() ⽅法来获取返回值。

27.多线程开发带来的问题与解决方法?

使用多线程主要会带来以下几个问题:
(一)线程安全问题
  线程安全问题指的是在某一线程从开始访问到结束访问某一数据期间,该数据被其他的线程所修改,那么对于当前线程而言,该线程就发生了线程安全问题,表现形式为数据的缺失,数据不一致等。
线程安全问题发生的条件:
1)多线程环境下,即存在包括自己在内存在有多个线程。
2)多线程环境下存在共享资源,且多线程操作该共享资源。
3)多个线程必须对该共享资源有非原子性操作。

线程安全问题的解决思路:
1)尽量不使用共享变量,将不必要的共享变量变成局部变量来使用。
2)使用synchronized关键字同步代码块,或者使用jdk包中提供的Lock为操作进行加锁。
3)使用ThreadLocal为每一个线程建立一个变量的副本,各个线程间独立操作,互不影响。

(二)性能问题
  线程的生命周期开销是非常大的,一个线程的创建到销毁都会占用大量的内存。同时如果不合理的创建了多个线程,cup的处理器数量小于了线程数量,那么将会有很多的线程被闲置,闲置的线程将会占用大量的内存,为垃圾回收带来很大压力,同时cup在分配线程时还会消耗其性能。
  解决思路:
  利用线程池,减少线程的频繁创建和销毁,节省内存开销和减小了垃圾回收的压力。同时因为任务到来时本身线程已经存在,减少了创建线程时间,提高了执行效率,而且合理的创建线程池数量还会使各个线程都处于忙碌状态,提高任务执行效率,线程池还提供了拒绝策略,当任务数量到达某一临界区时,线程池将拒绝任务的进入,保持现有任务的顺利执行,减少池的压力。
  
(三)活跃性问题
1)死锁
假如线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。多个线程环形占用资源也是一样的会产生死锁问题。
解决方法:
避免一个线程同时获取多个锁
避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
尝试使用定时锁,使用 lock.tryLock(timeout) 来代替使用内部锁机制。
  想要避免死锁,可以使用无锁函数(cas)或者使用重入锁(ReentrantLock),通过重入锁使线程中断或限时等待可以有效的规避死锁问题。

2)饥饿
饥饿指的是某一线程或多个线程因为某些原因一直获取不到资源,导致程序一直无法执行。如某一线程优先级太低导致一直分配不到资源,或者是某一线程一直占着某种资源不放,导致该线程无法执行等。

解决方法:与死锁相比,饥饿现象还是有可能在一段时间之后恢复执行的。可以设置合适的线程优先级来尽量避免饥饿的产生。

3)活锁,活锁体现了一种谦让的美德,每个线程都想把资源让给对方,但是由于机器“智商”不够,可能会产生一直将资源让来让去,导致资源在两个线程间跳动而无法使某一线程真正的到资源并执行,这就是活锁的问题。

(四)阻塞
阻塞是用来形容多线程的问题,几个线程之间共享临界区资源,那么当一个线程占用了临界区资源后,所有需要使用该资源的线程都需要进入该临界区等待,等待会导致线程挂起,一直不能工作,这种情况就是阻塞,如果某一线程一直都不释放资源,将会导致其他所有等待在这个临界区的线程都不能工作。
当我们使用synchronized或重入锁时,我们得到的就是阻塞线程,无论是synchronized或者重入锁,都会在试图执行代码前,得到临界区的锁,如果得不到锁,线程将会被挂起等待,直到其他线程执行完成并释放锁且拿到锁为止。

解决方法:
可以通过减少锁持有时间,读写锁分离,减小锁的粒度,锁分离,锁粗化等方式来优化锁的性能。

临界区:
临界区是用来表示一种公共的资源(共享数据),它可以被多个线程使用,但是在每次只能有一个线程能够使用它,当临界区资源正在被一个线程使用时,其他的线程就只能等待当前线程执行完之后才能使用该临界区资源。

28. Atomic 原⼦类

Atomic 是指⼀个操作是不可中断的。即使是在多个线程⼀起执⾏的时候,⼀个操作⼀旦开始,就不会被其他线程⼲扰。所以,所谓原⼦类说简单点就是具有原⼦/原⼦操作特征的类。并发包 java.util.concurrent 的原⼦类都存放在java.util.concurrent.atomic 下。

问:讲讲 AtomicInteger (整形原⼦类)的使⽤
使⽤ AtomicInteger 之后,不⽤对其⽅法加锁也可以保证线程安全。AtomicInteger 类主要利⽤ CAS (compare and swap) + volatile来保证原⼦操作,从⽽避免synchronized 的⾼开销,执⾏效率⼤为提升。

29 AQS

1. AQS 介绍
AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下⾯。AQS是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器, ⽐如前面提到的ReentrantLock,Semaphore。

2. AQS 原理分析

用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态,成功则获取锁成功,失败则进入等待队列,等待被唤醒。

即AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

CLH队列是⼀个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成⼀个CLH锁队列的⼀个结点(Node)来实现锁的分配。
在这里插入图片描述
共享资源变量state,它是int数据类型的,使⽤volatile修饰保证线程可⻅性。

private volatile int state;//共享变量,使⽤volatile修饰保证线程可⻅性

AQS使⽤CAS对该同步状态进⾏原⼦操作实现对其值的修改,其访问方式有3种:
getState()
setState(int newState)
compareAndSetState(int expect, int update)
(上述3种方式均是原子操作)

AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

AQS将大部分的同步逻辑均已经实现好,自定义同步器只需要实现state的获取(acquire)和释放(release)的逻辑代码就可以,主要包括下面方法:

  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,成功则返回true,失败则返回false。
  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS(Compare and Swap)减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

3. AQS 组件总结

  • Semaphore(信号量): synchronized 和 ReentrantLock 都是⼀次只允许⼀个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch (倒计时器): CountDownLatch是⼀个同步⼯具类,⽤来协调多个线程之间的同步。这个⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。
  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch ⾮常类似,它也可以实现线程间的技术等待,但是它的功能⽐ CountDownLatch 更加复杂和强⼤。主要应⽤场景和CountDownLatch 类似。CyclicBarrier 的字⾯意思是可循环使⽤的屏障。它要做的事情是,让⼀组线程到达⼀个屏障时被阻塞,直到最后⼀个线程到达屏障时,屏障才会开⻔,所有被屏障拦截的线程才会继续⼲活。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值