Skip to content

面渣逆袭-Java并发编程

About 5669 wordsAbout 19 min

2025-08-05

面渣逆袭-Java并发编程

基础

1、并行和并发有什么区别?

  • 并行时多核cpu上的多任务处理,多个任务在同一时间真正的同时执行。
  • 并发是单核cpu上多任务处理,多个任务在同一时间段内交替执行,通过时间片轮转实现交替执行,用于解决io密集型任务的瓶颈

你是如何理解线程安全的?

如果一段代码被多个线程执行,还能够得到正确的答案,那么这段代码或者方法就是线程安全的。

  • 原子性:一个操作要么完全执行,要么完全不执行。(可以使用synchornized保证原子性)
  • 可见性:当一个线程修改了共享变量,其他线程能够立即看到变化。(可以使用volatile保证可见性)
  • 有序性:要确保线程不会因为死锁、饥饿等问题导致无法继续执行

2、说说进程和线程的区别?

进程是操作系统分配资源的最小单位。简单来说,就是我们电脑上启动的一个应用。

线程是进程中的独立执行单元,多个线程可以共享进程中的资源,如内存;每个线程都有自己独立的程序计数器、虚拟机栈。

线程之间是如何进行通信的?

原则上可以通过消息传递和共享内存两种方式来实现。java采用的是共享内存的并发模型。

一句话来概括就是:共享变量存储在主内存中,每个线程的私有本地内存,存储的是这个共享变量的副本。

线程a和线程b要通信,要经历两个步骤:

  • 线程a把本地内存A中的共享变量副本刷新到主内存中。
  • 线程b到主内存中读取线程a刷新过的共享变量,在同步到自己的共享变量副本中。

⭐️ 3、说说线程有几种创建方式?

创建线程的方式有下面三种:

  • 继承Thread,并重写run方法
  • 实现Runnable接口,并重写run方法
  • 实现Callable接口,重写call方法,这种方式可以通过FutureTask获取任务执行的返回值。

run方法和start方法有什么区别?

  • run():封装线程执行的代码,直接调用相当于调用普通方法
  • start(): 启动线程,然后由jvm调用此线程的run方法。

继承Thread的方式好还是实现Runnable接口好?

Runnable好

  • java单继承 的问题
  • 适合多个相同的代码去处理统一资源的情况,把线程、代码和数据有效的分离 ,更符合面向对象的设计思想。Callable和Runnable相似,但可以返回一个结果

方法:

  • sleep():使当前执行的线程暂停毫秒数,也就是进入休眠的状态
try{
  Thread.sleep(1000);
}catch(InterruptedException e){
  e.printStackTrace();
}
  • Join():等待这个线程执行完直呼才会轮到后续线程得到cpu的执行权,使用这个也要捕获异常。
  • setDaemon: 将此线程标记为守护线程,就是服务其他的线程, 像java中的垃圾回收线程,就是典型的守护线程。
  • yield:是一个静态方法,用于暗示当前线程愿意放弃当前的时间片,允许其他线程执行。

6、线程有几种状态?

6种

新建、就绪、运行、阻塞、等待、终止

  • NEW:新建状态,通过new Thread创建
  • RUNNABLE:调用start,线程进入可运行状态。
  • BLOCKED:
  • WAITING:线程进入等待状态,调用wait
  • TIME_WAITING:
  • TERMINATED:终止状态

推荐阅读

1、synchronized关键字

把synchronized关键字讲的那叫一个透彻

在java中,关键字synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,(主要是对方法或者代码块中的共享数据的操作),sycnhronized可以保证一个线程的变化可以被其他线程所看到(保证可见性,完全可以替代volatile功能)

synchronized关键字主要有以下3种应用方式:

  • 同步方法,为当前对象(this)加锁,进入同步代码前要获得当前对象的锁
  • 同步静态方法,为当前类加锁(锁的是class对象),进入同步代码前要获得当前类的锁
  • 同步代码块,指定加锁对象,对给定对象加锁,进入同步代码前要获取到给定对象的锁。

synchronized同步方法

