孤尽训练营Day23

109 阅读11分钟

一、创建线程和线程池时要指定有意义的名称

创建线程时要指定有意义的名称,以便问题追溯。

A、线程名

public static void main(String[] args)
{
    //订单模块
    Thread threadOne = new Thread(new Runnable()
    {
        public void run()
        {
            System.out.println("保存订单的线程");
            try
            {
                Thread.sleep(500);
            }
            catch (InterruptedException e)
            {
                e.printStackTrace();
            }
            throw new NullPointerException();
        }
    });
    
    //发货模块
    Thread threadTwo = new Thread(new Runnable()
    {
        public void run()
        {
            System.out.println("保存收获地址的线程");
        }
    });
    
    threadOne.start();
    threadTwo.start();
}

以上代码分别创建了线程one和线程two并且启动执行,运行上面代码可能会有如下输出:

保存订单的线程
保存收获地址的线程
Exception in thread "Thread-0" java.lang.NullPointerException
	at top.cfish.java.ext.Loop$1.run(Loop.java:34)
	at java.lang.Thread.run(Thread.java:748)

从运行结果可知Thread-0抛出了NullPointerException异常,但是从日志本无法判断是订单模块的线程抛出的异常。

当一个系统中有多个业务模块而每个模块中又都是用了自己的线程,除非抛出与业务相关的异常,否则根本没法判断是哪一个模块出现了问题。

static final String THREAD_SAVE_ORDER = "THREAD_SAVE_ORDER";
static final String THREAD_SAVE_ADDR = "THREAD_SAVE_ADDR";

public static void main(String[] args)
{
    // 订单模块
    Thread threadOne = new Thread(new Runnable()
    {
        public void run()
        {
            System.out.println("保存订单的线程");
            throw new NullPointerException();
        }
    }, THREAD_SAVE_ORDER);
    
    // 发货模块
    Thread threadTwo = new Thread(new Runnable()
    {
        public void run()
        {
            System.out.println("保存收货地址的线程");
        }
    }, THREAD_SAVE_ADDR);
    
    threadOne.start();
    threadTwo.start();
}

从运行结果就可以定位到是保存订单模块抛出了NullPointerException异常。

保存订单的线程
保存收货地址的线程
Exception in thread "THREAD_SAVE_ORDER" java.lang.NullPointerException
	at top.cfish.java.ext.Loop$1.run(Loop.java:29)
	at java.lang.Thread.run(Thread.java:748)

B、线程池名

一般一个应用中会创建不止一个线程池,为了业务隔离,一般不同的业务使用不同的线程池,同样不指定线程池名会很难难定位问题。

static ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());
static ThreadPoolExecutor executorTwo = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>());

public static void main(String[] args)
{
    //接受用户链接模块
    executorOne.execute(new Runnable()
    {
        public void run()
        {
            System.out.println("接受用户链接线程");
            throw new NullPointerException();
        }
    });
    
    //具体处理用户请求模块
    executorTwo.execute(new Runnable()
    {
        public void run()
        {
            System.out.println("具体处理业务请求线程");
        }
    });
    
    executorOne.shutdown();
    executorTwo.shutdown();
}

运行结果,并不知道是哪个模块的线程池抛出的异常。

接受用户链接线程
具体处理业务请求线程
Exception in thread "pool-1-thread-1" java.lang.NullPointerException
	at top.cfish.java.ext.Loop$1.run(Loop.java:29)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

默认线程池使用DefaultThreadFactory来创建线程和指定名称,所以可以定制线程工厂。

// 命名线程工厂
static class NamedThreadFactory implements ThreadFactory
{
    private static final AtomicInteger poolNumber = new AtomicInteger(1);
    private final ThreadGroup group;
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    private final String namePrefix;
    
    NamedThreadFactory(String name)
    {
        
        SecurityManager s = System.getSecurityManager();
        group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup();
        if (null == name || name.isEmpty())
        {
            name = "pool";
        }
        
        namePrefix = name + "-" + poolNumber.getAndIncrement() + "-thread-";
    }
    
    public Thread newThread(Runnable r)
    {
        Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0);
        if (t.isDaemon())
        {
            t.setDaemon(false);
        }
        if (t.getPriority() != Thread.NORM_PRIORITY)
        {
            t.setPriority(Thread.NORM_PRIORITY);
        }
        return t;
    }
}

static ThreadPoolExecutor executorOne = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(), new NamedThreadFactory("ASYN-ACCEPT-POOL"));
static ThreadPoolExecutor executorTwo = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>(), new NamedThreadFactory("ASYN-PROCESS-POOL"));

public static void main(String[] args)
{
    //接受用户链接模块
    executorOne.execute(new Runnable()
    {
        public void run()
        {
            System.out.println("接受用户链接线程");
            throw new NullPointerException();
        }
    });
    
    //具体处理用户请求模块
    executorTwo.execute(new Runnable()
    {
        public void run()
        {
            System.out.println("具体处理业务请求线程");
        }
    });
    
    executorOne.shutdown();
    executorTwo.shutdown();
}

