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不用
相关推荐
夏天的味道٥3 小时前
使用 Java 执行 SQL 语句和存储过程
java·开发语言·sql
冰糖码奇朵4 小时前
大数据表高效导入导出解决方案,mysql数据库LOAD DATA命令和INTO OUTFILE命令详解
java·数据库·sql·mysql
好教员好4 小时前
【Spring】整合【SpringMVC】
java·spring
浪九天5 小时前
Java直通车系列13【Spring MVC】(Spring MVC常用注解)
java·后端·spring
堕落年代6 小时前
Maven匹配机制和仓库库设置
java·maven
功德+n6 小时前
Maven 使用指南:基础 + 进阶 + 高级用法
java·开发语言·maven
香精煎鱼香翅捞饭7 小时前
java通用自研接口限流组件
java·开发语言
ChinaRainbowSea7 小时前
Linux: Centos7 Cannot find a valid baseurl for repo: base/7/x86_64 解决方案
java·linux·运维·服务器·docker·架构
囧囧 O_o7 小时前
Java 实现 Oracle 的 MONTHS_BETWEEN 函数
java·oracle
去看日出7 小时前
RabbitMQ消息队列中间件安装部署教程(Windows)-2025最新版详细图文教程(附所需安装包)
java·windows·中间件·消息队列·rabbitmq