线程不安全的解决方法

目录

一、此时我们需要知道造成线程不安全的原因

二、我们一条一条分析,看是否能改变,即加上一些操作使多线程变得安全

synchronized的作用

修饰方法

修饰代码块

刷新内存

可重入

其他语言产生死锁的原因是

Java没有产生死锁的原因

注意

标准库中(集合类)线程安全的类

Vector(“弃用”)

HashTable

Stack

ConcurrentHashMap

StringBuffer

String

volatile关键字

那么什么时候该用到volatile呢?

三、JMM(java memory model)java内存模型【重点】

四、synchronized和volatile的区别【经典面试题】                                                                            

一、此时我们需要知道造成线程不安全的原因

1.线程之间是抢占式执行的

2.多个线程修改同一个变量

3.原子性

4.内存可见性

5.指令重排序

二、我们一条一条分析,看是否能改变,即加上一些操作使多线程变得安全

1.对于第一条,我们没有办法,因为这种执行方式是由操作系统的内核实现的,我们改变不了

2.对于第二条

一个线程修改一个变量,没有线程安全问题,结果确定;

多个线程读取同一变量,也没有线程安全问题,读只是单纯的把内存中的数据放到CPU中;

多个线程修改不同的变量,举例来说就是十个线程修改十个变量,一对一的关系,也没有线程安全问题,这个类似于第一种情况

所以为了规避线程安全问题,可以变换代码的组织形式,让一个线程只修改一个变量,但是有些场景下可以这么变换,有些却不可以。
3.对于第三条,像++这样的操作,本质上是三个步骤,是一个“非原子”的操作

像=这样的操作,本质上是一个步骤,是一个“原子”操作,++操作本身不是原子操作,可以通过加锁(synchronized)的方式,把这个操作变成原子操作,因此它是可以改变的。

public class ThreadDemo16 {
    static class Counter{
        public int count=0;
        synchronized public void increase(){
            count++;
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Counter counter=new Counter();
        Thread t1=new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();

        Thread t2=new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

执行结果为

synchronized的作用

修饰方法

上述代码之前也写过,只不过之前的代码有线程安全问题,即得到的结果不是我们想要的,而现在的代码只不过在increase()方法的前面加了一个synchronized

如果两个线程同时并发地尝试调用这个synchronized修饰的方法,此时一个线程会先执行这个方法,另外一个线程会等待,等到第一个线程方法执行完了之后,第二个线程才会继续执行。

其实调用带synchronized的方法,就相当于是加锁和解锁,进入synchronized修饰的方法,就相当于加锁,出来synchronized修饰的方法,就相当于是解锁,如果当前已经是加锁的状态,其他的线程无是法执行这里的逻辑的,只能阻塞等待。

synchronized的功能本质上就是把“并发”变成“串行”,适当地牺牲一些速度,换一个更加准确的值

修饰代码块

synchronized除了修饰方法之外,还可以修饰代码块

public void increase(){
            synchronized(this){
                count++;
            }
}

synchronized如果是修饰代码块的时候,需要显示地在()中指定一个要加锁的对象

如果是synchronized直接修饰的非静态方法,相当于加锁的对象就是this

Java中任意的对象都可以作为“加锁的对象”

刷新内存

synchronized不光可以起到的加锁的作用,还可以刷新内存(解决内存可见性的问题)

public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }

上述代码中每次自增的过程都是load、add、save,编译器为了提升效率就进行了优化,即把中间的一些load和save给省略了

加上synchronized之后,就把上面的优化给禁止了,保证了每次进行自增的时候都能够从内存中取出数据,进行add操作后,把数据写回内存。它也是为了结果的正确使得速度变慢。

可重入

加入这个功能是为了防止程序员误加两次锁(忘了自己加了两次,且很难发现)出现死锁的情况

synchronized public void increase(){
            count++;
}
synchronized public void increase2(){
            increase();
}

这个仔细看,还可以发现,但实际开发中几百行代码,看出来的难度就可想而知了。

可重入意思是synchronized可以针对一把锁,连续加锁两次

synchronized public void increase(){
            synchronized(this){
                count++;
            }
        }

进入increase方法,加了一次锁,进入代码块,又加了一次锁,这种操作对synchronized来说没问题,因为synchronized在这里进行了特殊处理,但是其他语言的话,这里可能会发生死锁。

其他语言产生死锁的原因是

第一次加锁,加锁成功

第二次尝试对这个线程加锁的时候,此时对象头的锁标记已经确认了,比如说为true,按照之前说的,此时这个线程就要阻塞等待,等待这个锁标记被改为false,然后竞争这把锁,可是,这把锁已经被自己用了,是永远等不到的,此时,就被认为是产生死锁了。

对象头的标记位:对象分为两部分,一部分是对象头,存贮这种对象的公共属性,其中一个属性为“锁标记”,对象的另一部分存储的是字段

Java没有产生死锁的原因

Java在这里之所以没有产生死锁是因为synchronized内部记录了当前这个锁是哪个线程持有的。

注意

synchronized修饰普通方法的话,相当于是对this进行加锁,这时如果两个线程并发的调用这个方法,此时是否会触发锁竞争就要看锁对象是否是同一个了。上面讲第三条时举的例子就是同一个锁对象,因此会触发锁竞争。

synchronized修饰静态方法的话,相当于是对类对象进行加锁,由于类对象是单例的,两个线程并发调用该方法一定会触发锁竞争。

synchronized(synchronizedDemo.class)

提到类对象,我们首先要了解反射

反射是面向对象中的一个基本特性,和继承、封装、多态是并列关系

反射也叫“自省”,在程序运行时,通过反射,我们可以知道这个对象包含哪些属性,每个属性的名字,是什么类型,如public 、private......,包含哪些方法,方法名各是什么,参数列表......,而这些信息是来自.class文件(.java被编译生成的二进制字节码)

.class文件会在JVM运行的时候加载到内存中,通过“类对象”来描述这个具体的.class文件的内容

类名.class就得到了这个类对象,特点是每个类的类对象都是单例的

标准库中(集合类)线程安全的类

这里的集合类,大部分是线程不安全的,即不能在多线程环境下去并发修改同一个变量。

线程安全的有

Vector(“弃用”)

它是一个顺序表(动态数组),可以自动扩容,使用synchronized来保证线程安全,即给它的很多方法都加上了synchronized,因为大多数情况下,并不需要在多线程中使用Vector,加了太多的synchronized会降低单线程环境下的执行效率。所以不建议使用。

HashTable

它是哈希表结构。做法和Vector类似,也是把很多方法都加上了synchronized,因此也是不建议使用。

Stack

继承自Vector

ConcurrentHashMap

是一个线程安全的哈希表,和HashMap相比较设计的就非常好了,这个后面介绍。

StringBuffer

它也是线程安全的,也是很多方法都加上了synchronized,因此也是不建议使用。

String

它被认为是线程安全的,但并没有加锁,原因是String是不可变对象,就是说你看似是改了,但实际上是新建了一个String对象,因此不可能存在两个线程并发修改同一个String

volatile关键字

在计算机中一般理解为“可变的,容易改变的”

volatile的功能是保证内存可见性不保证原子性

举例代码

public class ThreadDemo17 {
    static class Counter{
        //volatile public int count=0;
        public int count=0;

    }
    public static void main(String[] args) {
        Counter counter=new Counter();
        Thread t1=new Thread(){
            @Override
            public void run() {
                while(counter.count==0){

                }
                System.out.println("线程运行结束");
            }
        };
        t1.start();
        Thread t2=new Thread(){
            @Override
            public void run() {
                System.out.println("请输入数据:");
                Scanner scanner=new Scanner(System.in);
                counter.count=scanner.nextInt();
            }
        };
        t2.start();
    }
}

执行结果为

