并发&多线程编程-synchronized、Volatile

基础知识

并发编程的优缺点

缺点

并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如**:内存泄漏、上下文切换、线程安全、死锁**等问题。

优点

充分利用多核CPU的计算能力:通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升方便进行业务拆分,提升系统并发能力和性能:在特殊的业务场景下,先天的就适合于并发编程。现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。

并发编程三要素

并发编程三要素(线程的安全性问题体现在):

原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)

出现线程安全问题的原因:

线程切换带来的原子性问题
缓存导致的可见性问题
编译优化带来的有序性问题

解决办法:

  • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
  • synchronized、volatile、LOCK,可以解决可见性问题
  • Happens-Before 规则可以解决有序性问题

并行和并发有什么区别?

并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。
做一个形象的比喻:

并发 = 两个队列和一台咖啡机。

并行 = 两个队列和两台咖啡机。

串行 = 一个队列和一台咖啡机。

多线程的劣势:

线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;
多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。

Synchronized

概述

在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。synchronized 可以修饰类、方法、变量。

另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

四个特性

  • 原子性
    所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 可见性
    可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。
  • 有序性
    有序性值程序执行的顺序按照代码先后执行。
  • 可重入性
    synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请同一个锁。可重复锁,表示该锁能够支持一个线程对资源的重复加锁。

使用

  • 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  • 修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
  • 修饰代码块 synchronized(class): 指定class加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁,如果是同一个class对象则需要排队进行执行不能并行

方式1:修饰实例方法

情景1:

public class SynchronizedTest { // 执行的task
    private static Integer count = 100;

    public synchronized void synchronizedMethod1() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "同步方法1");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void synchronizedMethod2() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "同步方法2");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void unsafeMethod() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "普通方法");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public class TestSync {

    public static void main(String[] args) {
        SynchronizedTest synchronizedTest = new SynchronizedTest(); // 使用同一个 class对象
        Thread thread1 = null;
        for (int i = 0; i < 3; i++) {
            thread1 = new Thread(() -> {
                synchronizedTest.synchronizedMethod1();
                synchronizedTest.synchronizedMethod2();
                synchronizedTest.unsafeMethod();
            });
            thread1.start();
        }

    }
}

结果:
image-20231213141757186

结论:给实例方法加synchronized修饰的时,当两个线程访问同一个实例对象,synchronized是给方法都上了同一个this类型的锁,所以都执行同步方法的时候不会并行执行,会依次执行,但是执行非同步方法的时候是不影响的

情景2:

public class TestSync {

    public static void main(String[] args) {
        Thread thread1 = null;
        for (int i = 0; i < 3; i++) {
            thread1 = new Thread(() -> {
                SynchronizedTest synchronizedTest = new SynchronizedTest(); // 不同的实例对象
                synchronizedTest.synchronizedMethod1();
                synchronizedTest.synchronizedMethod2();
                synchronizedTest.unsafeMethod();
            });
            thread1.start();
        }

    }
}

结果:
image-20231213142107704

**结论:**当多个线程访问的是不同的加锁实例对象的时候,是可以并行访问的,所以synchronized只是给当前创建的实例对象进行加锁。不同的实例对象之间不共用一把锁,可以并行访问。

方式2:修饰代码块

语法:

synchronized (实例对象) {  }

本质是对括号内的内容进行加锁,里面可以是几个语句,可以是一个类的对象,都进行加锁处理。是否是一个锁根据实例对象来判断,如果是同一个实例对象则需要串行等待,如果不同的对象则可以并行运行

public class SynchronizedTest { // 任务类
    Object ob = null;
    public SynchronizedTest(Object ob) {
        this.ob = ob;
    }

    public SynchronizedTest() {
    }

    public void testSync() {
        String name = Thread.currentThread().getName();
        System.out.println("线程: " + name + "开始开始执行 !");
        synchronized (SynchronizedTest.class) {
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + " --同步方法1");
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }

    public void print() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "---非同步方法方法");
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
// 测试类入口
public class TestSync {

    public static void main(String[] args) {
        //SynchronizedTest synchronizedTest = new SynchronizedTest();
        Thread thread1 = null;
        Object ob = new Object();
        for (int i = 0; i < 3; i++) {
            thread1 = new Thread(() -> {
                SynchronizedTest synchronizedTest = new SynchronizedTest(ob);
                synchronizedTest.print();
                synchronizedTest.testSync();
            });
            thread1.start();
        }

    }
}

