Java并发面试题

Java基础

浏览数:104

2019-5-8

多线程

java中有几种方法可以实现一个线程?

继承Thread类;
实现Runnable接口;
实现Callable接口通过FutureTask包装器来创建Thread线程;
使用ExecutorService、Callable、Future实现有返回结果的多线程(也就是使用了ExecutorService来管理前面的三种方式)。

详情参见:

https://radiancel.github.io/2018/08/02/Multithreading/

如何停止一个正在运行的线程?

1、使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。

2、使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。

3、使用interrupt方法中断线程。

参考:https://www.cnblogs.com/greta/p/5624839.html

notify()和notifyAll()有什么区别?

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。

优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

参考:

https://www.cnblogs.com/haoerlv/p/8648973.html

sleep()和 wait()有什么区别?

对于sleep()方法,我们首先要知道该方法是属于Thread类中的。而wait()方法,则是属于Object类中的。

sleep()方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁。

当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。

参考:

https://www.cnblogs.com/hongten/p/hongten_java_sleep_wait.html

什么是Daemon线程?它有什么意义?

Java语言自己可以创建两种进程“用户线程”和“守护线程”

用户线程:就是我们平时创建的普通线程.

守护线程:主要是用来服务用户线程.

Daemon就是守护线程,他的意义是:

只要当前JVM实例中尚存在任何一个非守护线程没有结束,守护线程就全部工作;只有当最后一个非守护线程结束时,守护线程随着JVM一同结束工作。

Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器),它就是一个很称职的守护者。

参考:

https://www.cnblogs.com/ChrisWang/archive/2009/11/28/1612815.html

java如何实现多线程之间的通讯和协作?

参考这篇:http://www.cnblogs.com/hapjin/p/5492619.html

什么是可重入锁(ReentrantLock)?

线程可以进入任何一个它已经拥有的锁所同步着的代码块。

代码设计如下:

public class Lock{
    boolean isLocked = false;
    Thread  lockedBy = null;
    int lockedCount = 0;
    public synchronized void lock()
            throws InterruptedException{
        Thread thread = Thread.currentThread();
        while(isLocked && lockedBy != thread){
            wait();
        }
        isLocked = true;
        lockedCount++;
        lockedBy = thread;
    }
    public synchronized void unlock(){
        if(Thread.currentThread() == this.lockedBy){
            lockedCount--;
            if(lockedCount == 0){
                isLocked = false;
                notify();
            }
        }
    }
}

参考链接:https://www.cnblogs.com/dj3839/p/6580765.html

当一个线程进入某个对象的一个synchronized的实例方法后,其它线程是否可进入此对象的其它方法?

如果其他方法前加了synchronized关键字,就不能,如果没加synchronized,则能够进去。

如果这个方法内部调用了wait(),则可以进入其他加synchronized的方法。

如果其他方法加了synchronized关键字,并且没有调用wai方法,则不能。

synchronized和java.util.concurrent.locks.Lock的异同?

主要相同点:Lock能完成Synchronized所实现的所有功能。

主要不同点:Lock有比Synchronized更精确的线程予以和更好的性能。Synchronized会自动释放锁,但是Lock一定要求程序员手工释放,并且必须在finally从句中释放。

乐观锁和悲观锁的理解及如何实现,有哪些实现方式?

乐观锁是假设每次操作都不会冲突,若是遇到冲突失败就重试直到成功;悲观锁是让其他线程都等待,等锁释放完了再竞争锁。

乐观锁实现方式:cas,volatile

悲观锁实现方式:synchronized,Lock

并发框架

SynchronizedMap和ConcurrentHashMap有什么区别?

SynchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步。而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以对map执行某些操作。

所以,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。同时,同步操作精确控制到桶,这样,即使在遍历map时,如果其他线程试图对map进行数据修改,也不会抛出ConcurrentModificationException。

参考:

https://www.cnblogs.com/shamo89/p/6700353.html

CopyOnWriteArrayList可以用于什么应用场景?

CopyOnWriteArrayList的特性是针对读操作,不做处理,和普通的ArrayList性能一样。而在写操作时,会先拷贝一份,实现新旧版本的分离,然后在拷贝的版本上进行修改操作,修改完后,将其更新至就版本中。

那么他的使用场景就是:一个需要在多线程中操作,并且频繁遍历。其解决了由于长时间锁定整个数组导致的性能问题,解决方案即写时拷贝。

