多线程基础概念 | 线程安全

本文探讨了线程安全的重要性,通过实例展示了代码不线程安全导致的结果,并详细解释了原子性、可见性、顺序性这三个关键特性。讲解了如何使用`synchronized`关键字和`volatile`关键字来确保线程安全,以及它们在实际场景的应用和注意事项。
摘要由CSDN通过智能技术生成

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

观察下边代码:

package UnsafeThread;

public class UnsafeThread {
    private static int COUNT;
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j = 0;j < 10000;j++){
                        COUNT++;
                    }
                }
            });
            t.start();
        }
        while (Thread.activeCount() > 1){
            Thread.yield();
        }
        System.out.println(COUNT);
    }
}

按经验来说,答案应该是20,0000。我们运行代码看看:

Connected to the target VM, address: '127.0.0.1:55920', transport: 'socket'
194290
Disconnected from the target VM, address: '127.0.0.1:55920', transport: 'socket'

结果不到20,0000。这就是线程不安全。


线程安全的三大特性

1.原子性:
把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制,A进入房间之后,还没有出来。B 也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。

解决这个问题呢,只要给房间加一把锁,A 进去就把门锁上,其他人就进不来了。这样就保证了这段代码的原子性了。

有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条 java 语句不一定是原子的,也不一定只是一条指令
比如刚才我们常用的 n++,其实是由三步操作组成的

  1. 从内存把数据读到 CPU
  2. 进行数据更新
  3. 把数据写回到 CPU

比如 Object object = new Object();
也分为三条指令:

  1. 分配内存
  2. 为变量赋值
  3. 写回到内存

不保证原子性会带来的问题:
一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。

2.可见性
为了提高效率,JVM在执行过程中,会尽可能的将数据在工作内存中执行,但这样会造成一个问题,共享变量在多线程之间不能及时看到改变,这个就是可见性问题。比如,两个线程同时执行,一个先获取到n = 1,对其进行++操作,还没有返回的时候。另一个线程也获取到n ,准备进行++操作,由于上个线程还没有把数据写回cpu,所以此时获取到n =1。当两个线程都结束的时候,n结果为2,发生了错误

3.代码顺序性
一段代码是这样的:

  1. 去A店吃饭
  2. 去B店吃饭
  3. 去C店吃饭

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,这种叫做指令重排序
代码重排序会给多线程带来什么问题

刚才那个例子中,单线程情况是没问题的,优化是正确的,但在多线程场景下就有问题了,什么问题呢。可能你去A吃饭,吃完之后,B店人满了进不去或者直接关门了,如果指令重排序了,代码就会是错误的。
线程内看自己代码运行都是有序的(保证代码执行的依赖关系),看其他线程代码都是无序的


解决线程不安全问题

1.synchronized关键字(满足线程安全三大特性)

实现原理: 线程进入到synchronized代码行时,需要获取对象锁:
1.获取成功,往下执行代码
2.获取失败,阻塞在synchronized代码行,jvm将竞争失败的线程全部放在同步队列。

当线程退出synchronized修饰的代码块或者synchronized方法时,
1.退回对象锁
2.通知JVM及系统,唤醒同步队列的其他线程,开始竞争这把锁


语法使用:
1.作用于静态方法(static )

//相当于对SafeThread.class,当前类对象加锁
package SafeThread;

public class synchronizedTest implements Runnable{
        //共享资源
        static int i =0;
        /**
         * synchronized 修饰静态方法
         */
        public static synchronized void increase(){
            i++;
        }
        @Override
        public void run(){
            for (int j =0 ; j<10000;j++){
                increase();
            }
        }

        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread(new synchronizedTest());
            Thread t2 = new Thread(new synchronizedTest());
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(i);
        }
}
Connected to the target VM, address: '127.0.0.1:51292', transport: 'socket'
20000
Disconnected from the target VM, address: '127.0.0.1:51292', transport: 'socket'

分析:由例子可知,两个线程实例化两个不同的对象,但是访问的方法是静态的,两个线程发生了互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类而不是对象的,当synchronized修饰静态方法时,锁是class对象。

2.实例方法(无static )

package SafeThread;

public class synchronizedTest implements Runnable {
    //共享资源
    static int i = 0;

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase() {
        i++;
    }

    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        synchronizedTest test = new synchronizedTest();
        Thread t1 = new Thread(test);
        Thread t2 = new Thread(test);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
Connected to the target VM, address: '127.0.0.1:51292', transport: 'socket'
20000
Disconnected from the target VM, address: '127.0.0.1:51292', transport: 'socket'

分析:当两个线程同时对一个对象的一个方法进行操作,只有一个线程能够抢到锁。因为一个对象只有一把锁,一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,就不能访问该对象的其他synchronized实例方法

3.代码块

package SafeThread;

public class SafeThread {
    private static int COUNT;

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        //this指new的Runnable()
                        synchronized (this){ 
                        //synchronized (SafeThread.class){  //SafeThread.class指当前类对象 
                        //synchronized (getClass()){  //指new的Runnable()实例对象
                            COUNT++;
                        }
                    }
                }
            });
            t.start();
        }
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(COUNT);
    }
}

