带你从零学大数据系列之Java篇---第二十一章:多线程高级

课程重点:

  • 临界资源问题理解
  • 线程锁
  • 同步代码块
  • 同步方法
  • 单例中的同步
  • 唤醒等待机制
  • 线程池(了解)

21.1. 临界资源问题

21.1.1. 临界资源问题简介

1. 临界资源

在一个进程中, 多个线程之间是可以资源共享的。 如果在一个进程中的一个资源同时被多个线程访问, 这个资源就是一个临界资源。

如果多个线程同时访问临界资源, 会对这个资源的值造成影响。

2. 临界资源问题

多个线程同时访问一个资源的情况下, 一个线程在操作这个资源的时候, 将值取出进行运算, 在还没来得及进行修改这块空间的值之前, 值又被其他的线程取走了。 此时就会出现临界资源的问题, 造成这个资源的值出现不是我们预期的值。

3. 解决方案

临界资源问题出现的原因就是多个线程在同时访问一个资源, 因此解决方案也很简单, 就是不让多个线程同时访问即可。

在一个线程操作一个资源的时候, 对这个资源进行“上锁”, 被锁住的资源, 其他的线程无法访问。

类似多个人去公共卫生间, 每一个人在进到卫生间的时候, 都会从里面进行反锁。 此时, 其他人如果也需要使用这个卫生间, 就得在门外等待。

21.1.2. 线程锁

线程锁, 就是用来“锁住”一个临界资源, 其他的线程无法访问。 在程序中, 可以分为对象锁类锁

  • 对象锁: 任何的对象, 都可以被当做是一把锁来使用。 但是需要注意, 必须要保证不同的线程看到的锁, 需要是同一把锁才能生效。 如果不同的线程看到的锁对象是不一样的, 此时这把锁将没有任何意义。
  • 类锁: 可以将一个类做成锁, 使用 类.class 来作为锁。

21.1.3. 同步代码段

同步代码段, 是来解决临界资源问题最常见的方式。 将一段代码放入到同步代码段中, 将这段代码上锁。

第一个线程抢到了锁标记后, 可以对这个紧接资源上锁, 操作这个临界资源。 此时其他的线程再执行到synchronized的时候, 会进入到锁池, 直到持有锁的线程使用结束后, 对这个资源进行解锁。 此时, 处于锁池中的线程都可以抢这个锁标记, 哪一个线程抢到了, 就进入到就绪态, 没有抢到锁的线程, 依然处于锁池中。

/**
 * @Description
 */
public class Program {
    public static void main(String[] args) {
        // 做⼀个 Runnable 接口的实现类对象,实现卖票
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                while (TicketCenter.ticketCount > 0) {
                    /*
                     * 同步代码段,这里的逻辑执行,会被上锁。当这里的逻辑执行结束之后,会自动的解锁。
                     * 小括号中需要写的是:锁。
                     * 这里的锁,可以分为:类锁 和 对象锁
                     */
                    synchronized (Thread.class) {
                        if (TicketCenter.ticketCount <= 0) {
                            break;
                        }
                        System.out.println(String.format("售票员【%s】卖出⼀张票,剩余: %d", Thread.currentThread().getName(), --TicketCenter.ticketCount));
                    }
                }
            }
        };
        // 实例化四个线程,模拟四个售票员
        Thread t1 = new Thread(runnable, "周杰伦");
        Thread t2 = new Thread(runnable, "林俊杰");
        Thread t3 = new Thread(runnable, "蔡依林");
        Thread t4 = new Thread(runnable, "周润发");

        t1.start();
        t2.start();
        t3.start();
        t4.start();
    }
}

class TicketCenter {
    public static int ticketCount = 100;
}

21.1.4. 同步方法

如果在一个方法中, 所有的逻辑, 都需要放到同一个同步代码段中执行。 这样的方法, 可以直接做成同步方法。

同步方法中的所有的逻辑, 都是在一个同步代码段中执行的。

如果是一个静态方法, 使用当前类做类锁; 如果是一个非静态方法, 使用this做对象锁。

/**
 * 使用 synchronized 修饰的方法,就是一个同步方法
 * 此时这个方法, 是一个静态的方法, 则这个方法使用的锁是类锁
 * @return
 */
public static synchronized Chairman getInstance() {
	if (Instance == null) {
		Instance = new Chairman();
 	}
	return Instance; 
}

21.1.5. 单例设计模式

懒汉式单例, 在多线程的环境下, 会出现问题。 由于临界资源问题的存在, 单例对象可能会被实例化多次。

因此, 单例设计模式, 尤其是懒汉式单例, 需要针对多线程的环境进行处理。

/**
 * @Description
 */
public class Boss {
    private Boss() {}
    private static Boss Instance = null;
    public static synchronized Boss getInstance() {
        if (Instance == null) {
            Instance = new Boss();
        }
        return Instance;
    }
}

21.1.6. 死锁

多个线程, 同时持有对方需要的锁标记, 等待对方释放自己需要的锁标记。

此时就是出现死锁。 线程之间彼此持有对方需要的锁标记, 而不进行释放, 都在等待。

/**
 * @Description
 */
public class Program {
    public static void main(String[] args) {
        Runnable runnable1 = () -> {
            synchronized ("a") {
                System.out.println("线程A,持有了a锁,在等待b锁");
                synchronized ("b") {
                    System.out.println("线程A同时持有了a锁和b锁");
                }
            }
        };

        Runnable runnable2 = () -> {
            synchronized ("b") {
                System.out.println("线程B,持有了b锁,在等待a锁");
                synchronized ("a") {
                    System.out.println("线程B同时持有了a锁和b锁");
                }
            }
        };
        new Thread(runnable1, "A").start();
        new Thread(runnable2, "B").start();
    }
}

