目录
一、线程的上下文切换问题
1. 简介
在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时刻只能被一个线程使
用,为了让用户感觉多个线程是在同时执行的,CPU资源的分配采用了时间片轮转的策略,也就
是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。当前线程使用完时间片后,
就会处于就绪状态并让出CPU让其他线程占用,这就是上下文切换,从当前线程的上下文切换到
了其他线程。那么就有一个问题,让出 CPU 的线程等下次轮到自己占有CPU时如何知道自己之
前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场,当再次执行时
根据保存的执行现场信息恢复执行现场。
线程上下文切换时机有:当前线程的 CPU 时间片使用完处于就绪状态时,当前线程被其他线程
中断时。
简单来说:
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机
制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程
执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个 任务。
但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的
状态。所以任务从保存到再加载的过程就是一次上下文切换。
2. 多线程一定比单线程快?
我们知道,使用多线程,可以同时执行多个任务,从表面上看,多线程明显是要快于单线程的。
但是,多线程的创建,上下文的切换也是需要开销的,所以多线程不一定比单线程快,接下来我
们来看一个简单的测试用例。
该测试用例分别使用单线程和多线程进行 a 的递增,b 的递减操作,我们通过控制循环次数,来
比较相同次数下,串行和并行所花时间。
public class TimeTest {
public final int count = 1000000;
public static void main(String[] args) throws InterruptedException {
TimeTest timeTest = new TimeTest();
System.out.println("执行 " + timeTest.count + " 次");
timeTest.serial(); //串行
timeTest.parallel(); //并行
}
public void serial() {
int a = 0;
int b = 0;
long l = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
a++;
}
for (int i = 0; i < count; i++) {
b--;
}
System.out.println("串行----->" + (System.currentTimeMillis() - l));
}
public void parallel() throws InterruptedException {
int b = 0;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
int a = 0;
for (int i = 0; i < count; i++) {
a++;
}
}
});
long l = System.currentTimeMillis();
thread.start();
for (int i = 0; i < count; i++) {
b--;
}
thread.join(); //等thread线程执行完毕再输出时间差
System.out.println("并行----->" + (System.currentTimeMillis() - l));
}
}
输出结果:
count | 100 | 10000 | 100000 | 500000 | 1000000 |
单线程(ms) | 0 | 0 | 2 | 2 | 4 |
多线程 (ms) | 0 | 0 | 2 | 3 | 2 |
由上表可以发现,执行次数在 50w 次左右,单线程比多线程快,100w 左右,多线程比单线程
快,所以,多线程不一定比单线程快。
注:以上测试结果对比可能不太明显,计算时间差时可以使用 System.nanoTime(),单位精确到
纳秒级。
得出结论:
多线程似乎一直给我们这样的印象就是多线程比单线程快,其实这是一个伪命题。
事无绝对,多线程有时候确实比单线程快,但也有很多时候没有单线程那么快。
首先简单区分一下并发性 ( concurrency ) 和并行性 ( parallel ) 。
并行是说同一时刻有多条命令在多个处理器上同时执行。
并发是说同一时刻只有一条指令执行,只不过进程(线程)指令在 CPU 中快速轮换,速度极快,给人看
起来就是”同时运行”的印象,实际上同一时刻只有一条指令进行. 但实际上如果我们在一个应用程
序中使用了多线程,线程之间的轮换以及上下文切换是需要花费很多时间的,这样的话,当我们执
行类似循环之类的操作的时候,是不是就意味着单线程一定会比多线程快呢(因为单线程的执行没
有线程切换的时间消耗)
3. 如何减少上下文切换
减少上下文切换的方法有无锁并发编程、CAS 算法、使用最少线程和使用协程。
1. 无锁并发编程
多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一 些办法来避免使用
锁,如将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
2. CAS算法
Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
3. 使用最少线程
避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这 样会造成大量线程都
处于等待状态。
4. 协程
在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
二、线程安全问题
1. 什么是线程安全?
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也
不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获
得正确的结果,那就称这个对象是线程安全的。
该定义要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手
段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证
多线程环境下的正确调用。
2. java语言中的线程安全
线程安全,将以多个线程之间存在共享数据访问为前提。为了更深入地理解线程安全,我们不把
线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来
排序,可以将java语言中各种操作共享的数据分为以下五类:
- 不可变
- 绝对线程安全
- 相对线程安全
- 线程兼容
- 线程对立
2.1. 不可变
在java语言里(JDK5之后),不可变(Immutable)的对象一定是线程安全的,无论是对象的方
法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。
“final关键字带来的可见性”提到过:只要一个不可变的对象被正确的构建出来(即没有发生this引
用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于
不一致的状态。
“不可变”带来的安全性是最直接,最纯粹的。
java语言中,如果多线程共享数据分为两类:
- 基本数据类型:只要在定义时使用 final 关键字修饰它就可以保证它是不可变的。
- 对象类型:需要对象自行保证其行为不会对其状态产生任何影响。
- 可以类比 java.lang.String 类的对象实例,它是一个典型的不可变对象,用户调用它的 substring()、replace() 和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都
声明为final,这样构造函数结束之后,它就是不可变的,如下java.lang.Integer构造函数,通过将
内部状态变量value定义为final来保障状态不变。
/**
* The value of the {@code Integer}.
*
* @serial
*/
private final int value;
/**
* Constructs a newly allocated {@code Integer} object that
* represents the specified {@code int} value.
*
* @param value the value to be represented by the
* {@code Integer} object.
*/
public Integer(int value) {
this.value = value;
}
java类库API中符合不可变要求的类型:
- java.lang.String。
- 枚举类型。
- java.lang.Number的部分子类。
- Long和Double等数值包装类型。
- BigInteger和BigDecimal等大数据类型。
例外:同为 Number 子类型的原子类 AtomicInteger 和 AtomicLong 则是可变的。
为啥这样设计?
它们是并发包下的类,所以AtomicInteger和AtomicLong肯定是在并发环境下使用的,可以用它们
来保证并发环境下的原子性操作。
2.2. 绝对线程安全
绝对线程安全的定义是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额
外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。在 java API 中标注自己是线程
安全的类,大多数都不是绝对的线程安全。
我们可以通过 java API 中一个不是“绝对线程安全”的“线程安全类型”来看看这个语境里的“绝对”
究竟是什么意思。Java.util.Vector 是一个线程安全的容器,因为它的 add()、get() 和 size() 等方
法都是被 synchronized修饰的,尽管这样效率不高,但保证了具备原子性、可见性和有序性。
不过,即使它所有的方法都被修饰成 synchronized,也不意味着调用它的时候就永远都不再需要
同步手段了。
package com.zhengge.thread.security;
import java.util.Vector;
public class VectorTest {
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
});
removeThread.start();
printThread.start();
while (Thread.activeCount() > 20) {
}
}
}
}
Exception in thread "Thread-1007" Exception in thread "Thread-1008" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 9
at java.util.Vector.get(Vector.java:751)
at com.example.xuniji.VectorTest$2.run(VectorTest.java:32)
at java.lang.Thread.run(Thread.java:748)
java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 7
at java.util.Vector.remove(Vector.java:834)
at com.example.xuniji.VectorTest$1.run(VectorTest.java:23)
at java.lang.Thread.run(Thread.java:748)
尽管这里使用到的 Vector 的 get()、remove() 和 size() 方法都是同步的,但是在多线程的环境
中,如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全的。因为如果另一个线
程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出一个
ArrayIndexOutOfBoundsException 异常。
如果要保证这段代码能正确执行下去,我们不得不把 removeThread 和 printThread 定义成如下
所示:
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
}
});
Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
System.out.println(vector.get(i));
}
}
}
});
假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每
次对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的。
2.3. 相对线程安全
相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安
全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可
能需要在调用端使用额外的同步手段来保证调用的正确性。以上代码(Vector)就是相对线程安
全的案例。
在java语言中,大部分声称线程安全的类都属于这种类型,例如 Vector、HashTable、
Collections的synchronizedCollection()方法包装的集合等。
2.4. 线程兼容
线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对
象在并发环境中可以安全地使用。
平常我们说一个类不是线程安全的,通常就是指这种情况。
Java类库API中大部分的类都是线程兼容的,比如 ArrayList 和 HashMap。
2.5. 线程对立
线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。
java 语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,通常都是有
害的,应当尽量避免。
一个线程对立的例子是 Thread 类的 suspend() 和 resume() 方法。
如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进
行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险。
3. java实现线程安全的方法?
3.1. 互斥同步
互斥同步是最常见、最重要的并发正确性保障手段,也称为堵塞同步。同步是指在多条线路并发
访问共享数据时,保证共享数据在同一时间只能使用一条线路(或者使用信号量时)。互斥是实现
同步的手段,临界区、互斥量和信号量是常见的互斥实现方式。因此,在互斥同步这四个字中,
互斥是原因,同步是果实的互斥是方法,同步是目的。Java中,互斥同步手段是 synchronized
关键词和重新开锁。
3.2. 非阻塞同步
互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,互斥同步属于一种悲观的并
发策略,无论共享的数据是否会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚
拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检
查是否有被阻塞的线程需要被唤醒等开销。
随着硬件指令集的发展,锁被原子机器指令(如比较和交换指令)取代,以保证并发访问中数据的
一致性。这种乐观并发策略的实现不再需要挂起线程阻塞,所以这种同步操作称为非阻塞同步,
使用这种措施的代码通常称为无锁编程。
从 Java5.0 开始,原子变量类可以用来构建高效的非阻塞算法。
3.3. 无同步方案
要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。
同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那
它自然就不需要任何同步措施去保证其正确性。
一种避免使用同步的方式就是不共享数据。这种技术被称为线程封闭 ( ThreadConfinement ),
它是实现线程安全性的最简单方式之一。
Java可以通过 java.lang.ThreadLocal 类来实现线程本地存储的功能。
三、资源限制问题
1. 什么是资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
例如,服务器的带宽只有 2Mb/s,某个资源的下载速度是 1Mb/s 每秒,系统启动 10 个线程下载
资源,下载速度不会变成 10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源
限制有带宽的上传/下载速度、硬盘读写速度和 CPU 的处理速度。
软件资源限制有数据库的连接数和 socket 连接数等。
2. 资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行, 但是如
果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不 会加快
执行,反而会更慢,因为增加了上下文切换和资源调度的时间。
例如,之前看到一段程 序使用多线程在办公网并发地下载和处理数据时,导致 CPU 利用率达到
100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。
3. 如何解决资源限制
如何解决资源限制的问题 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资
源有限制,那么就让程序在多机上运行。
比如使用 ODPS、Hadoop 或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过
“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。对于软件资
源限制,可以考虑使用资源池将资源复用。
比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建
立一个连接。
4. 如何在资源限制情况下,让程序更快
在资源限制情况下进行并发编程如何在资源限制的情况下,让程序执行得更快呢?
方法就是,根据不同的资源限制调整程序的并发度。
- 比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。
- 有数据库操作时,涉及数据库连接数,如果 SQL 语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接。