JAVA
文章目录
一、什么是线程
1.1线程与进程
一个进程是一个正在执行的应用程序,而是线程是一个正在执行的应用程序中的某一段具体的任务.比如一段程序或者一个函数.线程也叫做“轻量级的进程”
比如:
工厂与工人的关系。工厂就类似于进程,工人类似于线程。
工厂为工人分配资源,工人共享这些资源。
工厂包含多个工人,每个工人都有各自的任务,并且分工明确,工厂的稳定需要工人协调有进行的展开各自的任务,这就类似于,一个CPU处理器执行多个线程----“多线程”.
- 线程与进程的比较
- 线程较进程而言,线程是更“轻量级”
- 创建,撤销线程要比进程的创建和撤销更快,开销小
1. 多线程的使用场景:
1. 在CPU密集型场景:代码中大部分工作,都在使用CPU进行运算,
使用多线程更好的利用CPU多核计算资源,从而提高效率
2. 在IO密集型场景:读写硬盘,读写网卡…这些操作需要花很大的时间等待! 像IO操作,都是几乎不消耗CPU就能快速读写数据,此时可以给CPU找点活干,避免CPU过于闲置.
2.1并行与并发
并行:
- 多个CPU同时执行多个任务,是多核CPU提出的。
- 双核CPU同时执行两个任务时,它们之间互不干扰,并且每个核心(CPU)各自执行一个任务。
- 早期的单核CPU
- 在早期单核CPU中,CPU处理器在执行任务时,只能执行一个任务(线程),并且执行完当前的任务才能执行下一个任务,速率大大降低。
- 在计算机运行时,会涉及到一些指令比如:I/O操作,这些操作的速度会低于CPU的执行速度,因此常常会因为I/O操作等指令导致CPU处于空闲等待状态,需要等待I/O操作等指令执行完毕才能继续执行后面的指令。
- 并发
- 一个CPU同时执行多个任务,单核CPU提出,在一个时间段,在一个CPU处理器上,执行多个任务(正在执行或者已经执行完毕).
比如: 在同一台电脑上(类似于CPU),在同一段时间内,打游戏以及听音乐,完成了从开始结束。那么打游戏和听音乐就是并发。
- 当一个任务在执行I/O操作时,CPU会转而执行其他的任务,等到I/O操作完毕,CPU会回到原来的任务继续执行。
将 CPU 资源合理地分配给多个任务共同使用,有效避免了 CPU 被某个任务长期霸占的问题,极大地提升了 CPU 资源利用率。
二、线程的创建
运行JAVA程序时,会创建JAVA进程,main方法为程序中的主线程,我们并没有手动创建其他线程, java会创建其他线程作为辅助功能,比如JVM的垃圾回收机制等等
2.1、继承Thread类
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();
}
}
- 首先继承Thread类
- 重写Thread类中的run方法,也是线程代码块
- 通过类对象调用继承了Thead类方法中的start()方法
不能直接通过类对象直接调用run()方法,这样并非启动线程.
start(),线程启动,操作系统内核中,创建出对应线程的PCB,让这个PCB加入到系统链表中,参与调度 PCB为"进程控制块"
- 面试题:
run和start的区别:
使用start是创建新的线程,那么新线程和旧线程是并发执行的。
使用run,并没有创建新的线程.
1、Thread的常用的构造方法
常用方法 | 作用 |
---|---|
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/
2.2、实现Runnable接口
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();
}
- 首先实现Runnable接口
- 因为是实现接口,必须重写Runnable接口中的所有方法,而run是唯一的方法
- 因此不能直接通过实现了Runnable的对象调用run(),这并非线程的启动,而且Runnable接口也没有提供线程的启动方法。
- 需要new一个Thread对象,其中Thread存在能够接收Runnbale接口的构造器,将Runnable接口的实现类对象作为参数传递给Thread
/**
这是Thread类中的有参构造器
*/
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
启动线程: Thread.start();
Runnable和Thread是任务和线程分离开,更好的解耦合,耦合性低。
耦合性:代码很多模块,希望模块和模块之间的耦合性尽量低
=》一个模块出了问题,对于另外一个模块影响不大。
2.3、线程创建的变形
1. 匿名内部类的的方式
- Thread匿名子类对象
//线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("这是创建线程Thread的匿名子类对象,重写run方法");
}
};
thread.start();
- Runnable接口
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("匿名内部类创建Runnable子类对象,重写run方法");
}
});
thread.start();
2. lambda表达式创建线程
Runnable是一个函数式接口,可以使用lambda表达式
Runnable r = ()->{
System.out.println("用lambda表达式创建Runnable子类");
};
new Thread(r).start();
用lambda表达式创建Thread子类对象.
new Thread(()->{
System.out.println("用lambda表达式创建Thread子类");
}).start();
- Thread和Runnable是在Java.lang包下.
三、线程的属性
3.1、线程的状态
- NEW(新建):当一个线程被创建之后,该线程并未启动,则这个状态是新建状态。
- Runnable(运行):当程序调用Thread.start()方法时,线程处于运行状态。
- Blocked(阻塞):当线程处于阻塞状态时,线程暂时是不执行的。(等待锁)
- Waiting(等待):线程处于等待,表示现场正排队等待其他事情。(wait)
- Timed wating(计时等待):等待超时期满或者接收适当的通知。(sleep)
- Teriminated(终止):线程终止(死亡),即线程完成了工作。
Runnable运行状态,线程并非一直始终保持运行状态,由上述讲到并发线程,运行的线程可能需要暂停,此时通过线程调度让其他线程有机会执行。
线程调度:系统为线程分配cpu使用权的过程.
抢占式调度:系统给每一个线程分配一个时间段来执行.
线程的状态周期
1.getState()获取线程状态
- 通过
Thread.getState()
方法可以获取线程的状态
//线程的匿名类
Thread thread = new Thread(){
@Override
public void run() {
System.out.println("这是创建线程Thread的匿名子类对象,重写run方法");
}
};
thread.start();
System.out.println("当前线程的状态为:"+thread.getState());
2.join()等待线程结束
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());
2. 执行join();
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限制,它们是同时往下走的
多线程是并发执行的,调度顺序不确定.
但是我们不能干涉调度器的行为,调度器咋随机还是咋随机
3.yeild()方法
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();
4.sleep()线程休眠
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() 当前的毫秒级时间戳
3.2、中断线程
1.什么是中断线程
- 自然中断线程:当run方法从start到结束完成整个run方法体时,线程被中断。
- 一般情况下,线程处于"等待",“阻塞”,“计时等待”,会抛出InterruptedException异常,例如:sleep方法睡眠,使线程进入休眠等待状态,此时需要捕获这个异常否则编译不通过。也就是线程"非运行状态"转化到"正在运行"或者在"转化运行状态的途中".
- 一般我们将中断线程为:线程请求一个中断请求
为什么中断线程需要捕获InterruptedException?
比如:
一座每次只能通过一辆车的重量的桥,假设有三辆车,A,B,C车,当A进入桥时,B和C进入等待状态,即sleep()或者wait();如果A的通过桥的速度大于sleep()中所设定的毫秒数,此时就会发生中断,也就是说B进入桥的时间要小于所设的的毫秒数。
假设没有中断请求,那么A结束了过桥,此时B和C还在等待状态,这导致时间等久,降低cpu执行效率
2.中断线程的方法
- 使用标志位
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();
}
}
但是这个标识存在缺陷,就是当发生中断时,程序运行并未告诉我们发生了中断事件。
- interrupt方法
方法 | 作用 |
---|---|
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异常
3.3、守护线程
守护线程:唯一的用户是为其他线程提供服务。也称作为"后台线程"
当只剩下守护线程的时候,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线程退出!");
}
很明显的发现,main执行结束之后由于非守护线程存在故JVM不能自动退出
- 设置守护线程的方法: t.setDaemon(true),,isDaemon()判断该线程是否是守护线程(‘后台线程’)]
- 该方法需要在线程启动之前设置
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线程退出!");
}
此时因为t1是守护线程,而主线程是非守护线程,所以当主线程退出时,JVM只剩下守护线程,故退出程序!
3.4、线程的优先级
每当线程调度有机会选择新线程时,它首先选择具有较高优先级的线程。但是,线程优先高度依赖于系统。当虚拟机依赖于宿主机平台的线程实现时,java线程的优先级会映射到宿主机平台的优先级。平台的线程优先级可能比java内部的10个级别多,也可能少。所以优先级不一定按照我们的意愿来执行线程的先后顺序
- 以下有三种默认优先级的参数值
线程优先级 | 优先级值 |
---|---|
static int MIN_PRIORITY | 1 |
static int NORM_PRIORITY | 5 |
static int MAX_PRIORITY | 10 |
- 可以通过setPrioiority(int newPriority) 来设置线程的优先级,虽然设置有一定程度上可以让目标线程优先执行,但是并非100%,其参数值必须是1-10之间.
3.5、线程的引用
为了对线程的中断,线程等待等操作,就需要获取线程的引用
什么是线程的引用,就是拿到目标线程,比如上述中的Thread.currentThread()方法。
- 如果是继承了Thread类重写了run方法,可以直接在run中直接调用this
- 如果是继承了Runnable和lambda表达式,this就不是指向Thread的实例
- 比较通用的方法是:Thread的静态方法,Thread.currentThread();
四、线程安全(重点)
1、线程安全概念
在多线程的代码环境下运行的结果符合我们的预期,且与单线程环境下应有的结果预期相同,则说这个程序是线程安全的
2、线程不安全的原因
演示线程不安全的情况
- 创建两个线程t1,t2,定义一个成员变量count,线程1和线程2共享这个count。
- 让线程1和线程2分别自增5w次,我们预期的结果是count最终的值为10w.
/**
* 计算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)、调度器的随机调度
多线程的运行是由调度器决定,而调度器对多线程的调度是随机调度的,或者是抢占式执行,这就导致了当非原子性的多线程在随机执行过程中,由于非原子性的指令不只一条,所以这就导致了在线程1指令执行的过程当中会发生线程2中的指令抢占cpu先执行。
(2)、修改共享数据
在上述举例代码中,count变量就是两个线程t1和t2的所共享的资源,它们共用并且能够修改资源,而这个count资源变量是存放在堆中的。因此这个变量可以被多个线程共享访问。
(3)、原子性
什么是原子性,就是不可再分,这里指的是代码只有一条指令或者不可再分出更多的指令。
一条java语句并非硬性就定义成一条指令,它可能含有多条指令,所以一条java语句可能是非原子性的。
- 在上述两个线程t1,t2对共享变量count进行修改中,具有一下三个步骤:
- 从内存中读取数据到cpu寄存器–> load
- 在cpu中执行++的指令 --> add
- 将数据写入内存 --> save
不难发现,在两个线程对共享资源count执行count++的时候,需要三个步骤,也就是三个指令,即非原子的代码,此时由于线程的调度器随机调度,导致两个线程在执行指令的时候,指令的先后顺序发生错乱。
线程安全情况下的三条指令执行顺序:
当线程t1执行完从内存读取load到执行add在到save写入内存之后,线程t2再完整执行三条指令或者线程t2先完整完成,再到t2。
但由于调度器随机调度多线程,这种情况的出现是概率也是有但是并非绝对。
线程不安全情况下的三条指令:
在多线程的随机调度中,指令的顺序有无数种可能.
一下展示其中的两种导致线程不安全的情况
当线程t1开始执行load的时候,由于t2抢占cpu执行,导致线程t1执行load的时候,后面的指令未执行完,而线程2从读取内存在++,写入内存,此时堆中所共享的变量已经发生变化,而由于线程t1已经读取了内存中的还未修改的数据。
以上图的红线左边为例子:
这显然不是我们想要得到的结果,我们希望多线程并发执行后,我们所预期的count的值应该是2,而两线程执行之后的存入的结果是1.
上述是多线程对同一个变量即共享变量进行修改的时候所发生的线程不安全,如果多线程对多个不同的变量资源进行修改,也就是一个线程修改一个变量此时不会发生线程不安全的情况。
(4)、内存可见性
内存可见性:
一个线程对共享变量值的修改,能够及时地被其他线程看到。内存可见性也会带来线程不安全
当线程t1,t2使用共享数据,同一资源变量。其中线程t1对数据反复读和判断,线程t2对数据进行修改并且写入内存。
理想情况下:当线程t2修改并且写入内存之后,线程t1会接收到数据被修改的消息,并且重新从内存中读数据。但是因为一些原因导致意外。这种意外可能是因为javac编译器、JVM或者操作系统对代码进行优化处理。
被优化的数据线程t1将不再从内存中读取,而是直接从cpu寄存器中读.
为什么要优化代码?主要是因为,读的操作往往比其他操作要慢很多个数量级,尤其是从内存中读数据比从cpu中读数据要慢很多!!
如果数据一样,总从内存中读数据就会大大降低效率。虽然代码得到了优化,但是线程且会带来一些不安全的情况. 二者总是不可兼得的。
(5)、代码/指令重序性
什么是代码重序性
举个例子: 一段代码
- 去前台取下 U 盘
- 去教室写 10 分钟作业
- 去前台取下快递
如果是在单线程情况下, JVM 、 CPU 指令集会对其进行优化,比如,按 1->3->2 的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序
编译器对于指令重排序的前提是 " 保持逻辑不发生变化 ". 这一点在单线程环境下比较容易判断 , 但是在多线程环境下就没那么容易了, 多线程的代码执行复杂程度更高 , 编译器很难在编译阶段对代码的执行效果进行预测, 因此激进的重排序很容易导致优化后的逻辑和之前不等价。
五、同步
在大多数多线程的应用中,多线程需要共享对同一个数据的存取。当两个线程调用了同一个方法对同一个数据进行存取,此时线程会互相覆盖,这也可能会导致对象被破坏。
- 比如:银行存取,余额:1000元,当有两个人对同一张银行卡进行操作的时候,并且在同一时间段对银行取钱,当第一个人取钱的时候,第二个人也取钱,此时两人看到的数据是一样的,如果第一个人已经从银行取了500,按理来说,第二次也就是第二个人看到银行卡余额应该是500,但是却是1000,这是就造成了数据错乱。
为了解决上述类似的问题,我们可以使用同步锁的方法。
5.1、synchronized关键字
synchronized是一个监听器锁,它可以是锁this,也可以是锁类对象
(1)、synchronized特性
1. 互斥
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);
}
为什么加锁之后能够实现互斥?打个比方
有一批人排队上厕所,厕所只有一间,如果一个人进入之后,这时门就被🔒了,其他人就在外面排队等待。如果没有锁,很容易就厕所已经有人了,其他人还能打开门,这就很不合理。
注意:当锁被释放的时候,未必会因为线程的先后排队顺序就一定能拿到锁,这个的比喻只是宏观上的,在线程能否能拿到锁,还需要看调度器的随机调度。
2.刷新内存
synchronized的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作的内存(CPU寄存器)
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁
3.保证原子性
5.2、synchronized使用示例
synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用.
(1)、 直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
public synchronized void methond() {
}
}
(2)、修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
public synchronized static void method() {
}
}
(3)、 修饰代码块: 明确指定锁哪个对象
锁当前对象
public class SynchronizedDemo {
public void method() {
synchronized (this) {
}
}
}
锁类对象
public class SynchronizedDemo {
public void method() {
synchronized (SynchronizedDemo.class) {
}
}
}
(4)、锁当前对象和类对象
public class SynchronizedDemo {
public static void method() {
synchronized (SynchronizedDemo.class) {
//不能使用this作为锁的对象,可以使用JAVA中的任意的类对象
//因为这个方法是静态方法
}
}
}
当多线程竞争同一把锁的,才会产生阻塞状态,如果多线程获取各不相同的锁,则不会发生竞争。
锁类对象的:
锁当前对象this:
5.3、volatile关键字
(1)、volatile保证了内存的可见性.
测试两个线程,共享同一个资源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关键字之后,线程t1能够从内存中读取到修改后的共享资源,但是这会使速度变慢,数据变得更准确。
- 代码在写入 volatile 修饰的变量的时候,
- 改变线程cpu寄存器中volatile变量副本的值
- 将改变后的副本的值从cpu寄存器刷新到主内存
- 代码在读取 volatile 修饰的变量的时候,
-
从主内存中读取volatile变量的最新值到线程的cpu寄存器中
-
从cpu寄存器中读取volatile变量的副本
(2)、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);
}
用了volatile修饰的变量不能够保证原子性,需要用synchronized加锁
5.4、wait & notify
由于多线程的调度是随机调度,抢占式执行的,也就是线程的执行顺序并非我们所预期,但是在实际开发中,有时我们希望合理的协调多线程之间的执行顺序。
能够使得多个线程协调的顺序执行,JAVA提供了以下的方法:
wait():让线程进入等待状态
notify/notifyAll:唤醒处于等待状态的线程/唤醒所有处于等待状态的线程
wait(),notify,notifyAll都是Object类的方法
(1)、wait()方法
方法 | 作用 |
---|---|
void wait() | 让线程进入等待状态 |
void wait(long timeout) | 让线程进入等待状态,等待timeout毫秒级 |
void wait(long timeout,int nanos) | 让线程进入等待状态,等待更精确的毫秒级 |
wait()方法需要做的事情是:
- 让当前执行代码的线程进入等待状态(将线程放在等待队列当中)
- 释放当前的锁
- 满足一定条件时被唤醒,重新尝试获取这个锁
wait()方法结束的条件:
- 其他线程调用该对象的 notify 方法.
- wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间)
- 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.
注意: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()被唤醒结束");
}
发现这个锁并没有被唤醒
(2)、notify()方法
为了唤醒wait(),我们就要使用notify()方法
- notify()也要在同步方法块或者同步方法中使用,用来通知那些可能正在等待该对象的对象锁的线程,当notify对其发出通知之后,该线程才有机会重新获取该对象的对象锁。
- 如果有多个线程等待,由于调度器的随机调度,调度器随机选取一个处于WATING的线程,让其重新获取该对象的对象锁,并非按照(先来后到的原则)
- notify()方法中的线程并不会马上释放锁,需要将当前线程执行完,也就是同步代码块的内容执行完毕,退出同步代码块才会释放对象锁。
代码示例:
- 创建锁对象
- 创建两个线程t1测试wait(),t2测试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();
(3)、notifyAll()方法
notify()只能唤醒某一个等待线程,而notifyAll可以唤醒所以等待线程.
代码示例:
- 再创建一个线程t3,也设为等待线程,用notify()测试
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();
- 使用notifyAll
Thread t2 = new Thread(()->{
synchronized (locked){
System.out.println("notify()开始唤醒wait()~~");
locked.notifyAll();//使用当前线程的锁对象的对象
System.out.println("notify()结束唤醒wait()~~");
}
});
注意:三个线程仍然需要竞争锁,使用notifyAll之后的线程仍然因为调度器的随机调度,需要竞争该对象的对象锁!!
(4)、wait()和sleep()
wait()和sleep()方法类似,都是能让线程进入阻塞状态,但是wait()是用于线程之间的通信,sleep()只是让线程暂时进入阻塞状态,也就是放权-cou执行劝.
- wait()需要搭配同步代码块(synchronized),sleep不需要
- wait是Object的方法,sleep是Thread的静态方法
一般的,在实际开发中,sleep是的使用频率比较少,因为wait方法也具备了sleep的方法,即wait也能够阻塞一段时间–> void wait(long timeout);
(5)、三种阻塞状态的区别
- WAITING:该阻塞状态是由wait()方法导致,需要另外一个线程notify来通知才被唤醒,或者等待timeout时间。
- BLOCKED:是因为多线程争取synchronized的对象锁而发生的阻塞状态
- TIMEWAITING: sleep()/join(),当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
六、多线程案例
6.1、单例模式
单例模式是设计模式的其中一种设计模式,分为:饿汉式和懒汉式
单例模式所创建出来的对象只有唯一的一个实例对象,不会创建出多个实例对象。
(1).饿汉式
什么是饿汉式?饿汉式是类加载的同时就创建出了单例实例对象。
class Singletom{
//私有静态实例,类加载同时创建单例实例对象
private static Singletom instance = new Singletom();
private Singletom(){} //私有构造器
public static Singletom getInstance(){
return instance;
}
}
不能再new新的实例对象,只能通过获取已经实例的对象
测试是否是同一个实例对象:
public static void main(String[] args) {
//只能获取已经实例的对象
Singletom s1 = Singletom.getInstance();
//不能再new多个实例
Singletom s2 = Singletom.getInstance();
System.out.println(s1==s2);
}
执行为ture说明是同一个实例对象。
(2).懒汉式
什么是懒汉式?
懒汉式不会随之类的加载而创建单例实例,当需要这个对象的时候才会被创建。
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;
}
}
6.2、阻塞队列
(1).什么是阻塞队列?
阻塞队列是一种特殊的队列,遵循"先进先出"的原则
阻塞队列是一种线程安全的数据结构:
- 当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素
- 当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞就是让线程暂时停下来等一等,本质上就是修改线程的状态,让其他线程参与调度
(2).生产消费者模型
- 生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。而阻塞队列就相当于一个容器
- 生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
这样做是为了降低代码之间的耦合性,使得代码之间不会因为另一方出现bug导致代码不可用而顺便也将另外的代码也一并带走。
生产者只需要考虑和阻塞队列的交互,不需要考虑和消费者的交互,同理消费者也是一样.
(3).标准库中的阻塞队列
在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可.
- BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
- put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
- BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性.
这里也可以用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();
}
(4).手动实现阻塞队列
实现阻塞队列:
- "循环队列"实现
- 利用synchronized加锁
- 如果入队列put为满时,需要wait()阻塞等待.
- take()取出元素时,判定如果队列为空,就进行wait().
/**
* 实现一个阻塞队列
* 首先实现普通的"循环队列"
* 由普通队列通过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();
}
}
实现阻塞队列的具体说明都在代码注释中.
6.3、定时器
(1).什么是定时器
- 定时器也是软件开发中的一个重要组件. 类似于一个 “闹钟”. 达到一个设定的时间之后, 就执行某个指定好的代码.
(2).java标准库中的定时器
- 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
- schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).
/**
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接口的实现类.
(3).实现定时器
定时器的构成:
- 一个带优先级的阻塞队列
为啥要带优先级呢?
因为阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来.
- 队列中的每个元素是一个 Task 对象.(任务+ms后执行的时间)
- 同时有一个 worker 线程一直扫描队首元素, 看队首元素是否需要执行
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<Task>,因为该队列存入的是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);
}
}
6.3、线程池
(1).什么是线程池
引入线程池,是为了减少线程的创建与销毁,在实际开发中,可能会遇到系统更加频繁的使用线程,造成线程的频繁创建和销毁,加重了消耗,系统可能吃不消。
比如为什么引进线程,也是因为进程的开销大,占用内存比线程大等等。
我们说:线程是"轻量级"的进程,而线程池就是"轻量级"的线程。
线程池是将一定数量的线程创建好后,放入到线程池,当需要的时候也就是创建线程,就会从线程池中取出使用,当线程销毁结束任务就将线程重新放回线程池. 这样就节省了创建和销毁线程的消耗。
(2).线程池的效率
线程池最大的好处就是减少每次启动、销毁线程的损耗
线程池涉及到操作系统内核问题:
用户态与内核态之间
什么是用户态:就是用户能自己操作的应用程序
内核态:调用到操作系统内核的程序,操纵硬件
线程池中线程的创建就是基于"用户态",创建这样效率更高,因为内核态是由系统控制,cpu执行未必会立即创建线程,因为cpu合数也不少,任务也多。
(3).标准库中的线程池
-
使用 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 创建线程池的几种方式:
- newFixedThreadPool: 创建固定线程数的线程池
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
(4).实现线程池
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("开始执行"));
}
}
七、常见的锁策略
有第七章开始,介绍面试过程中面试官常常也会问到的问题,对于实际开发的作用并不大,但对于程序员而言,还是需要多了解一些锁的知识,合理使用锁。
7.1悲观锁&乐观锁
悲观锁
悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
举个例子,一个病人去医院,但是去医院的人很多,找同一个医生的人也很多(医生相当于数据,病人就是线程),那么这个病人可以先打个电话或者预约等等,询问医生是否可以抽时间(相当于上锁),因为医生被这个病人先预约了。如果其他病人先预约那就需要等待这个病人看完。
乐观锁
乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。. 因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
举个例:病人A大概概率能够猜到医生是有空的,所以就直接登门拜访(没有加锁,直接访问数据),如果医生确实闲,那就直接解决问题。但是如果很忙,那就直接走,不打扰医生,下一次再来。(这就是识别了数据冲突,判断是否有空).
并非说悲观锁就不好,也不是说乐观锁就一定好,悲观锁和乐观锁在特定场合就有它们各自的优势,并没有说谁优谁劣。
- Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略:目的是为了线程安全
- 乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.
什么是"版本号",相当于一个标记符位,这个"版本号"只能递增,不能递减,而且存入主内存时,需要判断"版本号",如果大于主内存的版本号,操作成功存入成功,如果小于或者等待,则操作失败。
比如:银行账户的存取:
7.2.读写锁
多线程之间,数据的读取方不会产生线程安全问题,但是数据的写入方互相之间和读取方之间都需要互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。
- 两个线程都只是读一个数据,不会有线程安全,直接并发的读取。
- 两个线程都要写一个数据,有线程安全问题
- 一个线程读,一个线程写,也有线程安全问题.
读写锁就是把读操作和写操作区别开来。
java标准库提供了ReentrantReadWriterLock类,实现读写锁
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
加读写锁有三种情况:
存在互斥,则线程就会挂起等待, 再次唤醒不知道需要等待多久时间
因此需要减少互斥的情况,提高效率
synchronized不是读写锁!!!
7.3.重量级锁&轻量级锁
- 重量级锁: 主要依赖了操作系统提供的锁,使用这种所容易产生阻塞等待.
- 轻量级锁: 主要尽量的避免使用操作系统提供的锁,而是尽量由"用户态"来完成功能,尽量避免"用户态"和"内核态"的切换,尽量避免挂起等待.
轻量级锁不是不适用操作系统提供的锁,而是尽可能的避免使用,如果遇到实在解决不了的再使用操作系统提供的锁:mutex.
mutex:
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
7.4.自旋锁
什么是自旋锁?
当线程枪锁失败之后,就会进入阻塞等待状态,在多线程当中,可能需要很久才会被调度。那就可能要放弃cpu。但是自旋锁的作用当枪锁失败之后,就会不断循环反复获取锁,直到获取锁为止。
一旦锁被释放,就会第一时间去获取锁!
自旋锁伪代码:
while (抢锁(lock) == 失败) {}
自旋锁和挂起等待锁:
区别在于自旋锁比挂起等待锁更加主动去争取锁
自旋锁是一种轻量级锁:
- 优点:自旋锁不涉及线程阻塞调度,不放弃cpu,当线程释放锁之后就能在第一时间获取锁
- 缺点:因为自旋锁不放弃cpu,所以一直占cpu资源,如果线程长时间不释放锁,那么自旋锁就会一直消耗cpu资源.
7.5.公平锁&非公平锁
- 公平锁: 遵循先来后到的原则,当线程A -> B-> C按照顺序,当线程A释放锁之后,线程B要先于线程C获取锁
- 非公平锁: 随机调度,线程B->线程C,当A释放锁之后,线程C抢占CPU先于B获取锁.
在操作系统内部的线程调度是随机调度的,如果不做任何限制操作,那就是非公平锁。
如果需要实现公平锁,则需要一些数据结构加以限制,在限制锁的获取按照线程的先后顺序获取。
synchronized 是非公平锁.
7.6.可重入锁&不可重入锁
- 可重入锁:
可重入锁->“可以重新进入相同的锁”,允许一个线程可以多次获取同一把锁
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
- 不可重入锁:
顾名思义,就是一个线程不能多次获取同一把锁,如果尝试获取同一把锁,就会发生"死锁",也就是锁不能释放,程序卡死在锁不能释放的情况。
//synchronized是可重入锁,不会发生死锁的情况.
// 如果非可重入锁,则这种情况就会发生死锁
synchronized (this){
synchronized (this){
}
}
八、CAS(比较并交换)
8.1.什么是CAS
Compare and swap,字面意思:”比较并交换“
一个CAS涉及到以下的操作:
- 比较A和V是否相等。( C)
- 如果相等,将B写入V。(S)
- 返回操作成功。
CAS 是一个原子的硬件指令完成的.
伪代码:
boolean CAS(address, expectValue, swapValue) {
//此伪代码并非CAS,只是用于辅助理解。
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
8.2.CAS应用
(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;
}
}
8.3.ABA问题
ABA问题指的是:指在CAS操作时,其他线程将变量值A改为了B,但是又被改回了A,等到本线程使用期望值A与当前变量进行比较时,发现变量A没有变,于是CAS就将A值进行了交换操作,但是实际上该值已经被其他线程改变过,这与乐观锁的设计思想不符合。
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.
解决方案:
在修改值的时候,可以引入"版本号"version.在CAS比较的时候,不仅仅要比较值也要比较版本号是否符合要求.
修改的时候:
如果当前版本号和读取的版本号相同,则修改数据,并把版本号+1
如果当前版本号高于读到的版本号,就操作失败.
九、Synchronized 原理
9.1.基本特点
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
9.2.加锁工作过程
1) 偏向锁
- 偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
- 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
- 如果后续有其他线程来竞争该锁, 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
- 偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
2)轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁.自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了. 这就是自适应
3) 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
- 执行加锁操作, 先进入内核态
- 在内核态判定当前锁是否已经被占用
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
9.3.其他的优化操作
1)锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
例如:StringBuffer中的append的调用就是涉及到加锁解锁,但是在单线程的情况下调用,然后操作加锁解锁就会白白浪费资源的开销
2)锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是在实际中,可能并没有其他线程来抢占这个锁,这种情况JVM就会自动将锁粗化.减少频繁的加锁和解锁操作.
十、Callable 接口
10.1.Callable接口用法
可以用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的常见类
JUC->(java.util.concurrent)
11.1.ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
- unlock(): 解锁
代码块:
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.
11.2.信号量 Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
- 可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
- 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
- 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
- 如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.
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();
}
11.3.CountDownLatch
同时等待 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("比赛结束");
}
}
十二、线程安全的集合类
12.1ArrayList
使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到.
12.2.队列
- ArrayBlockingQueue
基于数组实现的阻塞队列
- LinkedBlockingQueue
基于链表实现的阻塞队列
- PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
- TransferQueue
最多只包含一个元素的阻塞队
12.3.哈希表
- HashMap
本身不是线程安全的.
- HashTable
只是简单的把关键方法加上了 synchronized 关键字.
如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
线程安全.
HashTable只有一把大锁,当两个线程访问HashTable中的任意数据都会出现锁冲突
- ConcurrentHashMap
线程安全.
读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
每个Hash桶都有一把锁,只有当两个线程恰好同时访问同一个哈希桶上的数据才会出现锁冲突。
经典面试题:
三个哈希的区别:
- HashMap线程不安全,HashTable和ConcurrentHashMap是线程安全的。
- HashTable是使用一把大锁,锁冲突的概率很高。ConcurrentHashMap则是每个哈希桶上都有一把锁,当两个线程恰好访问同一个哈希桶上的数据时才会出现锁冲突。
- HashMap key允许为null,另外两个不允许为null。