多线程(3)-线程安全
1.线程安全
1.1线程不安全的原因
-
线程之间是抢占式执行的(根本原因,线程不安全的万恶之源)
-
修改共享数据
static class Counter { public int count = 0; void increase() { count++; } } public static void main(String[] args) throws InterruptedException { final Counter counter = new Counter(); Thread t1 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 50000; i++) { counter.increase(); } }); //t1和t2线程针对count变量进行修改,此时这个count是个一个多个线程都能访问到的"共享数据" //这样就会导致线程不安全 t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(counter.count); }
-
有些操作不是原子的
像 ++ 这样的操作,本质上是三个步骤(LOAD,ADD,SAVE),是一个"非原子" 的操作,像 = 操作,本质上就是一个步骤,认为是一个"原子" 操作 (可通过加锁方式解决,变成原子的)
-
内存可见性(与编译器优化有关)
一个内存修改,一个内存读取,由于编译器的优化,可能把中间环节的 SAVE 和 LOAD 操作去掉了 ,此时读取的线程可能是未修改的结果。
-
指令重排序(也与编译器优化有关)
编译器会自动调整执行指令的顺序,以达到提高执行效率的效果,前提是需要保证最终效果不变,但是在多线程下,会影响结果
2.解决线程不安全的方法
2.1synchronized 关键字-监视器锁monitor lock
2.1.1synchronzied的特性
-
互斥
- synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到
同一个对象 synchronized 就会阻塞等待. - 进入 synchronized 修饰的代码块, 相当于加锁
- 退出 synchronized 修饰的代码块, 相当于解锁
- synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到
-
刷新内存
synchronzied的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本的工作内存中
- 执行代码
- 将更改后的共享变量的值刷新到主内存中
- 释放互斥锁
因此synchronzied也可以保证内存可见性
-
可重入
-
synchronzied是可重入锁,对同一条线程来说是可重入的
-
在可重入锁内部,包含了”线程持有者“和”计数器“两个信息:
- 如果某个线程加锁的时候,发现自己已经被人占用了。但是占用的是自己,那么仍然可以继续获取到锁,并让计数器自增
- 解锁的时候计数器递减为0,才真正释放锁(才能被别的线程获取到)
-
2.1.2使用示例
-
直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo { public synchronized void methond() { } }
-
修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo { public synchronized static void method() { } }
-
修饰代码块: 明确指定锁哪个对象
锁当前对象
public class SynchronizedDemo { public void method() { synchronized (this) { } } }
锁类对象
public class SynchronizedDemo { public void method() { synchronized (SynchronizedDemo.class) { } } }
2.2volatile关键字
2.2.1volatile能保证内存可见性
-
代码在写入volatile修饰的变量的时候
- 改变线程工作内存中的volatile变量副本的值
- 将改变后的副本的值从工作内存刷新到主内存中
-
代码在读取volatile修饰的变量的时候
- 从主内存中读取volatile变量的最新值到线程的工作内存中
- 从工作内存中读取volatile变量的副本
代码示例
static class Counter {
//public int flag = 0;
public volatile int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (counter.flag == 0) {
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
// 执行效果1
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)
// 执行效果2
// 当用户输入非0值时, t1 线程循环能够立即结束.
2.2.2volatile不保证原子性
volatile保证的是内存可见性,synchronzied保证的是原子性
static class Counter {
volatile public int count = 0;
void increase() {
count++;
}
}
public static void main(String[] args) throws InterruptedException {
final Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.increase();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.count);
}
//执行结果 count的值不是100000
2.2.3synchronzied也可以保证内存可见性
synchronized 既能保证原子性, 也能保证内存可见性.
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
while (true) {
synchronized (counter) {
if (counter.flag != 0) {
break;
}
}
// do nothing
}
System.out.println("循环结束!");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("输入一个整数:");
counter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束.
3.wait和notify
3.1wait()方法
3.1.1wait做的事情
- 使当前执行的代码的线程进行等待(把线程放到等待队列中)
- 释放当前的锁
- 满足一定条件被唤醒,重现尝试获取这个锁
3.1.2wait结束等待的条件
- 其他线程调用该对象的notfiy方法
- wait等待的时间超时(wait方法提供一个带有timeout参数的版本,来指定等待的时间)
- 其他线程调用该等待线程的interrupted方法,导致wait抛出Interruption异常
3.1.3代码示例
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("等待中");
object.wait();
System.out.println("等待结束");
}
//程序会在调用wait方法后就一直等待下去
3.2notify()方法
notifiy方法是唤醒等待的线程
- 方法notfiy也要在同步方法或同步代码块中调用,该方法时用来通知那些可能等待该对象的对象锁的其他线程,对其发出notify,并使它们重新获取到该对象的对象锁
- 如果有多个线程等待,则有线程调度器随机选出一个呈wait状态的线程
- 在notify方法后,当前线程不会马上释放该对象的锁,要等到notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁
3.3notifyAll()方法
notifyAll方法可以唤醒所有的等待线程
3.2wait和sleep的对比
- wait需要搭配synchronzied使用,sleep不需要
- wait是Object的方法,sleep的Thread的静态方法
- wait是用于线程之间的通信的,sleep是让线程阻塞一段时间
- 两者都可以让线程放弃执行一段时间
4.多线程案例
4.1单例模式
4.1.1饿汉模式
在类加载的同时,创建实例
class Singleton {
private static Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
}
4.1.2懒汉模式
-
懒汉模式-单线程版
类加载的时候不创建实例. 第一次使用的时候才创建实例.class Singleton { private static Singleton instance = null; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
-
懒汉模式-多线程版
上面的懒汉模式的实现是线程不安全的- 线程安全问题发生在首次创建实例时. 如果在多个线程中同时调用 getInstance 方法, 就可能导致创建出多个实例.
- 一旦实例已经创建好了, 后面再多线程调用 getInstance 就不再有线程安全问题了(不再修改instance了)
解决方法:加上synchronzied关键字
class Singleton { private static Singleton instance = null; private Singleton() {} public synchronized static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
-
懒汉模式-多线程版(改进)
- 使用双重 if 判定, 降低锁竞争的频率.
- 给 instance 加上了 volatile
class Singleton { private static volatile Singleton instance = null; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
4.2阻塞式队列
4.2.1什么是阻塞式队列
阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:
-
当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
-
当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列的一个典型应用场景就是 “生产者消费者模型”. 这是一种非常典型的开发模型.
4.2.2标准库中的阻塞队列
在Java内置了阻塞队列,如果我们需要在一些程序中使用阻塞队列,直接使用标准库的
- BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue.
- put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
- BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// 入队列
queue.put("abc");
// 出队列. 如果没有 put 直接 take, 就会阻塞.
String elem = queue.take()
4.2.3生产者消费者模型
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等
待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
-
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力
-
阻塞队列也能使生产者和消费者之间 解耦.
4.2.4简单实现生产者消费者模型
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<Integer>();
Thread customer = new Thread(() -> {
while (true) {
try {
int value = blockingQueue.take();
System.out.println("消费元素: " + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "消费者");
customer.start();
Thread producer = new Thread(() -> {
Random random = new Random();
while (true) {
try {
int num = random.nextInt(1000);
System.out.println("生产元素: " + num);
blockingQueue.put(num);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "生产者");
producer.start();
customer.join();
producer.join();
}
5.线程池
线程池最大的好处就是减少每次启动,销毁线程的损耗
5.1标准库中的线程池
-
使用Executors.newFixedThreadPool能创建出固定包含的10个线程的线程池
-
返回值类型为ExecutorService
-
通过ExecutorService.submit可以注册一个任务到线程池中
ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(new Runnable() { @Override public void run() { System.out.println("hello"); } });
5.2Executors创建线程的几种方式
- newFixedThreadPool:创建固定线程数的线程池
- newCachedThreadPool:创建线程数数码动态增长的线程池
- newSingleThreadExecutor: 创建只包含单个线程的线程池.
- newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
Executors 本质上是 ThreadPoolExecutor 类的封装
6.保证线程安全的思路
-
使用没有共享资源的模型
-
适用共享资源只读,不写的模型
- 不需要写共享资源的模型
- 使用不可变对象
-
直面线程安全
- 保证原子性
- 保证顺序性
- 保证可见性
7.进程和线程
7.1线程的优点
- 创建一个新线程的代价要比创建一个新进程小的多
- 与进程之间的互相切换相比,线程之间的切换需要操作系统做的工作要少得多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器上运行,将运算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作
7.2进程和线程之间的区别
- 进程是系统进行资源分配和调度一个独立单位,线程是程序执行的最小单位
- 进程是有自己的内存地址空间,线程只独享指令执行的必要资源,如寄存器和栈
- 由于同一进程的各个线程间共享内存和文件资源,可以不通过内核进行直接通信
- 线程的创建,切换及终止效率高