Java进阶之多线程安全性

  • 一、多线程的安全问题
  • 1.1 多线程的内存图
  • 1.2 Java内存模型
  • 1.3 线程的可见性
  • 1.4 线程的有序性
  • 1.5 线程的原子性
  • 1.6 volatile关键字
  • 1.7 原子类
  • 1.7.1 AtomicInteger解决原子性问题
  • 1.7.2 CAS机制
  • 1.8 synchronized关键字
  • 1.8.1 同步代码块
  • 1.8.2 同步方法
  • 1.8.3 同步小结
  • 1.9 Lock接口解决线程安全问题
  • 1.10 并发包
  • 1.10.1 CopyOnWriteArrayList
  • 1.10.2 CopyOnWriteArraySet
  • 1.10.3 ConcurrentHashMap


一、多线程的安全问题

1.1 多线程的内存图

栈内存是线程私有,每一个线程都有一个自己的栈空间,用来运行自己的方法。

方法是通过哪个线程调用,那么方法就会在哪个线程的栈空间中运行。

Java 保证线程读取值唯一 java保证多线程安全_java

1.2 Java内存模型

Java内存模型定义的是线程对于共享变量的访问规则。

  • 主内存:线程共享的数据是保存在主内存中
  • 线程工作内存:工作内存保存的数据的副本,线程操作主内存中的数据过程是会先将该数据保存到工作内存,完成操作后,再把新的数据赋值给主内存的数据

这就导致了线程操作时的三个问题:

  • 可见性
  • 有序性
  • 原子性

1.3 线程的可见性

public class MyThread extends Thread{
    
    boolean flag = false;
    //定义run方法
    @Override
    public void run() {
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //将flag的值进行修改
        flag = true;
        //输出flag的值
        System.out.println("flag:" + flag);
    }
}
public class Demo01Test {
    public static void main(String[] args) {
        //创建Thread子类对象
        MyThread m = new MyThread();
        //线程启动
        m.start();
        //死循环
        while (true) {
            //判断如果m对象中的flag是true,就输出内容
            if (m.flag) {
                System.out.println("线程执行了");
            }
        }
    }
}

上面的案例没有执行打印操作,因为主线程中拿到的flag值还没有改变。

Java 保证线程读取值唯一 java保证多线程安全_同步代码块_02

1.4 线程的有序性

程序在编译过程中,对一些没有顺序要求的代码顺序进行打乱,有可能会对程序的结果产生影响:

Java 保证线程读取值唯一 java保证多线程安全_System_03

1.5 线程的原子性

我们以实际案例来分析:

public class Task implements Runnable{
    //定义成员变量
    int count = 0;
    @Override
    public void run() {
        //对count的值进行增加100次
        for(int i = 0; i < 100; i++) {
            //线程休眠
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //让count自增
            count++;
            //输出count的值
            System.out.println("count->" + count);
        }
    }
}
public class Demo01Test {
    public static void main(String[] args) {
        //创建Task对象
        Task t = new Task();
        //创建线程
        new Thread(t).start();
        new Thread(t).start();
        new Thread(t).start();

    }
}

结果:

Java 保证线程读取值唯一 java保证多线程安全_Java 保证线程读取值唯一_04


按照我们的设想,一个线程打印100次,3个线程应该打印300次,但结果并没有达到,造成这个结果的原因在于++操作不具有原子性。

原子性是指不可分割的操作,而某个线程要对共享数据执行++操作会分为三个步骤:

  • 1.将主内存中的数据读取到自己的工作内存中
  • 2.将工作内存中的数据+1
  • 3.将新的数据赋值给主内存中的原数据

由于++不具备原子性,在执行操作时可能会被其他线程插队,导致count值多次重复。

Java 保证线程读取值唯一 java保证多线程安全_java_05

1.6 volatile关键字

volatile关键字可以保证变量对于线程的可见性,对于被volatile修饰的变量,如果某个线程对该变量进行修改并保存到主内存时,对于其他线程来说,也是可见的,即其他线程也会读取到新的值。

volatile关键字也可以解决有序性问题,当变量被修饰为volatile时,会禁止代码重排。

volatile只能保证保证单一变量的可见性,不能解决代码块的原子性问题。

1.7 原子类

在Java开发编程中,要想保证一些操作不被其他线程干扰,就需要保证原子性,JDK中提供了13个原子操作类来帮助我们进行开发,这里我们以整数原子类AtomicInteger举例学习原子类。

AtomicInteger构造方法:

  • AtomicInteger():使用该构造方法创建出来的AtomicInteger对象表示整数0
  • AtomicInteger(int initialValue):根据指定的数字值创建AtomicInteger对象

常用成员方法:

  • public final int getAndIncrement():获取当前的值然后进行自增操作,返回的是自增前的值
  • public final int incrementAndGet():获取当前的值然后进行自增操作,返回的是自减后的值
  • int get():获取当前AtomicInteger对应的整数值
