Java一多线程
目录:
一、进程与线程的概念
二、多线程的概念
三、多线程所存在的问题(线程安全问题、上下文切换)
四、多线程的三大核心
五、解决多线程的线程安全问题
六、优化多线程的上下文切换问题
一、进程与线程
1.进程
一个可并发执行的具有独立功能的程序关于某个数据集合的一次执行过程,也是操作系统进行资源分配和保护的基本单位。简单的说,进程就是一个程序的一次执行过程。
2.引入线程的动机和思路
操作系统采用进程机制使得多任务能够并发执行,提高了资源使用和系统效率。在早期操作系统中,进程是系统进行资源分配的基本单位,也是处理器调度的基本单位,进程在任一时刻只有一个执行控制流,这种结构称为单线程进程。单线程进程调度时存在进程时空开销大、进程通信代价大、进程并发粒度粗、不适合于并发计算等问题,操作系统引入线程机制来解决这些问题。线程机制的基本思路是,把进程的两项功能——独立分配资源和被调度分派执行分离开来,后一项任务交给线程实体完成。这样,进程作为系统资源分配与保护的独立单位,不需要频繁切换;线程作为系统调度和分派的基本单位会被频繁的调度和切换。
3.线程定义
线程是操作系统进程中能够独立执行的实体,是处理器调度和分派的基本单位。线程是进程的组成部分,每个进程内允许包含多个并发执行的线程。同一个进程中所有的线程共享进程的主存空间和资源,但是不拥有资源。
4.进程和线程的区别
- 定义方面:进程是程序在某个数据集合上的一次执行过程;线程是进程中的一个执行路径。
- 角色方面:在支持线程机制的系统中,进程是系统资源分配的单位,线程是系统调度的单位。
- 资源共享方面:进程之间不能共享资源,而线程共享所在进程的地址空间和其它资源。同时线程还有自己的栈和栈指针,程序计数器等寄存器。
- 独立性方面:进程有自己独立的地址空间,而线程没有,线程必须依赖于进程而存在。
二、多线程的概念
线程就是进程中的一个负责程序执行的一个控制单元(执行路径)。一个进程中可以有多个执行路径,称之为多线程。
三、多线程所存在的问题
线程安全问题:
非线程安全会在多个线程对同一个对象中的实例变量进行并发访问时发生,产生的后果就是脏读,也就是取到的数据其实是被更改过的。而“线程安全”就是以获得的实例变量的值是经过同步处理,不会出现脏读的现象。
线程安全问题产生的原因:
1.多个线程在操作共享的数据。
2.操作共享数据的线程代码有多条。当一个线程在执行操作共享数据的多条代码过程中,其他线程参与了运算。就会导致线程安全问题的产生。
解决思路:
将多条操作共享数据的线程代码封装起来,当有线程在执行这些代码的时候,其他线程不可以参与运算。必须要当前线程把这些代码都执行完毕之后,其他线程才可以参与运算。
上下文切换
有时也称做进程切换或任务切换,是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)。上下文是指某一时间点 CPU 寄存器和程序计数器的内容。
寄存器是cpu内部的少量的速度很快的闪存,通常存储和访问计算过程的中间值提高计算机程序的运行速度。
程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体实现依赖于特定的系统。
举例说明 线程A - B
1.先挂起线程A,将其在cpu中的状态保存在内存中。
2.在内存中检索下一个线程B的上下文并将其在 CPU 的寄存器中恢复,执行B线程。
3.当B执行完,根据程序计数器中指向的位置恢复线程A。
CPU通过为每个线程分配CPU时间片来实现多线程机制。CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。
但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的,意味着此操作会消耗大量的 CPU 时间,故线程也不是越多越好。如何减少系统中上下文切换次数,是提升多线程性能的一个重点课题。
四、多线程的三大核心
多线程三大核心就是原子性、可见性、有序性,我们在处理多线程的线程安全问题的时候,实际上也就是在解决这三大特性的问题。
1、 原子性
Java 的原子性就和数据库事务的原子性差不多,一个操作中要么全部执行成功或者失败。
JMM 只是保证了基本的原子性,但类似于 i++ 之类的操作,看似是原子操作,其实里面涉及到:
-
获取 i 的值。
-
自增。
-
再赋值给 i。
这三步操作,所以想要实现 i++ 这样的原子操作就需要用到 synchronized 或者是 lock 进行加锁处理。
如果是基础类的自增操作可以使用 AtomicInteger 这样的原子类来实现(其本质是利用了 CPU 级别的 的 CAS 指令来完成的)。
其中用的最多的方法就是: incrementAndGet() 以原子的方式自增。 源码如下:
public final long incrementAndGet() {
for (;;) {
long current = get();
long next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
首先是获得当前的值,然后自增 +1。接着则是最核心的 compareAndSet() 来进行原子更新。
public final boolean compareAndSet(long expect, long update) {
return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
其逻辑就是判断当前的值是否被更新过,是否等于 current,如果等于就说明没有更新过然后将当前的值更新为 next,如果不等于则返回false 进入循环,直到更新成功为止。
还有其中的 get() 方法也很关键,返回的是当前的值,当前值用了 volatile 关键词修饰,保证了内存可见性。
private volatile int value;
2、可见性
可见性,也指内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。现代计算机中,由于 CPU 直接从主内存中读取数据的效率不高,所以都会对应的 CPU 高速缓存,先将主内存中的数据读取到缓存中,线程修改数据之后首先更新到缓存,之后才会更新到主内存。每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。如果此时还没有将数据更新到主内存其他的线程此时来读取就是修改之前的数据。请结合Java-内存模型基础知识来理解
3、顺序性
以下这段代码:
int a = 100 ; //1
int b = 200 ; //2
int c = a + b ; //3
正常情况下的执行顺序应该是 1>>2>>3。但是有时 JVM 为了提高整体的效率会进行指令重排导致执行的顺序可能是 2>>1>>3。但是 JVM 也不能是什么都进行重排,是在保证最终结果和代码顺序执行结果一致的情况下才可能进行重排。
重排在单线程中不会出现问题,但在多线程中会出现数据不一致的问题。
Java 中可以使用 volatile 来保证顺序性,synchronized 和 lock 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的。
除了通过 volatile 关键字显式的保证顺序之外, JVM 还通过 happen-before 原则来隐式的保证顺序性。
其中有一条就是适用于 volatile 关键字的,针对于 volatile 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。
五、解决多线程的线程安全问题
volatile
volatile主要有以下两个功能:
- 保证变量的内存可见性
- 禁止volatile变量与普通变量重排序。
在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。
应用场景参考:https://blog.csdn.net/xichenguan/article/details/119425408
synchronized
说到锁,我们通常会谈到synchronized这个关键字。它翻译成中文就是“同步”的意思。
我们通常使用synchronized关键字来给一段代码或一个方法上锁。它通常有以下三种形式:
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在静态方法上,锁为当前Class对象
public static synchronized void classLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
我们这里介绍一下“临界区”的概念。所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。
通过上面的例子我们可以看到,下面这两个写法其实是等价的作用:
// 关键字在实例方法上,锁为当前实例
public synchronized void instanceLock() {
// code
}
// 关键字在代码块上,锁为括号里面的对象
public void blockLock() {
synchronized (this) {
// code
}
}
synchronized保证了原子性、顺序性和可见性。
synchronized是可以保证可见性的,但是前提是"大家"通过了同一把锁,很显然单例模式中双重检查锁singleton的代码不满足这个条件,所以需要volatile来保证可见性。
Atomic原子类型
Lock
六、优化多线程的上下文切换问题
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
无锁并发编程:
多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
CAS算法:
Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
使用最少线程(合理的线程池大小):
避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
协程:
在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。