 可以看出线程1并没有结束,按我们的预期是输入一个不为0的数,线程1就会终止,但是实际情况却没有挺停,没有听的原因就是内存可见性,即编译器优化了执行过程,不再每次都从内存中取数据。解决这个问题的办法就是在

public int count = 0;这条语句前加上关键字volatile,原理是禁止编译器进行刚才的优化。

加上之后,执行结果为

 volatile的用法比较单一,只能修饰一个具体的属性,此时代码中针对这个属性的读写操作就一定会涉及内存操作了。

synchronized不仅保证原子性,也保证了内存可见性

public class ThreadDemo17 {
    static class Counter{
        //volatile public int count=0;
        public int count=0;

    }
    public static void main(String[] args) {
        Counter counter=new Counter();
        Thread t1=new Thread(){
            @Override
            public void run() {
                while(true){
                    synchronized (this){
                        if ( counter.count != 0) {
                            break;
                        }
                    }
                }

                System.out.println("线程运行结束");
            }
        };
        t1.start();
        Thread t2=new Thread(){
            @Override
            public void run() {
                System.out.println("请输入数据:");
                Scanner scanner=new Scanner(System.in);
                counter.count=scanner.nextInt();
            }
        };
        t2.start();
    }
}

执行结果为

那么什么时候该用到volatile呢?

一般来说,如果某个变量,在同一个线程中读和写,大概率要用到。

三、JMM(java memory model)java内存模型【重点】