21.1.7. wait、notify

1. 方法简介

Object类中几个方法如下:

  • wait()
    • 等待,让当前的线程,释放自己持有的指定的锁标记,进入到等待队列。
    • 等待队列中的线程,不参与CPU时间⽚的争抢,也不参与锁标记的争抢。

 

  • notify()
    • 通知、唤醒。唤醒等待队列中,⼀个等待这个锁标记的随机的线程。
    • 被唤醒的线程,进⼊到锁池,开始争抢锁标记。

 

  • notifyAll()
    • 通知、唤醒。唤醒等待队列中,所有的等待这个锁标记的线程。
    • 被唤醒的线程,进⼊到锁池,开始争抢锁标记。

2. wait和sleep的区别

  • sleep()方法,在休眠时间结束后,会自动的被唤醒。 而wait()进入到的阻塞态,需要被notify/notifyAll手动唤醒。
  • wait()会释放自己持有的指定的锁标记,进入到阻塞态。sleep()进入到阻塞态的时候,不会释放自己持有的锁标记。

3. 注意事项

无论是wait()方法,还是notity()/notifyAll()⽅法,在使用的时候要注意,⼀定要是自己持有的锁标记,才可以做这个操作。否则会出现 IllegalMonitorStateException 异常。

4. 示例代码

/**
 * @Description
 */
public class Program {
    public static void main(String[] args) {
        Runnable runnable1 = () -> {
            synchronized ("a") {
                System.out.println("线程A,持有了a锁,在等待b锁");
                synchronized ("b") {
                    System.out.println("线程A同时持有了a锁和b锁");
                    // 当 "b" 锁使用结束之后,通知另外⼀个线程使用结束了
                    "b".notify();
                }
            }
        };
        Runnable runnable2 = () -> {
            synchronized ("b") {
                System.out.println("线程B,持有了b锁,在等待a锁");
                try {
                    // 释放自己持有的 "b" 锁标记
                    "b".wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized ("a") {
                    System.out.println("线程B同时持有了a锁和b锁");
                }
            }
        };
        new Thread(runnable1, "A").start();
        new Thread(runnable2, "B").start();
    }
}

21.2. 线程池

21.2.1. 线程池的简介

线程池, 其实就是一个容器, 里面存储了若干个线程。

使用线程池, 最主要是解决线程复用的问题。 之前使用线程的时候, 当我们需要使用一个线程时, 实例化了一个新的线程。 当这个线程使用结束后, 对这个线程进行销毁。 对于需求实现来说是没有问题的, 但是如果频繁的进行线程的开辟和销毁, 其实对于CPU来说, 是一种负荷, 所以要尽量的优化这一点。

可以使用复用机制解决这个问题。 当我们需要使用到一个线程的时候, 不是直接实例化, 而是先去线程池中查找是否有闲置的线程可以使用。 如果有, 直接拿来使用; 如果没有, 再实例化一个新的线程。 并且, 当这个线程使用结束后, 并不是马上销毁, 而是将其放入到线程池中, 以便下次继续使用。

21.2.2. 线程池的开辟

在Java中, 使用ThreadPoolExecutor类来描述线程池, 在这个类的对象实例化的时候, 有几个常见的参数:

  • BlockingQueue
    • ArrayBlockingQueue
    • LinkedBlockingQueue
    • SynchronouseQueue

 

  • RejectedExecutionHandler
    • ThreadPoolExecutor.AbortPolicy : 丢弃新的任务,并抛出异常 RejectedExecutionException
    • ThreadPoolExecutor.DiscardPolicy : 丢弃新的任务,但是不会抛出异常
    • ThreadPoolExecutor.DiscardOldestPolicy : 丢弃等待队列中最早的任务
    • ThreadPoolExecutor.CallerRunsPolicy : 不会开辟新的线程,由调用的线程来处理

21.2.3. 线程池的工作原理

线程池中的所有线程, 可以分为两部分: 核心线程 和 临时线程

核心线程:

核心线程常驻于线程池中, 这些线程, 只要线程池存在, 他们不会被销毁。 只有当线程池需要被销毁的时候, 他们才会被销毁。

临时线程:

就是临时工。 当遇到了临时的高密度的线程需求时, 就会临时开辟一些线程, 处理一些任务。 这些临时的线程在处理完自己需要处理的任务后, 如果没有其他的任务要处理, 就会闲置。 当闲置的时间到达了指定的时间之后, 这个临时线程就会被销毁。

任务分配逻辑:

  1. 当需要处理并发任务的时候, 优先分配给核心线程处理。
  2. 当核心线程都已经分配了任务, 又有新的任务出现时,会将这个新的任务存入等待队列。
  3. 当等待队列被填满后, 再来新的任务时, 会从开辟一个临时线程,处理这个新的任务。
  4. 当临时线程加核心线程数量已经到达线程池的上限,再来新的任务的时候,就会触发拒绝访问策略。

21.2.4. 线程池的常用方法

21.2.5. 线程池的工具类

线程池的开辟, 除了可以使用构造方法进行实例化, 还可以通过Executors工具类进行获取。 实际应用中, 大部分的场景下, 可以不用前面的构造方法进行线程池的实例化, 而是用Executors工具类中的方法进行获取。

展开阅读全文
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值