注意:

  • 一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取到该对象的锁,所以无法访问该对象的其他的synchronized方法,但是可以访问其他的非synchronized方法。

  • 每个对象都有一把锁,不同的对象,他们的锁不会相互影响。解决这种问题的方式是将synchronized做用于静态资源上面,这样的话,对象锁的就是类了, 无论创建多少个对象,类只有一个,这种情况下锁就是唯一的。

synchronized同步静态方法

同步静态方法,锁的是类,不影响实例对象锁的获取,两者互相不影响,本质上是this和class的不同。

如果线程a调用一个对象的非静态synchronized方法,线程b调用这个对象所属类的静态synchronized方法,是不会发生互斥的,因为访问静态synchronized方法占用的是class对象,而非静态synchronized方法占用的是当前对象的锁。

synchronized同步代码块

某些情况,我们编写的代码比较多,如果同步方法的话,可能比较耗时,而需要同步的 代码只有一部分。

我们将synchronized作用与一个给定的实例对象instance上面,就是当前对象就是锁的对象,当线程进入synchronized包裹的代码块是,就要求当前线程有instance对象的锁,如果当前有其他线程在操作的话,那么新的线程就需要等待。

除了instance作为对象外,还可以使用this或者当前类的class作为锁,比如:

//this,当前实例对象锁
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}
//Class对象锁
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

synchronized属于可重入锁

从互斥的角度来看,一个线程操作另外一个线程持有的对象的锁的临界资源的时候,将会进入阻塞状态;但是一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,将会请求成功。

synchronized就是可重入锁,因此一个线程在调用synchronized的时候,内部调用该对象的另外一个synchronized方法时允许的。

2、synchronized到底锁的什么?偏向锁、轻量级锁、重量级锁到底是什么?

首先需要明确一点:java多线程的锁都是基于对象的,java中的每一个对象都可以作为一个锁。

还有一点,就是我们常说的类锁其实也是对象锁。(class对象是一种特殊的java对象

锁的基本用法

临界区:指的是某一块代码区域,它同一时刻只能由一个线程执行。

下面这两个写法其实是等价的:

// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this) {
        // code
    }
}

同理,下面的两个方法也是等价的。

// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
    // code
}

// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
    synchronized (this.getClass()) {
        // code
    }
}

锁的四种状态及锁降级

jdk1.6及其以后,一个对象其实有四种锁状态,他们级别由低到高分别是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

各种锁的优缺点:(来自《Java并发编程艺术》)

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比,仅存在纳秒级差距如果线程间存在锁竞争会带来额外的锁撤销的消耗适用于只有一个线程访问同步快
轻量级锁竞争的线程不会阻塞,提高程序的响应速度如果始终得不到锁的线程使用自旋会消耗cpu追求响应时间
重量级锁线程竞争不使用自旋,不会消耗cpu线程阻塞,响应时间慢追求吞吐量,同步执行时间较长

对象的锁放在什么地方

每个java对象都有一个对象头。如果是非数组类型,则用2个字宽存储对象头,如果是数组,则用3个字宽来存储对象头。在32位处理器中,一个字宽32位,在64位虚拟机中,一个字宽64位。

对象头内容如下:

长度内容说明
32/64bitMark Word存储对象的hashcode或者锁信息
32/64bitClass Metadata Address存储到对象类型数据的指针
32/64bitArray Length数组的长度(如果是数组)

可以看到,当对象状态位偏向锁,mark word存储的就是偏向的线程id;当状态为轻量级锁时,mark word存储的就是执行线程中lock record的指针;当状态为重量级锁时,mark word为指向堆中的monitor(监视器)对象的指针。

偏向锁

Hotspot的作者研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,于是便引入了偏向锁。

偏向锁的实现原理。

一个线程在第一次进入同步快时,会在对象头和栈帧中的锁记录里面存储锁偏向的线程id,当下次该线程进入 这个同步块时,会去检查锁的mark word里面是不是自己放的线程id。

