
JAVA并发编程
程序、进程与线程
程序:从最基础的角度说,程序是指一组为了完成特定任务而编写的指令集或代码合计。比如Windows中的exe文件。
进程:一个程序被执行,操作系统就会创建一个进程。进程是一个正常运行的程序实例。比如Windows任务管理器中的进程。
线程:一个进程至少包含一个线程,即主线程。线程也是CPU分配与调度的基本单位。
并发和并行
并发:并发指的是在同一个时间段内处理多个任务的能力。这些任务可能看起来像是同时进行的,但实际上它们是在快速交替执行的。
从硬件的角度来说,单核处理器一次只能执行一个线程,但通过操作系统的时间片轮转调度,处理器可以在多个线程之间快速切换,造成“同时执行”多个线程的假象。
并行:指的是真正同时执行多个任务或线程的能力,这通常需要硬件支持,比如多核处理器。
当提到并行时,我们通常是在说利用这些多个核心来同时执行代码,以加快计算速度。
并发与并行最关键的区别点是:是否同时执行。
同步和异步
同步:发出调用之后,没有得到结果之前,该调用不可以返回,需一直等待。
异步:发出调用之后,不用等待返回结果,该调用直接返回,可以执行下一步。
线程上下文切换
线程在执行过程中会有自己的运行条件和状态(也就是上下文)。
线程从占用CPU的状态中退出,这时会保存线程的上下文,并加载下一个将要占用CPU的线程上下文,这就完成了线程上下文切换。
也就是当前正在执行的线程发生了切换。
保存的上下文用于下次线程占用CPU时可以恢复执行。
什么时候线程会从占用CPU的状态中退出?
线程主动退出CPU占用,如调用了sleep(),wait()等
调用阻塞类型的系统中断,如请求IO,线程被阻塞
时间片用完,这个与操作系统相关,操作系统为了防止一个线程或进程长期占用CPU而导致其它线程与进程没有资源。
被终止或结束运行
为什么要使用多线程?
从计算机来讲,线程是程序执行的最小单位,线程之间的切换和调用的成本低于进程。
从项目上来讲,多线程并发可以提高系统整体的并发能力与性能,可以支持更多的用户。
比如使用Windows右键删除有N个小文件的文件夹时,删除很缓慢,因为Windows使用的是单线程删除。而使用FastCopy进行多线程删除效率非常高,肉眼可见。
线程创建在JAVA线程和线程池中有详细的解释。
什么是JUC?
JDK1.5以后提供了一个并发工具包 java.util.concurrent ,简称JUC包。这个包提供了用于并发编程的工具类和接口,皆在帮助开发者更高效地编写并发程序,简化多线程编程的复杂度。
java.util.concurrent 包中包含多个重要组件,如:
Executor(调度器):提供了线程池管理能力,可以有效的管理和调度线程执行。
锁机制:除了synchronized关键字之外,还提供了重入锁ReentrantLock。
并发集合:提供了ConcurrentHashMap、CopyOnWriterArrayList等线程安全的集合类。
原子变量类:如AtoomicInteger、AtomicLong等,支持无锁的线程安全编程模型。
什么是线程池?
线程池是一种设计模式,它通过预先创建一组线程并将其保持在一个池中,这些线程在执行任务的时候被使用,执行完毕后,不会销毁,而是进入空闲状态,等待下一个任务的到来。
使用线程池的优点:
减少资源开销:复用已存在的线程,避免了频繁创建和销毁线程的资源开销。
提高响应速度:因为线程已经存在在线程池中,所以执行任务的时候可以立即开始,无需等待线程创建。
提高稳定性:线程池往往限制了最大线程的数量,也就是说资源占用是可控的。如果不使用线程池,可能会出现大量的新线程创建,从而击溃服务器。
线程池这种设计理念在很多地方都有使用,比如MySQL的连接池与之十分相似,只不过线程池管理的是线程,MySQL连接池管理的是数据库连接。
什么是线程不安全?
多线程环境下对同一份数据的访问是否能够保证其正确性和一致性。
安全代表着无论多少格线程同时访问一份数据,都不会出现数据混乱、错误或者丢失等问题。
这个问题通常对公共资源加锁来解决。(Synchronized/ReentrantLock/JUC包中其他工具类)
JUC中的锁与工具包在JAVA线程安全里面有详细的描述。
原子性
原子性是指一个操作或多个操作要么同时执行,执行过程中不会被任何因素打断,要么就都不执行。
我们常说的事务就具备原子性,想要保障线程安全也就是要具备原子性。
JUC提供了一个Atomic包,里面有多个不同数据类型的原子操作类,比如AtomicInteger 就是 java.util.concurrent.atomic 包中的一个类,提供了对 int 类型的原子操作。
在JAVA线程安全中Atomic包有详细的描述。
JAVA线程的生命周期
初始(new):使用 new 关键字完成了实例化。
就绪(ready):调用了 .start() 方法,进入就绪状态,等待CPU分配对应的时间片。
运行中(running):分配到对应的CPU时间片,自动调用 run 方法,进入运行中。
阻塞(blocked):线程在等待执行,等待的原因有很多,比如I/O写入大文件,那么会等待写入,sleep()睡眠,Lock争执锁资源等等。
死亡(dead):线程运行完毕进入死亡状态,一般指执行完了 run 方法。
可以调用Thread类的run方法码?
new一个Thread,线程进入了初始状态,调用start(),线程进入就绪状态,分配到时间片后就会开始运行。
start()会执行线程相应的准备工作,然后自动执行run()方法的内容,这是多线程。
但是直接执行run()方法会把run()方法当成一个main线程(主线程)下的普通方法去执行,并不会在其他线程中执行它,所以这就不是多线程了。
调用start()方法可以启动线程并使线程进入运行状态,然后自动执行run()方法
直接调用run()方法则是在主线程下执行,并不会以多线程的方式执行
sleep() 方法和 wait() 方法对比
sleep()与wait()的区别
共同点
两者都可以暂停线程的执行。
区别
sleep()没有释放锁,而wait()释放了锁(当前线程占有的对象锁)
wait()通常被用于线程间交互/通信,sleep()通常被用于线程暂停执行
wait()方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()或notifyAll(),当然,可以使用wait(long timeout)超时后线程会自动苏醒
sleep()方法执行完成后,线程会自动苏醒
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涉及到三个操作数:
V:要更新的变量值(Var)
E:预期值(Expected)
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问题的解决思路是在变量前面追加上版本上或者时间戳。
公平锁和非公平锁有什么区别?
公平锁
锁被释放之后,先申请的线程先得到锁。
性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
非公平锁
锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。
性能更好,但可能会导致某些线程永远无法获取到锁
共享锁和独占锁有什么区别?
共享锁
一把锁可以被多个线程同时获得
独占锁
一把锁只能被一个线程获得
读写