另外需要注意的是CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

参考:

https://www.cnblogs.com/duchanggang/p/4627082.html

https://www.cnblogs.com/yw-technology/p/7476106.html

线程安全

什么叫线程安全?servlet是线程安全吗?

线程安全就是说多线程访问同一代码,不会产生不确定的结果。

在多线程环境中,当各线程不共享数据的时候,即都是私有(private)成员,那么一定是线程安全的。但这种情况并不多见,在多数情况下需要共享数据,这时就需要进行适当的同步控制了。

线程安全一般都涉及到synchronized, 就是一段代码同时只能有一个线程来操作 不然中间过程可能会产生不可预制的结果。

如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

Servlet不是线程安全的,详见:漫画 | Servlet属于线程安全的吗?

同步有几种实现方法? 

1.同步方法

即有synchronized关键字修饰的方法。

由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。

2.同步代码块

即有synchronized关键字修饰的语句块。

被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。

3.使用特殊域变量(volatile)实现线程同步

a.volatile关键字为域变量的访问提供了一种免锁机制,

b.使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,

c.因此每次使用该域就要重新计算,而不是使用寄存器中的值

d.volatile不会提供任何原子操作,它也不能用来修饰final类型的变量

4.使用重入锁实现线程同步

在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。

5.使用局部变量实现线程同步。

参考:

https://www.cnblogs.com/jiansen/p/7351872.html

volatile有什么用?能否用一句话说明下volatile的应用场景?

作用是:作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值,即不是从寄存器里取备份值,而是去该地址内存存储的值。

一句话说明volatile的应用场景:

对变量的写操作不依赖于当前值且该变量没有包含在具有其他变量的不变式中。

请说明下java的内存模型。

Java内存模型的逻辑视图

为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是内存模型。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。

通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。

它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:

  1. 限制处理器优化
  2. 使用内存屏障

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

参考:

https://www.cnblogs.com/nexiyi/p/java_memory_model_and_thread.html

http://ifeve.com/java-memory-model-6/

为什么代码会重排序?

直接参考:

https://www.cnblogs.com/toov5/p/9831696.html

https://blog.csdn.net/qq_32646795/article/details/78221064

并发容器和框架

如何让一段程序并发的执行,并最终汇总结果?

使用CyclicBarrier 在多个关口处将多个线程执行结果汇总, CountDownLatch 在各线程执行完毕后向总线程汇报结果。

CountDownLatch : 一个线程(或者多个), 等待另外N个线程完成某个事情之后才能执行。

CyclicBarrier : N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待。

这样应该就清楚一点了,对于CountDownLatch来说,重点是那个“一个线程”, 是它在等待,而另外那N的线程在把“某个事情”做完之后可以继续等待,可以终止。而对于CyclicBarrier来说,重点是那N个线程,他们之间任何一个没有完成,所有的线程都必须等待。

从api上理解就是CountdownLatch有主要配合使用两个方法countDown()和await(),countDown()是做事的线程用的方法,await()是等待事情完成的线程用个方法,这两种线程是可以分开的(下面例子:CountdownLatchTest2),当然也可以是同一组线程;CyclicBarrier只有一个方法await(),指的是做事线程必须大家同时等待,必须是同一组线程的工作。

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
/**
 * 各个线程执行完成后,主线程做总结性工作的例子
 * @author xuexiaolei
 * @version 2017年11月02日
 */
public class CountdownLatchTest2 {
    private final static int THREAD_NUM = 10;
    public static void main(String[] args) {
        CountDownLatch lock = new CountDownLatch(THREAD_NUM);
        ExecutorService exec = Executors.newCachedThreadPool();
        for (int i = 0; i < THREAD_NUM; i++) {
            exec.submit(new CountdownLatchTask(lock, "Thread-"+i));
        }
        try {
            lock.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("大家都执行完成了,做总结性工作");
        exec.shutdown();
    }
 
    static class CountdownLatchTask implements Runnable{
        private final CountDownLatch lock;
        private final String threadName;
        CountdownLatchTask(CountDownLatch lock, String threadName) {
            this.lock = lock;
            this.threadName = threadName;
        }
        @Override public void run() {
            System.out.println(threadName + " 执行完成");
            lock.countDown();
        }
    }
}

CyclicBarrier例子:

import java.util.concurrent.*;
 
/**
 *
 * @author xuexiaolei
 * @version 2017年11月02日
 */
public class CyclicBarrierTest {
    private final static int THREAD_NUM = 10;
    public static void main(String[] args) {
        CyclicBarrier lock = new CyclicBarrier(THREAD_NUM, new Runnable() {
            @Override public void run() {
                System.out.println("这阶段大家都执行完成了,我总结一下,然后开始下一阶段");
            }
        });
        ExecutorService exec = Executors.newCachedThreadPool();
        for (int i = 0; i < THREAD_NUM; i++) {
            exec.submit(new CountdownLatchTask(lock, "Task-"+i));
        }
        exec.shutdown();
    }
 