public class Demo02AtomicIntegerTest {
    public static void main(String[] args) {
        //创建AtomicInteger对象
        //AtomicInteger atomicInteger = new AtomicInteger(10);
        //System.out.println(atomicInteger);//10

        //int getAndIncrement():获取当前的值然后自增,返回的是自增前的值
        int count = atomicInteger.getAndIncrement();
        System.out.println(count);//10

        //int incrementAndGet():先自增然后获取值。 返回的是自增后的值
        int count1 = atomicInteger.incrementAndGet();
        System.out.println("count1:" + count1);//12
        System.out.println("atomicInteger:" + atomicInteger);//12

        //int get():获取当前AtomicInteger对应的整数值
        int num = atomicInteger.get();
        System.out.println("num:" + num);//12
    }
}

1.7.1 AtomicInteger解决原子性问题

public class Task implements Runnable{
    //定义成员变量,初始值是0
    AtomicInteger count = new AtomicInteger();
    @Override
    public void run() {
        //对count的值进行增加100次
        for(int i = 0; i < 100; i++) {
            //线程休眠
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //让count自增
            int num = count.incrementAndGet();
            //输出num的值
            System.out.println("num->" + num);
        }
    }
}
public class Test02 {
    public static void main(String[] args) {
        //创建Task对象
        Task task = new Task();
        //创建线程对象,执行任务
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}

输出结果:

Java 保证线程读取值唯一 java保证多线程安全_多线程_06

1.7.2 CAS机制

原子类是使用CAS机制解决原子性问题的。

Java 保证线程读取值唯一 java保证多线程安全_同步代码块_07

1.8 synchronized关键字

synchronized可以解决多行代码的原子性问题。

synchronized表示同步,可以修饰代码块,也可以修饰方法。

1.8.1 同步代码块

如果synchronized修饰代码块,那么这个代码块就是同步代码块

同步代码块格式:
        synchronized (锁对象) {
            ...
        }
锁对象就是一个普通的Java对象,锁对象可以是任何类型的,可以是Object, ArrayList
锁对象仅仅起到一个标记的作用,除此之外,就没有其他含义了

同步代码块特点:

  • 只有持有锁的线程才能够进入到同步代码块,保证每次只有一个线程执行同步代码块
  • 当线程离开同步代码块,线程会释放掉自己的锁。这样线程就又可以去竞争这个锁了,哪个线程能抢到,哪个线程去执行
/*
    如果多线程同时操作共享数据,就有可能会引发线程安全问题。
 */
@SuppressWarnings("all")
public class Ticket2 implements Runnable{
    //定义变量,表示票的数量。
    int count = 100;

    //创建对象,当做锁对象,该对象可以是任何类型的,该对象仅仅起到一个标记作用。
    //锁对象一定要是唯一的。 多个线程使用的锁必须是同一个锁对象,否则也不能保证线程安全。
    Object lock = new Object();

    //在run方法中编写线程要执行的任务,线程要执行的任务是卖票任务。
    @Override
    public void run() {
        //定义死循环模拟售票窗口一直卖票的过程
        while (true) {
            //当线程执行到同步代码块时会看一下同步代码块上面有没有锁。
            //如果同步代码块上面有锁,那么线程会获取到锁,然后进入到同步代码块中。
            //如果同步代码块上面没有锁,那么线程会在同步代码块位置一直等着获取锁
            synchronized (lock) {
                //如果有票,那么才往外卖票
                if (count > 0) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "正在卖票:" + count);
                    count--;
                }
            }
            
        }
    }
}
public class Demo02TicketTest {
    public static void main(String[] args) {
        //创建Ticket2对象
        Ticket2 t = new Ticket2();
        new Thread(t).start();
        new Thread(t).start();
        new Thread(t).start();
    }
}

1.8.2 同步方法

如果synchronized修饰方法,那么该方法就是同步方法,同步方法同样可以解决线程安全问题。

synchronized格式:

修饰符 synchronized 返回值类型 方法名(参数列表) {
        方法体;
        return 返回值;
    }

同步方法相当于将整个的方法体都加了同步代码块

同步方法也是有锁的

  • 如果同步方法是非静态的,那么锁对象是this。
  • 如果同步方法是静态的,那么锁对象是类名.class(字节码文件对象)
/*
    如果多线程同时操作共享数据,就有可能会引发线程安全问题。
 */
@SuppressWarnings("all")
public class Ticket3 implements Runnable{
    //定义变量,表示票的数量。
    int count = 100;

    @Override
    public void run() {
        while (true) {
            //调用方法,卖票
            sell2();
        }
    }
    //定义同步方法,用来卖票
    //同步方法,相当于将整个的方法体都加了同步代码块。
    //同步方法是非静态的,锁对象是this
    public synchronized void sell2() {
        //如果有票,那么才往外卖票
        if (count > 0) {
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "正在卖票:" + count);
            count--;
        }
    }
}

