线程的基础知识

线程和进程的区别

进程的正在运行程序的实例,一个进程包含了多个线程,每个线程执行不同的任务。

不同的进程使用不同的内存空间,当前进程下的线程可以共享内存空间。

线程更加轻量,线程上下文切换成本一般会比进程的上下文切换低。

并行与并发的区别

如果是单核CPU,只有并发,没有并行。

并发是在单位时间内交替运行多个线程。

并行是在单位时间内同时运行多个线程。

创建线程的方式有哪些

继承Thread类

重写run方法,调用start方法启动线程。

实现Runnable接口

与第一种一样。

实现Callable接口

  1. 实现Callable接口,需要传入一个泛型。

  2. 实现call方法,call方法返回类型就是传入的泛型。

  3. 创建Callable实现类的对象a。

  4. 创建FutureTask对象b,将对象a传入。

  5. 创建Thread对象c,将对象b传入。

  6. 调用c对象的start()方法启动线程。

  7. 可以使用b.get()获取线程执行结果。

线程池创建

使用Executors.newFiexdThreadPool()创建一个线程池,然后调用submit方法提交任务,传入的参数必须要实现Runnable接口。

Runnable和Callable有什么区别

  1. Runnable的run方法是没有返回值的。
  2. Callable接口的call方法有返回值,是个泛型,配合FutureTask可以获取线程执行后的结果。
  3. Callable接口的的call方法允许抛出异常,而Runnable方法的run方法不允许抛异常,只能在内部处理。

在启动线程的时候,可以使用run方法吗?run方法和start方法有什么区别?

调用run方法就是调用一个对象的普通方法,会不开启线程的。可以多次调用。

调用start方法会开启一个线程,通过该线程执行run方法内的逻辑,只允许调用一次。

线程中有哪些状态,状态之间如何切换的

Java中线程有六种状态,分别是:NEW(新建)、RUNNABLE(可执行)、BLOCKED(阻塞)、WAITING(等待)、TIMED_WATING(时间等待)、TERMINATED(终止)。

image-20230722185028808

几个方法

join()

不妨假设有A、B两个线程,若在B线程中调用A.join()则,B线程会等待A线程执行完成后才会继续执行下面的代码。

notify()

随机唤醒一个wait线程。

notifyAll()

唤醒所有的wait线程。

wait和sleep

共同点

wait()wait(long)sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态。

不同点

  1. 方法归属不同

    sleep(long)是Thread类的静态方法。

    wait()wait(long)是Object类成员的方法,每个对象都有。

  2. 醒来时机不同

    执行sleep(long)wait(long)的线程,在等待响应毫秒后都会自动唤醒。

    wait(long)wait()的线程,可以使用notify()或者notifyAll()唤醒,wait()如果不被唤醒,则会一直休眠下去。

  3. 锁特性不同

    wait方法调用之前,必须获得wait对象的锁,而sleep无此限制。

    wait方法执行成后会释放对象锁,允许其它线程获取该对象锁。

    sleep方法如果在synchronized代码块中执行,并不会释放对象锁。

如何停止一个正在运行的线程

  1. 使用推出标志,使线程正常退出。就是在run方法中添加一个执行条件。

  2. 使用Thread对象的stop方法强行终止(已被弃用)。

  3. 使用Thread对象的interrupt方法中断线程

    打断阻塞(sleep、wait、join)的线程,线程会抛出InterruptedException异常。

    打断正常的线程,可以根据打断状态来标记是否退出线程。Thread.currentThread().isInterrupted()方法获取标记,默认是false,当线程调用了interrupt()方法后,修改成true

线程中并发安全

Synchronized底层原理

底层是Monitor。被译为监视器,是用jvm提供,C++实现。

Monitor由WaitSetEntryListOwner三个部分组成。

WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程。

EntryList:关联没有抢到锁的线程,处于Blocked状态的线程。

Owner:存储当前获取锁的线程,只有一个线程可以获取。

Monitor实现属于重量级锁,锁升级是什么?

Monitor实现属于重量级锁,里面涉及到了用户态和内核态之间的切换、进程的上下文切换,成本较高,性能较低。