结果:

image-20231213143721128

结论:非同步方法不影响,只会对代码块进行加锁处理。多个线程中代码块括号中的是同一个对象则只能串行执行,不能并行。

方式3:修饰静态方法

任务类:

// 任务类
public class SynchronizedTest {
    public static synchronized void testSync() {
        String name = Thread.currentThread().getName();
        System.out.println("线程: " + name + "开始开始执行 !");
            for (int i = 0; i < 3; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + " --同步方法1");
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
    }
        public void print() {
        for (int i = 0; i < 3; i++) {
            try {
                System.out.println(Thread.currentThread().getName() + "---非同步方法");
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

测试入口

public class TestSync {

    public static void main(String[] args) {
        //SynchronizedTest synchronizedTest = new SynchronizedTest();
        Thread thread1 = null;
        for (int i = 0; i < 3; i++) {
            thread1 = new Thread(() -> {
                SynchronizedTest synchronizedTest = new SynchronizedTest();
                synchronizedTest.print();
                SynchronizedTest.testSync();
            });
            thread1.start();
        }

    }
}

结果:
image-20231213144523809

**结论:**给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象

具体使用:双重校验锁单例模式

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueInstance == null) {
            //类对象加锁
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. 为 uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
关于更多单例模式可以查看这篇博客:单例模式

synchronized 底层实现原理

synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件。
synchronized 同步语句块的情况
image-20231213203934215

通过JDK 反汇编指令 javap -c -v SynchronizedDemo
image-20231213204001736

可以看出在执行同步代码块之前之后都有一个monitor字样,其中前面的是monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行monitorexit指令。

为什么会有两个monitorexit呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个monitorexit是保证在异常情况下,锁也可以得到释放,避免死锁。

仅有ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要monitorenter,退出该方法时需要monitorexit。

synchronized可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

volatile

三大特性

可见性

保证不同线程对这个变量进行操作时的可见性,即变量一旦改变所有线程立即可见

使用volatile修饰共享变量,被volatile修改的变量有以下特点:
1.线程中读取的时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存
2.线程中修改了工作内存中变量的副本,修改之后会立即刷新到主内存

测试:

// 任务类
public class VolatileTest implements Runnable{

    private static volatile Integer count = 100;
    
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + "线程中 count = " + count);
            count -= 1;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
// 调用类
public class Main {
    public static void main(String[] args) throws Exception{
        Thread thread1 = new Thread(new VolatileTest());
        Thread thread2 = new Thread(new VolatileTest());
        thread1.start();
        Thread.sleep(100);
        thread2.start();
    }
}

结果:

image-20231213211213024

防止指令重排

**指令重排:**是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段,有时候会改变程序语句的先后顺序。不存在数据依赖关系,可以重排序; 存在数据依赖关系,禁止重排序 。但重排后的指令绝对不能改变原有的串行语义。

volatile有关禁重排的行为:

  1. 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前(volatile读之后的操作,都禁止重排序到volatile之前)
  2. 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后 (volatile写之前的操作,都禁止重排序到volatile之后)
  3. 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排。(volatile写之后volatile读,禁止重排序的)

使用volatile场景

单例模式,与上面的双重校验锁一样

无原子性

**原子性:**原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响

对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步。

以**i++**为例,不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,
分3步完成。如果第二个线程在第一个线程读取旧值和写回新值期间(上图所指三步期间)读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全。

栗子:

volatile修饰的number几乎不可能加到20000,就是因为存在极小段真空期,被其他线程读取,导致写丢失一次

public class VolatileTest implements Runnable{ // 任务类
    private static volatile Integer count = 0;
    
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            count += 1;
            System.out.println(Thread.currentThread().getName() + "线程中 count = " + count);
        }
    }
}

public class Main {  // 执行类
    public static void main(String[] args) throws Exception{
        for (int i = 0; i < 20; i++) {
            Thread thread = new Thread(new VolatileTest());
            thread.start();
        }
    }
}

结果:
image-20231213214444711

大多数的时候,会因为不符合原子性导致出现线程不安全的情况