一个进程是一个正在执行的应用程序,而是线程是一个正在执行的应用程序中的某一段具体的任务.比如一段程序或者一个函数.线程也叫做“轻量级的进程”
比如:
工厂与工人的关系。工厂就类似于进程,工人类似于线程。
工厂为工人分配资源,工人共享这些资源。
工厂包含多个工人,每个工人都有各自的任务,并且分工明确,工厂的稳定需要工人协调有进行的展开各自的任务,这就类似于,一个CPU处理器执行多个线程----“多线程”.
1. 多线程的使用场景:
1. 在CPU密集型场景:代码中大部分工作,都在使用CPU进行运算,
使用多线程更好的利用CPU多核计算资源,从而提高效率
2. 在IO密集型场景:读写硬盘,读写网卡…这些操作需要花很大的时间等待! 像IO操作,都是几乎不消耗CPU就能快速读写数据,此时可以给CPU找点活干,避免CPU过于闲置.
并行:
比如: 在同一台电脑上(类似于CPU),在同一段时间内,打游戏以及听音乐,完成了从开始结束。那么打游戏和听音乐就是并发。
将 CPU 资源合理地分配给多个任务共同使用,有效避免了 CPU 被某个任务长期霸占的问题,极大地提升了 CPU 资源利用率。
运行JAVA程序时,会创建JAVA进程,main方法为程序中的主线程,我们并没有手动创建其他线程, java会创建其他线程作为辅助功能,比如JVM的垃圾回收机制等等
public class A1 extends Thread{
@Override
public void run() {
System.out.println("这是一个继承了Thread类的线程代码");
}
public static void main(String[] args) {
// A1 a1 = new A1();
Thread a1 = new A1();//向上转型
/**
start(),线程启动,操作系统内核中,创建出对应线程的PCB,让这个PCB加入到系统链表中,参与调度
*/
a1.start();
}
}
不能直接通过类对象直接调用run()方法,这样并非启动线程.
start(),线程启动,操作系统内核中,创建出对应线程的PCB,让这个PCB加入到系统链表中,参与调度 PCB为"进程控制块"
run和start的区别:
使用start是创建新的线程,那么新线程和旧线程是并发执行的。
使用run,并没有创建新的线程.
常用方法 | 作用 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用Runnable对象创建线程 |
Thread(String name) | 创建线程对象,并且以name为线程命名 |
Thread(Runnable target,String name) | 使用Runnable对象创建线程,并且以name为线程命名 |
详情可以通过查阅Thread的APIhttps://docs.oracle.com/javase/8/docs/api/
public class MyThread implements Runnable{
@Override
public void run() {
System.out.println("这是实现了Runnable接口的代码");
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread thread = new Thread(myThread);
thread.start();
}
/**
这是Thread类中的有参构造器
*/
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
启动线程: Thread.start();
Runnable和Thread是任务和线程分离开,更好的解耦合,耦合性低。
耦合性:代码很多模块,希望模块和模块之间的耦合性尽量低
=》一个模块出了问题,对于另外一个模块影响不大。
//线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("这是创建线程Thread的匿名子类对象,重写run方法");
}
};
thread.start();
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("匿名内部类创建Runnable子类对象,重写run方法");
}
});
thread.start();
Runnable是一个函数式接口,可以使用lambda表达式
Runnable r = ()->{
System.out.println("用lambda表达式创建Runnable子类");
};
new Thread(r).start();
用lambda表达式创建Thread子类对象.
new Thread(()->{
System.out.println("用lambda表达式创建Thread子类");
}).start();
Runnable运行状态,线程并非一直始终保持运行状态,由上述讲到并发线程,运行的线程可能需要暂停,此时通过线程调度让其他线程有机会执行。
线程调度:系统为线程分配cpu使用权的过程.
抢占式调度:系统给每一个线程分配一个时间段来执行.
Thread.getState()
方法可以获取线程的状态 //线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("这是创建线程Thread的匿名子类对象,重写run方法");
}
};
thread.start();
System.out.println("当前线程的状态为:"+thread.getState());
join() 是Thread类的方法
一个线程运行中调用另外线程的join(),则当前线程停止执行,一直等到新join进来的线程执行完毕,才会继续执行!!
虽然线程有一定的调度,但是可以控制线程谁先结束,谁后结束
方法 | 作用 |
---|---|
void join() | 等待终止指定的线程 |
void join(long millis) | 等待指定的线程终止或等待经过指定的毫秒数 |
join方法的使用需要捕获InterruptedException异常
1. 未执行join();
System.out.println("Main主线程开始启动");
//线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("t1开始执行");
System.out.println("t2结束运行");
}
};
thread.start();
/*try {
thread.join(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
System.out.println("Main主线程结束执行");
System.out.println("当前线程的状态为:"+thread.getState());
System.out.println("Main主线程开始启动");
//线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("t1开始执行");
System.out.println("t1结束运行");
}
};
thread.start();
try {
thread.join(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main主线程结束执行");
System.out.println("当前线程的状态为:"+thread.getState());
main线程等待t1线程执行
如果t1和main没有join限制,它们是同时往下走的
多线程是并发执行的,调度顺序不确定.
但是我们不能干涉调度器的行为,调度器咋随机还是咋随机
yeild()是Thread类的方法
暂停当前正在执行的线程,并执行其他线程。
使当前线程从运行状态处于就绪状态(可运行状态),向另外一个线程交出执行权
未使用yield方法
//线程一:
Thread t1 = new Thread("t1线程"){
@Override
public void run() {
for(int i = 0;i<=100;i++){
System.out.println("这是:"+Thread.currentThread()+i);
}
//Thread.yield();
}
};
//线程二:
Thread t2 = new Thread("t2"){
@Override
public void run() {
for(int i = 0;i<=100;i++){
System.out.println("这是:"+Thread.currentThread()+i);
}
}
};
t1.start();
t2.start();
使用了yield方法
//线程一:
Thread t1 = new Thread("t1线程"){
@Override
public void run() {
for(int i = 0;i<=100;i++){
System.out.println("这是:"+Thread.currentThread()+i);
}
Thread.yield();//使用了yield方法
}
};
//线程二:
Thread t2 = new Thread("t2"){
@Override
public void run() {
for(int i = 0;i<=100;i++){
System.out.println("这是:"+Thread.currentThread()+i);
}
}
};
t1.start();
t2.start();
sleep()方法是Thread类的静态方法,Thread.sleep();
该方法需要捕获InterruptedException
方法 | 作用 |
---|---|
void sleep(long millis) | 当前线程休眠millis毫秒 |
void sleep(long millis,int nanos) | 当前线程休眠更高精度的millis毫秒 |
sleep()让线程进入以millis毫秒的休眠状态,将cpu的执行权交给另外一个线程,当超时间等待期满时,即过了sleep的时间,也不一定拿到cpu的执行权。
需要看调度器的调度
System.out.println("开始执行:"+System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("结束执行:"+System.currentTimeMillis());
System.currentTimeMillis() 当前的毫秒级时间戳
为什么中断线程需要捕获InterruptedException?
比如:
一座每次只能通过一辆车的重量的桥,假设有三辆车,A,B,C车,当A进入桥时,B和C进入等待状态,即sleep()或者wait();如果A的通过桥的速度大于sleep()中所设定的毫秒数,此时就会发生中断,也就是说B进入桥的时间要小于所设的的毫秒数。
假设没有中断请求,那么A结束了过桥,此时B和C还在等待状态,这导致时间等久,降低cpu执行效率
volatile:(详细可见线程锁章节)
线程的可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
public volatile static boolean flag = false; //用作线程循环标记
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(!flag){
System.out.println(Thread.currentThread().getName()+"这是t1线程");
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();//一般来说用catch捕获InterruptedException,最好不要进行空白处理
}
});
t1.start();//启动线程
try {
//Main主线程睡眠时间5s
Thread.sleep(5000);
flag = true; //将标记修改为true;
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}
但是这个标识存在缺陷,就是当发生中断时,程序运行并未告诉我们发生了中断事件。
方法 | 作用 |
---|---|
public void interrupt() | 向线程发出中断请求,若线程处于阻塞状态,则抛出异常通知 |
public static boolean interrupted() | 判断当前线程是否被中断,调用该方法后将清除标志位 |
public void isInterrupted() | 判断线程是否中断,调用后不会改变线程的中断状态-标志位 |
使用interrupt()
Thread t1 = new Thread(()->{
//isInterrupted默认为flase
while(!Thread.currentThread().isInterrupted()){
System.out.println("新线程正在执行......");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
//一般来说用catch捕获InterruptedException,最好不要进行空白处理
//方法一:e.printStackTrace 抛出异常
//e.printStackTrace();
break; //通过break退出程序
}
}
});
t1.start();//启动线程
try {
//Main主线程睡眠时间5s
Thread.sleep(5000);
System.out.println("主线程退出");
} catch (InterruptedException e) {
e.printStackTrace();
}
t1.interrupt();//调用该方法,让sleep抛出异常,此时isInterrupt从false-》true
}
如果t1线程没有处于阻塞状态,此时interrupt就会修改内置的标志位
如果t1线程处于阻塞状态,此时interrupt会让线程内部产生阻塞的方法(sleep)抛出interruptException异常
守护线程:唯一的用户是为其他线程提供服务。也称作为"后台线程"
当只剩下守护线程的时候,JVM会自动退出,因为只剩下守护线程就没有必要继续执行了。
而JVM能够正常退出的情况是当没有非守护线程的时候(即只剩下守护线程),JVM进程退出。
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(true){ //非守护线程,执行ture循环
System.out.println("t1线程正在执行!!");
try {
Thread.sleep(2000);//睡眠2s
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();//线程启动
try {
Thread.sleep(5000);//睡眠5s
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main线程退出!");
}
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(true){
System.out.println("t1线程正在执行!!");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.setDaemon(true);
t1.start();
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main线程退出!");
}
每当线程调度有机会选择新线程时,它首先选择具有较高优先级的线程。但是,线程优先高度依赖于系统。当虚拟机依赖于宿主机平台的线程实现时,java线程的优先级会映射到宿主机平台的优先级。平台的线程优先级可能比java内部的10个级别多,也可能少。所以优先级不一定按照我们的意愿来执行线程的先后顺序
线程优先级 | 优先级值 |
---|---|
static int MIN_PRIORITY | 1 |
static int NORM_PRIORITY | 5 |
static int MAX_PRIORITY | 10 |
为了对线程的中断,线程等待等操作,就需要获取线程的引用
什么是线程的引用,就是拿到目标线程,比如上述中的Thread.currentThread()方法。
在多线程的代码环境下运行的结果符合我们的预期,且与单线程环境下应有的结果预期相同,则说这个程序是线程安全的
演示线程不安全的情况
/**
* 计算count自增
*/
class Count{
public int count;
void increase(){
count++;
}
}
public class A7 {
public static void main(String[] args) {
Count count = new Count();
//创建t1线程
Thread t1 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
//创建线程t2
Thread t2 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
t1.start();
t2.start();
try{
t1.join(); //让t1 t2执行完
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("count:"+count.count);
}
我们发现两个线程在对同一一个资源变量count进行++的时候,在两个线程结束之后,计算的count值在5w和10w之间,这显然是不正确的,因为我们所预期的结果是10w
多线程的运行是由调度器决定,而调度器对多线程的调度是随机调度的,或者是抢占式执行,这就导致了当非原子性的多线程在随机执行过程中,由于非原子性的指令不只一条,所以这就导致了在线程1指令执行的过程当中会发生线程2中的指令抢占cpu先执行。
在上述举例代码中,count变量就是两个线程t1和t2的所共享的资源,它们共用并且能够修改资源,而这个count资源变量是存放在堆中的。因此这个变量可以被多个线程共享访问。
什么是原子性,就是不可再分,这里指的是代码只有一条指令或者不可再分出更多的指令。
一条java语句并非硬性就定义成一条指令,它可能含有多条指令,所以一条java语句可能是非原子性的。
不难发现,在两个线程对共享资源count执行count++的时候,需要三个步骤,也就是三个指令,即非原子的代码,此时由于线程的调度器随机调度,导致两个线程在执行指令的时候,指令的先后顺序发生错乱。
当线程t1执行完从内存读取load到执行add在到save写入内存之后,线程t2再完整执行三条指令或者线程t2先完整完成,再到t2。
但由于调度器随机调度多线程,这种情况的出现是概率也是有但是并非绝对。
线程不安全情况下的三条指令:
在多线程的随机调度中,指令的顺序有无数种可能.
一下展示其中的两种导致线程不安全的情况
当线程t1开始执行load的时候,由于t2抢占cpu执行,导致线程t1执行load的时候,后面的指令未执行完,而线程2从读取内存在++,写入内存,此时堆中所共享的变量已经发生变化,而由于线程t1已经读取了内存中的还未修改的数据。
这显然不是我们想要得到的结果,我们希望多线程并发执行后,我们所预期的count的值应该是2,而两线程执行之后的存入的结果是1.
上述是多线程对同一个变量即共享变量进行修改的时候所发生的线程不安全,如果多线程对多个不同的变量资源进行修改,也就是一个线程修改一个变量此时不会发生线程不安全的情况。
内存可见性:
一个线程对共享变量值的修改,能够及时地被其他线程看到。内存可见性也会带来线程不安全
当线程t1,t2使用共享数据,同一资源变量。其中线程t1对数据反复读和判断,线程t2对数据进行修改并且写入内存。
理想情况下:当线程t2修改并且写入内存之后,线程t1会接收到数据被修改的消息,并且重新从内存中读数据。但是因为一些原因导致意外。这种意外可能是因为javac编译器、JVM或者操作系统对代码进行优化处理。
被优化的数据线程t1将不再从内存中读取,而是直接从cpu寄存器中读.
为什么要优化代码?主要是因为,读的操作往往比其他操作要慢很多个数量级,尤其是从内存中读数据比从cpu中读数据要慢很多!!
如果数据一样,总从内存中读数据就会大大降低效率。虽然代码得到了优化,但是线程且会带来一些不安全的情况. 二者总是不可兼得的。
什么是代码重序性
举个例子: 一段代码
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
如果是在单线程情况下, JVM 、 CPU 指令集会对其进行优化,比如,按 1->3->2 的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 " 保持逻辑不发生变化 ". 这一点在单线程环境下比较容易判断 , 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高 , 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价。
在大多数多线程的应用中,多线程需要共享对同一个数据的存取。当两个线程调用了同一个方法对同一个数据进行存取,此时线程会互相覆盖,这也可能会导致对象被破坏。
- 比如:银行存取,余额:1000元,当有两个人对同一张银行卡进行操作的时候,并且在同一时间段对银行取钱,当第一个人取钱的时候,第二个人也取钱,此时两人看到的数据是一样的,如果第一个人已经从银行取了500,按理来说,第二次也就是第二个人看到银行卡余额应该是500,但是却是1000,这是就造成了数据错乱。
为了解决上述类似的问题,我们可以使用同步锁的方法。
synchronized是一个监听器锁,它可以是锁this,也可以是锁类对象
synchronized起到互斥的作用,当多线程执行同一个对象的synchronized的时候,由于调度器的调度,其中一个线程先执行synchronized,当其他线程想要在执行这个相同对象的时候就会"阻塞等待"
- 进入 synchronized 修饰的代码块, 相当于 加锁
- 退出 synchronized 修饰的代码块, 相当于 解锁
synchronized是对需要同步的方法进行加锁.
我们就以第四节线程安全中的例子:两线程对count进行++操作:
这是根据当前对象加锁,也就是this,这是其中一个加锁的方式,非静态.
两线程调用的是同一个锁对象,具有竞争互斥的关系。
以下测试代码:
/**
* 计算count自增
*/
class Count{
public int count;
synchronized void increase(){ //加锁
count++;
}
}
public class A7 {
public static void main(String[] args) {
Count count = new Count();
//创建t1线程
Thread t1 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
//创建线程t2
Thread t2 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
t1.start();
t2.start();
try{
t1.join(); //让t1 t2执行完
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("count:"+count.count);
}
为什么加锁之后能够实现互斥?打个比方
有一批人排队上厕所,厕所只有一间,如果一个人进入之后,这时门就被了,其他人就在外面排队等待。如果没有锁,很容易就厕所已经有人了,其他人还能打开门,这就很不合理。
注意:当锁被释放的时候,未必会因为线程的先后排队顺序就一定能拿到锁,这个的比喻只是宏观上的,在线程能否能拿到锁,还需要看调度器的随机调度。
synchronized的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存(CPU寄存器)
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.
public class SynchronizedDemo {
public synchronized void methond() {
}
}
public class SynchronizedDemo {
public synchronized static void method() {
}
}
锁当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
锁类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
public class SynchronizedDemo {
public static void method() {
synchronized (SynchronizedDemo.class) {
//不能使用this作为锁的对象,可以使用JAVA中的任意的类对象
//因为这个方法是静态方法
}
}
}
当多线程竞争同一把锁的,才会产生阻塞状态,如果多线程获取各不相同的锁,则不会发生竞争。
测试两个线程,共享同一个资源flag
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag==0){
//作空循环处理
}
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(()->{
System.out.println("请输入:");
Scanner scan = new Scanner(System.in);
flag = scan.nextInt();
});
t1.start();
t2.start();
}
说明flag在线程t1严重还是0,所以循环条件还是满足,程序未退出。
原因:JVM或是程序代码或是操作系统,将程序进行了优化。因为在循环种,读取的flag数据一直是0,为了减少从内存中读取数据,程序就进行了优化,导致线程t1直接从cpu寄存器中读数据,而并不是从主内存中读取。
为了能够让线程能够从及时从内存中读取修改的共享数据可以使用volatile关键字修饰变量.
public volatile static int flag = 0;
从主内存中读取volatile变量的最新值到线程的cpu寄存器中
从cpu寄存器中读取volatile变量的副本
volatile 和 synchronized 有着本质的区别. synchronized 能够保证原子性, volatile 保证的是内存可见性.
/**
* 计算count自增
*/
class Count{
public volatile int count;
void increase(){
count++;
}
}
public class A7 {
public static void main(String[] args) {
Count count = new Count();
//创建t1线程
Thread t1 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
//创建线程t2
Thread t2 = new Thread(()->{
for(int i = 0 ; i <50000;i++){
count.increase();
}
});
t1.start();
t2.start();
try{
t1.join(); //让t1 t2执行完
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("count:"+count.count);
}
由于多线程的调度是随机调度,抢占式执行的,也就是线程的执行顺序并非我们所预期,但是在实际开发中,有时我们希望合理的协调多线程之间的执行顺序。
能够使得多个线程协调的顺序执行,JAVA提供了以下的方法:
wait():让线程进入等待状态
notify/notifyAll:唤醒处于等待状态的线程/唤醒所有处于等待状态的线程
wait(),notify,notifyAll都是Object类的方法
方法 | 作用 |
---|---|
void wait() | 让线程进入等待状态 |
void wait(long timeout) | 让线程进入等待状态,等待timeout毫秒级 |
void wait(long timeout,int nanos) | 让线程进入等待状态,等待更精确的毫秒级 |
wait()方法需要做的事情是:
wait()方法结束的条件:
注意:wait()方法的使用必须要在加锁的方法里即同步方法块,也就是要搭配synchronized来使用,如果在synchronized方法中使用wait()会抛出异常.
wait()的调用需要与被加锁的类对象或者当前对象一致,
例如synchronized(this) { this.wait()}
代码示例:
Object locked = new Object();//锁
synchronized (locked){
System.out.println("wait()开始等待被唤醒");
try {
locked.wait(); //wait的使用要与被加锁的对象一致
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait()被唤醒结束");
}
为了唤醒wait(),我们就要使用notify()方法
代码示例:
Object locked = new Object();//锁
//wait()
Thread t1 = new Thread(()->{
synchronized (locked){
System.out.println("wait()开始等待被唤醒");
try {
locked.wait(); //wait的使用要与被加锁的对象一致
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait()被唤醒结束");
}
});
t1.start();
//notify()
Thread t2 = new Thread(()->{
synchronized (locked){
System.out.println("notify()开始唤醒wait()~~");
locked.notify();//使用当前线程的锁对象的对象
System.out.println("notify()结束唤醒wait()~~");
}
});
t2.start();
notify()只能唤醒某一个等待线程,而notifyAll可以唤醒所以等待线程.
代码示例:
Object locked = new Object();//锁
//wait()
Thread t1 = new Thread(()->{
synchronized (locked){
System.out.println("t1-wait()开始等待被唤醒");
try {
locked.wait(); //wait的使用要与被加锁的对象一致
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1-wait()被唤醒结束");
}
});
Thread t3 = new Thread(()->{
synchronized (locked){
System.out.println("t3-wait()开始等待被唤醒");
try {
locked.wait(); //wait的使用要与被加锁的对象一致
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3-wait()被唤醒结束");
}
});
t1.start();
t3.start();
//notify()
Thread t2 = new Thread(()->{
synchronized (locked){
System.out.println("notify()开始唤醒wait()~~");
locked.notify();//使用当前线程的锁对象的对象
System.out.println("notify()结束唤醒wait()~~");
}
});
t2.start();
Thread t2 = new Thread(()->{
synchronized (locked){
System.out.println("notify()开始唤醒wait()~~");
locked.notifyAll();//使用当前线程的锁对象的对象
System.out.println("notify()结束唤醒wait()~~");
}
});
注意:三个线程仍然需要竞争锁,使用notifyAll之后的线程仍然因为调度器的随机调度,需要竞争该对象的对象锁!!
wait()和sleep()方法类似,都是能让线程进入阻塞状态,但是wait()是用于线程之间的通信,sleep()只是让线程暂时进入阻塞状态,也就是放权-cou执行劝.
一般的,在实际开发中,sleep是的使用频率比较少,因为wait方法也具备了sleep的方法,即wait也能够阻塞一段时间–> void wait(long timeout);
单例模式是设计模式的其中一种设计模式,分为:饿汉式和懒汉式
单例模式所创建出来的对象只有唯一的一个实例对象,不会创建出多个实例对象。
什么是饿汉式?饿汉式是类加载的同时就创建出了单例实例对象。
class Singletom{
//私有静态实例,类加载同时创建单例实例对象
private static Singletom instance = new Singletom();
private Singletom(){} //私有构造器
public static Singletom getInstance(){
return instance;
}
}
测试是否是同一个实例对象:
public static void main(String[] args) {
//只能获取已经实例的对象
Singletom s1 = Singletom.getInstance();
//不能再new多个实例
Singletom s2 = Singletom.getInstance();
System.out.println(s1==s2);
}
执行为ture说明是同一个实例对象。
什么是懒汉式?
懒汉式不会随之类的加载而创建单例实例,当需要这个对象的时候才会被创建。
1. 单线程懒汉式:
/**
* 懒汉式创建实例
*/
class LazySingleton{
//此时类加载时未创建实例
private static LazySingleton instance = null;
private LazySingleton(){}
public static LazySingleton getInstance(){
//如果为null才能创建实例,如果非null不需要再创建了
if (instance==null){
instance = new LazySingleton();
}
return instance;
}
}
public class demo11 {
public static void main(String[] args) {
LazySingleton ls1 = LazySingleton.getInstance();
//同样会报错!!
//LazySingleton ls2 = new LazySingleton();
LazySingleton ls3 = LazySingleton.getInstance();
System.out.println(ls1==ls3); //ture
}
2. 多线程的懒汉式:
- 饿汉式是线程安全的,因为它只有读操作.
- 多线程的懒汉式是线程不安全的,因为它由读和写操作.
也就是说懒汉式未使用synchronized前是非原子性的,是线程不安全的。
为了保证线程安全,需要加个synchronized,以保证当多个线程同时创建实例的时候,由于其他线程未获取锁,而不会发生读写冲突,保证原子性。
class LazySingleton{
//此时类加载时未创建实例
private static LazySingleton instance = null;
private LazySingleton(){}
//加个synchronized同步
public synchronized static LazySingleton getInstance(){
//如果为null才能创建实例,如果非null不需要再创建了
if (instance==null){
instance = new LazySingleton();
}
return instance;
}
}
3. 多线程的懒汉式(优化):
/**
* 懒汉式创建实例
*/
class LazySingleton{
//此时类加载时未创建实例,加volatile防止编译时被系统"优化",保证内存的可见性
private volatile static LazySingleton instance = null;
//私有的构造方法,防止程序员再创建新的对象
private LazySingleton(){}
public static LazySingleton getInstance(){
/*
* 在多线程中,外层的if判断是为了减少反复加锁的操作,
* 如果instance为null则不需要再次获取锁和释放锁操作
* 内层的if才是判断是否需要创造实例即第一次使用这个实例对象的时候
* */
if(instance==null){
//synchronized代码块需要包含if和new,以保证原子性
synchronized (LazySingleton.class){
if (instance==null){
instance = new LazySingleton();
}
}
}
return instance;
}
}
阻塞队列是一种特殊的队列,遵循"先进先出"的原则
阻塞队列是一种线程安全的数据结构:
- 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素
- 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞就是让线程暂时停下来等一等,本质上就是修改线程的状态,让其他线程参与调度
- 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。而阻塞队列就相当于一个容器
- 生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
这样做是为了降低代码之间的耦合性,使得代码之间不会因为另一方出现bug导致代码不可用而顺便也将另外的代码也一并带走。
生产者只需要考虑和阻塞队列的交互,不需要考虑和消费者的交互,同理消费者也是一样.
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
这里也可以用poll方法出队列,但是take是线程安全的能够阻塞式的出队列
生产消费者模型的示例代码:
public static void main(String[] args) {
//创建内置阻塞队列
BlockingQueue<Integer> bq = new LinkedBlockingDeque<>();
Thread customer = new Thread(()->{
while(true){
try {
int n = bq.take(); //阻塞式出队列,自动拆箱
System.out.println("消费者消费:"+n);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
customer.start();
Thread producer = new Thread(()->{
int n = 0;
while(true){
try {
bq.put(++n); //阻塞式添加
System.out.println("生产者生产:"+n);
Thread.sleep(500); //让生产者休眠500ms
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
producer.start();
}
实现阻塞队列:
/**
* 实现一个阻塞队列
* 首先实现普通的"循环队列"
* 由普通队列通过synchronized加锁创建阻塞队列
*/
public class MyBlockedQueue {
//创建一个数组充当容器
private int[] container = new int[1000];
private volatile int size=0;//计算队列长度,保证内存可见性
private int head=0;//队头
private int rear=0;//队尾
/**
* 将元素入队操作
* @param element
*/
public void put(int element) throws InterruptedException {
//同步代码块,this指的是当前对象
synchronized (this){
/**
* 最好是使用while循环来判断wait().
* 因为当notifyAll将所有线程都唤醒的时候
* 并非队列就一定是不满的
* 因为当前线程没有抢到锁,说明其他线程抢到后
* 也操作了入队列,此时就需要继续等待
*/
while(size==container.length){
this.wait();
}
/**
if(size==container.length){
//当队列满时,需要阻塞等待出队操作,才能进行下一步操作
this.wait();
}**/
container[rear] = element;//将元素入队
/**
循环队列尾指针后移,保证数组能够不在扩容的情况下重复利用
为什么不是用%size,因为size是队列中元素的个数,非数组的长度
rear = (rear+1)%container.length;
**/
rear++;
size++;//长度+1
//这样判断效率会更高,当尾指针到数组的末尾,将rear设置成受指针即可.
if(rear==container.length){
rear=0;
}
notifyAll();//将获取当前对象的对象锁的所有wait线程唤醒.
}
}
/**
* 出队列
* @return 返回出队列的元素
* @throws InterruptedException
*/
public int take() throws InterruptedException {
int result = 0;//用来保存需要出队列的元素
synchronized (this){
while(size==0){
this.wait();//此出的wait与put操作原理相同
}
/**
if(size==0){
//队列为空,就无法出队列
this.wait();//等待有元素入队列
}**/
result = container[head];//取队头元素
// head = (head+1)%container.length;
head++;
if (head==container.length){
head=0;
}
size--;//元素少一个
notifyAll();
}
return result;
}
public synchronized int getSize(){
return size;
}
//测试实现的阻塞队列
public static void main(String[] args) {
MyBlockedQueue myBlockedQueue = new MyBlockedQueue();
//创建线程为:生产者
Thread producer = new Thread(()->{
int n = 0;
while(true){
try {
n++;
myBlockedQueue.put(n);
System.out.println(Thread.currentThread()+"生产:"+n);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"生产者");
producer.start();
//创建线程:消费者
Thread customer = new Thread(()->{
while(true){
try {
int value = myBlockedQueue.take();
System.out.println(Thread.currentThread()+"消费:"+value);
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"消费者");
customer.start();
}
}
实现阻塞队列的具体说明都在代码注释中.
/**
new Timer().schedule(TimerTask task,long delay)
*/
Timer timer = new Timer();
timer.schedule(new TimerTask(){
//重写run
public void run(){
System.out.println("hellow,Timer!");
}
},3000);
TimerTask是实现了Runnable接口的实现类.
定时器的构成:
为啥要带优先级呢?
因为阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来.
import java.util.concurrent.PriorityBlockingQueue;
/**
* 定时器的构造:
* 首先定义一个Timer类,Timer类中创建两个内部类Task和work类
* 一个优先级队列
*/
public class MyTimer {
/**
* Task类实现
*/
static class Task implements Comparable<Task>{
//这个Task类中包含Runnable和time时间戳
private Runnable command;
private long time;
//有参构造,给Task分配任务
public Task(Runnable command,long time){
this.command=command;
this.time=time;
}
//自定义run方法,就是调用了command中的run
public void run(){
command.run();
}
//实现Comparable,因为该队列存入的是command和time,优先级队列需要作比较
@Override
public int compareTo(Task o) {
return (int)(this.time-o.time);
}
}
//具有阻塞优先队列.存放Task任务对象
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
//用于避免线程出现忙等的情况,充当锁对象
private Object testBox = new Object();
//woker用于判断Task中最快优先达到可以执行的线程
class worker extends Thread{
@Override
public void run() {
while(true){
try {
//取出任务
Task task = queue.take();
long curTime = System.currentTimeMillis();
//当task中的时间属性大于当前的时间,说明还未到达可以执行的时间
if(task.time>curTime){
//将task重新put进队列
queue.put(task);
//设置锁解决while(true)不断循环忙等的过程,
synchronized (testBox){
//设置等待时间,当时间task.time-curTime之后执行
testBox.wait(task.time-curTime);
}
}else
task.run();//时间到了可以执行任务
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
//当创建了Timer无参构造器时,启动worker
public MyTimer(){
worker worker = new worker();
worker.start();//因为继承了Thread
}
/**
* 定时器的核心代码
* 设置任务和时间
* @param command
* @param after
*/
public void schedule(Runnable command,long after){
Task task = new Task(command,after);
//存入task对象
queue.offer(task);
synchronized (testBox){
testBox.notify();//唤醒woker中的等待线程执行
}
}
public static void main(String[] args) {
MyTimer timer = new MyTimer();//此时,woker被调用启动
Runnable command = new Runnable() {
@Override
public void run() {
System.out.println("自定义定时器");
timer.schedule(this,3000);
}
};
timer.schedule(command,3000);
}
}
引入线程池,是为了减少线程的创建与销毁,在实际开发中,可能会遇到系统更加频繁的使用线程,造成线程的频繁创建和销毁,加重了消耗,系统可能吃不消。
比如为什么引进线程,也是因为进程的开销大,占用内存比线程大等等。
我们说:线程是"轻量级"的进程,而线程池就是"轻量级"的线程。
线程池是将一定数量的线程创建好后,放入到线程池,当需要的时候也就是创建线程,就会从线程池中取出使用,当线程销毁结束任务就将线程重新放回线程池. 这样就节省了创建和销毁线程的消耗。
线程池最大的好处就是减少每次启动、销毁线程的损耗
线程池涉及到操作系统内核问题:
用户态与内核态之间
什么是用户态:就是用户能自己操作的应用程序
内核态:调用到操作系统内核的程序,操纵硬件
线程池中线程的创建就是基于"用户态",创建这样效率更高,因为内核态是由系统控制,cpu执行未必会立即创建线程,因为cpu合数也不少,任务也多。
使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
返回值类型为 ExecutorService
通过 ExecutorService.submit 可以注册一个任务到线程池中.
//没有创建实例对象,是因为用了工程模式中,实现类调用方法
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
Executors 创建线程池的几种方式:
import java.util.concurrent.LinkedBlockingQueue;
/**
* 使用Woker类描述一个工作线程,使用Runnable描述一个任务
*/
class Worker extends Thread{
private LinkedBlockingQueue<Runnable> queue = null;
public Worker(LinkedBlockingQueue<Runnable> queue){
this.queue=queue;
}
@Override
public void run() {
try{
while(!Thread.currentThread().isInterrupted()){
Runnable task = queue.take();
task.run();
}
}catch (InterruptedException e){
e.printStackTrace();
}
}
}
public class MyThreadPool {
private int maxWorkerCount=10;//最大工作线程数
//阻塞队列保存任务线程
private LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
/**
* 核心操作,将任务加入线程池
* @param task
*/
public void submit(Runnable task){
try {
queue.put(task);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//构造线程池
public MyThreadPool(int maxWorkerCount){
this.maxWorkerCount = maxWorkerCount;
for(int i = 0 ; i < this.maxWorkerCount ; i++){
//创建线程,将任务线程的阻塞队列作为参数。
Worker worker = new Worker(queue);
worker.start();
}
}
public static void main(String[] args) {
MyThreadPool myThreadPool = new MyThreadPool(10);
for(int i = 0 ; i <100;i++){
myThreadPool.submit(()-> System.out.println("开始执行"));
}
}
有第七章开始,介绍面试过程中面试官常常也会问到的问题,对于实际开发的作用并不大,但对于程序员而言,还是需要多了解一些锁的知识,合理使用锁。
悲观锁
悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
举个例子,一个病人去医院,但是去医院的人很多,找同一个医生的人也很多(医生相当于数据,病人就是线程),那么这个病人可以先打个电话或者预约等等,询问医生是否可以抽时间(相当于上锁),因为医生被这个病人先预约了。如果其他病人先预约那就需要等待这个病人看完。
乐观锁
乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。. 因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
举个例:病人A大概概率能够猜到医生是有空的,所以就直接登门拜访(没有加锁,直接访问数据),如果医生确实闲,那就直接解决问题。但是如果很忙,那就直接走,不打扰医生,下一次再来。(这就是识别了数据冲突,判断是否有空).
并非说悲观锁就不好,也不是说乐观锁就一定好,悲观锁和乐观锁在特定场合就有它们各自的优势,并没有说谁优谁劣。
什么是"版本号",相当于一个标记符位,这个"版本号"只能递增,不能递减,而且存入主内存时,需要判断"版本号",如果大于主内存的版本号,操作成功存入成功,如果小于或者等待,则操作失败。
多线程之间,数据的读取方不会产生线程安全问题,但是数据的写入方互相之间和读取方之间都需要互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。
读写锁就是把读操作和写操作区别开来。
java标准库提供了ReentrantReadWriterLock类,实现读写锁
存在互斥,则线程就会挂起等待, 再次唤醒不知道需要等待多久时间
因此需要减少互斥的情况,提高效率
synchronized不是读写锁!!!
轻量级锁不是不适用操作系统提供的锁,而是尽可能的避免使用,如果遇到实在解决不了的再使用操作系统提供的锁:mutex.
mutex:
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
什么是自旋锁?
当线程枪锁失败之后,就会进入阻塞等待状态,在多线程当中,可能需要很久才会被调度。那就可能要放弃cpu。但是自旋锁的作用当枪锁失败之后,就会不断循环反复获取锁,直到获取锁为止。
一旦锁被释放,就会第一时间去获取锁!
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
自旋锁和挂起等待锁:
区别在于自旋锁比挂起等待锁更加主动去争取锁
自旋锁是一种轻量级锁:
在操作系统内部的线程调度是随机调度的,如果不做任何限制操作,那就是非公平锁。
如果需要实现公平锁,则需要一些数据结构加以限制,在限制锁的获取按照线程的先后顺序获取。
synchronized 是非公平锁.
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
顾名思义,就是一个线程不能多次获取同一把锁,如果尝试获取同一把锁,就会发生"死锁",也就是锁不能释放,程序卡死在锁不能释放的情况。
//synchronized是可重入锁,不会发生死锁的情况.
// 如果非可重入锁,则这种情况就会发生死锁
synchronized (this){
synchronized (this){
}
}
Compare and swap,字面意思:”比较并交换“
一个CAS涉及到以下的操作:
CAS 是一个原子的硬件指令完成的.
伪代码:
boolean CAS(address, expectValue, swapValue) {
//此伪代码并非CAS,只是用于辅助理解。
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
(1)实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的
AtomicInteger 类例子
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.
(2)自旋锁
自旋锁伪代码
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;
}
}
ABA问题指的是:指在CAS操作时,其他线程将变量值A改为了B,但是又被改回了A,等到本线程使用期望值A与当前变量进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过,这与乐观锁的设计思想不符合。
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.
解决方案:
在修改值的时候,可以引入"版本号"version.在CAS比较的时候,不仅仅要比较值也要比较版本号是否符合要求.
修改的时候:
如果当前版本号和读取的版本号相同,则修改数据,并把版本号+1
如果当前版本号高于读到的版本号,就操作失败.
2)轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁.自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 这就是自适应
3) 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
1)锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
例如:StringBuffer中的append的调用就是涉及到加锁解锁,但是在单线程的情况下调用,然后操作加锁解锁就会白白浪费资源的开销
2)锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是在实际中,可能并没有其他线程来抢占这个锁,这种情况JVM就会自动将锁粗化.减少频繁的加锁和解锁操作.
可以用Callable接口创建线程,相比于Runnbale接口创建线程,Callable接口有返回值。
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定
//创建Callable并重写Callable的call方法,这是Callable的核心操作
Callable<Integer> callable = new Callable<Integer>() {
public Integer call() throws Exception{
int sum = 0;
for(int i = 1;i<=1000;i++) {
sum+=i;
}
return sum;
}
};
//创建FutureTask 将callable作为参数传入
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread thread = new Thread(futureTask);
thread.start();
try {
int result = futureTask.get();
System.out.println(result);
}catch (Exception e) {
e.printStackTrace();
}
}
JUC->(java.util.concurrent)
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
代码块:
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock 和 synchronized 的区别:
synchronized 是一个关键字, 是 JVM 内部实现的,ReentrantLock 是标准
库的一个类, 在 JVM 外实现的
synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
- 如果需要使用公平锁, 使用 ReentrantLock.
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
同时等待 N 个任务执行结束(好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩)。
public class Demo {
public static void main(String[] args) throws Exception {
//10个线程任务.
CountDownLatch latch = new CountDownLatch(10);
Runnable r = new Runable() {
@Override
public void run() {
try {
Thread.sleep(Math.random() * 10000);
latch.countDown();
} catch (Exception e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 10; i++) {
new Thread(r).start();
}
// 必须等到 10 人全部回来
latch.await();
System.out.println("比赛结束");
}
}
使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到.
基于数组实现的阻塞队列
基于链表实现的阻塞队列
基于堆实现的带优先级的阻塞队列
最多只包含一个元素的阻塞队
HashTable只有一把大锁,当两个线程访问HashTable中的任意数据都会出现锁冲突
每个Hash桶都有一把锁,只有当两个线程恰好同时访问同一个哈希桶上的数据才会出现锁冲突。
经典面试题:
三个哈希的区别: