Java多线程基础知识
进程和线程区别
-
根本区别:进程是操作系统资源分配(CPU、内存等)的基本单位,而线程是处理器任务调度和执行的基本单位。
-
包含关系:通常一个进程都有若干个线程,至少包含一个线程。
-
内存分配:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK8之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈。
-
程序计数器主要有下面两个作用:
-
字节码解释器通过改变程序计数器的值,来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
-
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候,能够知道该线程上次运行到哪儿了。
所以,程序计数器私有主要是为了线程切换后能恢复到正确的执行位置。
-
-
虚拟机栈和本地方法栈为什么是私有的?
-
虚拟机栈:每个Java方法在执行的同时,会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在Java虚拟机栈中入栈和出栈的过程。
-
本地方法栈:和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。 在HotSpot虚拟机中合二为一。
所以,为了保证线程中的局部变量不被别的线程访问到,虚拟机栈和本地方法栈是线程私有的。
-
-
堆和元空间
- 堆和元空间是所有线程共享的资源;
- 堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存);
- 元空间主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
单核与多核CPU的进程与线程
单核CPU的进程与线程
-
实现多进程依靠于操作系统的进程调度算法,比如时间片轮转算法。例如有3个正在运行的程序(即三个进程),操作系统会让单核CPU轮流来运行这些进程,这样看起来就像多个进程同时在运行,从而实现多进程。
-
通常一个任务不光CPU上要花时间,IO上也要花时间(例如去数据库查数据,去抓网页,读写文件等)。 一个进程在等IO的时候,CPU是闲置的,另一个进程正好可以利用CPU进行计算。 多几个进程一起跑,可以把IO和CPU都跑满了。
-
单核CPU同一时间只能处理1个线程,只有1个线程在执行。多线程同时执行,是CPU快速的在多个线程之间切换实现的。CPU调度线程的时间足够快,就造成了多线程的“同时”执行。如果线程数非常多,CPU会在\(n\)个线程之间切换,消耗大量的CPU资源。每个线程被调度的次数会降低,线程的执行效率降低。
-
一个拥有两个线程的进程的执行时间,可能比一个线程的进程执行两遍的时间还长一点。因为线程的切换也需要时间。即采用多线程可能不会提高程序的运行速度,反而会降低速度,但是对于用户来说,可以减少用户的响应时间。
多核CPU的进程与线程
-
多核CPU是一枚处理器(同一时间只有1个线程在执行)中集成多个完整的计算引擎(内核)。多核CPU和单核CPU对于进程来说都是并发,并不是并行。
-
但是多核CPU每一个核心都可以独立执行一个线程,所以多核CPU可以真正实现多线程的并行。例如,四核可以把
线程1、2、3、4
分配给核心1、2、3、4
,如果还有线程5、6、7,就要等待CPU的调度。线程1、2、3、4
属于并行;如果一会核心1停止执行线程1,改为执行线程5,那线程1、5
属于并发。
并行和并发
-
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。
-
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并发编程三个核心问题
上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。
时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像同时读两本书,当在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。
这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。并发执行的速度可能会比串行慢,因为线程有创建和上下文切换的开销!
如何减少上下文切换
-
无锁并发编程
多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁。 -
CAS算法
Java的Atomic包使用CAS算法来更新数据,而不需要加锁。 -
协程
在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换 -
使用最少的线程
并发编程适用的场景
多线程不一定就比单线程高效,比如Redis,因为它是基于内存操作,这种情况下,单线程可以很高效的利用CPU。而多线程的使用场景一般时存在相当比例的I/O或网络操作!
CPU密集型程序
定义:一个完整请求,I/O操作可以在很短时间内完成,CPU还有很多运算要处理,也就是说CPU计算的比例占很大一部分!
使用场景:计算1+2+….100亿
的总和。
单核CPU
在单核CPU下,如果创建4个线程来分段计算,即:线程1计算 [1,25亿)
;...线程4计算 [75亿,100亿]
:
由于是单核CPU,所有线程都在等待CPU时间片。按照理想情况来看,四个线程执行的时间总和与一个线程5独自完成是相等的,但不能忽略了四个线程上下文切换的开销!
所以,单核CPU处理CPU密集型程序,这种情况并不太适合使用多线程!
多核CPU
此时如果在4核CPU下,同样创建四个线程来分段计算:
每个线程都有CPU来运行,并不会发生等待CPU时间片的情况,也没有线程切换的开销。理论情况来看效率提升了4倍。
所以,如果是多核CPU处理CPU密集型程序,完全可以最大化的利用CPU核心数,应用并发编程来提高效率!
创建线程的合适数量
线程数量 = CPU核数(逻辑)+ 1
I/O密集型程序
定义:与CPU密集型程序相对,一个完整请求,CPU运算操作完成之后,还有很多I/O操作要做,也就是说I/O操作占比很大部分!
在进行I/O操作时,CPU是空闲状态,所以要最大化的利用CPU,不能让其是空闲状态。
在单核CPU的情况下:
从上图中可以看出,每个线程都执行了相同长度的CPU耗时和I/O耗时,如果将上面的图多画几个周期,CPU操作耗时固定,将I/O操作耗时变为CPU耗时的3倍,那么CPU又有空闲了(在CPU空闲时,创建新线程执行任务,避免使其空闲!),这时就可以新建线程4,来继续最大化的利用CPU。
创建线程的合适数量
一个CPU核心的最佳线程数:最佳线程数 = (1/CPU利用率)
= 1 + (I/O耗时/CPU耗时)
多个CPU核心的最佳线程数:最佳线程数 = CPU核心数
* (1/CPU利用率)
= CPU核心数
* (1 + (I/O耗时/CPU耗时))
线程安全问题
线程安全问题只在多线程环境下才出现,单线程串行执行不存在此问题。保证高并发场景下的线程安全,可以从以下四个维度考量:
-
数据单线程内可见
。单线程总是安全的。通过限制数据仅在单线程内可见,可以避免数据被其他结程篡改。- 最典型的就是
线程局部变量
,它存储在独立虚拟机栈帧的局部变量表中,与其他线程毫无瓜葛。ThreadLocal
就是采用这种方式来实现线
程安全的。
- 最典型的就是
-
只读对象
。只读对象总是安全的。它的特性是允许复制、拒绝写人。最典型的只读对象有String
、Integer
等。一个对象想要拒绝任何写人,必须要满足以下条件:- 使用
final
关键字修饰类,避免被继承,使用private final
关键字避免属性被中途修改;没有任何更新方法;返回值不能可变对象为引用。
- 使用
-
线程安全类
。某些线程安全类的内部有非常明确的线程安全机制。比如StringBuffer
就是一个线程安全类,它采用synchronized
关键字来修饰相关方法。 -
同步与锁机制
。如果想要对某个对象进行并发更新操作,但又不属于上述三类,需要开发工程师在代码中实现安全的同步机制。虽然这个机制支持的并发场景很有价值,但非常复杂且容易出现问题。
线程安全的核心理念就是要么只读
、要么加锁
。合理利用好JDK提供的并发包,往往能化腐朽为神奇。并发包主要分成以下几个类族:
-
线程同步类
。这些类使线程间的协调更加容易,支持了更加丰富的线程协调场景,逐步淘汰了使用Object
的wait()
和notify()
进行同步的方式。主要代表为CountDownLatch
、Semaphore
、CyclicBarrier
等。 -
并发集合类
。集合并发操作的要求是执行速度快,提取数据准。最著名的类非ConcurrentHashMap
莫属,它不断地优化,由刚开始的锁分段到后来的CAS,不断地提升并发性能。其他还有ConcurrentSkipListMap
、CopyOnWriteArrayList
、BlockingQueue
等。 -
线程管理类
。虽然Thread
和ThreadLocal
在JDKl.O 就已经引入,但是真正把Thread发扬光大的是线程池
。根据实际场景的需要,提供了多种创建线程池的快捷方式,如使用Executors静态工厂
或者使用ThreadPoolExecutor
等。另外,通过ScheduledExecutorService
来执行定时任务。 -
锁相关
。锁以Lock接口
为核心,派生出在一些实际场景中进行互斥操作
的锁相关类。最有名的是ReentrantLock
。
什么是锁
并发包中的锁类
并发包的类族中,Lock
是JUC包的顶层接口,它的实现逻辑并未用到synchronized,而是利用了volatile
的可见性
。
在Lock的继承类图中,ReentrantLock
对于Lock接口
的实现主要依赖了Sync
,而Sync
继承了AbstractQueuedSynchronizer
(AQS), 它是JUC包实现同步的基础工具。
在AQS中, 定义了一个volatile int state
变量作为共享资源
:
- 如果线程获取资源失败,则进入同步FIFO队列中等待;
- 如果成功获取资源就执行临界区代码。
- 执行完释放资源时,会通知同步队列中的等待线程,来获取资源后出队并执行。
AQS是抽象类,内置自旋锁实现的同步队列,封装入队和出队的操作,提供独占、共享、中断等特性的方法。AQS的子类可以定义不同的资源,实现不同性质的方法:
-
可重入锁
ReentrantLock
, 定义state
为0时可以获取资源并置为1。若已获得资源,state
不断加1,在释放资源时state减1,直至为0; -
CountDownLatch初始时定义了资源总量
state=count
,countDown()
不断将state减1,当state=0
时才能获得锁,释放后state就一直为0。所有线程调用await()
都不会等待,所以CountDownLatch
是一次性的,用完后如果再想用就只能重新创建一个,如果希望循环使用,推荐使用基于ReentrantLock
实现的CyclicBarrier
。 -
Semaphore
与CountDownLatch
略有不同,同样也是定义了资源总量state=permits
,当state>0
时就能获得锁,并将state减1,当state=0
时只能等待其他线程释放锁,当释放锁时state加1,其他等待线程又能获得这个锁。- 当Semphore的permits定义为
1
时,就是互斥锁
,当permits>1
就是共享锁
。
- 当Semphore的permits定义为
利用同步代码块
同步代码块
一般使用Java的synchronized关键字
来实现,有两种方式对方法进行加锁操作
:
- 在
方法签名
处加synchronized关键字; - 使用
synchronized(对象或类)
进行同步。
这里的原则是锁的范围尽可能小,锁的时间尽可能短,即能锁对象,就不要锁类;能锁代码块,就不要锁方法。
synchronized锁特性由JVM负责实现。在JDK的不断优化迭代中,synchronized锁的性能得到极大提升,特别是偏向锁
的实现,使得synchronized已经不是昔日那个低性能且笨重的锁了。
JVM底层是通过监视锁
来实现synchronized同步的。监视锁即monitor
,是每个对象与生俱来的一个隐藏字段。使用synchronized时,JVM会根据synchronized的当前使用环境,找到对应对象的monitor,再根据monitor的状态进行加、解锁的判断。
例如,线程在进入同步方法或代码块时,会获取该方法或代码块所属对象的monitor,进行加锁判断。如果成功加锁就成为该monitor的唯一持有者。monitor在被释放前,不能再被其他线程获取。
方法元信息中会使用ACC_SYNCHRONIZED
标识该方法是一个同步方法
。同步代码块
中会使用monitorenter
及monitorexit
两个字节码指令获取和释放monitor。
JVM对synchronized的优化主要在于对monitor的加锁、解锁上。JDK6后不断优化使得synchronized提供三种锁的实现,包括偏向锁
、轻量级锁
、重量级锁
,还提供自动的升级和降级机制。JVM就是利用CAS在对象头上设置线程ID
,表示这个对象偏向于当前线程,这就是偏向锁。
线程同步
在多个线程对同一变量进行写操作时,如果操作没有原子性,就可能产生脏数据。
所谓原子性是指不可分割的一系列操作指令,在执行完毕前不会被任何其他操作中断,要么全部执行,要么全部不执行。如果每个线程的修改都是原子操作,就不存在线程同步问题。
有些看似非常简单的操作其实不具备原子性,典型的就是i=++
操作,它需要分为三步
,即ILOAD → IINC → ISTORE
。另一方面,更加复杂的CAS(Compare and Swap)操作却具有原子性。