线程学习(2)线程创建,等待,安全,synchronized(三)

简介: 线程学习(2)线程创建,等待,安全,synchronized(三)

线程学习(2)线程创建,等待,安全,synchronized(二)+https://developer.aliyun.com/article/1413578

五.线程安全问题

有些代码如果只是一个线程单独去执行,执行结果是完全正确的

但是,同样的代码,如果使用多个线程同时去执行,执行结果就可能产生问题,这种就是"线程安全问题"/"线程不安全"

比如我们要对一个数使其自增1w,如果只使用一个线程来解决,其结果一定正确

public static void main(String[] args) {
        // 在主线程中单独执行
        int cnt = 0;
        for (int i = 0; i < 10000; i++) {
            cnt++;
        }
        System.out.println(cnt);// 输出10000
    }

如果使用两个线程实现这个目标,则应该是一个线程自增5000次,加起来一共自增1w次

private static int cnt = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                cnt++;
            }
        });
        // 线程开启
        t1.start();
        t2.start();
        // 让主线程等待两个线程结束
        t1.join();
        t2.join();
        // 输出打印
        System.out.println(cnt);// 输出7351
}

最后的打印结果是一个莫名其妙的数,不是我们想的1w,如果继续重复尝试,发现每次打印的结果还都不相同 ,程序出现bug了,这种问题就是在并发编程中常遇到的线程安全问题

为什么会出现这种问题呢,此时就要深入底层去看下cnt++这个操作是如何实现的

cnt++的实现在底层中分为三步

  1. load 把数据从内存中 读取到cpu寄存器中
  2. add 把寄存器中的数据+1
  3. save  把寄存器中的数据,保存到内存之中

站在cpu的角度,cnt++这个操作分别对应着三条cpu指令,是由这三条指令实现的~

如果使用多线程来执行上述代码,由于线程之间的调度顺序是随机的,就会导致在一些调度顺序下发生错误,下面来看都有哪些可能的调度顺序

可以看出,调度顺序的种类其实是无数种!!!一是调度操作的逻辑顺序,二是每个线程执行多少次我们并不知道,在图中,只有前两种的调度顺序才能达到我们想要的结果,下面以一个反例来验证其他顺序的错误

由于线程调度的随机性,也就说上述调度顺序也是随机的,所以最终产生的结果也是随机的(但是最终的结果一定比1w小,因为只有前两种调度顺序才能实现数字的正确增加)

那一定比5000大么,这也是不一定的,如果在t1自增一次的过程中,t2自增了两次,一共消耗了三次自增,但实际上只自增了一次,如果这种逻辑顺序占多数,就有可能出现<5000的情况

产生线程安全问题的原因

  1. 操作系统中,线程的调度顺序是随机的(抢占式执行) 罪魁祸首
  2. 多个线程,针对同一个变量进行修改(上述例子就是)
  3. 修改操作不是原子的,cnt++这个操作是分三步执行的,不是原子的。什么是原子的呢》比如存在一个cpu指令能同时完成cnt++的三个操作
  4. 内存可见性问题
  5. 指令重排序问题

说明:

对于第二种原因,改变一些描述就不是线程安全问题了

  • 一个线程,针对同一个变量进行修改  ok
  • 多个线程,针对不同的变量进行修改  ok
  • 多个线程,针对不同的变量进行读取  ok

通过加锁就能解决上述问题

六.锁 synchronized

1.基本概念

 如何给Java的代码进行加锁呢?其中最常用的方法是通过synchronized关键字(最好还是掌握下他的发音和含义)

synchronnized在使用的时候需要搭配{}来使用,进了{}就相当于"加锁",出了{}就是"解锁",在已经加锁的状态下,如果另一个线程也尝试同样加这个锁,就会发生"锁竞争"/"锁冲突",后一个线程就会阻塞等待

加锁,我们要明确是给谁加锁,也就是要对具体的对象进行加锁,只有当两个线程针对同一个对象进行加锁,才会发生冲突,针对不同的对象加锁,就不会发生冲突(可以把加锁理解为确立男女朋友关系,一旦确立(加锁),就不允许其他人再进入了,除非原先的关系破裂(解锁),不能脚踏两只船~~~)

代码实现:

// 锁竞争的对象
        Object locker = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                // 使用synchronized关键字进行加锁
                synchronized(locker) {
                    cnt++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                // 使用synchronized关键字进行加锁
                synchronized(locker) {
                    cnt++;
                }
            }
        });

在这个代码中,我们先是创建了一个用于"加锁"的对象locker,接着进行加锁,如何加锁呢?根据上述引发线程安全的2"多个线程,针对同一个变量进行修改",我们要限制的是两个线程不能同时对同一个变量进行修改,所以应该加锁的操作是"cnt++",使用synchronized(locker){}对其进行加锁

 

这种情况是我们上述所说的会引发线程安全问题的一种调度顺序,下面看看加锁是如何解决这个问题的

  对象存在的意义有且仅有一个,当多个线程针对同一个对象进行加锁的时候,就会发生锁冲突,一个线程拿到锁,就继续执行代码,而另一个线程没拿到锁,就会处于阻塞状态。直到另一个线程释放锁,才能继续执行剩余代码~

 这样做实际上是把"并发执行"转换为"串行执行",这样就避免了操作之间的穿插,导致错误的出现

注意:必须是多个线程针对同一个对象进行加锁,如果是不同的对象就不会发生锁冲突,也就不会出现线程阻塞

// 锁竞争的对象
        Object locker = new Object();
        Object locker2 = new Object();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                synchronized(locker) {
                    cnt++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                // 此时两个线程针对的是不同的对象进行加锁  不发生锁冲突
                synchronized(locker2) {
                    cnt++;
                }
            }
        });

还是那句话,锁的对象是谁不重要,只要锁的是同一个对象就一定会引发锁冲突!!!

2.synchronized对方法进行加锁

除了对对象加锁,synchronized还可以对方法加锁

下面先修改一下我们的代码:

class Counter {
    public static int cnt;
    // 将cnt++这个操作放到一个方法内部
    public void increment() {
        cnt++;
    }
}
 public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.cnt);
    }

此时继续执行代码,发现结果仍然是随机值,原因在于还是出现了"两个线程同时针对同一个变量进行修改"这样的操作,解决方法就是进行加锁,此时我们可以对increment()这个方法进行加锁

synchronized public void increment() {
        cnt++;
    }

加锁之后,只能有一个线程拥有increment(),另一个线程想要使用的话只能等前一个线程释放锁之后才能使用

上述对方法加锁的方式还有一个等价方式

public void increment2() {
    // 这其实是对"实例方法"进行加锁的
    // 实例方法的实现取决于实例化的对象 也就是本质上是对调用这个方法的对象进行加锁
    // 让调用方法的对象拥有这个锁
        synchronized (this) {
            cnt++;
        }
    }

同样的,synchronized还可以对"类方法进行加锁",对类方法加锁本质上就是对类对象进行加锁

// 类方法
    synchronized public static void increment3() {
        cnt++;
    }
    public static void increment4() {
        // 这里利用到了反射
        synchronized (Counter.class) {
            cnt++;
        }
    }

说明:类对象在一个Java文件中是唯一的,类对象中包含很多信息,比如类的属性,方法,继承关系,实现接口等等,类对象可以通过反射的方式获取

3.对象的"锁属性"是存在于对象头中的

我们知道一旦一个对象被synchronized修饰就代表该对象被上锁了,也就是说synchronized改变了对象的原有属性,而这个决定对象是否被"锁"的属性存在于"对象头"之中

 对于一个对象来说,除了其自定义的一些属性,还有一些系统为其分配的属性,这些属性的集合被称为"对象头",对象头中,就有属性用于表示对象是否被锁,以下是对象数据的组成