从ASYN-ACCEPT-POOL-1-thread-1就可以知道是接受连接线程池抛出的异常。

接受用户链接线程
Exception in thread "ASYN-ACCEPT-POOL-1-thread-1" java.lang.NullPointerException
具体处理业务请求线程
	at top.cfish.java.ext.Loop$1.run(Loop.java:66)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

二、不要new线程,要用线程池

不要new线程,要用线程池。

首先线程的创建和销毁是有系统开销的,线程是系统很宝贵的资源,如果无限制地创建会把系统资源消耗殆尽,其次我们来看看线程池的好处,线程池主要解决以下两个问题。

A、一方面,当执行大量异步任务时线程池能够复用线程,提供较好的性能,在不使用线程池的时候,每当需要执行异步任务时是直接new一线程进行运行,然后任务执行完毕后被销毁回收,而线程的创建和销毁是需要开销的。

static class Demo
{
    private int index;
    
    public void doTask()
    {
        System.out.println("i:" + index + " doTask() called");
    }

    public Demo(int index)
    {
        this.index = index;
    }
}

static void process(Demo demo)
{
    new Thread(new Runnable()
    {
        @Override
        public void run()
        {
            try
            {
                demo.doTask();
            }
            catch (Exception e)
            {
                System.out.println(e.getLocalizedMessage());
            }
        }
    }).start();
}

public static void main(String[] args) throws InterruptedException
{
    for (int i = 0; i < 1000; ++i)
    {
        Demo demo = new Demo(i);
        process(demo);
    }
}

使用线程池的时候,线程池里面的线程是可复用的,不会每次执行异步任务时都重新创建和销毁线程。

static class Demo
{
    private int index;
    
    public void doTask()
    {
        System.out.println("i:" + index + " doTask() called");
    }
    
    public Demo(int index)
    {
        this.index = index;
    }
}

static ThreadPoolExecutor threadPool = new ThreadPoolExecutor(8, 8, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<Runnable>());

static void process(Demo demo)
{
    threadPool.execute(new Runnable()
    {
        @Override
        public void run()
        {
            try
            {
                demo.doTask();
            }
            catch (Exception e)
            {
                System.out.println(e.getLocalizedMessage());
            }
        }
    });
}

public static void main(String[] args) throws InterruptedException
{
    for (int i = 0; i < 1000; ++i)
    {
        Demo demo = new Demo(i);
        process(demo);
    }
    threadPool.shutdown();
}

B、另一方面,线程池提供了一种资源限制和管理的手段,比如可以限制线程的个数、动态新增线程等,每个ThreadPoolExecutor也保留了一些基本的统计数据。

其中getActiveCount返回当前激活的线程个数(正在执行任务的线程),getCompletedTaskCount返回已经完成的任务个数,getTaskCount返回所有任务个数,包含已经完成的、正在执行的和队列里面缓存的。

三、线程池不建议使用工具类Executors创建

不允许使用工具类Executors创建线程池,而是通过手动构造ThreadPoolExecutor来创建。

JDK提供Executors类的目的是为了便于开发人员创建线程池,通过Executors的下列方法可以创建不同用途的线程池。

A、newFixedThreadPool

创建一个核心线程个数和最大线程个数都为nThreads的线程池,并且阻塞队列长度为 Integer.MAX_VALUE,keepAliveTime=0 说明只要线程个数比核心线程个数多并且当前空闲则回收。

public static ExecutorService newFixedThreadPool(int nThreads)
{
    return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());
}

//使用自定义线程创建工厂
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory)
{
    return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory);
}

B、newSingleThreadExecutor

创建一个核心线程个数和最大线程个数都为1的线程池,并且阻塞队列长度为 Integer.MAX_VALUE,keepAliveTime=0 说明只要线程个数比核心线程个数多并且当前空闲则回收。

public static ExecutorService newSingleThreadExecutor()
{
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()));
}

//使用自己的线程工厂
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory)
{
    return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory));
}

C、newCachedThreadPool

创建一个按需创建线程的线程池,初始线程个数为0,最多线程个数为 Integer.MAX_VALUE,并且阻塞队列为同步队列,keepAliveTime=60 说明只要当前线程60s内空闲则回收。这个的特殊性在于加入到同步队列的任务会马上被执行,同步队列里面最多只有一个任务。

public static ExecutorService newCachedThreadPool()
{
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>());
}

//使用自定义的线程工厂
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory)
{
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), threadFactory);
}

可知Executors类只是ThreadPoolExecutor的一个门面,开发人员可以很方便地使用它来创建线程池。

比如创建一个单个线程的线程池,使用Executors工具类:

