这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
Synchronized 不是读写锁.
重量级锁:主要是依赖了 操作系统 提供的 锁;使用这种锁,就容易产生阻塞等待.
轻量级锁:主要尽量的避免使用 操作系统 提供的 锁;而是尽量在用户态来完成功能,尽量避免 用户态 和 内核态 的切换,尽量避免挂起等待.
synchronized 是自适应锁,既是轻量级锁,又是重量级锁.
当冲突程度不高时,是轻量级锁;当冲突程度很高时,是重量级锁.
自旋锁:当发现锁冲突的时候,不会挂起等待,会迅速再来尝试看这个锁能不能获取到!
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
自旋锁:更轻量,乐观锁
- 一旦锁被释放,就可以第一时间获取到.
- 如果锁一直不释放,就会消耗大量的 CPU.
挂起等待锁:更重量,悲观锁
- 一旦锁被释放,就不能第一时间获取到.
- 在锁被其他线程占用的时候,会放弃 CPU 资源.
synchronized 作为轻量级锁的时候,内部是自旋锁;作为重量级锁的时候,内部是挂起等待锁.
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生什么呢?
注意:操作系统内部对于挂起等待锁,就是非公平的;如果想要使用公平锁,就需要搞额外的数据结构来进行控制实现.
synchronized 是非公平锁.
例1:
package thread;
public class Demo26 {
private static void func() {
// 进行一些多线程操作..........
// 第一次加锁
synchronized (Demo26.class) {
// 第二次加锁
synchronized (Demo26.class) {
}
}
}
public static void main(String[] args) {
func();
}
}
对于例1代码:
第一次加锁能够成功,此时 Demo26.class 处于被加锁的状态;
第二次加锁的时候,由于 Demo26.class 已经处于被加锁的状态;
按照之前的理解,这里加锁就会阻塞等待,需要等待第一次加锁释放,第二次加锁才能成功;但是第一次加锁释放需要第二次加锁成功后,执行完才能释放掉,这就形成了一个逻辑上的循环,即死锁.
为了避免上述问题,就引入了 “可重入锁”:一个线程,可以对同一个锁,反复加锁多次!
可重入锁:
在内部记录这个锁是哪个线程获取到的,如果发现当前加锁的线程和持有锁的线程是同一个,则不挂起等待,而是直接获取到锁.
同时还会给锁内部加上一个计数器,记录当前是第几次加锁,只有当计数器为 0 时,才会真正释放锁.
synchronized 是可重入锁.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.
读写锁就是把读操作和写操作区分对待.
其中,
读写锁最主要用在 “频繁读, 不频繁写” 的场景中.
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相对于挂起等待锁:
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁. 实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数).
如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增.
CAS 是操作系统 / 硬件,给 JVM 提供的另外一种 更轻量的 原子操作的机制.
CAS 是 CPU 提供的一个特殊指令:Compare and swap(比较并交换).
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.
例2:CAS 伪代码
// address:内存地址
// expecteValue:用来比较的值(寄存器)
// swapValue:用来交换的值(另一个寄存器)
boolean CAS(address, expecteValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
例3:
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class Demo27 {
// private static int count = 0;
public static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// count++;
// 这个方法相当于 count++
count.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count); // 10_0000
}
}
例4:getAndIncrement 伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
例5:自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
当 owner 为 null 的时候,CAS 才能成功,循环结束;
当 owner 为 非null,则说明当前的锁已经被其他线程占用了,就要继续循环(自旋).
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A.
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A.
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 我们 有 1000 存款. 想从 ATM 取 500 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
正常情况:
- 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
- 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 500, 和之前读到的 1000 不相同, 执行失败.
异常情况:
- 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
- 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 500, 账户余额变成 1000 !!
- 轮到线程2 执行了, 发现当前存款为 1000, 和之前读到的 1000 相同, 再次执行扣款操作.
本来只想扣款一次,但是实际上扣款了两次!!!
引入了 “版本号” 来解决 ABA 问题!
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑.
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增;如果发现当前版本号比之前读到的版本号大, 就认为操作失败.
synchronized 使用的所策略:
synchronized 在加锁的时候要经历几个阶段:
无锁(没加锁)
偏向锁(刚开始加锁,未产生竞争的时候)
轻量级锁(产生锁竞争了)
重量级锁(锁竞争的更激烈了)
偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.
这个过程类似于单例模式 “懒汉模式”,必要的时候再加锁,节省开销.
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
锁消除:编译器自动判定,如果认为这个代码没必要加锁,就不加了.
例6:
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.
锁的粒度:synchronized 包含的代码范围是大还是小.
范围越大,粒度约粗;范围约小,粒度约细.
举例:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.
偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
参考该章节全部内容!
和 Runnable 非常相似.都是可以在创建线程的时候,来指定一个 “具体的任务”.
例7:创建线程计算 1 + 2 + 3 + … + 1000
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo28 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i < 1000; i++) {
sum += i;
}
return sum;
}
};
// 套上一层,目的是为了获取到后续的结果.
FutureTask<Integer> task = new FutureTask<Integer>(callable);
Thread t = new Thread(task);
t.start();
// 在线程 t 执行结束之前,get 会阻塞等待,直到 t 执行完了,结果算好了.
// get 才能返回,返回值是 call 方法 return 的内容.
System.out.println(task.get()); // 499500
}
}
介绍下 Callable 是什么
Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 和 synchronized 的区别:
例8:
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class Demo29 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
try {
// 加锁
// locker.lock();
locker.tryLock(); // 加锁失败,不会死等.
// 代码...... 如果中间抛出异常了,就可能执行不到 unlock
} finally {
// 解锁
locker.unlock();
}
}
}
如何选择使用哪个锁?
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); // i += delta;
decrementAndGet(); // --i;
getAndDecrement(); // i--;
incrementAndGet(); // ++i;
getAndIncrement(); // i++;
例9:
package thread;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo24 {
public static void main(String[] args) {
ExecutorService threadPool = Executors.newFixedThreadPool(10);
// Executors.newCachedThreadPool();
threadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
Executors 创建线程池的几种方式
Executors 本质上是 ThreadPoolExecutor 类的封装.
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理.
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
可用把信号量视为一个更加广义的锁,当信号量的取值为 0 ~ 1 的时候,就退化成了一个普通的锁.
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
例10:
package thread;
import java.util.concurrent.Semaphore;
public class Demo30 {
public static void main(String[] args) throws InterruptedException {
// 构造方法传入有效资源的个数
Semaphore semaphore=new Semaphore(3);
// P 操作 申请资源
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 打印
semaphore.acquire();
System.out.println("申请资源"); // 没有资源,无法打印
// V 操作 释放资源
// semaphore.release();
}
}
相当于,在一个大任务被拆分为若干的子任务的时候,用这个来衡量什么时候这些子任务都执行结束.
例如:进行一次跑步比赛,CountDownLatch 描述什么时候所有人都通过终点.
例11:
package thread;
import java.util.concurrent.CountDownLatch;
public class Demo31 {
public static void main(String[] args) throws InterruptedException {
// 模拟跑步比赛
// 构造方法中设定有几个选手参赛
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int finalI = i;
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println(finalI + "号选手到达终点");
// countDown 相当于 “撞线”
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
}
// await 在等待所有的线程 “撞线”
// 即调用 countDown 的次数达到初始化的时候设定的值
// await 就返回. 否则 await 就阻塞等待!
latch.await();
System.out.println("比赛结束!");
}
}
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更 灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时 间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的 线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线 程.
参考 CAS - CAS 应用 - 实现原子类 - 例4:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.
参考 ThreadPoolExecutor 章节
理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
- corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
- maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
- keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
- unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
- workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
- threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
- RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
- AbortPolicy(): 超过负荷, 直接抛出异常.
- CallerRunsPolicy(): 调用者负责处理.
- DiscardOldestPolicy(): 丢弃队列中最老的任务.
- DiscardPolicy(): 丢弃新来的任务.
前面做过很多相关的讨论了. 此处不再展开.
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
CopyOnWriteArrayList:如果出现修改操作,就把 ArrayList 进行复制.
先拷贝一份数据,,新线程修改副本,再用副本替换原有的数据. 这样做的好处是我们可以对 CopyOnWrite
容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
缺点:
① ArrayBlockingQueue
基于数组实现的阻塞队列
② LinkedBlockingQueue
基于链表实现的阻塞队列
③ PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
④ TransferQueue
最多只包含一个元素的阻塞队列
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
HashTable 保证线程安全,直接 synchronized !!!
这相当于直接针对 HashTable 对象本身加锁.
相比于 Hashtable 做出了一系列的改进和优化(Java 8).
读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了 volatile 关键字.
这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment),
针对每个段分别加锁. 目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.
取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树.
很多同学会说:HashMay key允许为 null,另外两个不允许为 null
这个是区别,但是不是重要的区别!
应该从线程安全上开始:
- HashMap 线程不安全,HashTable 和 ConcurrentHash 线程安全.
- HashTable 是一把大锁,锁冲突的概率很高;ConcurrentHash 则是每个哈希桶一把锁,锁冲突的概率大大降低.
- 然后再说 ConcurrentHash 使用了 CAS 特性,以及扩容优化.
- 最后再说 HashMay key允许为 null,另外两个不允许为 null.
死锁是多线程代码中的常见 BUG!
尝试加锁的时候发现上次锁没有及时释放(因为一些原因,BUG),导致加锁加不上.
造成死锁的原因:
一个线程一把锁
两个线程两把锁
N 个线程 M 把锁
死锁的四个必要条件:
其中,前三条是在描述锁的基本特点;第四条和代码编写,密切相关,是可以通过注意解决的!
破坏循环等待
最常用的一种死锁阻止技术就是锁排序. 假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M).
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁. 这样就可以避免环路等待.
参考整个 “死锁” 章节
被问到什么是死锁,起手式别搞错了,千万不要上来就说 “死锁的四个必要条件”!
流程:
- 概况死锁的概念
- 产生死锁的三个典型常见
- 死锁的必要条件(一定要说出来 循环等待)
- 从 循环等待 的角度切入,对锁编号,并按顺序加锁,破坏循环等待.
volatile 能够保证内存可见性. 强制从主内存中读取数据. 此时如果有其他线程修改被 volatile 修饰的变量, 可以第一时间读取到最新的值.
JVM 把内存分成了这几个区域: 方法区, 堆区, 栈区, 程序计数器.
其中堆区这个内存区域是多个线程之间共享的.
只要把某个数据放到堆内存中, 就可以让多个线程都能访问到.
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
- 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.
LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.
- NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
- RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在 CPU 上运行/在即将准备运行 的状态.
- BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
- WAITING: 调用 wait 方法会进入该状态.
- TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
- TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态.
- 使用 synchronized / ReentrantLock 加锁
- 使用 AtomInteger 原子操作.
Servlet 本身是工作在多线程环境下.
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的.
Thread 类描述了一个线程.
Runnable 描述了一个任务.
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用 Runnable 来描述这个任务.
第一次调用 start 可以成功调用.
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常.
synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:
- 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到锁之后才能执行方法内容.
如果这两个方法属于不同实例:
- 两者能并发执行, 互不干扰.
进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
进程是系统分配资源的最小单位,线程是系统调度的最小单位。