    static class CountdownLatchTask implements Runnable{
        private final CyclicBarrier lock;
        private final String threadName;
        CountdownLatchTask(CyclicBarrier lock, String threadName) {
            this.lock = lock;
            this.threadName = threadName;
        }
        @Override public void run() {
            for (int i = 0; i < 3; i++) {
                System.out.println(threadName + " 执行完成");
                try {
                    lock.await();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
 
        }
    }
}

如何合理的配置java线程池?如CPU密集型的任务,基本线程池应该配置多大?IO密集型的任务,基本线程池应该配置多大?用有界队列好还是无界队列好?任务非常多的时候,使用什么阻塞队列能获取最好的吞吐量?

虽然Exectors可以生成一些很常用的线程池,但毕竟在什么情况下使用还是开发者最清楚的。在某些自己很清楚的使用场景下,java线程池还是推荐自己配置的。下面是java线程池的配置类的参数,我们逐一分析一下:

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler
  1. corePoolSize – 池中所保存的线程数,包括空闲线程。
  2. maximumPoolSize – 池中允许的最大线程数。
  3. keepAliveTime – 当线程数大于核心时,此为终止前多余的空闲线程等待新任务的最长时间。
  4. unit – keepAliveTime 参数的时间单位。
  5. workQueue – 执行前用于保持任务的队列。此队列仅保持由 execute 方法提交的 Runnable 任务。用BlocingQueue的实现类都可以。
  6. threadFactory – 执行程序创建新线程时使用的工厂。自定义线程工厂可以做一些额外的操作,比如统计生产的线程数等。
  7. handler – 饱和策略,即超出线程范围和队列容量而使执行被阻塞时所使用的处理程序。策略有:Abort终止并抛出异常,Discard悄悄抛弃任务,Discard-Oldest抛弃最老的任务策略,Caller-Runs将任务退回给调用者策略。

至于线程池应当配置多大的问题,一般有如下的经验设置:

1. 如果是CPU密集型应用,则线程池大小设置为N+1。

2. 如果是IO密集型应用,则线程池大小设置为2N+1。

用有界队列好还是无界队列好?这种问题的答案肯定是视情况而定:

1. 有界队列有助于避免资源耗尽的情况发生。但他带来了新的问题:当队列填满后,新的任务怎么办?所以有界队列适用于执行比较耗资源的任务,同时要设计好相应的饱和策略。

2. 无界队列和有界队列刚好相反,在资源无限的情况下可以一直接收新任务。适用于小任务,请求和处理速度相对持平的状况。

3. 其实还有一种同步移交的队列 SynchronousQueue ,这种队列不存储任务信息,直接将任务提交给线程池。可以理解为容量只有1的有界队列,在特殊场景下有特殊作用,同样得设计好相应的饱和策略。

参考:https://blog.csdn.net/qq_34039315/article/details/78542498

如何使用阻塞队列实现一个生产者和消费者模型?请写代码。

下面这是一个完整的生产者消费者代码例子,对比传统的wait、nofity代码,它更易于理解。

ProducerConsumerPattern.java如下:

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.logging.Level;
import java.util.logging.Logger;
 
public class ProducerConsumerPattern {
 
    public static void main(String args[]){
 
     //Creating shared object
     BlockingQueue sharedQueue = new LinkedBlockingQueue();
 
     //Creating Producer and Consumer Thread
     Thread prodThread = new Thread(new Producer(sharedQueue));
     Thread consThread = new Thread(new Consumer(sharedQueue));
 
     //Starting producer and Consumer thread
     prodThread.start();
     consThread.start();
    }
}

生产者,Producer.java如下:

class Producer implements Runnable {
 
    private final BlockingQueue sharedQueue;
 