在JDK1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制的性能开销问题。

听晕了。

JMM(Java内存模型)

JMM定义了共享内存多线程程序读写操作的行为规范,通过这些规则来规定对内存的读写操作从而保证指令的正确性。

JMM把内存分为两块,一块是线程私有的工作区域(工作内存),一块是所有线程共享的区域(主内存)。

线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存。

CAS

Compare And Swap,它体现的一种乐观锁的思想,在无锁的情况下保证线程操作共享数据的原子性。

CAS用到的地方很多,比如AQS框架、AtomicXXX类等。

在操作共享变量的时候使用自旋锁,效率上更高一些。

CAS的底层是调用Unsafe类中的方法,都是操作系统提供的,其他语言实现的。

乐观锁和悲观锁的区别

CAS是基于乐观锁的思想:最乐观的设计,不怕别的线程来修改共享变量,就算改了也没关系,自己再重试。

synchronized是基于悲观锁的思想:最悲观的设计,得防着其他线程来修改共享变量。

volatile

一旦一个共享变量(类的成员变量、类的静态成员变量)背volatile修饰后,就具备了保证线程之间的可见性禁止进行指令重排的含义。

线程之间的可见性:用volatile修饰的共享变量,能够防止编译器等优化发送,让一个线程对共享变量的修改对另一个线程可见。

禁止指令重排:用volatile修饰的共享变量会在读、写共享变量时加入不同的内存屏障,阻止其他写操作越过屏障,从而达到阻止重排序的效果。

image-20230723211030342

使用小技巧:

写变量让volatile修饰的变量在代码最位置。

读变量让volatile修饰的变量在代码最位置。

什么是AQS

全称是AbstractQueuedSynchronizer,抽象队列同步器,它是构建锁或者其他同步组件的基础框架。

AQS和synchronized的区别

synchronized AQS
关键字,C++实现 Java实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

AQS常见的类

ReentrantLock 阻塞式锁

Semaphore 信号量

CountDownLatch 倒计时锁

如何实现的

AQS内部维护了一个先进先出的双向队列,队列中存储的排队线程。

在AQS内部有一个state属性,这个属性相当于一个资源,默认是0(无锁状态),如果队列中有一个线程成功修改了state值为1,则当前线程就相当于获取了资源。

image-20230723212639612

多个线程共同抢这个资源如何保证原子性

使用的CAS保证原子性。

是公平锁还是非公平锁

都可以是。

公平锁

新的线程直接到等待队列中等待,只让队列中第一个线程获取锁。

非公平锁

新的线程与等待队列中线程竞争。

ReentrantLock

ReentrantLock被译为可重入锁,相当于synchronized,它具备以下特点

  • 可中断
  • 可设置超时时间
  • 可设置公平锁
  • 支持多个条件变量
  • 与synchronized一样,都支持重入。

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似。

构造方法接受一个可选的公平参数(默认非公平锁),当为true时表示公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。

image-20230723213536603

  • 线程来抢锁后,使用CAS的方式来修改state状态,将exclusiveOwnerThread指向成功修改状态为1的线程,表示获取锁成功。
  • 获取锁失败则进入双向队列中等待。
  • exclusiveOwnerThreadnull时,则会唤醒在双向队列中等待的线程。
  • 公平锁体现按照先后顺序获取锁,非公平锁体现在不在队列中的线程也可以抢锁。

synchronized和Lock有什么区别

语法

synchronized是关键字,源码在jvm中,用C++实现。

Lock是接口,源码由jdk提供,用Java实现。

使用synchronized时,退出同步代码块锁会自动释放,而Lock需要手动调用unlock释放。

功能

二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能。

Lock提供了许多synchronized不具备的功能,如公平锁、可打断(等待过程中)、可超时、多条件变量。

Lock有适合不同场景的实现,如ReentrantLock、ReentrantReadWriteLock(读写锁)。

性能

在没有竞争时,synchronized做了许多优化,如偏向锁、轻量级锁,性能不错。

