多线程与并发(三)——电商系统中的应用

1 常见问题

1.1 线程协作

1)Object中

  • wait:让出锁,阻塞等待
  • notify/notifyAll:唤醒wait的进程,注意,具体唤醒哪一个要看优先级,同优先级的看运气
public class NotifyTest {
    public static void main(String[] args) {
        byte[] lock = new byte[0];
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait();
                    System.out.println("t1 started");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait();
                    System.out.println("t2 started");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t3 = new Thread(() -> {
            synchronized (lock) {
                try {
                    Thread.sleep(1000);
                    System.out.println("t3 notify");
                    lock.notifyAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        t1.setPriority(1);
        t2.setPriority(3);
        t3.setPriority(2);
        t1.start();
        t2.start();
        t3.start();
    }
}

2)Thread中

  • sleep:暂停一下,只是让出CPU的执行权,并不释放锁。
public class SleepTest {
    public static void main(String[] args) {
        final byte[] lock = new byte[0];
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("start");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("end");
            }
        }).start();
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("need lock");
            }
        }).start();
    }
}

新的thread无法异步执行,被迫等待锁,跟着sleep。

  • yield:不释放锁,运行中转为就绪,让出cpu给大家去竞争。当然有可能自己又抢了回来
public class YieldTest {
    public static void main(String[] args) throws InterruptedException {
        final byte[] lock = new byte[0];
        //让出执行权,但是锁不释放
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("start");
                Thread.yield();
                System.out.println("end");
            }
        });
        //可以抢t1,但是拿不到锁,白费
        Thread t2 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("need lock");
            }
        });
        //不需要锁,可以抢t1的执行权,但是能不能抢得到,不一定
        //所以多执行几次,会看到不同的结果……
        Thread t3 = new Thread(() -> {
            System.out.println("t3 started");
        });
        t1.start();
        t2.start();
        t3.start();
    }
}

分析:
t3会插队抢到执行权,但是t2不会,因为t2和t1共用一把锁而yield不会释放
t3不见得每次都能抢到。可能t1让出又抢了回去

  • join:父线程等待子线程执行完成后再执行,将异步转为同步。注意调的是子线程,阻断的是父线程
public class JoinTest implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("I am sub");
    }

    public static void main(String[] args) throws InterruptedException {
        Thread sub = new Thread(new JoinTest());
        sub.start();
        // sub.join();
        System.out.println("I am main");
    }
}

分析:如果不join,main先跑完;如果join,main必须等待sub之后才输出

1.2 死锁

1) 现象,互相等待对方释放锁

public class DeadLock {
    byte[] lock1 = new byte[0];
    byte[] lock2 = new byte[0];

    void f1() throws InterruptedException {
        synchronized (lock1) {
            System.out.println("持有lock1,准备获取lock2");
            Thread.sleep(1000);
            synchronized (lock2) {
                System.out.println("f1");
            }
        }
    }

    void f2() throws InterruptedException {
        synchronized (lock2) {
            System.out.println("持有lock2,准备获取lock1");
            Thread.sleep(1000);
            synchronized (lock1) {
                System.out.println("f2");
            }
        }
    }

    public static void main(String[] args) {
        DeadLock deadLock = new DeadLock();
        new Thread(() -> {
            try {
                deadLock.f1();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(() -> {
            try {
                deadLock.f2();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

2)死锁的条件
互斥使用,即资源只能独享,一个占了其他都必须等。
不可抢占,资源一旦被占,就只能等待占有者主动释放,其他线程抢不走。
贪婪占有,占着一把锁不释放,同时又需要申请另一把。
循环等待,即存在等待环路,A → B → C → A。
3)排查
jdk自带工具

  • jps + jstack pid
    通过jps找到线程号,再执行jstack pid,找到 Found one Java-level deadlock:xxx
  • jconsole
    执行jconsole,打开窗口,找到 线程 → 检测死锁
  • jvisualvm
    执行jvisualvm,打开窗口,双击线程pid,打开线程,会提示死锁,dump查看线程信息
    4)如何避免
  • 合理搭配锁顺序,如果必须获取多个锁,我们就要考虑不同线程获取锁的次序搭配
  • 少用synchronized,多用Lock.tryLock方法并配置超时时间
  • 对多线程保持谨慎。拿不准的场景宁可不用。线上一旦死锁往往正是高访问时间段。代价巨大

1.3 饥饿线程

1)概念:一个线程因为 CPU 时间全部被其他线程抢走而始终得不到 CPU 运行时间。

public class HungryThread {
    ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    void write() {
        readWriteLock.writeLock().lock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        readWriteLock.writeLock().unlock();
    }

    void read() {
        readWriteLock.readLock().lock();
        System.out.println("read");
        readWriteLock.readLock().unlock();
    }

    public static void main(String[] args) {
        HungryThread hungryThread = new HungryThread();
        Thread t1 = new Thread(() -> {
            //不停去拿写锁,拿到后sleep一段时间,释放
            while (true) {
                hungryThread.write();
            }
        });
        Thread t2 = new Thread(() -> {
            //不停去拿读锁,虽然是读锁,但是...看下面!
            while (true) {
                hungryThread.read();
            }
        });
        t1.setPriority(9);
        //优先级低!
        t2.setPriority(1);
        t1.start();
        t2.start();
    }
}

结果分析:
read几乎不会出现,甚至一直都拿不到锁。处于饥饿状态
StampedLock的乐观读锁

public class StampedThread {
    StampedLock lock = new StampedLock();

    void write() {
        long stamp = lock.writeLock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        lock.unlock(stamp);
    }

    void read() {
        //乐观读
        long stamp = lock.tryOptimisticRead();
        //判断是否有写在进行,没占用的话,得到执行,打印read
        if (lock.validate(stamp)) {
            System.out.println("read");
        }
    }

    public static void main(String[] args) {
        StampedThread stampedThread = new StampedThread();
        Thread t1 = new Thread(() -> {
            while (true) {
                stampedThread.write();
            }
        });
        Thread t2 = new Thread(() -> {
            while (true) {
                stampedThread.read();
            }
        });
        t1.setPriority(9);
        t2.setPriority(1);
        t1.start();
        t2.start();
    }
}

结果分析:
read间隔性打出,提升了读操作的并发性
注意,StampedLock的使用有局限性!

  • 对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock
  • StampedLock 在命名上并没有 Reentrant,StampedLock 是不可重入的!
  • StampedLock 的悲观读锁、写锁都不支持条件变量

2) 饥饿线程产生原因

  • 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
  • 锁始终被别的线程抢占。

3) 解决饥饿问题的方案

  • 保证资源充足
  • 避免持有锁的线程长时间执行,设置一定的退出机制
  • 在高风险地方使用公平锁

2 解决方案

2.1 超时订单

2.1.1 设计方案

1)定时扫表:写定时任务轮询扫订单表,挨个比对时间,超时的更新掉

  • 数据量小时,一般万级以内可以。几万到上亿的数据,显然不可取。
  • 当前项目多处于分库分表模式,扫描需要扫多个表甚至跨库
    2)延迟消费:在下订单时,同时投放一个队列用于延迟操作,常见队列有三种
  • DelayQueue,简单,不借助任何外部中间件,可借助db事务,down机丢失,同时注意内存占用
  • 消息队列,设置延迟并监听死信队列,注意消息堆积产生的监控报警
  • redis过期回调,redis对内存空间的占用

具体采取哪种延迟手段,根据企业实际情况,临时性的场合(比如某个抢购活动),可以采用方案一DelayQueue,系统化的订单取消,比如电商系统默认30分钟不支付取消规则,2号方案消息队列居多。
本次实现采用DelayQueue

2.1.2 实现

  • 定义delay的对象,实现Delay接口
public class OrderDto implements Delayed {

    private int id;

    private long invalid;

    public OrderDto(int id, long delayTime) {
        this.id = id;
        this.invalid = delayTime * 1000 + System.currentTimeMillis();
    }

    public int getId() {
        return id;
    }

    public long getInvalid() {
        return invalid;
    }

    //倒计时,降到0时队列会吐出该任务
    @Override
    public long getDelay(TimeUnit unit) {
        return invalid - System.currentTimeMillis();
    }

    @Override
    public int compareTo(Delayed o) {
        OrderDto o1 = (OrderDto) o;
        return invalid - o1.getInvalid() > 0 ? 1:-1;
    }
}
  • 定义监控类,启动守护进程,如果有超时任务,提交进线程池
@Component
public class OrderMonitor {

    @Autowired
    private OrdersMapper ordersMapper;

    //延时队列
    final DelayQueue<OrderDto> delayQueue = new DelayQueue<>();
    //任务池
    ExecutorService service = Executors.newFixedThreadPool(3);
    //投放延迟订单
    public void put(OrderDto orderDto) {
        delayQueue.put(orderDto);
    }

    // 在构造方法中添加守护线程
    public OrderMonitor() {
        new Thread(()->{
            // 一直从delayQueue中获取超时的订单
            while (true) {
                try {
                    OrderDto dto = delayQueue.take();
                    // 放入线程池执行订单取消操作
                    service.execute(new Task(dto));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
    }

    // 执行取消超时订单
    class Task implements Runnable {

        private OrderDto orderDto;

        public Task(OrderDto orderDto) {
            this.orderDto = orderDto;
        }
        @Override
        public void run() {
            Orders orders = new Orders();
            orders.setId(orderDto.getId());
            orders.setUpdatetime(new Date());
            orders.setStatus(-1);
            ordersMapper.updateByPrimaryKeySelective(orders);
        }
    }
}
  • 在add订单业务中,同时扔一份到queue,注意事务性
@PostMapping("/add")
    @Transactional
    @ApiOperation("下订单")
    public int add(@RequestParam String name){
        Orders order = new Orders();
        order.setName(name);
        order.setCreatetime(new Date());
        order.setUpdatetime(new Date());
        //超时时间,取10-20之间的随机数(秒)
        order.setInvalid(new Random().nextInt(10)+10);
        order.setStatus(0);
        mapper.insert(order);
        //事务性验证
//        int i = 1/0;
        monitor.put(new OrderDto(order.getId(),order.getInvalid()));
        return order.getId();
    }

2.2 加/减库存

2.2.1 设计方案

1)rabbitmq异步排队:使用rabbitmq先排队,请求到来时直接入队,界面显示排队中,消费端逐个消费,同时扣减库存,界面轮询查询结果。可能会出现排队半天抢完的情况。
2)库存预热:使用缓存或内存变量,活动开始前从db中提取库存值初始化,请求到来时直接扣减,及时提醒。可能出现一种感觉,活动刚开始就抢没了……
实际企业秒杀场景下,方案1居多,为学习多线程,本次采用2

2.2.2 实现

  • 初始化库存缓存
@RestController
@RequestMapping("/promotion")
@Api(value = "多线程库存demo")
public class PromotionController {

    @Autowired
    ProductMapper productMapper;
    @Autowired
    RabbitTemplate template;

    //思考:不用ConcurrentHashMap可以吗?
    Map<Integer,AtomicInteger> products = new HashMap();

    //热加载数据
    @GetMapping("/load")
    @ApiOperation(value = "热加载库存")
    //bean初始化后,立刻热加载
    @PostConstruct
    public Map load(){
        products.clear();
        List<Product> list = productMapper.selectByExample(null);
        list.forEach(product -> {
            products.put(product.getId(),new AtomicInteger(product.getNum()));
        });
        return products;
    }
}
  • 抢购代码,开启10个线程,不停去抢,减库存,如果抢到,异步刷库。
    //抢购
    @GetMapping("/go")
    @ApiOperation(value = "抢购")
    public void go(int productId){

        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                // 每个用户抢到的数量
                int count = 0;
                long useId = Thread.currentThread().getId();
                while (products.get(productId).getAndDecrement()>0) {
                    count++;
                    template.convertAndSend("promotion.order",productId+","+useId);
                }
                System.out.println(Thread.currentThread().getName()+"抢到:"+count);
            }).start();
        }
    }
  • 处理队列中的消息
@Component
@RabbitListener(queues = "promotion.order")
public class PromotionMonitor {
    @Autowired
    private ProductMapper productMapper;
    @Autowired
    private FlashorderMapper flashorderMapper;

    @RabbitHandler
    @Transactional
    public void receive(String msg) {
        if (msg != null && msg.length()!=0){
            System.out.println("get it! "+msg);
            String[] ids = msg.split(",");
            Flashorder flashorder = new Flashorder();
            flashorder.setProductid(Integer.valueOf(ids[0]));
            flashorder.setUserid(Integer.valueOf(ids[1]));
            //加入抢购订单表
            flashorderMapper.insert(flashorder);
            //db减库存
            productMapper.decr(Integer.valueOf(ids[0]));
        }
    }
}
  • 前端线程立刻抢购得到结果,给出每个线程抢到的商品数
  • 后面异步处理缓慢得到结果,操作db

2.3 价格排序

2.3.1 设计方案

1)直接数据库sort,这种最典型
2)redis缓存zset获取,在商品列表缓存,web网站排序场景中常见
3)内存排序,有时候,需要复杂的运算和比较逻辑,sql sort操作表达不出来时,必须进入内存运算
本次使用方案3,规则模拟按价格排序

2.3.2 实现

1)针对内存排序,首先想到的是实现Comparable接口,在多线程知识背景下,可以运用所学的ForkJoin实现归并排序。
2)ForkJoinTask,任务实现算法

public class SortTask extends RecursiveTask<List<Product>> {
    private List<Product> list;
    public SortTask(List<Product> list){
        this.list = list;
    }

    @Override
    //分拆与合并
    protected List<Product> compute() {
        if (list.size() > 2){
            //如果拆分的长度大于2,继续拆
            int middle = list.size() / 2 ;
            //拆成两个
            List<Product> left = list.subList(0,middle);
            List<Product> right = list.subList(middle+1,list.size());
            //子任务fork出来
            SortTask task1 = new SortTask(left);
            task1.fork();
            SortTask task2 = new SortTask(right);
            task2.fork();
            //join并返回
            return mergeList(task1.join(),task2.join());
        }else if (list.size() == 2 && list.get(0).getPrice() > list.get(1).getPrice()){
            //如果长度达到2个了,但是顺序不对,交换一下
            //其他如果2个且有序,或者1个元素的情况,不需要管他
            Product p = list.get(0);
            list.set(0,list.get(1));
            list.set(1,p);
        }
        //交换后的返回,这个list已经是每个拆分任务里的有序值了
        return list;
    }

    //归并排序的合并操作,目的是将两个有序的子list合并成一个整体有序的集合
    //遍历两个子list,依次取值,两边比较,从小到大放入新list
    //注意,left和right是两个有序的list,已经从小到大排好序了
    private List<Product> mergeList(List<Product> left,List<Product> right){
        if (left == null || right == null) return null;

        //合并后的list
        List<Product> total = new ArrayList<>(left.size()+right.size());
        //list1的下标
        int index1 = 0;
        //list2的下标
        int index2 = 0;
        //逐个放入total,所以需要遍历两个size的次数之和
        for (int i = 0; i < left.size()+right.size(); i++) {
            //如果list1的下标达到最大,说明list1已经都全部放入total
            if (index1 == left.size()){
                //那就从list2挨个取值,不需要比较直接放入total
                total.add(i,right.get(index2++));
                continue;
            }else if (index2 == right.size()){
                //如果list2已经全部放入,那规律一样,取list1
                total.add(i,left.get(index1++));
                continue;
            }

            //到这里说明,1和2中还都有元素,那就需要比较,把小的放入total
            //list1当前商品的价格
            Float p1 = left.get(index1).getPrice();
            //list2当前商品的价格
            Float p2 = right.get(index2).getPrice();

            Product min = null;
            //取里面价格小的,取完后,将它的下标增加
            if (p1 <= p2){
                min = left.get(index1++);
            }else{
                min = right.get(index2++);
            }
            //放入total
            total.add(min);
        }
        //这样处理后,total就变为两个子list的所有元素,并且从小到大排好序
        System.out.println(total);
        System.out.println("------------------");
        return total;
    }
}

3)调用过程

@RestController
@RequestMapping("/sort")
@Api(value = "多线程排序测试demo")
public class SortController {
    @Autowired
    ProductMapper mapper;

    @GetMapping("/list")
    List<Product> sort() throws ExecutionException, InterruptedException {
        //查商品列表
        List<Product> list = mapper.selectByExample(null);
        //线程池
        ForkJoinPool pool = new ForkJoinPool(2);
        //开始运算,拆分与合并
        Future<List<Product>> future = pool.submit(new SortTask(list));
        return future.get();
    }
}

你可能感兴趣的:(学习笔记系列)