    public Producer(BlockingQueue sharedQueue) {
        this.sharedQueue = sharedQueue;
    }
 
    @Override
    public void run() {
        for(int i=0; i<10; i++){
            try {
                System.out.println("Produced: " + i);
                sharedQueue.put(i);
            } catch (InterruptedException ex) {
                Logger.getLogger(Producer.class.getName()).log(Level.SEVERE, null, ex);
            }
        }
    }
}

消费者,Consumer.java如下所示:

class Consumer implements Runnable{
 
    private final BlockingQueue sharedQueue;
 
    public Consumer (BlockingQueue sharedQueue) {
        this.sharedQueue = sharedQueue;
    }
 
    @Override
    public void run() {
        while(true){
            try {
                System.out.println("Consumed: "+ sharedQueue.take());
            } catch (InterruptedException ex) {
                Logger.getLogger(Consumer.class.getName()).log(Level.SEVERE, null, ex);
            }
        }
    }
 
}

参考:

https://www.cnblogs.com/expiator/p/9317929.html

多读少写的场景应该使用哪个并发容器,为什么使用它?比如你做了一个搜索引擎,搜索引擎每次搜索前需要判断搜索关键词是否在黑名单里,黑名单每天更新一次。

Java中的锁

如何实现乐观锁(CAS)?如何避免ABA问题?

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

这里再强调一下,乐观锁是一种思想。CAS是这种思想的一种实现方式。

ABA问题:

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

解决方法:通过版本号(version)的方式来解决,每次比较要比较数据的值和版本号两项内容即可。

读写锁可以用于什么应用场景?

在多线程的环境下,对同一份数据进行读写,会涉及到线程安全的问题。比如在一个线程读取数据的时候,另外一个线程在写数据,而导致前后数据的不一致性;一个线程在写数据的时候,另一个线程也在写,同样也会导致线程前后看到的数据的不一致性。

这时候可以在读写方法中加入互斥锁,任何时候只能允许一个线程的一个读或写操作,而不允许其他线程的读或写操作,这样是可以解决这样以上的问题,但是效率却大打折扣了。因为在真实的业务场景中,一份数据,读取数据的操作次数通常高于写入数据的操作,而线程与线程间的读读操作是不涉及到线程安全的问题,没有必要加入互斥锁,只要在读-写,写-写期间上锁就行了。

对于以上这种情况,读写锁是最好的解决方案!其中它的实现类:ReentrantReadWriteLock–顾名思义是可重入的读写锁,允许多个读线程获得ReadLock,但只允许一个写线程获得WriteLock

读写锁的机制:

  1. “读-读” 不互斥
  2. “读-写” 互斥
  3. “写-写” 互斥

参考:https://www.cnblogs.com/liang1101/p/6475555.html

什么时候应该使用可重入锁?

可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。

在JAVA环境下 ReentrantLock 和synchronized 都是 可重入锁。

参考:http://ifeve.com/java_lock_see4/

什么场景下可以使用volatile替换synchronized?

状态标志:把简单地volatile变量作为状态标志,来达成线程之间通讯的目的,省去了用synchronized还要wait,notify或者interrupt的编码麻烦。

替换重量级锁:如果某个变量仅是单次读或者单次写操作,没有复合操作(i++,先检查后判断之类的)就可以用volatile替换synchronized。

并发工具

如何实现一个流控程序,用于控制请求的调用次数?

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
 
/**
 * 阻塞访问的线程,直到获取了访问令牌
 * @author xuexiaolei
 * @version 2017年11月15日
 */
public class FlowControl2 {
    private final static int MAX_COUNT = 10;
    private final Semaphore semaphore = new Semaphore(MAX_COUNT);
    private final ExecutorService exec = Executors.newCachedThreadPool();
 
    public void access(int i){
        exec.submit(new Runnable() {
            @Override public void run() {
                semaphore.acquireUninterruptibly();
                doSomething(i);
                semaphore.release();
            }
        });
    }
 
    public void doSomething(int i){
        try {
            Thread.sleep(new Random().nextInt(100));
            System.out.println(String.format("%s 通过线程:%s 访问成功",i,Thread.currentThread().getName()));
        } catch (InterruptedException e) {
        }
    }
 
    public static void main(String[] args) {
        FlowControl2 web = new FlowControl2();
        for (int i = 0; i < 2000; i++) {
            web.access(i);
        }
    }
}