Javaee:线程安全问题和synchronized关键字

文章目录

概念

多线程并发执行的情况下,出现了bug,就称为线程不安全,没有bug,就是线程安全

原因

随机调度

操作系统调度线程的顺序是随机

随机调度使⼀个程序在多线程环境下,执行顺序存在很多的变数.
抢占式执行

修改共享数据

java 复制代码
public static int count=0;

由于线程共享同一个进程下的资源,此时的count是一个能够被多个进程访问到的"共享数据"

多个线程修改同一个变量会引发线程安全问题

例如:创建两个线程,每个线程对count进行自增5000次

java 复制代码
public class Demo {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock locker=new ReentrantLock();
        Thread t1=new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                    count++;
            }
        });
        Thread t2=new Thread(()->{
            for (int i = 0; i < 5000; i++) {
                    count++;
                }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }
}

结果和预期不一致,说明出现了线程安全问题

原因:count++操作对应到3个CPU指令

load:把内存中count的值,加载到cpu的寄存器

add:把寄存器中的内容+1

save:把寄存器中的内容保存回内存中

由于随机调度的根本性原因,可能执行了t1的load,之后就调度了t2中的load,执行着某个操作,执行着执行着可能就被其他线程调走了,count++操作就并没有真的做到count+1

理解并发和并行

并发:在同一个CPU上执行(两个线程有不同的上下文(一组自己的寄存器的值))

并行:在不同CPU上执行

修改操作不是原子的

原子性

概念:一个操作在执行的过程中是不可分割的,即该操作要么全部执行,要么全部不执行,不会在执行过程中被打断。

理解:我们把⼀段代码想象成⼀个公共厕所,每个线程就是要进⼊这个厕所的人。如果没有任何机制保证,女生进入厕所之后,还没有出来;是不是男生也可以进入房间,打断女生在厕所里的隐私。这个就是不具备原子性的。

在java中,赋值操作是原子的,而++,--,+=,-=等操作就不一定是原子的

原因:它们实际上包含了多个步骤:读取变量i的值、将值加1(或减1)、然后将新值写回变量i。在这些步骤之间,其他线程可能会插入并执行自己的操作,从而干扰原始线程的操作结果。

内存可见性

概念:⼀个线程对共享变量值的修改,能够及时地被其他线程看到

JMM ------ java内存模型

目的:是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到⼀致的并发效果.

• 线程之间的共享变量存在主内存(Main Memory)------也就是我们平时说的内存

• 每⼀个线程都有自己的"工作内存"(Working Memory)

• 当线程要读取⼀个共享变量的时候, 会先把变量从主内存拷贝到工作内存,再从工作内存读取数据.

• 当线程要修改⼀个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存.

把读内存操作优化成读寄存器操作,也是上面的操作

"主内存"才是真正硬件角度的"内存".

"工作内存",则是指CPU的寄存器和高速缓存.

寄存器虽然快,但是空间太小,存不了多少东西,所以大佬建设了一些存储空间,称为"缓存"

CPU的三级缓存

对于java程序员而言,内存数据缓存到CPU里,具体是在寄存器上,还是在L1,L2 ,L3上,不清楚,对java代码而言,无区别

由于每个线程有自己的工作内存,这些工作内存中的内容相当于同⼀个共享变量的"副本".此时修改线程1的工作内存中的值,线程2的工作内存不⼀定会及时变化.

指令重排序

理解指令重排序

保证逻辑没有发生改变的情况下,编译器对代码进行优化

解决方案

针对多个线程修改同一个变量的解决方案------提供适当的同步机制

使用锁机制

引入java中的synchronzied关键字

synchronized的锁特性

互斥性

synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,其他线程如果也执行到同⼀个对象synchronized就会阻塞等待.

java 复制代码
synchronized(locker){
//同步代码块
}

进入到synchronized修饰的代码块,相当于拿到锁,对当前对象进行"加锁"
退出synchronized修饰的代码块,相当于对当前对象"解锁"

而锁对象可以是任意

