程序、进程与线程

程序:从最基础的角度说,程序是指一组为了完成特定任务而编写的指令集或代码合计。比如Windows中的exe文件。

进程:一个程序被执行,操作系统就会创建一个进程。进程是一个正常运行的程序实例。比如Windows任务管理器中的进程。

线程:一个进程至少包含一个线程,即主线程。线程也是CPU分配与调度的基本单位。

并发和并行

并发:并发指的是在同一个时间段内处理多个任务的能力。这些任务可能看起来像是同时进行的,但实际上它们是在快速交替执行的。

从硬件的角度来说,单核处理器一次只能执行一个线程,但通过操作系统的时间片轮转调度,处理器可以在多个线程之间快速切换,造成“同时执行”多个线程的假象。

并行:指的是真正同时执行多个任务或线程的能力,这通常需要硬件支持,比如多核处理器。

当提到并行时,我们通常是在说利用这些多个核心来同时执行代码,以加快计算速度。

并发与并行最关键的区别点是:是否同时执行。

同步和异步

同步:发出调用之后,没有得到结果之前,该调用不可以返回,需一直等待。

异步:发出调用之后,不用等待返回结果,该调用直接返回,可以执行下一步。

线程上下文切换

线程在执行过程中会有自己的运行条件和状态(也就是上下文)。

线程从占用CPU的状态中退出,这时会保存线程的上下文,并加载下一个将要占用CPU的线程上下文,这就完成了线程上下文切换。

也就是当前正在执行的线程发生了切换。

保存的上下文用于下次线程占用CPU时可以恢复执行。

  • 什么时候线程会从占用CPU的状态中退出?

  1. 线程主动退出CPU占用,如调用了sleep(),wait()等

  2. 调用阻塞类型的系统中断,如请求IO,线程被阻塞

  3. 时间片用完,这个与操作系统相关,操作系统为了防止一个线程或进程长期占用CPU而导致其它线程与进程没有资源。

  4. 被终止或结束运行

为什么要使用多线程?

从计算机来讲,线程是程序执行的最小单位,线程之间的切换和调用的成本低于进程。

从项目上来讲,多线程并发可以提高系统整体的并发能力与性能,可以支持更多的用户。

比如使用Windows右键删除有N个小文件的文件夹时,删除很缓慢,因为Windows使用的是单线程删除。而使用FastCopy进行多线程删除效率非常高,肉眼可见。

线程创建在JAVA线程和线程池中有详细的解释。

什么是JUC?

JDK1.5以后提供了一个并发工具包 java.util.concurrent ,简称JUC包。这个包提供了用于并发编程的工具类和接口,皆在帮助开发者更高效地编写并发程序,简化多线程编程的复杂度。

java.util.concurrent 包中包含多个重要组件,如:

  1. Executor(调度器):提供了线程池管理能力,可以有效的管理和调度线程执行。

  2. 锁机制:除了synchronized关键字之外,还提供了重入锁ReentrantLock。

  3. 并发集合:提供了ConcurrentHashMap、CopyOnWriterArrayList等线程安全的集合类。

  4. 原子变量类:如AtoomicInteger、AtomicLong等,支持无锁的线程安全编程模型。

什么是线程池?

线程池是一种设计模式,它通过预先创建一组线程并将其保持在一个池中,这些线程在执行任务的时候被使用,执行完毕后,不会销毁,而是进入空闲状态,等待下一个任务的到来。

使用线程池的优点:

  1. 减少资源开销:复用已存在的线程,避免了频繁创建和销毁线程的资源开销。

  2. 提高响应速度:因为线程已经存在在线程池中,所以执行任务的时候可以立即开始,无需等待线程创建。

  3. 提高稳定性:线程池往往限制了最大线程的数量,也就是说资源占用是可控的。如果不使用线程池,可能会出现大量的新线程创建,从而击溃服务器。

线程池这种设计理念在很多地方都有使用,比如MySQL的连接池与之十分相似,只不过线程池管理的是线程,MySQL连接池管理的是数据库连接。

什么是线程不安全?

多线程环境下对同一份数据的访问是否能够保证其正确性和一致性。

安全代表着无论多少格线程同时访问一份数据,都不会出现数据混乱、错误或者丢失等问题。

这个问题通常对公共资源加锁来解决。(Synchronized/ReentrantLock/JUC包中其他工具类)

JUC中的锁与工具包在JAVA线程安全里面有详细的描述。

原子性

原子性是指一个操作或多个操作要么同时执行,执行过程中不会被任何因素打断,要么就都不执行。

我们常说的事务就具备原子性,想要保障线程安全也就是要具备原子性。

JUC提供了一个Atomic包,里面有多个不同数据类型的原子操作类,比如AtomicInteger 就是 java.util.concurrent.atomic 包中的一个类,提供了对 int 类型的原子操作。

JAVA线程安全中Atomic包有详细的描述。

