Java中的多线程(中)

目录

 一、线程间的可通信

二、线程安全

引入一个小栗子:

1.线程不安全出现的原因

(1)开发者角度(两方面)

(2)系统角度(三方面)

2.不需要考虑线程安全问题的情况:

3.作为程序员如何考虑线程安全问题

4.常见类的线程安全问题

三、synchronize锁

1 锁(Lock)的理解

2.synchronize锁——同步锁

👻👻👻语法:

👻👻👻原理看这里:

👻👻👻synchronized锁的特性结论:

👻👻👻栗子来啦:

👻👻👻习题大测试

👻👻👻杂谈


 一、线程间的可通信

大部分场景下,几个线程之间是需要协调配合,一起工作来完成一个任务的,这就需要线程之间可通信;

线程间的通信其实就是线程间数据的数据交换与共享

我们知道,当几个线程共同属于一个进程时,这几个线程是共同享有OS分配的资源的

因为资源共享,这也就方便了线程之间的通信

可是,根据内容的不同,线程与线程之间也并不是所有资源都共享的,让我们来看下在JVM的内存划分中,哪些是共享的,哪些不是共享的:

【注意!】JVM的内存区域划分

  1. PC保存区(保存PC值)——每个线程各有一份,每个执行流独立,指令独立

  2. 栈——每个线程各有一份,每个线程都有自己要处理的临时数据

    虚拟机栈(保存局部变量)

    本地方法栈(保存本地方法的局部变量)

  3. 堆——new出来的都在堆上保存,整个JVM就一份

  4. 方法区——类,常量,静态变量,方法字节码等,整个JVM就一份

  5. 运行时常量池——保存字面量,符号引用等,整个JVM就一份

所以:总结下来就是,堆、方法区和运行时常量池线程是共享的,整个JVM就只有一份

之于代码:

就是new出来的对象,加载的类,常量,静态变量,字面量等是可以共享的(当然,类那边的,前提是有该类的访问权限,线程有该对象的引用)

如下图:

二、线程安全

接上面的线程通信,线程间部分资源共享,方便了线程间通信,但是,不加处理,也可能引发线程不安全的情况

所谓线程安全,就是如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的

反之,线程不安全,就是程序最终的运行结果与我们的预期不符

引入一个小栗子:

// 演示线程不安全的现象
public class Main {
    // 定义一个共享的数据 —— 静态属性的方式来体现
    static int r = 0;

    // 定义加减的次数
    static final int COUNT = 8000;

    // 定义两个线程,分别对 r 进行 加法 + 减法操作
    static class Add extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < COUNT; i++) {
                r++;
            }
        }
    }

    static class Sub extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < COUNT; i++) {
                r--;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Add add = new Add();
        add.start();

        Sub sub = new Sub();
        sub.start();

        add.join();
        sub.join();

        // 理论上,r 被加了 COUNT 次,也被减了 COUNT 次
        // 所以,结果应该是 0
        System.out.println(r);
    }
}

单看代码,貌似是么有问题的,对同一个变量 r 加了8000次1,又减了8000次1,最后预期结果应该是0,但实际结果是这样的吗?

运行结果竟然是: 

 这是个啥离谱数字???我不信,再运行一次

 OMG,回回运行回回不认识的离谱数字,由此看来,这个多线程并没有实现我们预期的结果欸

【小声说一句:count越大,出错的概率也越大】

1.线程不安全出现的原因

(1)开发者角度(两方面)

  • 多个线程之间操作了同一块数据(共享数据)

  • 至少有一个线程在修改这块共享数据

(2)系统角度(三方面)

  • 原子性被破坏

🍧一条语句很可能对应多条指令;(比如刚才我们看到的 n++,其实是由三步操作组成的: 从内存把数据读到 CPU——进行数据更新——把数据写回到 CPU)

🧁线程调度是可能发生在任意时刻的,但是不会切割指令(程序员预期的是一个原子性操作,但实际执行起来保证不了原子性,所以可能会出错)

🧁比如:+-操作同时发生,当某一个操作尚未被保存,而发生了线程调度去执行另一个操作,然后再根据上下文接着执行第一个操作去保存,会发现,第二个操作的结果被覆盖掉了

🧁再看一个详细解释:

所以我们一般用加锁操作来保证原子性,下面再详细说

  • 内存可见性问题

忘记了主内存和工作内存,可以先看下面的原理解释,再读这段话:

💫一个线程对数据的操作其他线程可能暂时是看不见的,缓存与缓存之间的操作变化互不可见,最后写回主内存会相互覆盖掉结果

也就是说一个线程对数据的操作,很可能另一个线程是感知不到的,甚至,某些情况下会被优化成完全看不到的结果

类似这个例子:

具体原理看这里:

【如上图,CPU为了提升数据获取速度,一般在CPU设置缓存Cache】

然后一般L1,L2是一个CPU有一套,而L3是共享一个

在JVM中:

一个线程模拟一个CPU

主存储/主内存:真实内存

工作存储/工作内存:CPU中缓存的模拟

原理解说:

线程的所有操作必须先从主内存加载到工作内存中,在工作内存中进行处理,完成最终处理后再把数据同步回主内存

这就导致,可能一个线程A修改一个共享数据,修改完后还没来得及从缓存中返回给主内存,而同时另一个线程B也对该数据操作,从主内存中取该数据时,取到的仍然是最开始的数据,线程B根本感知不到A已经对相同的该数据作出过修改这就导致,最后的最后,都写回主内存,其中一个线程的操作是被另一个线程的操作覆盖掉了的

  • 代码重排序导致

我们写的程序是经过中间很多环节优化的结果,并不保证最终执行的语言和我们写的一样

程序可看作一个状态机——>留给编译器很大的空间去优化(比如a=0,for循环+1加100次,跟a=100是一样的)

举个不太恰当的问题,比如中毒喝解药,正常来说是先中毒然后喝解药解毒,但是在实际中,有可能编译器会在它的视角下来优化这个步骤,可能优化成先喝了解药(假定无毒喝解药没有任何作用)然后才中的毒

2.不需要考虑线程安全问题的情况:

(1)几个线程之间无任何共享数据;

(2)都是读操作,没有写操作

3.作为程序员如何考虑线程安全问题

(1)尽可能线程之间不作数据共享

(2)有共享操作,尽可能不去修改,而是只读操作

(3)必须共享写入操作——>学习线程安全机制,和JVM沟通,避免出现上述问题

4.常见类的线程安全问题

(1)线程不安全的:ArrayList、LinkedList、PriorityQueue、TreeMap、TreeSet、HashMap、HashSet、StringBUilder

(多线程中,不要再用线程不安全的)

ArrayList不是线程安全的——多个线程同时对ArrayList对象修改操作,结果会出错

(2)线程安全的:Vectot 、Stack 、Dictionary、 StringBuffer

(但这几个类都是Java设计失败的产品,不要用它们)

(3)常见的违反原子性的场景

read-write场景(arr[i] = 4;i ++)

check-update场景( if(        ) {        } )

 那么,我们作为开发人员,要怎么解决线程安全问题呢?答案是lock

三、synchronize锁

1 锁(Lock)的理解

锁有两种状态:

locked (锁上,false) & unlocked(打开,true)

如果锁是打开状态,A线程抢到了锁,A就可以把锁锁上,锁上以后安心执行A的锁内保护的代码,对于其他相同锁的线程B,B得到的就会是锁被锁上了的状态,所以,B是不能运行的,B会加入锁的阻塞队列中,等待锁成为打开状态后,再就绪

所以,最基本的原理,通过加锁,可以保证原子性

所以,如果两个线程A和B都是要操作同一个共享数据,我们可以通过加锁的操作,保证互斥性,就是A操作这个数据时,B是不能操作的(因为A把锁锁上了)

重点注意:前提是两个或多个线程竞争的是同一把锁,没抢到锁的线程才会阻塞等待,保证互斥性,如果不是同一把锁,是互不影响的 

2.synchronize锁——同步锁

👻👻👻语法:

(1)修饰方法(普通、静态方法都可)——>同步方法(修饰普通方法,相当于对当前对象加锁,修饰静态方法被视为对静态方法所在的类加锁)

(2)修饰代码块——>同步代码块 synchronize(引用)

对于普通方法
写法一:
	public synchronized void add(){
    
	}