 我们需要知道的是,代码中需要读一个变量的时候,不一定是真的在读内存,可能这个数据已经在内存或者cache中缓存着了,这个时候就可能绕过内存,直接从CPU或者cache中来取这个数据

JMM针对计算机的硬件结构又进行了一次抽象(考虑到java的跨平台性)

把CPU的寄存器L1、L2、L3cache统称为“工作内存”

真正的内存称为“主内存”

上图中,三个线程都有自己的工作内存,每个线程都有自己独立的上下文,独立的上下文就是各自的一组寄存器/cache上的内容 

CPU在和内存交互的时候,经常会把主内存的内容拷贝到工作内存,然后进行操作写回到主内存,这个过程就容易出现数据不一致的情况,这一点尤其在编译器开启优化后更严重。

volatile和synchronized能够强制接下来的操作是操作内存,原理是在生成的Java字节码中强制插入一些“内存屏障”的指令,这些指令的效果是强制同步主内存和工作内存的内容 ,牺牲了效率,换来了正确结果。

四、synchronized和volatile的区别【经典面试题】   

首先synchronized既保证原子性,又保证内存可见性,而volatile只保证内存可见性

像++这样的操作,本质上是三个步骤,是一个“非原子”的操作

像=这样的操作,本质上是一个步骤,是一个“原子”操作

我们需要知道的是操作系统调度线程的时候是“抢占式执行”的方式,也就是竞争的关系,某个线程什么时候上CPU执行,什么时候切换出CPU,是完全不确定的。而且另一方面,两个线程在两个不同的CPU可以并发执行。因此,两个线程的执行顺序是完全不可预测的。

我们先假设两个线程针对一个变量进行++操作,我们需要知道count++的操作并不是完全三步都要执行完,也可能是线程1只执行完load,线程2把三步都执行完,线程1再执行后面的两步,此时,算出的结果就会出问题,假设内存中初始值为0,线程2执行完count++后内存中的值变为了1,线程1把后面两步都执行完之后,内存中的值还是1,这就出现了问题,因为我们的两个线程执行了两次count++操作,内存中的结果按理来说应该是2。而且这只是出现问题的其中一种情况。
++操作本身不是原子操作,但是我们可以通过加锁的方式,也就是在方法前加synchronized,把这个操作变成原子操作。

内存可见性具体就是同一个线程修改和读取,由于编译器的优化,可能把++操作一些中间环节的LOAD和SAVE省略掉了,此时读的线程可能读到的是未修改过的结果。

这里我们需要知道:++操作每次执行都有LOAD和SAVE,由于ADD比LOAD和SAVE要快一万倍,所以在执行很多次++操作的时候,很多LOAD和SAVE操作就被省略掉了,这样做是为了提高程序的整体效率。这个省略操作是编译器和JVM综合配合达成的效果。

这种优化在单线程下具有很高的效率,但是在多线程的时候,另一个线程也尝试读取/修改这个数据,这时候就会出问题。

提到内存可见性,我们需要提到冯诺伊曼体系结构中的CPU(可能还有缓存cache)和内存,线程在CPU上运行,代码中需要读一个变量的时候,不一定是真的在读内存,可能这个数据已经在CPU或者cache中缓存着了,这个时候就可能绕过内存,直接从CPU或者cache中来取这个数据

JMM针对计算机的硬件结构又进行了一次抽象(考虑到java的跨平台性)

我们把CPU的寄存器L1、L2、L3cache统称为“工作内存”,把真正的内存称为“主内存

CPU在和内存交互的时候,经常会把主内存的内容拷贝到工作内存,然后进行操作写回到主内存,这个过程就容易出现数据不一致的情况,这一点尤其在编译器开启优化后更严重。

而volatile和synchronized能够强制接下来的操作是操作内存,原理是在生成的Java字节码中强制插入一些“内存屏障”的指令,这些指令的效果是强制同步主内存和工作内存的内容 ,牺牲了效率,换来了正确结果。

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
线程下可能会出现线程安全问题,比如多个线程同时访问同一个共享资源,可能会导致数据不一致或者程序崩溃等问题。常用的解决方法有以下几种: 1. 互斥锁(mutex):使用互斥锁可以保证同一时间只有一个线程能够访问共享资源,其他线程需要等待互斥锁的释放。可以使用 C++11 中的 std::mutex 来实现互斥锁。 2. 读写锁(read-write lock):如果共享资源被频繁地读取而很少修改,可以使用读写锁来提高程序的性能。读写锁允许多个线程同时读取共享资源,但只能有一个线程写入共享资源。可以使用 C++11 中的 std::shared_mutex 来实现读写锁。 3. 原子操作(atomic operation):在多线程环境下,如果有多个线程同时修改同一个变量,可能会导致数据不一致的问题。可以使用原子操作来保证变量的原子性,即同一时间只有一个线程能够修改变量。可以使用 C++11 中的 std::atomic 来实现原子操作。 4. 条件变量(condition variable):当一个线程需要等待某个条件满足时,可以使用条件变量来阻塞线程并等待条件变量的通知。可以使用 C++11 中的 std::condition_variable 来实现条件变量。 示例代码如下: ``` #include <iostream> #include <thread> #include <mutex> std::mutex mtx; void print(int id) { mtx.lock(); // 上锁 std::cout << "Thread " << id << " is printing." << std::endl; mtx.unlock(); // 解锁 } int main() { std::thread t1(print, 1); std::thread t2(print, 2); t1.join(); t2.join(); return 0; } ``` 上述代码中,两个线程都会执行 print 函数,但由于使用了互斥锁 mtx,同一时间只有一个线程能够访问 std::cout。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值