其中
作用于静态方法

public static synchronized void add() { 
}

public static void add() { 
	synchronized (SafeThread.class){  
	}
}

的作用是一样的,锁的是当前类的class对象 ,进入同步代码前要获得当前类对象的锁


作用于实例方法

//new SafeThread().add() 相当于对SafeThread()对象加锁
public synchronized void add() { //谁调用它,对谁加锁

}

public void add()
	synchronized (this){ 

}

作用是一样的,锁的是当前实例对象 ,进入同步代码前要获得当前实例的锁
如果不在同步代码块内部,则没有同步互斥作用。


使用synchronized加锁操作的关注点:
1.对哪个对象加锁 —— 一个对象只有一把锁
2.只有同一个对象,才会有同步互斥的作用(我正在使用这个方法,你就不能使用,满足了线程安全的三大特性)
3.对于synchronized内代码,同一个时间只有一个线程在运行
4.运行的线程数越多,性能下降越快(因为线程越多,归还对象锁的时候,越多的线程就会不停的在唤醒、阻塞间互相切换)
5.synchronized内的代码执行时间越短,性能下降越快


2.Volatile关键字(满足可见性,顺序性)

1.被volatile修饰的变量保证对所有线程可见。
既然volatile变量对所有线程是立即可见的,在各个线程中不存在一致性问题。那么,我们是否能得出:volatile变量在并发运算下是线程安全的呢?不多说,直接上代码

package Volatile;


public class Volatile {
    private  volatile static int COUNT;
    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j = 0;j < 10000;j++){
                        COUNT++;
                    }
                }
            });
            t.start();
        }
        while (Thread.activeCount() > 1){
            Thread.yield();
        }
        System.out.println(COUNT);
    }
}

结果:

Connected to the target VM, address: '127.0.0.1:54850', transport: 'socket'
193702
Disconnected from the target VM, address: '127.0.0.1:54850', transport: 'socket'

答案还是不对。
原因在于被volatile修饰的变量,不满足三大特性中的原子性, 所以线程还是不安全的

volatile赋值时候不能依赖变量(常量赋值可以保证线程安全)

使用场景:
可以结合线程加锁的一些手段,提高效率
只是变量的读取,常量的赋值,可以不加锁,而是使用volatile,可以提高效率

案例:


    // 饿汉模式
    class Singleton {
        private static Singleton instance = new Singleton();

        private Singleton() {
        }

        public static Singleton getInstance() {
            return instance;
        }
    }

    // 懒汉模式-单线程版
    class Singleton {
        private static Singleton instance = null;
        private Singleton() {
        }
        public static Singleton getInstance() {
            if (instance == null) {
                instance = new Singleton();
            }
            return instance;
        }
    }

     // 懒汉模式-多线程版-性能低
    class Singleton {
         private static Singleton instance = null;
         private Singleton() {
         }
         public synchronized static Singleton getInstance() {
             if (instance == null) {
                 instance = new Singleton();
             }
             return instance;
         }
     }

2.禁止指令重排序优化 -(单例模式-双重校验锁)

指令重排序是什么?简单点说就是jvm会把代码中没有依赖赋值的地方打乱执行顺序,由于一些规则限定,我们在单线程内观察不到打乱的现象(线程内表现为串行的语义),但是在并发程序中,从别的线程看另一个线程,操作是无序的。

双重校验锁描述:
1.volatile 修饰变量
2.私有构造方法
3.双重校验锁的写法保证线程安全

    // 懒汉模式-多线程版-双重校验锁-性能高
    class Singleton {
        private static volatile Singleton instance = null;
        private Singleton() {
        }
        public static Singleton getInstance() {
        //由于该步骤只是读取变量,具有原子性,用volatile修饰,可以保证可见性,提高效率
            if (instance == null) {   
                synchronized (Singleton.class) {
                // 如果少了内部校验,会产生多次new。保证单例
                    if (instance == null) { 
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }

如果不禁止重排序,会发生什么问题?
上文说过 instance = new Singleton()可分解为:

1.分配对象的内存空间
2.初始化对象
3.设置instance指向刚分配的内存地址

操作2依赖1,但是操作3不依赖2,此时若是线程内出现指令重排序,则有可能出现1,3,2的顺序。
当出现这种顺序,也就是程序执行了1和3之后,变量已经赋值成功,但不是instance对象,instance仍然为空。
此时其他线程若也在运行,将会在第一个if判断错误,对象有可能没有还正确初始化instance,就直接返回了变量的值,而不是我们需要的对象,此时程序就会出错。

volatile通过禁止指令重排序保证了有序性,避免了这种错误出现

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值