写法二:修饰代码块
    public void add(){
    synchronized(this){
        
    }
}
对于静态方法
    写法一:
	public static synchronized void add(){
    
	}
	写法二:修饰代码块
    public static void add(){
        synchronized(类名.class){
            
        }
    }

👻👻👻原理看这里:

尝试加锁的内部操作:(整个尝试加锁的操作已经被JVM保证了原子性)

如果锁没有被锁上,

if(locked == false){

当前线程得到该锁,把锁锁上,然后执行自己的代码

lock = true;

return

}else{

// 如果锁已经被锁上了

Queue<线程> 该锁的阻塞队列 queue=……

queue.add(Thread.currentThread());加入该锁的阻塞队列,等待锁被打开后再运行

Thread.currentThread().state = 阻塞;

Thread.yield();相当于让出CPU

}

执行完代码块后释放锁的内部操作:

(前提:这个过程由系统保证了原子性)

释放锁:lock = false

从等待锁的阻塞队列中选一个线程出来回复CPU

Thread t = queue.poll();

t.state = 就绪

👻👻👻synchronized锁的特性结论:

(1)互斥

当多个线程都有加锁操作并且申请同一把锁时,会形成互斥现象

加锁 代码s(临界区) 解锁

临界区代码互斥着进行,现象就是属于同一个锁的两个线程不会同时进行,谁抢到锁,谁就执行,直到执行完后锁被打开了,才会换下一个线程

(2)保证内存可见性

synchronized 的工作过程:

获得互斥锁——从主内存拷贝变量的最新副本到工作的内存—— 执行代码——将更改后的共享变量的值刷新到主内存——释放互斥锁

所以 synchronized 也能保证内存可见性

(3)可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题

死锁是啥,不可重入是啥呢?就是我第一次拿到锁办完事后没有解锁,直接也进入阻塞队列中等待第二次拿锁,而锁一直没开,导致所有线程都在傻等着,这就是死锁,死锁导致我自己也不可重入没办法去开锁

 Java 中的 synchronized 是 可重入锁, 因此没有上面的问题

👻👻👻栗子来啦:

public class Main {
    // 这个对象用来当锁对象
    static Object lock = new Object();

    static class MyThread1 extends Thread {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 1_0000; i++) {
                    System.out.println("我是张三");
                }
            }
        }
    }

    static class MyThread2 extends Thread {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 1_0000; i++) {
                    System.out.println("我是李四");
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new MyThread1();
        t1.start();


        Thread t2 = new MyThread2();
        t2.start();
    }
}

 结果:

先打印完10000个”张三“后,再去打印10000个”李四“(当然顺序不一定,也可能先打印完10000个李四,再打印10000个张三,关键在于谁先抢到锁)

如果不加锁:

张三和李四交替出现,两个线程类似同时进行

👻👻👻习题大测试

好了,说了这么久,来检验下自己到底懂了没吧?

前情小提示:

互斥的必要条件:两个线程都有加锁操作,并且锁的是同一把锁

看下面这段代码,分别来分析下

public class SomeClass {
    public synchronized void m1() {
    }

    public synchronized void m2() {
    }

    public void m3() {
    }

    public void m4() {
        synchronized (this) {

        }
    }

    public void m5() {
        synchronized (SomeClass.class) {

        }
    }

    Object o1 = new Object();

    public void m6() {
        synchronized (o1) {

        }
    }

    static Object o2 = new Object();

    public void m7() {
        synchronized (o2) {

        }
    }
}
public class Main {
    public static void main(String[] args) {
        SomeClass s1 = new SomeClass();
        SomeClass s2 = new SomeClass();
        SomeClass s3 = new SomeClass();
        s3 = s1;
//    试分析下列方法的调用是否互斥,直接在表格中分析叭
        s1.m1();
        s2.m1();
//        ……
    }

}

 

最后几个关于m7的易错,注意一下 

👻👻👻杂谈

  • JVM把每个对象都当作锁使用
  • synchronized(null){} 如果是null,会有空指针异常
  • 加锁粒度越细,并发的可能性越高
  • 正确加锁得到互斥现象——保证了原子性
  • synchronized在有限程度上可以保证内存的可见性

  • synchronized也可以给代码重排序增加一定的约束

  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

笨笨在努力

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值