一、多线程并发环境下,数据的安全问题(重点)
1.什么是多线程安全问题
当多线程并发的环境下,有共享数据,并且这个数据还会被修改时,那么该进程中就会产生线程安全的问题,(即在多线程并发的情况下,当共享的数据发生了修改的行为 就会产生安全问题)
2.什么时候数据在多线程并发的环境下会存在安全问题呢?
满足三个条件:
- 条件1:多线程并发。
- 条件2:有共享数据。
- 条件3:共享数据有修改的行为。
满足以上3个条件之后,就会存在线程安全问题。
我用一个买票的例子来举例
//模拟多个线程抢10张票
public class TickDemo implements Runnable{
private int ticks=10;
@Override
public void run() {
for (int i=1;i<=ticks;ticks--) {
System.out.println(Thread.currentThread().getName() + "---拿到第" + ticks + "张票");
}
}
public static void main(String[] args) {
TickDemo tickDemo = new TickDemo();
//创建4个线程去买10张不同的票
Thread thread = new Thread(tickDemo, "小韦");
Thread thread1 = new Thread(tickDemo, "小梅");
Thread thread2 = new Thread(tickDemo, "小何");
Thread thread3 = new Thread(tickDemo, "小李");
thread.start();
thread1.start();
thread2.start();
thread3.start();
}
}
运行结果如下
由结果可以知道,在一个进程中,并发执行的4个线程都拿到了第10张票,但是第10张票应该只存在一张,所以这是不合理的
3.如何解决线程安全问题
思路:
就是将多条操作共享数据的线程代码封装起来,当有线程在执行这些代码的时候,其他线程不可以参与运算。必须要当前线程把这些代码都执行完毕后,其他线程才可以参与运算.
即使 线程排队执行。(使其不能并发)。用排队执行解决线程安全问题。
这种机制被称为:线程同步机制。
专业术语叫做:线程同步,实际上就是线程不能并发了,线程必须排队执行。
线程同步就是线程排队了,线程排队了虽然会牺牲一部分效率 ,但是数据安全放在第一位,只有数据安全了,我们才可以谈效率。数据不安全,没有效率的事儿。
同步代码块的格式:
synchronized(对象){
需要被同步的代码
}
同步的好处: 解决了线程的安全问题
同步的弊端:当线程相当多时,因为每个线程都会去判新同步上的锁,这是很耗费资源的,无形中会降低程
序的运行效率。
同步的前提: 必须有多个线程并使用同一个锁。
package com.example.demo;
//多个线程操作同一个对象------线程的安全性
//模拟多个线程抢10张票
public class TickDemo implements Runnable {
private int ticks = 10;
Object object = new Object();
// ----------------线程同步机制 synchronized,-------抢到锁后执行完当前线程,退出代码块,其他线程抢锁在进行执行
@Override
public void run() {
while (true) {
synchronized (object) {
if (ticks > 0) {
System.out.println(Thread.currentThread().getName() + "---拿到第" + ticks-- + "张票");
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
TickDemo tickDemo = new TickDemo();
Thread thread = new Thread(tickDemo, "小韦");
Thread thread1 = new Thread(tickDemo, "小梅");
Thread thread2 = new Thread(tickDemo, "小何");
Thread thread3 = new Thread(tickDemo, "小李");
// 线程优先级
// thread.setPriority(1);
// thread1.setPriority(10);
thread.start();
thread1.start();
thread2.start();
thread3.start();
}
}
上图显示安全问题已被解决,原因在于0bject对象相当于是一把锁,只有抢到锁的线程,才能进入同步代码块向下执行。
因此,当ticks=1时,CPU切换到某个线程后,如上图的线程,其他线程将无法通过同步代码块继而进行if判断语句,只有等到线程执行完“ticks--”操作(最后ticks的值为0),并且跳出同步代码块后,才能抢到锁。其他线程即使抢到锁,然而,此时ticks已为0,也就无法通过if语句判断,从而无法再执行“ticks--”的操作了,也就不会出现0、-1、-2等情况了。
二、死锁
在使用同步代码块synchronized()解决数据安全问题时,若两个线程都需要使用相同的多个线程锁时,则会在代码运行时产生死锁的情况,如下图
DeathLock1 中的同步代码块需要先获取demo对象锁,再执行代码块中的另一个同步代码块获取tostringDemo对象锁,然后才能执行其中的同步代码块。
DeathLock2 中的同步代码块需要先获取tostringDemo对象锁,再执行代码块中的另一个同步代码块获取demo对象锁,然后才能执行其中的同步代码块。
当线程thread1获取到demo对象锁执行同步代码块,线程thread2获取到tostringDemo对象锁执行同步代码块。
DeathLock1中的第二个同步代码块因无法获取到tostringDemo对象锁无法执行,
DeathLock2中的第二个同步代码块因无法获取到demo对象锁无法执行,
因此就会产生死锁;
//主函数
public static void main(String[] args) {
Demo demo = new Demo();
TostringDemo tostringDemo = new TostringDemo();
DeathLock1 deathLock1 = new DeathLock1(demo,tostringDemo);
DeathLock2 deathLock2 = new DeathLock2(demo,tostringDemo);
Thread thread1 = new Thread(deathLock1);
Thread thread2 = new Thread(deathLock2);
thread1.start();
thread2.start();
}
//死锁线程1
public class DeathLock1 implements Runnable{
Demo demo;
TostringDemo tostringDemo;
public DeathLock1(Demo demo,TostringDemo tostringDemo){
this.demo=demo;
this.tostringDemo=tostringDemo;
}
@Override
public void run() {
while (true){
try {
synchronized (demo){
System.out.println("DeathLock1----占用了共享资源---Demo");
Thread.sleep(100);//使线程休眠,等待DeathLock2使用tostringDemo锁
synchronized (tostringDemo){
System.out.println("DeathLock1----占用了共享资源---toStringDemo");
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
//死锁线程2
public class DeathLock2 implements Runnable{
Demo demo;
TostringDemo tostringDemo;
public DeathLock2(Demo demo,TostringDemo tostringDemo){
this.demo=demo;
this.tostringDemo=tostringDemo;
}
@Override
public void run() {
while (true){
try {
synchronized (tostringDemo){
System.out.println("DeathLock2----占用了共享资源---toStringDemo");
Thread.sleep(100);//使线程休眠,等待DeathLock1使用demo锁
synchronized (demo){
System.out.println("DeathLock2----占用了共享资源---Demo");
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
举一个简单的死锁例子就是
t1想先穿衣服在穿裤子
t2想先穿裤子在传衣服
此时:t1拿到衣服,t2拿到裤子
由于t1拿了衣服,t2找不到衣服
t2拿了裤子,t1找不到裤子
就会导致死锁的发生!
注意区分:
- 对象锁:1个对象1把锁,100个对象100把锁。
- 类锁:100个对象,也可能只是1把类锁。
我在这个例子中使用的是类锁!
三、关于Object类的wait()、notify()、notifyAll()方法
void wait() | 让活动在当前对象的线程无限等待(释放之前占有的锁) |
void notify() | 唤醒当前对象正在等待的线程(只提示唤醒,不会释放锁) |
void notifyAll() | 唤醒当前对象全部正在等待的线程(只提示唤醒,不会释放锁) |
特别注意wait和notify方法不是线程对象的方法,是java中任何一个java对象都有的方法,因为这两个方法是 Object类中自带
的。
wait方法和notify方法不是通过线程对象调用,
不是这样的:t.wait(),也不是这样的:t.notify()…不对。
而是通过实例化的对象进行调用
Object o = new Object();
o.wait()
o.notify()
o.notifyAll()
(1)wait()让正在对象上活动的线程进入等待状态,无期限等待,直到被唤醒为止。
(2)notify()唤醒正在对象上等待的线程
(3)notifyAll()唤醒正在对象上所有等待的线程
总结
1、wait和notify方法不是线程对象的方法,是普通java对象都有的方法。
2、wait方法和notify方法建立在 线程同步 的基础之上。因为多线程要同时操作一个数据。有线程安全问题。
3、wait方法作用:o.wait() 让正在o对象上活动的线程t进入等待状态,并且释放掉t线程之前占有的o对象的锁。
4、notify方法作用:o.notify() 让正在o对象上等待的线程唤醒,只是通知,不会释放o对象上之前占有的锁
5、wait和sleep区别:
1)wait可以指定时间也可以不指定。sleep必须指定时间
2)在同步中时,对CPU的执行权和锁的处理不同
wait:释放执行权,释放锁。
sleep:释放执行权,不释放锁
四、生产者与消费者
1、在一般的生产者-消费者问题中一般有3种角色:生产者,消费者,缓冲区
1.1 生产者
生产者负责生成数据并将其放入共享缓冲区中。
1.2 消费者
消费者负责从共享缓冲区中取出数据并对其进行处理。
1.3 三者关系
因为生产者和消费者共享缓冲区,所以它们必须协调它们的活动,以避免竞态条件和其他同步问题
2、解决生产消费者问题
为了解决生产者-消费者问题,我们需要使用某种同步机制来协调生产者和消费者线程的活动。最常用的同步机制是信号量和互斥锁。这些机制确保线程可以安全地访问共享资源,而不会产生竞态条件和其他同步问题。
2.1 信号量和互斥锁
在使用信号量或互斥锁时,我们通常需要定义一个缓冲区来存储生产者生成的数据和消费者要处理的数据。
缓冲区通常是一个固定大小的队列,可以存储一定数量的数据项。
当生产者生成一个新的数据项时,它将该数据项添加到队列的尾部。当消费者需要处理一个数据项时,它将该数据项从队列的头部取出。
2.2 条件变量
当生产者和消费者线程之间存在同步问题时,我们需要采用某种方法来协调它们的活动。最常见的方法是使用条件变量
条件变量允许线程在共享资源上等待,并在其他线程对该资源进行某些操作时被唤醒。
在生产者-消费者问题中,我们可以使用两个条件变量:一个用于生产者等待缓冲区非满,另一个用于消费者等待缓冲区非空
3、具体实例
为了更好地理解生产者-消费者问题的工作方式,我们可以考虑以下示例。
3.1 实例描述
假设有一个生产者线程和一个消费者线程,需要处理20条数据,它们共享一个缓冲区。缓冲区是一个长度为10的队列,可以存储10条数据。生产者线程负责生成数据并将它们放入队列中。消费者线程负责从队列中取出整数并将它们打印到控制台上。为了确保生产者和消费者线程之间的同步,我们可以使用一个信号量来跟踪缓冲区中的元素数量,并使用两个条件变量来确保生产者只在缓冲区非满时才能添加元素,消费者只在缓冲区非空时才能取出元素。
3.2 main函数
public static void main(String[] args) {
// 创建1个缓冲区对象,共享的类锁。
List list = new ArrayList();
//缓冲区长度
Integer LCapital = 10;
// 创建两个线程对象
// 生产者线程
Thread t1 = new Thread(new Producer(list, LCapital, 20));
// 消费者线程
Thread t2 = new Thread(new Consumer(list, LCapital));
t1.setName("生产者线程");
t2.setName("消费者线程");
t1.start();
t2.start();
}
3.3 生产者
// 生产线程
class Producer implements Runnable {
// 缓冲区
private List list;
//缓冲区大小
private int Capital;
// 需要生产的数据的数量
private int Num;
public Producer(List list, int Capital, int Num) {
this.list = list;
this.Capital = Capital;
this.Num = Num;
}
@Override
public void run() {
for (int i = 0; i < Num; i++) {
try {
synchronized (list) {
//判断缓冲区是否满了,满了则让生产者停止生产,
if (list.size() == Capital) {
System.out.println(Thread.currentThread().getName() + "执行---------->wait" );
list.wait();
}
//若缓冲区没满,则生产数据并加入缓冲区
String HB = "汉堡包" + i;
list.add(HB);
System.out.println(Thread.currentThread().getName() + "---------->生产了" + HB);
// 通知并等待消费者进行消费
list.notifyAll();
}
// 模拟生产花费的时间
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
3.4 消费者
// 消费线程
class Consumer implements Runnable {
// 缓冲区
private List list;
//缓冲区大小
private int Capital;
public Consumer(List list, int Capital) {
this.list = list;
this.Capital = Capital;
}
@Override
public void run() {
// 一直消费
while (true) {
try {
synchronized (list) {
//如果缓冲区为空,消费者线程等待,释放掉list集合的锁
if (list.size() == 0) {
System.out.println(Thread.currentThread().getName() + "执行---------->wait" );
list.wait();
}
//缓冲区不为空,消费者进行消费
Object removeDate = list.remove(0);
System.out.println(Thread.currentThread().getName() + "---------->消费了" + removeDate);
// 每消费一次,缓冲区不为空了就可以通知生成者进行生产
list.notifyAll();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
4、 总结
(1)在生产者线程中,如果缓冲区已满,则调用wait()方法阻塞等待消费者线程消费数据。如果缓冲区非满,生产者线程将生成数据并将其添加到缓冲区中。在将数据添加到缓冲区之后,生产者线程调用notifyAll()方法通知等待的消费者线程可以从缓冲区中消费数据。
(2)在消费者线程中,如果缓冲区为空,则调用wait()方法阻塞等待生产者线程生产数据。如果缓冲区非空,消费者线程将从缓冲区中消费数据并将其移除。在从缓冲区中消费数据之后,消费者线程调用notifyAll()方法通知等待的生产者线程可以向缓冲区中添加数据。
(3)如果消费者线程消费数据的速度比生产者线程生产数据的速度快,那么在缓冲区为空时,消费者线程将等待生产者线程生产数据。如果生产者线程生产数据的速度比消费者线程消费数据的速度快,那么在缓冲区已满时,生产者线程将等待消费者线程消费数据。这种情况下,生产者和消费者线程可能会在缓冲区中等待一段时间,但它们不会死锁或饥饿,因为它们会定期释放共享资源,从而允许其他线程访问缓冲区。