文章目录
一、线程安全
1.1、线程安全
如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。
通过模拟卖票来演示并发问题:
模拟票:
public class Ticket implements Runnable {
private int ticket = 100;
/*
* 执行卖票操作
*/
@Override
public void run() {
//每个窗口卖票的操作
//窗口 永远开启
while (true) {
if (ticket > 0) {//有票 可以卖
//出票操作
//使用sleep模拟一下出票时间
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// TODO Auto‐generated catch block
e.printStackTrace();
}
//获取当前线程对象的名字
String name = Thread.currentThread().getName();
System.out.println(name + "正在卖:" + ticket--);
}
}
}
}
测试类:
public class Demo {
public static void main(String[] args) {
//创建线程任务对象
Ticket ticket = new Ticket();
//创建三个窗口对象
Thread t1 = new Thread(ticket, "窗口1");
Thread t2 = new Thread(ticket, "窗口2");
Thread t3 = new Thread(ticket, "窗口3");
//同时卖票
t1.start();
t2.start();
t3.start();
}
}
运行结果可能会有这种情况:
- 同一张票被卖了两次
- 出现了0和-1张票
这种问题,几个窗口(线程)票数不同步了,这种问题称为线程不安全。
线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
1.2、线程同步
- 同一个对象被多个线程同时操作。当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。
- 要解决上述多线程并发访问一个资源的安全性问题:也就是解决重复票与不存在票问题,Java中提供了同步机制(synchronized)来解决。
- 线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用
- 在线程中,只有队列并不能保证线程安全,还需要加锁
由于同一进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制 synchronized,当一个线程获得对象的排它锁,独占资源,其他线程必须等待,使用后释放锁即可。
-
同步机制存在以下问题:
- 一个线程持有锁会导致其他所有需要此锁的线程挂起
- 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题
- 如果一个优先级高的线程等待一个优先级低的线程释放锁,会导致优先级倒置,引起性能问题
-
Java提供了三种方式完成同步操作:
- 同步代码块。
- 同步方法。
- 锁机制。
1.3、三大不安全案例
-
模拟卖票
重复数:多个线程都抢到同一个票
负数:剩最后一张票时,同时抢到,其中一个拿走后,剩下两个就会变成负数// 不安全的买票 // 线程不安全,有负数,重复数 public class UnsafeBuyTicket { public static void main(String[] args) { BuyTicket station = new BuyTicket(); new Thread(station, "客户").start(); new Thread(station, "第三方抢票").start(); new Thread(station, "黄牛").start(); } } class BuyTicket implements Runnable { // 票 private int ticketNums = 10; boolean flag = true; // 外部停止方式 @Override public void run() { // 买票 while (flag) { buy(); } } private void buy() { // 判断是否有票 if (ticketNums <= 0) { flag = false; return; } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } // 买票 System.out.println(Thread.currentThread().getName() + "拿到第" + ticketNums-- + "张票"); } }
-
银行取钱
// 不安全的取钱 // 两个人去银行取同一个账户的钱 public class UnsafeBank { public static void main(String[] args) { // 账户 Account account = new Account(100, "结婚基金"); Drawing you = new Drawing(account, 50, "you"); Drawing wife = new Drawing(account, 100, "wife"); you.start(); wife.start(); } } // 账户 class Account { int money; // 余额 String name; // 卡名 public Account(int money, String name) { this.money = money; this.name = name; } } // 银行 取款 class Drawing extends Thread { Account account; // 账户 // 取了多少钱 int drawingMoney; // 现在手里有多少钱 int nowMoney; public Drawing(Account account, int drawingMoney, String name) { super(name); this.account = account; this.drawingMoney = drawingMoney; } // 取钱 @Override public void run() { // 判断有没有钱 if (account.money - drawingMoney < 0) { System.out.println(Thread.currentThread().getName() + "钱不够,取不了"); return; } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } // 卡内余额 = 余额 - 取的钱 account.money = account.money - drawingMoney; // 手里的钱 nowMoney = nowMoney + drawingMoney; System.out.println(account.name + "余额为:" + account.money); // this.getName() 等价于 Thread.currentThread().getName() System.out.println(this.getName() + "手里的钱" + nowMoney); } }
-
线程不安全的集合
// 线程不安全的集合 public class UnsafeList { public static void main(String[] args) { List<String> list = new ArrayList<>(); for (int i = 0; i < 10000; i++) { new Thread(() -> { list.add(Thread.currentThread().getName()); }).start(); } try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(list.size()); } }
ArrayList集合线程不安全的原因:
ArrayList源码:public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable { /** * 列表元素集合数组 * 如果新建ArrayList对象时没有指定大小,那么会将EMPTY_ELEMENTDATA赋值给elementData, * 并在第一次添加元素时,将列表容量设置为DEFAULT_CAPACITY */ transient Object[] elementData; /** * 列表大小,elementData中存储的元素个数,即集合长度 */ private int size; }
ArrayList的add方法:
public boolean add(E e) { /** * 添加一个元素时,做了如下三步操作 * 1.判断列表的capacity容量是否足够,是否需要扩容 * 2.真正将元素放在列表的元素数组里面 * 3.将集合长度+1 */ ensureCapacityInternal(size + 1); // Increments modCount!! elementData[size++] = e; return true; }
- 索引越界异常:
列表大小为9时,即size=9
线程A开始进入add方法,这时它获取到size的值为9,调用ensureCapacityInternal方法进行容量判断。
线程B此时也进入add方法,它获取到size的值也为9,也开始调用ensureCapacityInternal方法。
线程A发现需求大小为10,而elementData的大小就为10,可以容纳。于是它不再扩容,返回。
线程B也发现需求大小为10,也可以容纳,返回。
线程A开始进行设置值操作, elementData[size++] = e 操作。此时size变为10。
线程B也开始进行设置值操作,它尝试设置elementData[10] = e,而elementData没有进行过扩容,它的下标最大为9。于是此时会报出一个数组越界的异常ArrayIndexOutOfBoundsException。 - 一个线程的值覆盖另一个线程添加的值
列表大小为0,即size=0
线程A开始添加一个元素,值为A。此时它执行第一条操作,将A放在了elementData下标为0的位置上。
接着线程B刚好也要开始添加一个值为B的元素,且走到了第一步操作。此时线程B获取到size的值依然为0,于是它将B也放在了elementData下标为0的位置上。
线程A开始将size的值增加为1
线程B开始将size的值增加为2
总结:造成ArrayList线程不安全的因素就是
size
值,当A、B两个线程都执行到add
操作时,如果A线程添加元素后,没有更新集合长度,B线程执行时就会覆盖A线程的值,并产生一个空的位置。
如果是添加最后一个元素时,A线程添加元素并更新集合长度,B线程添加元素时就会造成索引越界。 - 索引越界异常:
二、线程同步
2.1、同步代码块
同步代码块: synchronized
关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问。
格式:
synchronized(同步锁Obj){
需要同步操作的代码
}
同步锁:
- 对象的同步锁只是一个概念,可以想象为在对象上标记了一个锁。
- 锁对象 可以是任意类型。
- 多个线程对象 要使用同一把锁。
- Obj 称之为 同步监视器
- Obj 可以是任何对象 , 但是推荐使用共享资源作为同步监视器
- 同步方法中无需指定同步监视器,因为同步方法的同步监视器就是this,就是这个对象本身 , 或者是class
- 同步监视器的执行过程
- 第一个线程访问 , 锁定同步监视器 , 执行其中代码
- 第二个线程访问 , 发现同步监视器被锁定 , 无法访问
- 第一个线程访问完毕 , 解锁同步监视器
- 第二个线程访问, 发现同步监视器没有锁 , 然后锁定并访
取钱案例:
// 取钱
// synchronized 默认锁的是this
@Override
public void run() {
// 锁的对象就是变化的量,需要增删改的对象
synchronized (account) {
// 判断有没有钱
if (account.money - drawingMoney < 0) {
System.out.println(Thread.currentThread().getName() + "钱不够,取不了");
return;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 卡内余额 = 余额 - 取的钱
account.money = account.money - drawingMoney;
// 手里的钱
nowMoney = nowMoney + drawingMoney;
System.out.println(account.name + "余额为:" + account.money);
// this.getName() 等价于 Thread.currentThread().getName()
System.out.println(this.getName() + "手里的钱" + nowMoney);
}
}
2.2、同步方法
同步方法:使用synchronized
修饰的方法,就叫做同步方法,保证A线程执行该方法的时候,其他线程只能在方法外等着。
synchronized
方法控制对 “对象” 的访问,每个对象对应一把锁,每个 synchronized 方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞,方法一旦执行,就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行
缺陷:若将一个大的方法申明为synchronized将会影响效率
格式:
public synchronized void method(){
可能会产生线程安全问题的代码
}
同步锁是谁?
对于非static方法,同步锁就是this。
对于static方法,我们使用当前方法所在类的字节码对象(类名.class)。
卖票案例:
private synchronized void buy() {
// 判断是否有票
if (ticketNums <= 0) {
flag = false;
return;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 买票
System.out.println(Thread.currentThread().getName() + "拿到第" + ticketNums-- + "张票");
}
2.3、死锁
多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,而导致两个或者多个线程都在等待对方释放资源,都停止执行的情形。
某一个同步块同时拥有 “ 两个以上对象的锁 ” 时,就可能会发生 “ 死锁 ” 的问题。
// 死锁:多个线程互相抱着对方需要的资源,然后形成僵持
public class DeadLock {
public static void main(String[] args) {
MakeUp m1 = new MakeUp(0, "一号");
MakeUp m2 = new MakeUp(1, "二号");
m1.start();
m2.start();
}
}
// 口红
class Lipstick {
}
// 镜子
class Mirror {
}
class MakeUp extends Thread {
// 需要的资源只有一份,用static来保证只有一份
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();
int choice; // 选择
String userName; // 使用化妆品的人
public MakeUp(int choice, String userName) {
this.choice = choice;
this.userName = userName;
}
@Override
public void run() {
// 化妆
try {
makeUp();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 化妆,互相持有对方的锁,就是需要拿到对方的资源
private void makeUp() throws InterruptedException {
if (choice == 0) {
// 获得口红的锁
synchronized (lipstick) {
System.out.println(this.userName + "获得口红的锁");
Thread.sleep(1000);
// 一秒钟后想获得镜子
synchronized (mirror) {
System.out.println(this.userName + "获得镜子的锁");
}
}
}else {
// 获得镜子的锁
synchronized (mirror) {
System.out.println(this.userName + "获得镜子的锁");
Thread.sleep(2000);
// 一秒钟后想获得口红
synchronized (lipstick) {
System.out.println(this.userName + "获得口红的锁");
}
}
}
}
}
// 输出结果
/*
一号获得口红的锁
二号获得镜子的锁
*/
产生死锁的四个必要条件:
- 互斥条件:一个资源每次只能被一个进程使用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件 : 进程已获得的资源,在末使用完之前,不能强行剥夺。
- 循环等待条件 : 若干进程之间形成一种头尾相接的循环等待资源关系
只要想办法破其中的任意一个或多个条件 就可以避免死锁发生
2.4、Lock锁
从JDK 5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当。
java.util.concurrent.locks.Lock
接口是控制多个线程对共享资源进行访问的工具。 锁提供了对共享资源的独占访问,每次只能有一个线程对Lock
对象加锁,线程开始访问共享资源之前应先获得Lock
对象
java.util.concurrent.locks.Lock
机制提供了比synchronized
代码块和synchronized
方法更广泛的锁定操作,同步代码块/同步方法具有的功能Lock
都有,除此之外更强大,更体现面向对象。
ReentrantLock(可重入锁)类实现了 Lock,它拥有与 synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。
-
Lock锁也称同步锁,加锁与释放锁方法化了,如下:
- public void lock():加同步锁。
- public void unlock():释放同步锁。
// 测试lock锁 public class TestLock { public static void main(String[] args) { TestLock2 testLock2 = new TestLock2(); new Thread(testLock2).start(); new Thread(testLock2).start(); new Thread(testLock2).start(); } } class TestLock2 implements Runnable { int ticketNums = 10; // 定义Lock锁 private final ReentrantLock lock = new ReentrantLock(); @Override public void run() { while (true) { try { lock.lock(); // 加锁 if (ticketNums > 0) { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(ticketNums--); }else { break; } }finally { lock.unlock(); // 解锁 } } } }
synchronized 与 Lock 的对比:
- Lock是显式锁(手动开启和关闭锁,别忘记关闭锁)synchronized是隐式锁,出了作用域自动释放
- Lock只有代码块锁,synchronized有代码块锁和方法锁
- 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
- 优先使用顺序:
- Lock > 同步代码块(已经进入了方法体,分配了相应资源)> 同步方法(在方法体之外)
三、等待唤醒机制
3.1、线程间通信
概念:多个线程在处理同一个资源,但是处理的动作(线程的任务)却不相同。
比如:线程A用来生成包子的,线程B用来吃包子的,包子可以理解为同一资源,线程A与线程B处理的动作,一个是生产,一个是消费,那么线程A与线程B之间就存在线程通信问题。
为什么要处理线程间通信:
多个线程并发执行时, 在默认情况下CPU是随机切换线程的,当我们需要多个线程来共同完成一件任务,并且我们希望他们有规律的执行, 那么多线程之间需要一些协调通信,以此来帮我们达到多线程共同操作一份数据。
如何保证线程间通信有效利用资源:
多个线程在处理同一个资源,并且任务不同时,需要线程通信来帮助解决线程之间对同一个变量的使用或操作。 就是多个线程在操作同一份数据时, 避免对同一共享变量的争夺。也就是我们需要通过一定的手段使各个线程能有效的利用资源。而这种手段即—— 等待唤醒机制。
3.2、等待唤醒机制
等待唤醒机制概念:
这是多个线程间的一种协作机制。谈到线程我们经常想到的是线程间的竞争(race),比如去争夺锁,但这并不是全部,线程间也会有协作机制。
就是在一个线程进行了规定操作后,就进入等待状态(wait()), 等待其他线程执行完他们的指定代码过后再将其唤醒(notify());在有多个线程进行等待时, 如果需要,可以使用 notifyAll()来唤醒所有的等待线程。
wait/notify 就是线程间的一种协作机制。
等待唤醒中的方法:
等待唤醒机制就是用于解决线程间通信的问题的,使用到的3个方法的含义如下:
- wait:线程不再活动,不再参与调度,进入 wait set 中,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程状态即是 WAITING。它还要等着别的线程执行一个特别的动作,也即是“通知(notify)”在这个对象上等待的线程从wait set 中释放出来,重新进入到调度队列(ready queue)中
- notify:则选取所通知对象的 wait set 中的一个线程释放;例如,餐馆有空位置后,等候就餐最久的顾客最先入座。
- notifyAll:释放所通知对象的 wait set 上的全部线程。
注意:
哪怕只通知了一个等待的线程,被通知线程也不能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以她需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调用 wait 方法之后的地方恢复执行。
总结如下:
如果能获取锁,线程就从 WAITING 状态变成 RUNNABLE 状态;
否则,从 wait set 出来,又进入 entry set,线程就从 WAITING 状态又变成 BLOCKED 状态
调用wait和notify方法需要注意的细节:
- wait方法与notify方法必须要由同一个锁对象调用。因为:对应的锁对象可以通过notify唤醒使用同一个锁对象调用的wait方法后的线程。
- wait方法与notify方法是属于Object类的方法的。因为:锁对象可以是任意对象,而任意对象的所属类都是继承了Object类的。
- wait方法与notify方法必须要在同步代码块或者是同步函数中使用。因为:必须要通过锁对象调用这2个方法。
3.3、线程间通信–管程法
// 测试:生产者消费者模型-->利用缓冲区解决:管程法
// 生产者,消费者,产品,缓冲区
public class TestPC {
public static void main(String[] args) {
SynContainer container = new SynContainer();
new Productor(container).start();
new Consumer(container).start();
}
}
// 生产者
class Productor extends Thread {
SynContainer container;
public Productor(SynContainer container) {
this.container = container;
}
// 生产
@Override
public void run() {
for (int i = 0; i < 100; i++) {
container.push(new Chicken(i));
System.out.println("生产了" + i + "只鸡");
}
}
}
// 消费者
class Consumer extends Thread {
SynContainer container;
public Consumer(SynContainer container) {
this.container = container;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println("消费了" + container.pop().id + "只鸡");
}
}
}
// 产品
class Chicken {
int id; // 产品编号
public Chicken(int id) {
this.id = id;
}
}
// 缓冲区
class SynContainer {
// 容器大小
Chicken[] chickens = new Chicken[10];
// 容器计数器
int count = 0;
// 生产者放入产品
public synchronized void push(Chicken chicken) {
// 如果容器满了,就需要等待消费者消费
if (count == chickens.length) {
// 通知消费者消费,生产者等待
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 如果没有满,就需要丢入产品
chickens[count] = chicken;
count++;
// 可以通知消费者消费了
this.notifyAll();
}
// 消费者消费产品
public synchronized Chicken pop() {
// 判断能否消费
if (count == 0) {
// 等待生产者生产,消费者等待
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 如果可以消费
count--;
Chicken chicken = chickens[count];
// 通知生产者生产
this.notifyAll();
return chicken;
}
}
3.4、信号灯法
// 测试:生产者消费者问题2:信号灯法,标志位解决
public class TestPC2 {
public static void main(String[] args) {
TV tv = new TV();
new Player(tv).start();
new Watcher(tv).start();
}
}
// 生产者-->演员
class Player extends Thread{
TV tv;
public Player(TV tv) {
this.tv = tv;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
if (i % 2 == 0) {
this.tv.play("电视剧");
}else {
this.tv.play("广告");
}
}
}
}
// 消费者-->观众
class Watcher extends Thread{
TV tv;
public Watcher(TV tv) {
this.tv = tv;
}
@Override
public void run() {
for (int i = 0; i < 20; i++) {
tv.watch();
}
}
}
// 产品-->节目
class TV {
// 演员表演,观众等待 true
String voice; // 表演的节目
boolean flag = true;
public synchronized void play (String voice) {
if (!flag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("演员表演了" + voice);
// 通知观众观看
this.notifyAll();
this.voice = voice;
this.flag = !this.flag;
}
// 观众观看,演员等待 false
public synchronized void watch() {
if (flag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("观众观看了" + voice);
// 通知演员表演
this.notifyAll();
}
}
四、线程池
4.1、概念
其实就是一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建线程对象的操作,无需反复创建线程而消耗过多资源。
- 好处:
- 提高响应速度(减少了创建新线程的时间)
- 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
- 便于线程管理(…)
- corePoolSize:核心池的大小
- maximumPoolSize:最大线程数
- keepAliveTime:线程没有任务时最多保持多长时间后会终止
4.2、使用
Java里面线程池的顶级接口是 java.util.concurrent.Executor
,但是严格意义上讲 Executor 并不是一个线程池,而只是一个执行线程的工具。真正的线程池接口是 java.util.concurrent.ExecutorService
。
要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能配置的线程池不是较优的,因此在 java.util.concurrent.Executors
线程工厂类里面提供了一些静态工厂,生成一些常用的线程池。官方建议使用Executors
工程类来创建线程池对象。
- Executors类中有个创建线程池的方法如下:
- public static ExecutorService newFixedThreadPool(int nThreads) :返回线程池对象。(创建的是有界线程池,也就是池中的线程个数可以指定最大数量)
- 获取到了一个线程池ExecutorService 对象,调用一个使用线程池对象的方法:public Future<?> submit(Runnable task) :获取线程池中的某一个线程对象,并执行
Future接口:用来记录线程任务执行完毕后产生的结果。线程池创建与使用。
使用线程池中线程对象的步骤:
- 创建线程池对象。
- 创建Runnable接口子类对象。(task)
- 提交Runnable接口子类对象。(take task)
- 关闭线程池(一般不做)。
// 测试:线程池
public class TestPool {
public static void main(String[] args) {
// 1、创建线程池
// 参数为线程池大小
ExecutorService service = Executors.newFixedThreadPool(10);
service.execute(new MyThread());
service.execute(new MyThread());
service.execute(new MyThread());
// 关闭连接
service.shutdown();
}
}
class MyThread implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}