本文讲解使用synchronized只是对synchronized的使用,底层原理将在后续文章
目录
从实际中理解共享带来的问题
Java代码实现共享带来的问题进行分析
临界区(Critical Section) 与 竞态条件(Race Condition)
临界区
竞态条件
synchronized解决方案
怎么理解synchronized中的 同步 与 互斥
理解synchronized关键字
synchronized加在方法上
从实际中理解共享带来的问题
有一个实际的例子 比如 老王有一个功能非常强大的算盘(操作系统),但是由于自己忙于工作,没有时间用,放着也是放着,于是就想把这个算盘租出去赚点钱. 于是有两个同学小明和小红就像租用.
好接下来,老王就把算盘租给小明用,但是由于小明在一些特殊情况没有使用,比如 睡觉的时候(sleep),吃饭的时候(比如读文件->InputStream),在比如上厕所的时候(写文件-->OutputStream)这种阻塞IO操作,还有的时候会放松一下(wait操作),这些时间小明都会进行阻塞状态.也就是这些时间内小明并没有使用算盘,而是占用着.
于是,老王就会想我这一天都把算盘租给你自己了,那么赚的钱还少,于是老王就让小红用一会,小明用一会.这样就能在相同的时间赚够足够的钱了.
由于小红和小明在计算过程中,中间的计算结果过多,导致两个人脑容量(工作内存)不足计算的结果记不住,于是又去申请了一个笔记本,来记录中间结果.
比如此时小明计算的结果为1,小红计算的结果为-1,所以合起来结果为0.
但是由于分时系统的原因(小明用一会,小红用一会-->上下文切换),就会导致这个最终的计算结果出现问题
比如 小明用了一会算盘刚算出结果,就是要+1,但是还没有写到笔记本中,老王就会给他说,你的时间到了(CPU时间片用光了),于是就给小红进行计算,从笔记本拿到值为0,小红计算的是要将其结果-1,所以为0-1=-1,于是将-1写到笔记本中,小红写到笔记本之后,小红就算完了,老王就会让小明继续算,小明刚才算出来了于是就把写过写到笔记本上,现在最终结果为1.
这就出现问题了,我们想要的最终结果为0,但是由于分时系统的原因(上下文切换的原因),在多个人(线程)协作计算结果的时候,就会出现的结果并不是我们所预期的那样.--->这就是线程安全问题
Java代码实现共享带来的问题进行分析
上述的操作我们用Java代码进行实现,就是两个线程对共享变量执行++操作.我们看如下代码 :
public static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i =0;i<5000;++i){
counter++;
}
});
Thread t2 = new Thread(()->{
for(int i =0;i<5000;++i){
counter--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
以上出现的结果是不可预测的,可能是正数也可能是负数,也可能是0.
这个问题的根本原因就是counter++ 和 counter-- 不是原子操作,也就是一个++,--操作会分为多个指令.
要想理解这个就需要从JVM的字节码角度去理解 :
对于 ++ 操作会执行以下指令 :
- getstatic counter // 获取静态变量counter的值
- iconst_1 //准备常量1
- iadd //进行自增操作
- putstatic counter //将修改后的值存入静态变量counter
对于 -- 操作会执行以下指令 :
- getstatic counter //得到静态变量counter的值
- iconst_1 //准备常量1
- isub //进行自减操作
- putstatic counter //把修改后的值存入静态变量counter
在Java中,会在Java内存模型(JMM)中的主内存和工作内存之间进行数据交换.
但是,如果是在单线程的场景下,不会进行交错的情况,按照代码的顺序执行,所以不会发生非0的情况,结果就为0.
正常情况 :
对于单线程条件下,不会出现指令交错的情况,都是一步一步按照代码的顺序执行,指令之间不会出现交错,所以计算的结果是我们所预期的那样
出现负数的情况 :
如果是多线程可能出现负数,也可能出现正数.(线程1执行i++操作,线程2执行i--操作)
我们先来看负数的情况 :
线程2读取静态变量,然后准备常数1,然后对i做--操作,i变为-1,但是还没有把结果写入到变量i中,这个时候CPU时间片用完了,进行线程上下文切换,切换到线程1,然后线程1也是按照指令进行执行从静态变量中读取,读取的是0,然后准备常数1,在执行自增操作i变为1,写入到i变量中,这时线程1执行完了,又进行线程的上下文切换,然后线程2接着刚才没有执行的操作继续执行,于是将其-1写入到变量i中.最终i的值不是我们预期的0,而是-1--->出现负数情况
出现正数的情况 :
线程1取静态变量,值为0,然后准备常数1,然后对i做++操作,i变为1,但是还没有把结果写入到变量i中,这个时候CPU时间片用完了,进行线程上下文切换,切换到线程2,然后线程2也是按照指令进行执行从静态变量中读取,读取的是0,然后准备常数1,在执行自减操作i变为-1,写入到i变量中,这时线程2执行完了,又进行线程的上下文切换,然后线程1接着刚才没有执行的操作继续执行,于是将其1写入到变量i中.最终i的值不是我们预期的0,而是1--->出现正数情况
临界区(Critical Section) 与 竞态条件(Race Condition)
临界区
什么情况下会出现问题 ? 什么情况下不会出现问题呢 ?
- 当我们一个程序运行多个线程本身是没有线程安全问题的.
- 多个线程访问共享资源就会出现线程安全问题
- 多个线程读共享资源不会出现线程安全问题
- 多个线程对共享资源进行读写操作的时候就会发生指令交错,就会出现线程安全问题
怎么理解临界区呢 ?
一段代码块中,如果存在多个线程对共享变量进行读写操作时候,这个代码块就被我们称为临界区.
public static int counter = 0;//共享变量
public static void increment()
// 临界区
{
counter ++;
}
public static void decrement()
//临界区
{
counter --;
}
竞态条件
就是多个线程在临界区执行代码,但是由于代码的执行序列不同而导致出现结果不符合预期就为竞态条件.
还是拿上面的代码counter为例 :
- counter在第一个线程for循环里面用到,发生结果不可预测的情况这就说明发生了竞态条件
- 同理 : counter在第二个线程for循环里面用到,发生结果不可预测的情况这也就说明发生了竞态条件
synchronized解决方案
解决多个线程访问共享变量对其进行操作(线程安全问题)有两种解决方案可以解决:
1.阻塞式的解决方案 : synchronized,Lock --->加锁
2.非阻塞的解决方案 : 原子变量
我们先来看看synchronized是怎么进行解决的.
synchronized又称为对象锁,它是采用互斥的方式能够在同一时刻只能有一个线程持有[对象锁],其他线程在想获取这个[对象锁]时候就会阻塞(进入阻塞状态),这样就能够保证拥有锁的线程就可以安全的执行临界区的代码.-->不用在关心线程的上下文切换.
怎么理解synchronized中的 同步 与 互斥
在java中 互斥 和 同步都是可以利用synchronized来实现的.
互斥就是保证临界区的代码发生竞态条件(多个线程读写共享变量),同一时刻只能够有一个线程执行临界区的代码.
同步就是由于线程的执行先后,顺序不同,就需要一个线程等待其他线程运行到某一个点.
理解synchronized关键字
synchronized(对象(多个线程共享的对象)){ //临界区 }
我们来实际通过代码先简单理解一下synchronized关键字
比如现在有两个线程(线程1,线程2)来竞争锁,比如线程1先获取到对象锁(锁的持有者),线程2没有获取到这个对象锁只能阻塞等待(阻塞状态->blocked状态,因为同一时刻只能有一个线程持有对象锁),当线程1执行完临界区的代码->执行完synchronized代码块了,线程1就会释放该对象锁,同时唤醒线程2(唤醒阻塞状态的线程,从blocked状态->可运行的状态). 再由线程2来执行临界区的代码
通过这种互斥方式,在同一时刻只能有一个线程持有对象锁,其他线程竞争这个锁都会进行阻塞.那么当线程执行临界区的代码的时候,就会安全的执行,临界区的代码就相当于是串行执行的
(只有一个线程执行完毕了,下一个线程才能执行)
public static int counter = 0;//共享变量counter
public static Object locker = new Object();//共享的锁对象
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(()->{
for(int i =0;i<5000;++i){
synchronized (locker){//加锁
//临界区
counter++;
}
}
});
Thread t2 = new Thread(()->{
for(int i =0;i<5000;++i){
synchronized (locker){//加锁
//临界区
counter--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter);
}
再次利用图解的方式来深入仔细理解synchronized
还是之前的例子 : 老王想租这个算盘,只有进入这个房间才能进行计算,现在有3个人想租这个算盘
我们可以将这个synchronized(对象),这个线程共享的对象看做是一个房间,这个房间一次只能有一个人进入(持有对象锁),其他人只能在门外等着(运行状态->阻塞状态),可以把门外的三个人想象成3个线程.
- 当t1人进入到房间(线程t1执行到synchronized(对象)),t1人就拿到这个房间的钥匙了(t1线程持有当前对象锁),进入房间后就会拿算盘计算(执行临界区的代码).
- 这个人也想进入到这个房间发现这个房间已经被锁住了,无法进入(其他线程t2,t3从运行状态->阻塞状态(上下文切换)),只能在门外等着(阻塞等待)
- 这个时候如果t1这个人用的算盘时间到了(CPU时间片用完了),他就会被老板赶出去,但是这个时候门还是锁住的,只有t1这个人有这个房间的钥匙,t2,t3这两个人还是在门外等着(t2,t3没有钥匙,只能阻塞等待),只有下次再次轮到t1的时候才能进入房间(下次任务调度器再次调度t1的时候,才会获取CPU时间片,继续持有锁,继续执行临界区代码)
- 当t1这个人全部计算完毕之后(t1线程执行完synchronized代码块了),就会先去解开房间的锁(t1线程释放锁),同时也会告诉t2,t3说我用完算盘了,你们可以用了(同时也会唤醒t2,t3线程),t2人和t3人会竞争算盘的使用(t2,t3线程会竞争锁,然后继续...,竞争到锁之后就可以执行临界区代码,然后没有竞争到的就会阻塞等待).
再次通过流程图理解synchronized
理解synchronized关键字保证原子性
怎么理解原子性呢 ?
原子性就是不可分割的整体,在代码执行的中间过程不会被打断.
理解synchronized保证原子性
synchronized就是利用对象锁保证了临界区内代码的原子性.->临界区的代码对外是不可分割的,不会被线程切换所打断.
就比如 count++,在JVM字节码这一行代码分为4个指令,这四个指令在一个线程执行的时候,四个指令必须都执行,不会出现在多线程的场景下因为线程上下文切换,而导致发生指令交错的情况.
怎么理解把synchronized(obj)放在for循环的外面呢 ?
我们经过前面的学习知道 ++,--这样的操作符是包含4个指令的,所以对整个for循环加锁就相当于 整个for循环的指令(5000*4)这么多指令都是原子的---->对for循环里面的指令做了原子保护.--->在执行的期间不会有其他线程来干扰
怎么理解如果把t1位synchronized(obj1) 而synchronized(obj2) 会怎么运作呢 ?
Thread t1 = new Thread(()->{
synchronized (locker1){//加锁
for(int i =0;i<5000;++i){
//临界区
counter++;
}
}
});
Thread t2 = new Thread(()->{
synchronized (locker2){//加锁
for(int i =0;i<5000;++i){
//临界区
counter--;
}
}
});
这样还是不能保证原子性,因为保护的是不同对象,不起互斥效果,就好像是t1去的是一个房间,而t2又去的另外一个房间,保护的是不同的资源-->不保证原子性->不互斥
这就给我们一个提示在进行加锁的时候,多个线程读写共享变量的时候,一定要进一个房间-->一定要对相同的对象加锁-->多个线程的锁对象一定要相同
多个线程读写共享资源,一定锁的是同一个对象,不能锁不同的对象.
如果t1synchronized(obj) 而 t2没有加锁,又该如何理解呢 ?
Thread t1 = new Thread(()->{
for(int i =0;i<5000;++i){
synchronized (locker){//加锁
//临界区
counter++;
}
}
});
Thread t2 = new Thread(()->{
for(int i =0;i<5000;++i){
//临界区
counter--;
}
});
这个还是不能保证原子性,因为在t1线程持有锁的时候进行上下文切换(比如CPU时间片用完了),线程t2不会尝试获取锁,不会进行阻塞,会接着继续执行,所以不能保证原子性.
这也给了我们提示,当多个线程读写同一个共享变量的时候,也就是要保护临界区的代码,每一个线程都要对其加锁
总结 :
当多个线程进行读写共享变量的时候,每一个线程都必须进行加锁,并且锁对象必须是同一个,才可以其互斥效果,保证一个线程在执行临界区代码的时候,其他线程不会干扰---->保证了原子性
面向对象改进
我们利用面向对象的思想对上面代码进行改进,我们不自己new一个object对象了,我们自己创建一个对象(room对象也为锁对象)来保护共享资源counter,类的内部封装自增,自减方法.
//自己封装一个类,来保护共享资源-->Room就是共享的锁对象-->对共享资源进行保护
class Room{
private int counter;//共享资源counter-->要对齐进行++,--操作
//提供自增的方法对++操作进行保护
public void increase(){
synchronized (this){//给当前对象加锁-->当前对象为锁对象
counter++;
}
}
//提供自减方法对--操作进行保护
public void decrease(){
synchronized (this){//给当前对象加锁-->当前对象为锁对象
counter--;
}
}
//获取的时候,也需要加锁,为了保证获取的是准确地结果,而不是中间的结果
public int getCounter() {
synchronized(this){
return counter;
}
}
}
public class IOPSynchronizedDemo {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();//利用room对象(锁对象)来保护共享资源
Thread t1 = new Thread(()->{
for(int i =0;i<5000;++i){
room.increase();
}
},"t1");
Thread t2 = new Thread(()->{
for(int i =0;i<5000;++i){
room.decrease();
}
},"t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(room.getCounter());
}
}
把互斥的逻辑都封装在Room来内部.对外只需要调用自增自减的这些方法,对共享资源进行保护由内部来实现
synchronized加在方法上
synchronized加在成员方法上
synchronized加在成员方法上就相当于对当前对象this加锁
synchronized加在静态方法上
synchronized加载静态方法上就相当于对类对象加锁
参考 : 黑马程序员JUC视频-->那老师真的超级牛x,原理讲的非常透彻,并发和JVM还有Spring原码讲的最好的-->爱死你了牛B满老师