在竞争激烈时,Lock的实现通常会提供更好的性能。

死锁产生的条件是什么?

获取锁资源形成了环。通常是一个线程获取多把锁时出现。

如何诊断

可以使用jdk自带的工具:jps和jstack。

jps:输出jvm中允许的进程状态信息。主要是获得进程id。

jstack:查看java进程内线程的堆栈信息。使用jstack -l 进程id

还可以使用jconsole和VisualVM。

jconsole:用于对jvm的内存,线程,类的监控,是一个基于jmx的GUI性能监控工具。

VisualVM:可以监控线程、内存情况、查看方法的CPU时间和内存中的对象、以被GC的对象,反向查看分配的堆栈。

这几个都是jdk自带的。

ConcurrentHashMap

ConcurrentHashMap是一种线程安全的Map集合。

1.7

底层使用分段数组和链表实现。

使用Segment分段锁实现,底层使用ReentrantLock。锁的访问比较大。

1.8

使用与HashMap同样的数据结构,数据和链表/红黑树。

采用CAS和synchronized来保证并发安全。

  • CAS控制数组节点的增加。向某个下标添加数据。
  • synchronized只锁定当前链表或红黑树的首节点,只要hash不冲突,就不会影响下标。

导致并发程序出现问题的根本原因是什么

也就是问Java程序中怎么保证多线程的执行安全

满足下面三个特点即可。

原子性:一段代码要么执行完成,要么不执行。执行过程中不允许暂停和被打断。加锁即可。

可见性:让一个线程对共享变量的修改让另一个线程可见。推荐使用volatile,不过加锁也可以。

有序性:处理器为了提高效率,可能会对代码进行优化,不一定保证程序代码执行的顺序,但会保证结果的一致性。使用volatile即可。

ThreadLocal

ThreadLocal是多线程中解决线程安全的一个操作类,它会为每个线程都分配一个独立的空间从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享。

Thread包含ThreaLocalMap,而ThreaLocalMap维护了一个Entry数组,Entry数组的key是ThreadLocal,value就是存放是数据。

每个线程内有一个ThreadLocalMap类型的成员变量,用来存储数据。

  1. 调用set方法,就是以ThreadLocal自己做为key,数据做为value,存入当前线程的ThreadLocalMap中。
  2. 调用get方法,就是以ThreadLocal自己做为key,到当前线程中获取数据。
  3. 调用remove方法,就是以ThreadLocal自己做为key,到当前线程中删除对应数据。

Thread可能需要长时间运行,如果key(ThreadLocal)不再使用,需要在内存不足(GC)时释放其占用的内存。但是value却是强引用,并不会被GC掉。故而导致内存泄漏。

线程池