4. synchronized的重要特性-可重入性

要了解可重入性,先了解什么是可重入锁,所谓的可重入锁就是指:

一个线程能够对同一个对象连续加锁两次,不会出现死锁,就是可重入。不满足,就是不可重入;

而被synchronized修饰的对象都具有可重入性,再简单来说,可重入性就是指一个线程再持有一个对象的锁之后,还可以再次对该对象加锁,举一个简单的例子

class MyClass {
    // 创建两个静态方法
    synchronized public static void methodA() {
        System.out.println("这是methodA");
        methodB();
    }
    synchronized public static void methodB() {
        System.out.println("这是methodB");
    }
    public static void main(String[] args) {
        // 调用类方法  此时synchronized是对类对象进行加锁
        MyClass.methodA();
    }
}

再这个例子中,主线程中我们调用了methodA,因为 methodA是被synchronized修饰的,此时主线程就持有了MyClass类对象类的锁,紧接着进入methodB,因为methodB也是synchronized修饰的,所以即便在methodA内部调用methodB,主线程也会再次获取MyClass类对象类的锁,会直接获取成功

今天线程的学习就到这里,敬请期待后续章节

目录
相关文章
|
2月前
|
Java 调度 C#
C#学习系列相关之多线程(一)----常用多线程方法总结
C#学习系列相关之多线程(一)----常用多线程方法总结
|
2月前
|
安全 编译器 C#
C#学习相关系列之多线程---lock线程锁的用法
C#学习相关系列之多线程---lock线程锁的用法
|
2月前
|
C#
C#学习相关系列之多线程---ConfigureAwait的用法
C#学习相关系列之多线程---ConfigureAwait的用法
|
2月前
|
C#
C#学习相关系列之多线程---TaskCompletionSource用法(八)
C#学习相关系列之多线程---TaskCompletionSource用法(八)
|
26天前
|
设计模式 安全 Java
Java并发编程实战:使用synchronized关键字实现线程安全
【4月更文挑战第6天】Java中的`synchronized`关键字用于处理多线程并发,确保共享资源的线程安全。它可以修饰方法或代码块,实现互斥访问。当用于方法时,锁定对象实例或类对象;用于代码块时,锁定指定对象。过度使用可能导致性能问题,应注意避免锁持有时间过长、死锁,并考虑使用`java.util.concurrent`包中的高级工具。正确理解和使用`synchronized`是编写线程安全程序的关键。
|
3天前
|
消息中间件 缓存 Java
【多线程学习】深入探究定时器的重点和应用场景
【多线程学习】深入探究定时器的重点和应用场景
|
3天前
|
监控 安全 Java
【多线程学习】深入探究阻塞队列与生产者消费者模型和线程池常见面试题
【多线程学习】深入探究阻塞队列与生产者消费者模型和线程池常见面试题
|
3天前
|
消息中间件 监控 安全
【JAVAEE学习】探究Java中多线程的使用和重点及考点
【JAVAEE学习】探究Java中多线程的使用和重点及考点
|
8天前
|
并行计算 算法 安全
Java从入门到精通:2.1.3深入学习Java核心技术——掌握Java多线程编程
Java从入门到精通:2.1.3深入学习Java核心技术——掌握Java多线程编程
|
9天前
|
安全 Java 编译器
是时候来唠一唠synchronized关键字了,Java多线程的必问考点!
本文简要介绍了Java中的`synchronized`关键字,它是用于保证多线程环境下的同步,解决原子性、可见性和顺序性问题。从JDK1.6开始,synchronized进行了优化,性能得到提升,现在仍可在项目中使用。synchronized有三种用法:修饰实例方法、静态方法和代码块。文章还讨论了synchronized修饰代码块的锁对象、静态与非静态方法调用的互斥性,以及构造方法不能被同步修饰。此外,通过反汇编展示了`synchronized`在方法和代码块上的底层实现,涉及ObjectMonitor和monitorenter/monitorexit指令。
24 0