JAVA线程的生命周期

  1. 初始(new):使用 new 关键字完成了实例化。

  2. 就绪(ready):调用了 .start() 方法,进入就绪状态,等待CPU分配对应的时间片。

  3. 运行中(running):分配到对应的CPU时间片,自动调用 run 方法,进入运行中。

  4. 阻塞(blocked):线程在等待执行,等待的原因有很多,比如I/O写入大文件,那么会等待写入,sleep()睡眠,Lock争执锁资源等等。

  5. 死亡(dead):线程运行完毕进入死亡状态,一般指执行完了 run 方法。

可以调用Thread类的run方法码?

new一个Thread,线程进入了初始状态,调用start(),线程进入就绪状态,分配到时间片后就会开始运行。

start()会执行线程相应的准备工作,然后自动执行run()方法的内容,这是多线程。

但是直接执行run()方法会把run()方法当成一个main线程(主线程)下的普通方法去执行,并不会在其他线程中执行它,所以这就不是多线程了。

调用start()方法可以启动线程并使线程进入运行状态,然后自动执行run()方法

直接调用run()方法则是在主线程下执行,并不会以多线程的方式执行

sleep() 方法和 wait() 方法对比

sleep()与wait()的区别

  • 共同点

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

  • 区别

  1. sleep()没有释放锁,而wait()释放了锁(当前线程占有的对象锁)

  2. wait()通常被用于线程间交互/通信,sleep()通常被用于线程暂停执行

  3. wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或notifyAll(),当然,可以使用wait(long timeout)超时后线程会自动苏醒

  4. sleep()方法执行完成后,线程会自动苏醒

  5. sleep()是 Thread 类的静态本地方法,wait()是Object类的本地方法

为什么wait()方法不定义在Thread中?

wait()是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁

每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁,并让其进入WAITING状态,自然操作对应的对象(Object),而非当前的线程(Thread)

为什么sleep()方法定义在Thread中是一样的道理

sleep()是让当前线程暂停执行,操作的是线程,不涉及到对象类,也不需要获取对象锁。

什么是悲观锁

悲观锁是一种思想,它总是假设每次都是最坏的情况。

认为共享资源每次被访问都会发生问题,所以在每次对资源的操作都会加锁。

这样当一个线程拿到资源后,其他线程获取该资源都会进入阻塞状态,需要等待该资源被释放。

java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

public void performSynchronisedTask() {
    synchronized (this) {
        // 需要同步的操作
    }
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
   // 需要同步的操作
} finally {
    lock.unlock();
}

在高并发的场景下,激烈的锁竞争会造成大量大线程阻塞,线程阻塞会导致系统频繁的触发线程上下文切换,增加系统的性能开销。

并且悲观锁会存在死锁问题,影响程序的运行。

什么是乐观锁?

乐观锁是一种思想,它总是假设每次都是最好的情况。

认为共享资源每次都被访问都不会出现问题,线程可以不停的执行,无需加锁,也无需等待。

只有在提交修改的时候去验证对应资源是否被其他线程修改了

java 中 java.util.concurrent.atomic 包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS 实现的。

// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好 // 代价就是会消耗更多的内存空间(空间换时间) 
LongAdder sum = new LongAdder();
sum.increment();

高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁的问题,在性能上往往会更胜一筹。

但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败和重试,这样同样会非常影响性能,导致 CPU 飙升。

乐观锁一般会使用版本号机制或CAS算法实现。

版本号机制

比如数据库中加一个数据的版本号version字段。

然后修改的时候先获取,提交修改时对比版本号是否一致。

如果一致,修改版本号的值,然后提交修改。

如果不一致,表示中途被其他线程修改,应该要驳回本次请求。

CAS算法

CAS的全称时 Compare And Swap(比较与交换),用于实现乐观锁,被广泛的应用各大框架中。

CAS的思想就是用一个预期值和要更新的变量值进行比较,两值相等才会更新数据。

CAS是一个原子操作,底层依赖于一条CPU的原子指令。

CAS涉及到三个操作数:

  1. V:要更新的变量值(Var)

  2. E:预期值(Expected)

  3. N:拟写入的新值(New)

仅当V的值等于E时,CAS通过原子方式来将N的值更新到V。

如果不相等,说明V已经被其他线程更新,本线程应该放弃更新。

简单案例

int a = 1;

需要将 i 的值更新为 6

那么 V = a,E = 1,N = 6

V与E进行比较,相等,说明 a 还没有被其他线程修改,可以将值更新为6

如果不相等,说明 a 已经被其他线程修改,本次放弃更新。

CAS算法的ABA问题

使用CAS算法时,因为V读取到的值是预期值,可能已经发生修改了,比如1修改为5然后又被修改为1,那么CAS操作就会认为它从来没有被修改过,这就是ABA问题。

ABA问题的解决思路是在变量前面追加上版本上或者时间戳。

公平锁和非公平锁有什么区别?

  • 公平锁

锁被释放之后,先申请的线程先得到锁。

性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

  • 非公平锁

锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。

性能更好,但可能会导致某些线程永远无法获取到锁

共享锁和独占锁有什么区别?

  • 共享锁

一把锁可以被多个线程同时获得

  • 独占锁

一把锁只能被一个线程获得

读写