Java 多线程并发

652 阅读11分钟

多线程三大特性:

  • 原子性:一个操作要么全部执行完成 (执行过程不能被任何因素打断),要么都不执行。(如:银行转账)
  • 可见性:当多个线程同时访问共享变量时,一个线程修改了这个变量的值,其他线程能够看到最新修改的值。(如线程A修改了共享变量 i 后,还没有刷新到主内存,这时线程B 又使用了变量 i , 此时的变量 i 还是之前的值,这就是可见性问题)
  • 有序性:处理器为了提高代码执行效率,可能会对代码进行重排序,他不保证程序中各个语句执行线后顺序和代码中的顺序一致,但会保证最终执行结果和代码顺序执行结果是一致的。重排序对单线程不会有问题,对多线程可能会出问题。

并发编程中的线程安全问题

造成线程安全问题有两点因素:

  1. 存在共享变量;
  2. 多个线程同时操作共享变量;

解决方案:当多个线程操作共享变量时,需要保证同一时刻有且只有一个线程在操作共享变量,其他线程必须等到该线程处理完数据后再进行操作。

synchronized 关键字

synchronized 关键字可以保证在同一时刻,只有一个线程可以执行某个方法或者某个代码块,并且可以保证一个线程对共享变量操作的结果,能被其他线程所看到。

synchronized 是通过 JVM 来实现的。可用在以下三种情景:

  1. 修饰实例方法: 对该类的实例对象加锁。
  2. 修饰静态方法: 对该类的 Class 对象加锁。
  3. 修饰代码块: 对 synchronized(xx) 括号内的对象加锁。

1.1 修饰实例方法

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 输出结果:
     * 2000000
     */
}

上述代码中,我们开启两个线程操作同一个共享资源即变量 i,由于 i++; 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败,因此对于 increase() 方法必须使用 synchronized 修饰,以便保证线程安全。此时我们应该注意到 synchronized 修饰的是实例方法 increase(),在这样的情况下,当前线程的锁便是实例对象instance ,注意 Java 中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的,倘若我们没有使用synchronized 关键字,其最终输出结果就很可能小于 2000000,这便是 synchronized 关键字的作用。这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法。但是其他线程还是可以访问该实例对象的其他非synchronized方法,当然如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了.

2.1 修饰静态方法 ——> ACC_SYNCHRONIZED标示实现的

public class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){    
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新实例
        Thread t1=new Thread(new AccountingSyncBad());
        //new新实例
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含义:当前线程A等待thread线程终止之后才能从thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

与前面代码不同的是: 我们同时创建了两个新实例 AccountingSyncBad,然后启动两个不同的线程对共享变量i 进行操作,但很遗憾操作结果是1452317 而不是期望结果 2000000,因为上述代码犯了严重的错误,虽然我们使用 synchronized 修饰了 increase() 方法,但却 new 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。解决这种困境的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。

3.1 修饰代码块 —> 使用的是monitorenter 和 monitorexit 指令实现的

在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了。

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    @Override
    public void run() {
        //省略其他耗时操作....
        //使用同步代码块对变量i进行同步操作,锁对象为instance
        synchronized(instance){
            for(int j=0;j<1000000;j++){
                    i++;
              }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

从代码看出,将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁。

  • 偏向锁
  • 轻量级锁
  • 重量级锁
  • 自旋锁

理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

blog.csdn.net/javazejian/…

Java 内存区域

Java 内存模型(JMM)

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念。 由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而 Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,**工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

硬件内存架构

volatile 关键字

volatile 是 JVM 提供的轻量级的同步机制,作用如下:

  • 保证 volatile 修饰的共享变量总是被所有线程立即可见的,也就是说,当一个线程修改了 volatile 修饰的共享变量时,其他线程都能够立即得知最新的值。
  • 禁止指令重排序。

volatile 是如何让共享变量对其他线程是立即可见的?

当写一个 volatile 变量时,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM 会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。

volatile 是如何实现禁止指令重排优化的?

是通过内存屏障来实现的,由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

synchronized 和 volatile 区别?

synchronized volatile
保证可见性 保证可见性
保证原子性 不能保证原子性
可以指令重排序优化 禁用指令重排序
可作用在实例方法,静态方法,代码块上 只能作用在变量上
可能会造成线程阻塞 不会造成线程阻塞

Lock 锁

public interface Lock {
  	// 获取锁,如果锁被其他线程获取,则进行等待
    void lock();
    // 获取锁,如果现场正在等待获取锁,则这个线程能够被中断等待过程
    void lockInterruptibly() throws InterruptedException;
    // 获取锁,获取成功返回 true,获取失败返回 false
    boolean tryLock();
    // 获取锁,拿不到锁时会等待一定的时间,如果一开始拿到锁或等待时间内拿到了锁,返回 true,否则返回false
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    // 释放锁
  	void unlock();
    Condition newCondition();
}

不同于 synchronized , volatile 是关键字, Lock 是一个类

  • 通过 Lock 可以知道线程有没有成功获取到锁。synchronized 是无法做到的。
  • 采用 synchronized 不需要用户去手动释放锁,当 synchronized 修饰的方法,代码块执行完成后,系统会自动让线程释放对锁的占有。而 Lock 则必须要用户去手动释放锁,如果没有主动释放锁,就可能导致死锁现象。
  • synchronized 当一个线程处于等待某个锁的状态,是无法被中断的,Lock 可以通过 lockInterruptibly() 中断线程的等待状态。
  • synchronized 在发生异常时,会自动释放线程占有的锁,不会导致死锁现象,而 Lock 在发生异常时,若没有主动释放锁,很可能会造成死锁现象,因此使用 Lock 时,需要在 finally 块中释放锁。

锁的分类

  • 可重入锁
  • 可中断锁
  • 公平锁
  • 读写锁

ReentrantLock 可重入锁

ReentrantLock 是唯一一个实现 Lock 接口的类。