如果是,表明线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费cas操作来加锁和解锁;如果不是,就代表有另外一个线程来竞争这个偏向锁。这个时候会尝试使用cas来替换mark word里面的线程id为心线程的id,这个时候要分两种情况:

  • 成功,表示之前的线程不存在了,mark word 里面的线程id为心线程的id,锁不会升级,仍然为偏向锁。
  • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁表示为0,并设置锁标志为00,升级为轻量级锁,会按照轻量级锁的方式竞争锁。

撤销偏向锁

偏向锁使用一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会什邡锁。

偏向锁升级成轻量级锁时,会暂停通邮偏向锁的线程,重置偏向锁表示,看起来容易,实则开销还是很大。

轻量级锁

多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞,正对这种情况,jvm采用轻量级锁来避免线程的阻塞于唤醒。

获取轻量级锁的过程,jvm会为每个先哼在当前线程的栈帧中创建用于存储锁记录的空间,我们称为di splaced mark word, 如果一个线程获得锁的时候发现是轻量级锁,会吧锁的mark word复制到自己的displaced mark word 里面。

然后线程尝试使用cas将锁的mark word替换为指向锁记录的指针,如果成功,或得锁,如果失败,会自旋,不断尝试去获取锁,自旋量费cpu资源,解决这个问题就是指定自旋的次数,例如10次,如果还没有获取到,就进入阻塞状态,jdk采用更聪明的方法,适应性自旋,简单来说就是线程如果自旋成功了,那么下次自旋的次数会更多,失败了,下次则减少。

自旋不会一直进行下去,如果到一定程度还没有获取到锁,自旋失败,线程进入阻塞状态,同时锁升级为重量级锁。

重量级锁

重量级锁依赖于操作系统,而操作系统中线程间装替啊的转换需要相对较长的时间,所以重量级锁效率很低,单被阻塞的线程不会消耗cpu。

3、乐观锁CAS

一文彻底搞清楚Java实现CAS的原理

如何保证原子性呢?

常见的做法就是加锁。

在java中,我们可以使用synchronized关键字和CAS来实现加锁的效果。

synchironized是悲观锁,随着jdk版本的升级,synchronized关键字已经轻量化了许多,但是依然是悲观锁。

悲观锁:总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程执行。

乐观锁:假设对共享资源的访问没有冲突,线程可以不停的执行,无需加锁也无需等待,一旦多个线程发生冲突,乐观锁通常使用一种称为CAS的技术来保证线程执行的安全性。(乐观锁天生免疫死锁)

  • 乐观锁用于读多写少的环境,避免频繁加锁影响性能
  • 悲观锁多用于写多读少的环境,避免频繁失败和重试影响性能。

什么是CAS?

  • v:要更新的变量
  • e:预期值
  • n:新值

判断v是否等于e,如果等于e,就将v修改成n,否则的话,什么都不做。

CAS是原子操作,他是一种系统源语,是一条CPU的原子指令,从cpu层面保证了它的原子性。

当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

CAS的原理?

Unsafe类,里面是一些native的方法,不同的操作系统,实现CAS的原理是不一样的。

CAS如何实现原子操作?

java.util.concurrent.atomic包里面有一些原子类,像是AtomicInteger、AtomicLong等等。

CAS的三大问题?

尽管CAS提供了一种有效的手段,但是也存在一些问题,比如ABA问题、长时间自旋、多个共享变量的原子操作。

  • ABA问题:就是一个值原来是A,变成了B,又变回了A,这个时候用CAS是检查不出来变化的,但实际上却被更新了两次。解决思路是在变量前面追加上版本号或者时间戳。
  • 长时间自旋:CAS多于自旋结合,如果自旋CAS长时间不成功,会占用大量的cpu资源。(解决思路是让jvm支持处理器提供的pause指令),pause指令能够让自旋失败时,cpu睡眠一小段时间在继续自旋,从而使读操作频率降低,为解决内存顺序冲突而导致的cpu流水线重拍的代价小一点。
  • 多个共享变量的原子操作:一个共享变量CAS能保证原子性,但是多个共享变量CAS就无法保证原子性了,通常有两种方法解决,1使用AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行CAS操作;2使用锁,锁内部的临界区代码可以保证只有当前线程能操作。