final static ExecutorService pool = Executors.newSingleThreadExecutor();

如果不用工具类需要用下面的方式创建:

    final static ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

上面两者是等价的,但是使用Executors创建的线程池,屏蔽了太多细节,比如阻塞队列大小,默认是无界限,那么可能有些开发人员就不会注意到这个,从而导致阻塞队列堆积了大量元素,导致OOM。

《阿里巴巴Java开发手册》规定开发人员创建线程池时使用后者来创建,因为创建时需要开发人员清楚地指定线程池中核心线程个数、最大线程个数、非活跃线程生存周期、阻塞队列类型和拒绝策略。开发人员指定这些参数的时候必须根据具体场景设置最合适的参数,从而规避资源耗尽的风险。

四、SimpleDateFormat是线程不安全的类

//(1)创建单例实例
static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args)
{
    // (2)创建多个线程,并启动
    for (int i = 0; i < 10; ++i)
    {
        Thread thread = new Thread(new Runnable()
        {
            public void run()
            {
                try
                {
                    // (3)使用单例日期实例解析文本
                    System.out.println(sdf.parse("2017-12-13 15:17:27"));
                }
                catch (Exception e)
                {
                    e.printStackTrace();
                }
            }
        });
        thread.start();// (4)启动线程
    }
}

一个SimpleDateFormat实例,10个线程,每个线程都共用同一个sdf对象对文本日期进行解析,多运行几次就会抛出java.lang.NumberFormatException异常,加大线程的个数更容易复现。

Javadoc里面也有说明:

Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.

SimpleDateFormat是线程不安全的,其中存在共享变量,并且没有在访问共享变量前进行适当的同步处理。

如果一定要使用SimpleDateFormat,有以下方案。

A、定义工具类

每次使用时返回一个SimpleDateFormat实例,保证每个实例使用自己的Calendar实例,但是每次使用都需要new一个对象,并且使用后由于没有其他引用,就会需要被回收,会增加开销。

static SimpleDateFormat getSimpleDateFormatInstance()
{
    return new SimpleDateFormat();
}

B、同步

多线程中可以使用synchronized进行同步。

// (1)创建单例实例
static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static void main(String[] args)
{
    // (2)创建多个线程,并启动
    for (int i = 0; i < 10; ++i)
    {
        Thread thread = new Thread(new Runnable()
        {
            public void run()
            {
                try
                {
                    // (3)使用单例日期实例解析文本
                    synchronized (sdf)
                    {
                        System.out.println(sdf.parse("2017-12-13 15:17:27"));
                    }
                }
                catch (ParseException e)
                {
                    e.printStackTrace();
                }
            }
        });
        thread.start();// (4)启动线程
    }
}

使用同步意味着多个线程要竞争锁,在高并发场景下会导致系统响应性能下降。

对于多线程环境,更推荐使用JodaTime进行处理。

五、高并发时考虑锁的性能损耗

高并发时,同步调用应考虑锁的性能,能用无锁,就不用锁,一定要用锁时候,加锁粒度尽量小。

在多线程编程中,少不了多个线程需要并发访问一个共享资源的情况,为避免多线程访问共享资源时的并发问题,一般需要在访问共享资源前进行适当同步,比如在处理共享资源前加独占锁,再处理资源,然后释放锁,但是锁的粒度使用不当,会影响并发性能。

一个共享变量,只有修改获取操作,并且共享变量的当前值并不依赖原来的值,这时候可以使用synchronized进行同步。

public class SharedValue
{
    private int val;
    
    public synchronized int getVal()
    {
        return val;
    }
    
    public synchronized void setVal(int val)
    {
        this.val = val;
        
    }
}

因为进入synchronized块前会清空锁块内本地内存中将会用到的共享变量,所以getVal方法获取的val是直接从主内存获取的,由于退出synchronized会把锁块内修改的本地变量刷新到主内存,从而保证了共享变量的内存可见性。

但是synchronized块加的是独占锁,这导致线程只能有一个线程获取锁调用getVal方法获取当前变量的值,而getVal本身并不会修改val的值,所以这大大降低了读取的并发性。

既然这里变量val的值并不依赖原来的值,那么这里其实使用无锁volatile修饰val变量就可以解决内存不可见性问题。

public class SharedValue 
{
    private volatile int val;

    public int getVal() 
    {
        return val;
    }

    public void setVal(int val) 
    {
        this.val = val;
    }
}

但是volatile只能保证内存可见性,不能保证原子性。

public class UnSafeCount 
{
    private volatile int val;

    public int getVal() 
    {
        return val;
    }

    public void inc() 
    {
        ++val;
    }
}

由于++val不具有原子性,所以该计数器不是线程安全的,应该保证inc方法是原子性的,这时候synchronized就派上用场了(synchronized保证原子性和内存可见性)。