1.8.3 同步小结

线程同步可以解决线程安全问题,会牺牲效率

同步代码块:

  • 优点:灵活,可以对任意代码进行同步。
  • 缺点:语法不如同步方法简洁

同步方法:

  • 优点:语法简洁。
  • 缺点:不如同步代码块灵活。 是直接将整个的方法体都加了同步

1.9 Lock接口解决线程安全问题

在JDK5的时候,提供了Lock接口,里面的方法可以手动的获取锁以及释放锁

  • void lock():获取锁
  • void unlock():释放锁

Lock是一个接口,如果要用,需要使用实现类,最常用的实现类是ReentrantLock

public class Ticket4 implements Runnable{
    //定义变量,表示票的数量。
    int count = 100;
    
    Lock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            //获取锁
            lock.lock();
            //如果有票,那么才往外卖票
            if (count > 0) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "正在卖票:" + count);
                count--;
            }
             //释放锁
            lock.unlock();
        }
    }
}
public class Demo04TicketTest {
    public static void main(String[] args) {
        //创建Ticket4对象
        Ticket4 t = new Ticket4();
        //创建线程对象,并执行
        new Thread(t).start();
        new Thread(t).start();
        new Thread(t).start();
    }
}

1.10 并发包

我们通常所说的并发包也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工具类,今天简单介绍其中几个。

1.10.1 CopyOnWriteArrayList

ArrayList是线程不安全的,多个线程一起对ArrayList进行操作有可能会有安全问题

CopyOnWriteArrayList是线程安全的,可以使用它来解决线程安全问题

public class Task implements Runnable{
    //定义集合【ArrayList是线程不安全的,会引发线程安全问题】
    //List<Integer> list = new ArrayList<>();

    //CopyOnWriteArrayList是线程安全的,可以使用它来解决这个问题
    List<Integer> list = new CopyOnWriteArrayList<>();

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(i);
        }
        System.out.println("添加完毕");
    }
}
public class Demo01Test {
    public static void main(String[] args) throws InterruptedException {
        //创建Task对象
        Task task = new Task();
        //创建三个线程,并执行任务
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();

        //线程休眠
        Thread.sleep(3000);

        //输出集合的大小
        System.out.println(task.list.size());
    }
}

1.10.2 CopyOnWriteArraySet

HashSet线程不安全,如果多线程同时对HashSet集合进行操作,有可能会产生安全性的问题

CopyOnWriteArraySet是线程安全的,我们可以使用这个集合进行多线程操作

public class Task implements Runnable{
    //创建HashSet集合【HashSet线程不安全,如果多线程同时对HashSet集合进行操作,有可能会产生安全性的问题】
    //Set<Integer> set = new HashSet<>();

    //CopyOnWriteArraySet是线程安全的,我们可以使用这个集合进行多线程操作
    Set<Integer> set = new CopyOnWriteArraySet<>();

    @Override
    public void run() {
        //向集合中添加数据
        for (int i = 0; i < 1000; i++) {
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //添加数据
            set.add(i);
        }
        System.out.println("添加完成");
    }
}
public class Demo01Test {
    public static void main(String[] args) throws InterruptedException {
        //创建Task对象
        Task t = new Task();
        //创建线程,启动
        new Thread(t).start();
        //在main线程中向集合中添加2000-3000之间的数字
        for (int i = 2000; i < 3000; i++) {
            Thread.sleep(5);
            t.set.add(i);
        }
        //休眠
        Thread.sleep(7000);
        //输出集合的大小
        System.out.println("集合的大小是:" + t.set.size());
    }
}

1.10.3 ConcurrentHashMap

HashMap集合不是线程安全的,如果多个线程同时操作HashMap,那么有可能会引发线程安全问题。

Hashtable集合是线程安全的,多个线程同时操作hashtable不会引发线程安全问题,效率非常低,目前基本已经淘汰了。

ConcurrentHashMap集合是线程安全的,但是效率相对Hashtable要高,其内部使用的是CAS+分段锁。

public class Task implements Runnable{
    //创建Map集合
    Map<String, String> map = new ConcurrentHashMap<>();

    @Override
    public void run() {
        //定义循环,在循环中向Map集合中添加10000条数据
        for (int i = 0; i < 10000; i++) {
            //向map集合中添加数据
            map.put(Thread.currentThread().getName() + i, i);
        }
    }
}
public class Demo01Test {
    public static void main(String[] args) throws InterruptedException {
        //创建Task对象
        Task task = new Task();
        //创建三个线程对象,执行任务
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
        //线程休眠
        Thread.sleep(3000);
        //输出map集合的大小
        System.out.println(task.map.size());
    }
}