4、线程池

什么是线程池

线程池是一种池化技术实现,池化技术的核心就是实现资源的复用,避免资源的重复创建和销毁带来的额外的开销。线程池可以管理一堆线程,让线程执行完任务之后不进行销毁,而是继续去处理其他线程已经提交的任务。

使用线程池的好处

  • 降低资源消耗,避免线程重复创建、销毁浪费资源
  • 提高响应时间,任务可以不用等待线程的创建就能执行。
  • 提高线程可管理性,线程是稀缺资源,如果无限创建,不仅会消耗系统资源,还会降低系统的稳定性。

线程池的构造

java主要通过构建ThreadPoolExecutor来创建线程池的。

下面是线程池的java源码

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          RejectedExecutionHandler handler) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), handler);
}

我们可以看到有六个参数,下面分别来说一下不同的参数的含义:

  • corePollSize:线程池中用来工作的核心线程数量。
  • maximumPoolSize:最大线程数,线程池允许创建的最大的线程数。
  • keepAliveTime:超出corePoolSize后创建的线程存活时间或者是所有线程最大存活时间,取决于配置。
  • unit:keepAliveTime的时间单位
  • workQueue:任务队列,是一个阻塞队列,当线程数大道核心线程数后,会将任务存储到阻塞队列中
  • threadFactory:线程池内部创建线程所用的工厂
  • handler:拒绝策略,当队列已满并且线程数量达到最大线程数量时,会调用该方法处理任务。

线程池的运行原理

线程池刚创建出来是空的,是没有线程的,只有一个空的阻塞队列。可以使用prestartAllCoreThreads方法来实现创建好核心线程。

当线程调用execute方法提交一个任务,会发生什么?

首先判断当前线程数是否小于核心线程,如果小于的话,那就使用threadFactory创建线程,否则的线程就执行任务,线程执行完任务之后,不会销毁,会继续在阻塞队列里面找任务执行。

这里有个细节,就是线程从阻塞队列里面没有获取到任务,如果线程数小于核心线程数,还是会去创建线程 ,不会复用已有的线程。

任务来了之后,会先到阻塞队列中,当阻塞队列满了之后会发生什么?

此时会判断线程池里面的线程数是否小于最大线程数,如果小于就创建线程,这里创建出来的是非核心线程,就算队列里面有任务,新创建的线程还是会线程处理这个提交的任务,而不是从队列里面获取。从这里可以看出,先提交的任务不一定先执行。

假如线程数已经达到了最大线程数,会怎么办?

这个时候就会执行拒绝策略,来处理任务。

jdk自带的实现RejectedExecutionHander有四种

  • AbortPolicy;丢弃任务,抛出运行时异常
  • CallerRunsPolicy:由提交任务的线程来执行任务
  • DiscardPolicy:丢弃任务,但是不抛异常
  • DiscardOldestPolicy:从队列中剔除最先进入队列的任务,然后在此提交任务。
  • 自定义的实现了RejectedExecutionHandler接口的类,可以将任务存到数据库或者缓存中。

线程池中实现线程复用的原理

就是runWork内部,使用了一个while死循环,当第一个任务执行完之后,会不断的通过getTask来获取任务,只要能获取到任务,就会调用run方法执行,如果获取 不到任务,就会调用processWorkerExit方法,将线程退出。

如何获取任务执行超时的

Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();

就是从队列里面获取任务的时候传入了超时时间,如果在这个时间段都没有获取到任务,那么 结束之后线程也没了。

Executors构建线程池以及问题分析

  • 固定线程数的线程池,核心线程数和最大线程数相等。
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
  • 单个线程数量的线程池
public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}
  • 接近无限大线程数量的线程池
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

但是不推荐使用Executors来创建线程池。

Changelog

8/20/25, 11:06 AM
View All Changelog
  • 4c155-Merge branch 'dev1'on

求求了,快滚去学习!!!

求求了求求了,快去学习吧!

【题单】贪心算法

不知道方向的时候,可以多看看书,书会给你指明下一步该干什么,加油!