public class SafeCount 
{
    private int val;

    public synchronized int getVal() 
    {
        return val;
    }

    public synchronized void inc() 
    {
        ++val;
    }
}

但是由于synchronized是独占锁,导致同时调用getVal方法读取val值的线程竞争独占锁,这降低了并发度,这时候可以降低锁的范围,让读写锁分离。

private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private int val;

public int getVal()
{
    readLock.lock();
    try
    {
        return val;
    }
    finally
    {
        readLock.unlock();
        
    }
}

public void inc()
{
    writeLock.lock();
    try
    {
        ++val;
    }
    finally
    {
        writeLock.unlock();
        
    }
}

使用读写分离锁,多个线程可以同时获取读锁然后获取val,增加了读取的并发度,但是上面的策略还是使用锁,其实关于计数器,JUC包提供了无锁CAS算法的实现,比如AtomicInteger、AtomicLong等类,如果在高并发下,大量线程在进行CAS失败后本地自旋重试,可以使用LongAdder。

六、并发修改同一条记录时需要加锁以避免更新丢失

并发修改同一条记录时需要加锁以避免更新丢失。锁可以是应用层的,可以是缓存层,也可以是数据库层。

多线程并发修改同一个记录的情况在项目实践中很常见,为了避免数据更新丢失,一般有两种方式,一种是悲观锁,一种是乐观锁。

如果每次访问冲突概率小于20%,建议使用乐观锁,这是因为乐观锁本身不需要对行记录加锁,并发性能较好,另外使用乐观锁时重试次数不少于3次。如果每次访问冲突概率比较大,那么使用乐观锁会导致重试次数比较高,并且等达到重试次数后,也不一定更新OK,所以这时候建议使用悲观锁。

对于应用层,一般是使用分布式锁来做同步,缓存上可以使用Redis的CAS操作来做同步。

七、使用ScheduledExecutorService替代Timer

Timer下启动多个任务,只要其中一个任务抛出了异常,其他任务会自动终止,ScheduledExecutorService就不会有这个问题。

// 创建定时器对象
static Timer timer = new Timer();

public static void main(String[] args)
{
    // 添加任务1,延迟500ms执行
    timer.schedule(new TimerTask()
    {
        @Override
        public void run()
        {
            System.out.println("---one Task---");
            try
            {
                Thread.sleep(1000);
            }
            catch (InterruptedException e)
            {
                e.printStackTrace();
            }
            throw new RuntimeException("error ");
        }
    }, 500);
    
    // 添加任务2,延迟1000ms执行
    timer.schedule(new TimerTask()
    {
        @Override
        public void run()
        {
            for (; ; )
            {
                System.out.println("---two Task---");
                try
                {
                    Thread.sleep(1000);
                }
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
            }
        }
    }, 1000);
}

先添加了一个任务在500ms后执行,然后添加了第二个任务在1s后执行。

期望的是当第一个任务输出---one Task---,后等待1s;第二个任务会输出---two Task---。

实际执行结果,第一个任务抛异常后,第二个任务就终止了。

---one Task---
Exception in thread "Timer-0" java.lang.RuntimeException: error 
	at top.cfish.java.ext.TestTimer$1.run(TestTimer.java:33)
	at java.util.TimerThread.mainLoop(Timer.java:555)
	at java.util.TimerThread.run(Timer.java:505)

Timer内部当任务执行过程中抛出了除InterruptedException之外的异常后,唯一的消费线程就会因为抛出异常而终止,那么队列里面的其他待执行的任务就会被清除。

要实现类似Timer的功能使用ScheduledThreadPoolExecutor的schedule是比较好的选择。ScheduledThreadPoolExecutor中的一个任务抛出了异常,其他任务不受影响。

static ScheduledThreadPoolExecutor scheduledThreadPoolExecutor = new ScheduledThreadPoolExecutor(1);

public static void main(String[] args)
{
    scheduledThreadPoolExecutor.schedule(new Runnable()
    {
        @Override
        public void run()
        {
            System.out.println("---one Task---");
            try
            {
                Thread.sleep(1000);
            }
            catch (InterruptedException e)
            {
                e.printStackTrace();
            }
            throw new RuntimeException("error ");
        }
    }, 500, TimeUnit.MICROSECONDS);
    
    scheduledThreadPoolExecutor.schedule(new Runnable()
    {
        @Override
        public void run()
        {
            for (int i = 0; i < 2; ++i)
            {
                System.out.println("---two Task---");
                try
                {
                    Thread.sleep(1000);
                }
                catch (InterruptedException e)
                {
                    e.printStackTrace();
                }
            }
        }
    }, 1000, TimeUnit.MICROSECONDS);
    
    scheduledThreadPoolExecutor.shutdown();
}

执行结果符合预期。

---one Task---
---two Task---
---two Task---

Process finished with exit code 0