学习目标
-
线程的概念
1.1. 线程和进程的区别
1.2. 串行、并行和并发的概念 -
线程的实现
-
线程的生命周期
-
线程的常用方法
-
线程的上下文切换
-
线程的安全(同步)问题
-
线程安全问题的解决方法
-
ThreadLocal的介绍
-
线程的等待和通知
-
生产者消费者模式
-
线程池
线程的概念
程序和进程的区别
- 程序是一种静态概念,是保存在磁盘上的一系列文件
- 进程是一种动态概念,是运行中的程序,一个程序包括一个或多个进程
进程和线程的区别
-
进程是程序执行相关资源(CPU、内存、磁盘等)分配的最小单元
进程之间是相互独立的,有自己的内存空间
-
线程是CPU资源分配的最小单元
进程包含一个或多个线程
线程需要的资源更少,可以看做是一种轻量级的进程
线程会共享进程中的内存,线程也有独立的空间(栈、程序计数器)
线程相互通信更加方便
串行、并行和并发
-
串行
多个指令依次执行
-
并发
每个线程单独执行一段指令,一个cpu在线程间切换(并不是同时执行)
-
并行
多个CPU内核同时执行多个线程,线程是同时执行的
线程的实现
Java几种实现线程的方式
- 继承Thread类
- 实现Runnable接口
- 实现Callable接口
- 使用线程池
继承Thread类
- 继承Thread类
- 重写run方法
- 调用start启动线程
/**
* 自定义线程类
*/
class MyThread extends Thread{
/**
* 执行指令
*/
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() +"执行了" + i);
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
//创建线程对象
MyThread myThread = new MyThread();
//启动线程
myThread.start();
//主线程执行
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() +"执行了" + i);
}
}
}
线程的run和start的区别
- run没有启动新线程,在主线程中执行
- start才能启动新线程
实现Runnable接口
- 实现Runnable接口
- 实现run方法
- 创建实现Runnable接口的对象,传入Thread对象中
- 启动线程
/**
* 实现Runnable接口的类
*/
class MyRunnable implements Runnable{
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "--" + i);
}
}
}
public class RunnableDemo {
public static void main(String[] args) {
//创建Runnable对象,传入Thread对象
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
//启动线程
thread.start();
//匿名内部类
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "--" + i);
}
}
});
thread2.start();
//Lambda表达式
new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "--" + i);
}
}).start();
}
}
继承Thread和实现Runnable的区别
- 继承Thread类,不能继承其它的类,语法有限制
- 实现Runnable接口,可以继承其它类,语法没有限制
- Runnalbe接口强制要求实现run方法,不容易出现错误
推荐实现Runnable方式
实现Callable接口
实现Callable接口可以返回值,继承Thread类和Runnable不行
-
实现Callable接口,实现call方法
-
创建Callable对象,传入FutureTask对象
-
创建FutureTask对象,传入Thread对象
-
启动线程
-
调用get方法得到返回结果
/**
* 实现Callable接口
*/
class MyCallable implements Callable<Long> {
@Override
public Long call() throws Exception {
//模拟复杂运算
long sum = 0;
for(long i = 0;i < 1000000000L;i++){
sum += i;
}
return sum;
}
}
public class CallableDemo {
public static void main(String[] args) {
//创建Callable对象,传入FutureTask对象
FutureTask<Long> task = new FutureTask<>(new MyCallable());
//创建FutureTask对象到Thread对象中
Thread thread = new Thread(task);
//启动线程
thread.start();
System.out.println("-----等待结果-----");
//获得返回值
try {
System.out.println(task.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
线程的生命周期
线程几种状态:
- 新建 NEW
- 准备/就绪 START
- 运行 RUNNING
- 阻塞 BLOCKING
- 死亡 DEAD
线程的常用方法
常用方法
方法 | 介绍 |
---|---|
start() | 启动 |
stop() | 停止(禁用,可能导致线程死锁等问题),停止线程可以让run执行结束 |
String getName() | 获得线程的名字 |
setName(String) | 设置线程名字 |
sleep(long) | 进入睡眠,毫秒 |
setPriority(int) | 设置线程的优先级(1~10从低到高)越高抢CPU几率更高 |
setDaemon(boolean) | 设置为后台线程 true ,后台线程是为其它线程服务的,如果没有其它线程存在,就自动死亡;使用案例:GC就是一种后台线程 |
join() | 线程的加入(合并)让其它线程先执行完,再执行自己的指令 |
线程的上下文切换
前提:一个CPU的内核一个时间只能运行一个线程中的一个指令
线程并发:CPU内核会在多个线程间来回切换运行,切换速度非常快,达到同时运行的效果
问题1:
线程切换回来后,如何从上次执行的指令后执行?
程序计数器(每个线程都有,用于记录上次执行的行数)
问题2:
线程执行会随时切换,如何保证重要的指令能完全完成?
线程安全问题
问题3:
CPU进行上下文切换的过程中,性能会降低。
线程的安全(同步)问题
CPU在多个线程间切换,可能导致某些重要的指令不能完整执行,出现数据的问题。
出现线程安全问题的三个条件:
- 多个线程
- 同一个时间
- 执行同一段指令或修改同一个变量
/**
* 银行转账的案例
*/
public class BankDemo {
//模拟100个银行账户
private int[] accounts = new int[100];
{
//初始化账户
for (int i = 0; i < accounts.length; i++) {
accounts[i] = 10000;
}
}
/**
* 模拟转账
*/
public void transfer(int from,int to,int money){
if(accounts[from] < money){
throw new RuntimeException("余额不足");
}
accounts[from] -= money;
System.out.printf("从%d转出%d%n",from,money);
accounts[to] += money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总账是:" + getTotal());
}
/**
* 计算总余额
* @return
*/
public int getTotal(){
int sum = 0;
for (int i = 0; i < accounts.length; i++) {
sum += accounts[i];
}
return sum;
}
public static void main(String[] args) {
BankDemo bank = new BankDemo();
Random random = new Random();
//模拟多次转账过程
for (int i = 0; i < 50; i++) {
new Thread(() -> {
int from = random.nextInt(100);
int to = random.nextInt(100);
int money = random.nextInt(2000);
bank.transfer(from,to,money);
}).start();
}
}
}
线程安全问题的解决方法
解决方法:给程序上锁,让当前线程完整执行一段指令,执行完释放锁,其它线程再执行
几种不同上锁方法:
- 同步方法
- 同步代码块
- 同步锁
同步方法
给方法添加synchronized关键字
作用是给整个方法上锁
过程:
当前线程调用方法后,方法上锁,其它线程无法执行,调用结束后,释放锁。
/**
* 模拟转账
*/
public synchronized void transfer(int from,int to,int money){
if(accounts[from] < money){
throw new RuntimeException("余额不足");
}
accounts[from] -= money;
System.out.printf("从%d转出%d%n",from,money);
accounts[to] += money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总账是:" + getTotal());
}
锁对象:
- 非静态方法 --> this
- 静态方法 —> 当前类.class
同步代码块
粒度比同步方法小,粒度越小越灵活,性能更高
给一段代码上锁
synchronized(锁对象){
代码
}
锁对象,可以对当前线程进行控制,如:wait等待、notify通知;
任何对象都可以作为锁,对象不能是局部变量
//同步代码块
synchronized (lock) {
accounts[from] -= money;
System.out.printf("从%d转出%d%n", from, money);
accounts[to] += money;
System.out.printf("向%d转入%d%n",to,money);
System.out.println("银行总账是:" + getTotal());
}
synchronized的基本的原理:
一旦代码被synchronized包含,JVM会启动监视器(monitor)对这段指令进行监控
线程执行该段代码时,monitor会判断锁对象是否有其它线程持有,如果其它线程持有,当前线程就无法执行,等待锁释放
如果锁没有其它线程持有,当前线程就持有锁,执行代码
底层汇编实现:
monitorenter
....
monitorexit
同步锁
在java.concurrent并发包中的
Lock接口
基本方法:
- lock() 上锁
- unlock() 释放锁
常见实现类
- ReentrantLock 重入锁
- WriteLock 写锁
- ReadLock 读锁
- ReadWriteLock 读写锁
使用方法:
- 定义同步锁对象(成员变量)
- 上锁
- 释放锁
//成员变量
Lock lock = new ReentrantLock();
//方法内部上锁
lock.lock();
try{
代码...
}finally{
//释放锁
lock.unlock();
}
三种锁对比:
-
粒度
同步代码块/同步锁 < 同步方法
-
编程简便
同步方法 > 同步代码块 > 同步锁
-
性能
同步锁 > 同步代码块 > 同步方法
-
功能性/灵活性
同步锁(有更多方法,可以加条件) > 同步代码块 (可以加条件) > 同步方法
悲观锁和乐观锁
悲观锁
认为线程的安全问题非常容易出现,会对代码上锁
前面所讲的锁机制都属于悲观锁
悲观锁的锁定和释放需要消耗比较多的资源,降低程序的性能
乐观锁
认为线程的安全问题不是非常常见的,不会对代码上锁
有两种实现方式:
-
版本号机制
利用版本号记录数据更新的次数,一旦更新版本号加1,线程修改数据后会判断版本号是否是自己更新的次数,如果不是就不更新数据。
-
CAS (Compare And Swap)比较和交换算法
- 通过内存的偏移量获得数据的值
- 计算出一个预计的值
- 将提交的实际值和预计值进行比较,如果相同执行修改,如果不同就不修改
悲观锁和乐观锁对比
- 悲观锁更加重量级,占用资源更多,应用线程竞争比较频繁的情况,多写少读的场景
- 乐观锁更加轻量级,性能更高,应用于线程竞争比较少的情况,多读少写的场景
public class AtomicDemo {
static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
new Thread(() ->{
count++;
}).start();
}
System.out.println(count);
}
}
问题:多线程同时执行++操作,最后结果少了
分析:
count++ 分解为三个指令:
- 从内存中读取count的值
- 计算count+1的值
- 将计算结果赋值给count
这三个指令不是原子性的,A线程读取count值10,加1后得到11,准备赋值给count;B线程进入读取count也是10,加1得到11,赋值给count为11;切换会A线程,赋值count为11。
解决方案:
-
悲观锁,使用同步方法、同步块、同步锁
-
乐观锁
使用原子整数
原子类
AtomicInteger类
原子整数,底层使用了CAS算法实现整数递增和递减操作
常用方法:
- incrementAndGet 原子递增
- decrementAndGet 原子递减
public class AtomicDemo {
static int count = 0;
static AtomicInteger integer = new AtomicInteger(0);
public static void main(String[] args) {
for (int i = 0; i < 10000; i++) {
new Thread(() ->{
count++;
//递增
integer.incrementAndGet();
}).start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count:"+count);
System.out.println("atomic:"+integer.get());
}
}
CAS算法存在的问题
- ABA问题 ????
- 如果预期值和实际值不一致处于循环等待状态,对CPU的消耗比较大
ThreadLocal
线程局部变量,会为每个线程单独变量副本,线程中的变量不会相互影响
以空间换时间
public class ThreadLocalDemo {
static int count = 0;
//线程局部变量
static ThreadLocal<Integer> local = new ThreadLocal<Integer>(){
//设置初始值
@Override
protected Integer initialValue() {
return 0;
}
};
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(()->{
count++;
local.set(local.get() + 1);
System.out.println(Thread.currentThread().getName() + "--->" + local.get());
}).start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
System.out.println(local.get());
}
}
ThreadLocal底层的实现
数据结构:Map键值对的结构
通过当前线程,得到当前线程中的ThreadLocalMap集合
将数据绑定到该Map中
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
线程的等待和通知
Object类中的方法
- wait() 让当前线程进入等待状态,直到被通知为止
- wait(long) 让当前线程进入等待状态,同时设置时间;直到被通知为止或时间结束
- notify() 随机通知一个等待线程
- notifyAll() 通知所有的等待线程
注意:等待和通知方法必须是锁对象,否则会抛出IllegalMonitorStateException
/**
* 通过锁对象将线程等待,经过5秒通知该线程来执行
*/
public class WaitDemo {
public synchronized void print() throws InterruptedException {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "--->" +i);
if(i == 50){
//让当前线程等待
this.wait();
}
}
}
public synchronized void notifyTest(){
//让等待的线程执行
this.notifyAll();
}
public static void main(String[] args) {
WaitDemo waitDemo = new WaitDemo();
new Thread(()->{
try {
waitDemo.print();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
waitDemo.notifyTest();
}
}
wait()和sleep()的区别
-
调用对象不同
wait() 由锁对象调用
sleep() 由线程调用
-
锁使用不同
执行wait后,自动释放锁
执行sleep后,不会释放锁
-
唤醒机制不同
执行wait后,可以被通知唤醒
执行sleep后,只能等待时间结束后,自动唤醒
生产者消费者模式
一种设计模式,不属于GOF23
生产者和消费者
-
生产者
某些程序/进程/线程负责生产数据就属于生产者
-
消费者
某些程序/进程/线程负责使用数据就属于消费者
生产者和消费者之间的问题
- 耦合性高,生产者和消费者联系紧密,不利于系统的扩展和维护
- 并发性能低,同时能处理请求量少
- 忙闲不均,生产者和消费者的速度不一致,带来系统资源的浪费
实现过程:
-
通过添加缓冲区,设置上限
-
生产者生产数据,向缓冲区存放,如果满了,生产者进入等待,直到缓冲区有空的位置通知生产者生产;
-
消费者从缓冲区取数据进行消费,如果空了,消费者进入等待,直到缓冲区有数据再通知消费者消费。
解决问题:
- 解耦
- 提高并发性能
- 解决忙闲不均
package com.hopu.thread;
import java.util.ArrayList;
import java.util.List;
/**
* 包子铺
*/
public class BaoziShop {
/**
* 包子
*/
class Baozi{
private int id;
public Baozi(int id) {
this.id = id;
}
@Override
public String toString() {
return "包子--" + id;
}
}
//上限
public static final int MAX_COUNT = 100;
//缓冲区 存放数据
private List<Baozi> baozis = new ArrayList<>();
/**
* 做包子
*/
public synchronized void makeBaozi() throws InterruptedException {
//判断缓冲区是否满了
if(baozis.size() == MAX_COUNT){
System.out.printf("缓冲区满了,%s等待%n",Thread.currentThread().getName());
//让生产者线程等待
this.wait();
}else{
//通知生产者线程生产
this.notifyAll();
}
//创建包子
Baozi baozi = new Baozi(baozis.size() + 1);
System.out.println(Thread.currentThread().getName()+"做了"+baozi);
//保存到缓冲区
baozis.add(baozi);
}
/**
* 拿包子
*/
public synchronized void takeBaozi() throws InterruptedException {
//判断缓冲区是否空了
if(baozis.size() == 0){
System.out.printf("缓冲区空了,%s等待%n", Thread.currentThread().getName());
//让消费者等待
this.wait();
}else{
//通知消费者消费
this.notifyAll();
}
//获得第一个包子,并删除
if(baozis.size() > 0){
Baozi baozi = baozis.remove(0);
System.out.println(Thread.currentThread().getName()+"吃了"+baozi);
}
}
public static void main(String[] args) {
BaoziShop baoziShop = new BaoziShop();
//一个生产者
Thread productor = new Thread(() -> {
for (int i = 0; i < 100; i++) {
try {
baoziShop.makeBaozi();
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
productor.start();
//10个消费者吃10个包子
for (int i = 0; i < 10; i++) {
Thread consumer = new Thread(() ->{
try {
for (int j = 0; j < 10; j++) {
baoziShop.takeBaozi();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
consumer.start();
}
}
}
应用场景:
-
12306售票
-
消息队列
-
线程池
-
等等
阻塞队列
应用了生产者消费者模式的集合,能够根据数据满或空的情况,自动对线程执行等待和通知
BlockingQueue 接口
- put 添加数据,达到上限会自动让线程等待
- take 取并删除数据,数据空了会自动让线程等待
实现类
ArrayBlockingQueue 类 数据结构为数组
LinkedBlockingQueue类 链表结构
/**
* 包子铺
*/
public class BaoziShop2 {
/**
* 包子
*/
static class Baozi{
private int id;
public Baozi(int id) {
this.id = id;
}
@Override
public String toString() {
return "包子--" + id;
}
}
public static void main(String[] args) {
//阻塞队列
BlockingQueue<Baozi> baozis = new ArrayBlockingQueue<>(100);
//生产者线程
new Thread(() -> {
for (int i = 0; i < 200; i++) {
//创建包子,添加到阻塞队列,满了就自动阻塞线程
Baozi baozi = new Baozi(baozis.size() + 1);
try {
baozis.put(baozi);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"生产了"+baozi);
}
}).start();
//消费者线程
for(int i = 0;i < 5;i++){
new Thread(() -> {
//取包子,空了会自动阻塞
for (int j = 0; j < 40; j++) {
try {
Baozi baozi = baozis.take();
System.out.println(Thread.currentThread().getName()+"消费了"+baozi);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
线程池
作用:回收利用线程资源
线程是一种宝贵的系统资源,执行完任务后会死亡,如果有大量任务需要处理,需要频繁的创建和销毁线程,造成系统性能降低。
线程池会保存一定量的线程,线程执行完任务后,会回到线程池中,等待下一个任务,节省系统资源,提升性能。
线程池的使用
顶层接口:Executor
- execute(Runnable) 启动线程执行一个任务
ExecutorService
继承 Executor
添加线程池管理方法,如:shutdown()、shutdownNow()
Executors
用于创建线程池的工具类
主要的方法
方法名 | 说明 |
---|---|
newCachedThreadPool() | 创建长度不限的线程池 |
newFixedThreadPool(int ) | 创建固定长度的线程池 |
newSingleThreadExecutor() | 创建单一个数的线程池 |
newScheduledThreadPool(int) | 创建可以调度的线程池 |
package com.hopu.thread;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ThreadPoolDemo {
public static void useCachedThreadPool(){
//创建长度不限的线程池
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
int n = i;
//使用线程池启动线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了任务" + n);
});
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
executorService.shutdown();
}
public static void useFixedThreadPool(){
//创建长度固定的线程池
ExecutorService executorService = Executors.newFixedThreadPool(5);
for (int i = 0; i < 100; i++) {
int n = i;
//使用线程池启动线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了任务"+n);
});
}
executorService.shutdown();
}
public static void useSingleThreadPool(){
//创建单一长度的线程池
ExecutorService executorService = Executors.newSingleThreadExecutor();
for (int i = 0; i < 100; i++) {
int n = i;
//使用线程池启动线程
executorService.execute(()->{
System.out.println(Thread.currentThread().getName()+"执行了任务"+n);
});
}
executorService.shutdown();
}
public static void useScheduledThreadPool(){
//获得可调度的线程池
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(10);
//执行可调度任务
System.out.println("------------");
scheduledExecutorService.scheduleAtFixedRate(()->{
System.out.println(Thread.currentThread().getName()+"---->"+ LocalDateTime.now());
},1,3, TimeUnit.SECONDS);
}
public static void main(String[] args) {
// useCachedThreadPool();
// useFixedThreadPool();
// useSingleThreadPool();
useScheduledThreadPool();
}
}
线程池的优化配置
线程池的实现类
ThreadPoolExecutor
线程池的构造方法参数:
- corePoolSize 核心线程数,创建线程池后自带线程,不会进行销毁
- maximumPoolSize 最大线程数
- keepAliveTime 存活时间,非核心线程能够闲置的时间,超过后被销毁
- timeUnit 时间单位
- blockingQueue 阻塞队列 存放任务(Runnable)的集合
优化配置
-
核心线程数 应该和CPU内核数量相关 CPU内核数 * N (N和任务执行需要时间和并发量相关)
Runtime.getRuntime().availableProcessors()
-
最大线程数可以和核心线程数一样,避免频繁创建和销毁线程
-
如果存在非核心线程,设置大一点,避免频繁创建和销毁线程
-
阻塞队列使用LinkedBlockingQueue,插入和删除任务效率更高
线程池的实现原理
问题:线程池是如何对线程进行回收利用的?
所有线程保存在HashSet中
/**
* Set containing all worker threads in pool. Accessed only when
* holding mainLock.
*/
private final HashSet<Worker> workers = new HashSet<Worker>();
总结
- 线程的等待和通知(是干什么,谁来调用)
- wait和sleep的区别
- 生产者和消费者模式(是什么,解决什么问题,如何实现的,项目中有哪些应用)
- 阻塞队列(了解)
- 线程池(作用,有哪几种线程池,线程池的创建有哪些参数,如何优化,实现原理(看源码、画图、归纳))
自学volatile关键字
作业
-
了解什么是ABA问题
-
编写懒汉式的单例模式,创建100个线程,每个线程获得一个单例对象,看是否存在问题(打印对象的hashCode,看是否相同)
分析问题原因,解决问题
作业
- 设计两个线程,一个线程负责打印1100以内所有的偶数;然后,另外一个线程负责打印1100以内所有的奇数。
测试时,分别设置线程的优先级,观察执行的顺序。
-
实现一个线程,用于扫描某个目录下的所有文本文件(包括:java、txt、html),并将文字内容打印出来。
-
某人正在看电视连续剧,从第1~88集,看到第10集时,来了一个送快递的,收完快递后后,继续看电视。
-
多线程模拟龟兔赛跑:
乌龟和兔子进行1000米赛跑,兔子前进5米,乌龟只能前进1米。
但兔子每20米要休息500毫秒,而乌龟是每100米休息500毫秒。
谁先到终点就结束程序,并显示获胜方
每天一篇博客,介绍今天学习的内容,编写的案例,完成作业(代码+截图)
预习内容:
什么是线程同步问题?
如何解决线程同步?
线程的通知和等待
html),并将文字内容打印出来。
-
某人正在看电视连续剧,从第1~88集,看到第10集时,来了一个送快递的,收完快递后后,继续看电视。
-
多线程模拟龟兔赛跑:
乌龟和兔子进行1000米赛跑,兔子前进5米,乌龟只能前进1米。
但兔子每20米要休息500毫秒,而乌龟是每100米休息500毫秒。
谁先到终点就结束程序,并显示获胜方
每天一篇博客,介绍今天学习的内容,编写的案例,完成作业(代码+截图)
预习内容:
什么是线程同步问题?
如何解决线程同步?
线程的通知和等待
生产者消费者模式是什么?