创建线程池的常用的两种方式

  1. 直接使用ThreadPoplExecutor

    最灵活的方式,可以指定线程池的7个参数。

    ThreadPoolExecutor executor = new ThreadPoolExecutor(corePoolSize, maxPoolSize, keepAliveTime, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
  2. 使用Executors(不推荐,原因见下文)。这个类提供了一些列静态方法来创建线程池,但是其内部还是使用的ThreadPoolExecutor来创建的。

    ExecutorService executor = Executors.newFixedThreadPool(10); // 创建一个固定大小的线程池,包含10个线程

为什么要使用线程池

  1. 每次创建线程的都需要内存空间和时间,不妨预先创建一些备用,在一个,如果一直创建下去可能会内存溢出。
  2. 创建大量的线程后,CPU处理能力有限,就会导致CPU在不同线程之间切换,上下文切换是会影响CPU性能的。

线程池的核心参数

和线程池的执行原理一致回答一致。

共有7个参数:

  1. 核心线程数
  2. 最大线程数 = 核心线程 + 救急线程
  3. 救急线程存活时间
  4. 时间的单位
  5. 阻塞队列,当没有空闲的核心线程时,新来的任务就会到阻塞队列中等待。如果阻塞队列满了,就会创建救急线程执行任务。
  6. 线程工厂
  7. 拒绝策略
    1. 之间抛出异常
    2. 调用者所在线程来执行
    3. 丢弃阻塞队列中最靠前的任务,并执行新任务
    4. 直接丢弃任务

线程池中有哪些常见的阻塞队列

  1. ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
  2. LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
  3. DelayedWorkQueue:优先队列,给任务设置延迟执行时间,执行时间越小越靠前。
  4. SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
LinkedBlockingQueue ArrayBlockingQueue
默认无界,支持有界 强制有界,创建时需要指定大小
单向链表 数组
惰性加载,创建节点的时候添加数据 提前初始化Node数组
两把锁(头和尾) 一把锁

如何确定核心线程数

需要考虑CPU的内核数量。

假设CPU核心数为N。

使用Runtime.getRuntime().availableProcessors()获取CPU核心数(可能是逻辑核心数)

IO密集型任务

如:文件读写、DB读写、网络请求。

核心线程数 = 2N + 1。

CPU密集型任务

如:计算型代码、bitmap转换、Gson转换。

核心线程数 = N + 1。

参考回答

  1. 高并发、任务执行时间短:N + 1,减少线程上下文切换。
  2. 并发不高,任务执行时间长
    • IO密集:2N + 1
    • 计算密集:N + 1
  3. 并发高,业务执行时间长。这种关键不在于线程池,而在于整体架构的设计,第一步是看看这些业务里某些数据能否做到缓存是,第二步是增加服务器。至于线程池的设置,参考第2点。

线程池的种类有哪些

java.concurrent.Executors类中提高了大量创建连接池的静态方法,常见有下面4种:

  1. newFixedThreadPool(int nThreads)
    • 核心线程数和最大线程数一致,没有救急线程。
    • 阻塞队列是LinkedBlockingQueue,最大容量是Integer.MAX_VALUE
    • 适用于任务量已知,相对耗时的任务。
  2. newSingleThreadExecutor():可以保证任务按照指定顺序执行。
    • 核心线程数和最大线程数都是1。
    • 阻塞队列是LinkedBlockingQueue,最大容量是Integer.MAX_VALUE
    • 适用于按照顺序执行的任务。
  3. newCachedThreadPool():可缓存线程池。
    • 核心线程数是0。
    • 救急线程只存活1分钟。
    • 最大线程数数是Integer.MAX_VALUE
    • 阻塞队列为SynchronousQueue,不存储数据,每个插入操作必须等待一个移出操作。
    • 适用于任务数比较密集,但每个任务执行时间较短的情况。
  4. newScheduledThreadPool(int corePoolSize):延迟任务的线程池。
    • 需要设置核心线程数,线程工厂、拒绝策略是可选的
    • 最大线程数为Integer.MAX_VALUE
    • 救急线程存活时长是0纳秒。
    • 阻塞队列是DelayedWorkQueue

为什么不推荐使用Executors创建线程池

常用的四个创建线程池方法创建出来的线程池最大线程数都是Integer.MAX_VALUE,线程数过多容易OOM。

使用场景

CountDownLatch

它用来进行线程同步协作,等待所有线程完成倒计时(一个或多个线程,等待其他线程完成后才能继续执行)。

其中构造参数用来初始化等待计数值

await()用来等待计数归零,规律后继续执行。

countDown()用来让计数器减一。

使用线程池+CountDownLatch把数据库中的数据导入到了ES(任意)中,避免OOM。

Future

商品汇总,调用多个接口来汇总数据,如果所有接口(或部分接口)没有依赖关系,就可以使用线程池+Future来提升性能。

异步调用(@Async注解)

为了避免下一级(子功能)方法影响上一级(主功能)方法(性能考虑),可以使用异步线程调用下一个方法(不需要下一级返回值),可以提升方法响应时间。

如何控制某个方法允许并发线程的数量

可以使用Semaphore这个类,被译为“信号量”。在并发情况下,可以控制方法的访问量。

  1. 创建一个Semaphore对象,可以给一个容量。
  2. acquire方法可以请求一个信号量,这时候信号量个数-1。若信号量为0则请求失败,会阻塞。
  3. release释放一个信号量,信号量个数+1。