synchronized用的锁是存在Java对象头里的。

可见性

当一个线程访问被synchronized修饰的类或对象时,必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的

可重入性

如果一个线程已经持有某个对象锁,那么它可以再次获取这个对象锁而不会发生死锁

理解:锁死

一个线程没有释放锁,然后又尝试再次加锁

假设第一次加锁成功

第二次加锁的时候锁已经被占用,此时阻塞等待

此时该线程若是一直拿着锁不释放,就会陷入死锁的状态

不可重入锁:当一个线程已经持有某个锁时,如果该线程再次尝试获取该锁,它将无法成功获取,即会被阻塞,直到持有锁的线程释放锁为止

synchronized是可重入锁,没有上述问题

写一个死锁(面试题)

两个线程两把锁,每个线程获取到一把锁之后,一个线程想获取另一个线程的锁,形成了竞争锁,会成阻塞状态blocked,通过jconsle观察

java 复制代码
public class Demo16 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Thread t1=new Thread(()->{
            synchronized (locker1) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t1拿到了锁");
                //不能释放第一把锁
                synchronized (locker2){
                    System.out.println("t1尝试拿t2的锁");
                }
            }

        });
        Thread t2=new Thread(()->{
            synchronized (locker2) {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("t2拿到了锁");
                synchronized (locker1){
                    System.out.println("t2尝试拿t1的锁");
                }
            }

        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}
构成死锁的条件

互斥 (不可打破)

资源是独占的,不能同时被多个进程共享

不可剥夺 (不可打破)

资源只能由持有它的进程主动释放,其他进程不能强制获取该资源

请求和等待(可打破------把嵌套的锁改成并列的锁)

一个进程在持有至少一个资源的同时,还在等待获取其他被其他进程持有的资源

循环等待 (可打破------加锁的顺序做出约定)

每个进程都在等待下一个进程释放资源,而下一个进程又在等待另一个进程,依此类推,导致所有进程都无法继续执行。

有序性

synchronized保证了每个时刻都只有一个线程访问同步代码块,确定了线程执行同步代码块是分先后顺序的,从而保证了有序性

非公平锁

synchronized是非公平锁,即不保证锁的获取顺序是按照线程请求的顺序进行的。这可能会带来一定的性能优势,因为线程可以在不等待其他线程释放锁的情况下尝试获取锁。

锁策略
悲观锁 vs 乐观锁

悲观:加锁的时候,预测接下来锁竞争的程度非常激烈,需要做额外的工作

乐观:加锁的时候,预测接下来锁竞争的程度不激烈,不需要做额外的工作

synchronized既是乐观锁又是悲观锁------自适应

重量级锁 vs 轻量级锁

重量级锁,在悲观场景下,此时付出更多的代价---->更低效

轻量级锁,在乐观场景下,此时付出更小的代价---->更高效

挂起等待锁 vs 自旋锁

挂起等待锁------>重量级锁的典型实现---->操作系统内核级别的.加锁的时候发现竞争,就会使该线程进入阻塞状态,后续就需要内核唤醒(获取锁的周期更长,很难做到及时获取,但是省CPU,阻塞等待的过程中不消耗CPU

自旋锁------>轻量级锁的典型实现----->应用程序级别的,加锁的时候发现竞争,一般也不是进入阻塞,而是通过忙等的形式进行等待获取锁的周期短,及时获取锁,这个过程会一直消耗CPU

公平锁 vs 非公平锁

公平锁:先来先得,锁的获取顺序是按照线程请求的顺序进行的

非公平锁:概率均等,即不保证锁的获取顺序是按照线程请求的顺序进行的。

synchronized的锁机制

synchronized的锁机制包括偏向锁、轻量级锁和重量级锁三种状态

无锁------>偏向锁:代码块进入synchronized代码块

偏向锁------>轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁(懒汉模式的思想体现------线程竞争不激烈)注意是尝试申请锁,并不存在锁竞争

轻量级锁------>重量级锁:JVM发现当前竞争锁的情况非常激烈,抢先拿到锁

synchronized的使用

synchronized必须要搭配⼀个具体的对象来使用

修饰代码块:明确指定锁哪个对象

锁任意对象

java 复制代码
public class SynchronizedDemo {
 private Object locker = new Object();
 
 	public void method() {
		synchronized (locker) {
			//代码块
 		}
 	}
}

锁当前对象

java 复制代码
public class SynchronizedDemo {
 	public void method() {
 		synchronized (this) {
 		//要执行的代码块
		}
	 }
}

修饰普通方法

锁的SynchronizedDemo对象

java 复制代码
public class SynchronizedDemo {
 public synchronized void methond() {
 	}
}

修饰静态方法

java 复制代码
public class SynchronizedDemo {
 public synchronized static void method(){
 
 }
}

了解了上述的用法之后,我们来进行加锁,处理上述对于count++的线程安全问题

需要明确synchronized锁的是什么.只有两个线程竞争同⼀把锁,才会产生阻塞等待.

java 复制代码
public class Demo13 {
    private static int count=0;
    public static void main(String[] args) throws InterruptedException {
        //写一个对象作为锁对象
        Object locker = new Object();
        Thread t1=new Thread(()->{
            for(int i=0;i<5000;i++){
                synchronized(locker){
                    count++;
                }

            }
            System.out.println("t1结束");
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<5000;i++){
                synchronized(locker){
                    count++;
                }
            }
            System.out.println("t2结束");
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //t1t2谁先结束是不一定的,随机调度
        System.out.println(count);
    }

}

使用volatile关键字解决内存可见性问题

代码在写入volatile修饰的变量的时候

• 改变线程工作内存中volatile变量副本的值

• 将改变后的副本的值从工作内存刷新到主内存

代码在读取volatile修饰的变量的时候

• 从主内存中读取volatile变量的最新值到线程的工作内存中

• 从工作内存中读取volatile变量的副本

示例:

创建两个线程,t1包含一个循环,这个循环以flag==0为循环条件

t2从键盘中读取一个整数,并把这个整数赋值给flag

预期用户输入非0的数,t1线程结束

java 复制代码
public class Demo17 {
    //加入volatile关键字,这样的变量的读取操作,就不会被编译器优化了
    
    private volatile static int flag=0;

    public static void main(String[] args) {
        Thread t1=new Thread(()->{
            while (flag==0){

            }
            System.out.println("t1线程结束");
        });
        Thread t2=new Thread(()->{
            Scanner sc=new Scanner(System.in);
            System.out.println("请输入flag的值:");
            flag=sc.nextInt();
        });
        t1.start();
        t2.start();
    }
}

volatile关键字不能保证原子性,但能保证内存可见性和禁止指令重排序

使用原子类解决原子性问题

java.util.concurrent.atomic 包中的 Atomic 原子类提供了一种线程安全的方式来操作单个变量。

基本类型原子类

使用原子的方式更新基本类型

AtomicInteger:整型原子类
AtomicLong:长整型原子类

AtomicBoolean:布尔型原子类

Atomic 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制

上面三个类提供的方法几乎相同,所以我们这里以 AtomicInteger 为例子来介绍。

AtomicInteger类常用方法

方法 说明
get() 获取当前的值
getAndSet() 获取当前的值,并设置新的值
getAndIncrement() 获取当前的值,并自增
getAndDecrement() 获取当前的值,并自减
getAndAdd 获取当前的值,并加上预期的值

基于原子类解决线程安全问题

java 复制代码
public class Demo32 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicInteger=new AtomicInteger();
        Thread t1=new Thread(()->{
            for(int i=0;i<5000;i++) {
                atomicInteger.incrementAndGet();
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<5000;i++) {
                atomicInteger.incrementAndGet();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(atomicInteger.get());
    }
}

wait和notify避免竞争关系

wait()

wait做的事情:

• 使当前执行代码的线程进行等待.(把线程放到等待队列中)

释放当前的锁

• 满足⼀定条件时被唤醒,重新尝试获取这个锁

wait要搭配synchronized来使用.脱离synchronized使用wait会直接抛出异常.

wait结束等待的条件:

其他线程调用该对象的notify方法.

• wait等待时间超时(wait方法提供⼀个带有timeout参数的版本,来指定等待时间).

• 其他线程调用该等待线程的interrupted方法,导致wait抛出InterruptedException 异常.

notify()方法

notify方法是唤醒等待的线程.

• 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其

它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

• 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程。(并没有"先来后到")

• 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁

notifyAll()方法

notify方法只是唤醒某⼀个等待线程.使用notifyAll方法可以⼀次唤醒所有的等待线程.

注意:虽然是同时唤醒所有线程,但是这些线程需要竞争锁.所以并不是同时执行,仍然是有先有后的执行.

举例:有三个线程,线程名称分别为:a,b,c。

每个线程打印自己的名称。

需要让他们同时启动,并按 c,b,a的顺序打印

java 复制代码
public class Demo18 {
    public static void main(String[] args) throws InterruptedException {
        Object locker1=new Object();
        Object locker2=new Object();
        Object locker3=new Object();

        Thread t1=new Thread(()->{

            try {

                for (int i=0;i<10;i++){
                    synchronized (locker1) {
                        locker1.wait();
                    }
                    System.out.println(Thread.currentThread().getName());
                    synchronized (locker3){
                        locker3.notify();
                    }
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        },"a");
        Thread t2=new Thread(()->{
            try {

                for (int i=0;i<10;i++){
                    synchronized (locker2) {
                        locker2.wait();
                    }
                    System.out.print(Thread.currentThread().getName());
                    synchronized (locker1){
                        locker1.notify();
                    }
                }

            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        },"b");
        Thread t3=new Thread(()->{
            try {

                for (int i=0;i<10;i++){
                    synchronized (locker3) {
                        locker3.wait();
                    }
                    System.out.print(Thread.currentThread().getName());
                    synchronized (locker2){
                        locker2.notify();
                    }
                }


            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        },"c");

        t1.start();
        t2.start();
        t3.start();
        Thread.sleep(1000);//确保四个线程都陷入等待

        synchronized (locker3){
            locker3.notify();
        }
    }
}

注意事项

在同步块中使用

wait和notify方法必须在同步块synchronized中调用,因为它们涉及对对象锁的获取和释放

避免虚假唤醒

由于notify方法可能随机唤醒一个等待的线程,因此在使用时需要注意虚假唤醒的问题

确保正确的唤醒顺序

在使用notify或notifyAll方法时,需要确保唤醒的线程是按照预期的顺序和条件被唤醒的。否则,可能会导致程序逻辑错误或死锁等问题。

wait和sleep的区别
  1. wait是Object类的普通方法,sleep是Thread类的静态方法
  2. wait用于线程间的通信和协调,确保线程按照特定顺序执行,sleep用于暂停线程的执行一段时间,让出CPU资源给其他线程
  3. wait需要搭配synchronized使用,sleep不用
相关推荐
小曲程序6 分钟前
vue3 封装request请求
java·前端·typescript·vue
陈王卜23 分钟前
django+boostrap实现发布博客权限控制
java·前端·django
小码的头发丝、24 分钟前
Spring Boot 注解
java·spring boot
java亮小白199729 分钟前
Spring循环依赖如何解决的?
java·后端·spring
飞滕人生TYF35 分钟前
java Queue 详解
java·队列
武子康1 小时前
大数据-230 离线数仓 - ODS层的构建 Hive处理 UDF 与 SerDe 处理 与 当前总结
java·大数据·数据仓库·hive·hadoop·sql·hdfs
武子康1 小时前
大数据-231 离线数仓 - DWS 层、ADS 层的创建 Hive 执行脚本
java·大数据·数据仓库·hive·hadoop·mysql
苏-言1 小时前
Spring IOC实战指南:从零到一的构建过程
java·数据库·spring
界面开发小八哥1 小时前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
草莓base1 小时前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring