高并发这样用SimpleDateFormat,快醒醒

大家好,今天我们一起聊聊Java开发必须知道的日期处理。

大纲

高并发这样用SimpleDateFormat,快醒醒

SimpleDateFormat处理日期

SimpleDateFormat是处理日期的常用工具类,在日常开发中,到处可见,以其简单易用、灵活方便、功能丰富,而被广泛使用。但SimpleDateFormat的使用,要看应用场景,在高并发的场景下使用,要非常留意谨慎,因为它线程不安全。

SimpleDateFormat线程不安全

看到SimpleDateFormat线程不安全,你可能会说,哥们我一直都是这么用SimpleDateFormat来处理日期的,也是在高并发的场景下使用的,怎么从来没有出现过线程不安全的报错?经过实践检验,它是线程安全的。

为什么会有这个错觉?那是因为,你的高并发场景,并非达到高并发的量,在高并发场景下,确实会存在线程不安全。

SimpleDateFormat线程不安全的原因

SimpleDateFormat派生自DateFormat,DateFormat维护了一个全局变量Calender:

/**
 * The {@link Calendar} instance used for calculating the date-time fields
 * and the instant of time. This field is used for both formatting and
 * parsing.
 *
 * <p>Subclasses should initialize this field to a {@link Calendar}
 * appropriate for the {@link Locale} associated with this
 * <code>DateFormat</code>.
 * @serial
 */

protected Calendar calendar;

从注释可以看出,calendar既用于日期格式化,也用于日期解析。我们知道,全局变量若只读不变化,则该全局变量不会存在线程安全问题;当全局变量的值可能发生变化,而又会被读取使用时,就存在线程安全问题了。那么是否线程安全,取决于使用过程中,是否存在修改的可能。

@Override
public Date parse(String text, ParsePosition pos)
{
//为简化代码,此处省调若干行不想管的代码,仅保留部分关键代码
    CalendarBuilder calb = new CalendarBuilder();

    Date parsedDate;
    try {
        parsedDate = calb.establish(calendar).getTime();
    }
    // An IllegalArgumentException will be thrown by Calendar.getTime()
    // if any fields are out of range, e.g., MONTH == 17.
    catch (IllegalArgumentException e) {
        pos.errorIndex = start;
        pos.index = oldStart;
        return null;
    }
    return parsedDate;
}

以上代码,不存在对全局变量calender的修改操作,那么我们看看calb调用的方法establish(calender)。

Calendar establish(Calendar cal) {
    boolean weekDate = isSet(WEEK_YEAR)
                        && field[WEEK_YEAR] > field[YEAR];
    if (weekDate && !cal.isWeekDateSupported()) {
        // Use YEAR instead
        if (!isSet(YEAR)) {
            set(YEAR, field[MAX_FIELD + WEEK_YEAR]);
        }
        weekDate = false;
    }

    cal.clear();
    // Set the fields from the min stamp to the max stamp so that
    // the field resolution works in the Calendar.
    for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
        for (int index = 0; index <= maxFieldIndex; index++) {
            if (field[index] == stamp) {
                cal.set(index, field[MAX_FIELD + index]);
                break;
            }
        }
    }

    if (weekDate) {
        int weekOfYear = isSet(WEEK_OF_YEAR) ? field[MAX_FIELD + WEEK_OF_YEAR] : 1;
        int dayOfWeek = isSet(DAY_OF_WEEK) ?
                            field[MAX_FIELD + DAY_OF_WEEK] : cal.getFirstDayOfWeek();
        if (!isValidDayOfWeek(dayOfWeek) && cal.isLenient()) {
            if (dayOfWeek >= 8) {
                dayOfWeek--;
                weekOfYear += dayOfWeek / 7;
                dayOfWeek = (dayOfWeek % 7) + 1;
            } else {
                while (dayOfWeek <= 0) {
                    dayOfWeek += 7;
                    weekOfYear--;
                }
            }
            dayOfWeek = toCalendarDayOfWeek(dayOfWeek);
        }
        cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
    }
    return cal;
}

细心的你,是否已经看到了,该方法中,存在对cal的变量的修改操作,cal.clear()和cal.setWeekDate(),也就是先清楚cal的值,再重新设置该值。由于Calender内部没有安全处理机制,且这两步操作,也没有进行加锁等特殊处理等,所以在多线程操作同一个对象时,会存在Calendar对象值存在混乱的情况,即会出现线程不安全。

SimpleDateFormat线程不安全验证

话不多说,代码撸起

private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
private static final int thdNum = 50;
private static final int executeCount = 10000;

@Test
public void testSimDateFormatNotSafe() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    sdf.parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

栗子中,使用50个线程,每个线程进行10000次操作。运行结果如下:

线程:pool-1-thread-8 格式化日期失败
线程:pool-1-thread-16 格式化日期失败
java.lang.NumberFormatException: multiple points
 at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
 at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
 at java.lang.Double.parseDouble(Double.java:538)
 at java.text.DigitList.getDouble(DigitList.java:169)
 at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
 at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
 at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
 at java.text.DateFormat.parse(DateFormat.java:364)
 at com.flycloud.test.datetime.SimpleDateFormatTest.lambda$testSimDateFormatNotSafe$0(SimpleDateFormatTest.java:38)
 at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
 at java.util.concurrent.FutureTask.run(FutureTask.java:266)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
 at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: empty String
 at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
 at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
 at java.lang.Double.parseDouble(Double.java:538)
 at java.text.DigitList.getDouble(DigitList.java:169)
 at java.text.DecimalFormat.parse(DecimalFormat.java:2056)
 at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
 at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
 at java.text.DateFormat.parse(DateFormat.java:364)
 at com.flycloud.test.datetime.SimpleDateFormatTest.lambda$testSimDateFormatNotSafe$0(SimpleDateFormatTest.java:38)
 at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
 at java.util.concurrent.FutureTask.run(FutureTask.java:266)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
 at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: For input string: ""
 at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
 at java.lang.Long.parseLong(Long.java:601)
 at java.lang.Long.parseLong(Long.java:631)
 at java.text.DigitList.getLong(DigitList.java:195)
 at java.text.DecimalFormat.parse(DecimalFormat.java:2051)
 at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
 at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
 at java.text.DateFormat.parse(DateFormat.java:364)
 at com.flycloud.test.datetime.SimpleDateFormatTest.lambda$testSimDateFormatNotSafe$0(SimpleDateFormatTest.java:38)
 at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
 at java.util.concurrent.FutureTask.run(FutureTask.java:266)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
 at java.lang.Thread.run(Thread.java:745)

进程已结束,退出代码 -1

以上栗子可以看出,SimpleDateFormat在多线程处理时,出现了异常报错,确实存在线程不安全的现象。

如何让SimpleDateFormat线程安全

有多种方式可以让SimpleDateFormat线程安全,我们分别举例说明。

使用局部变量

局部变量,方法内使用,当然是线程安全的。代码撸起:

@Test
public void testSimDateFormatByLocalParam() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
                    sdf.parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

结果如下:

所有线程格式化日期成功
进程已结束,退出代码 0

使用此方式,可以解决SimplDateFormat的线程安全问题,但又带来另一个问题,频繁的创建SimpleDateFormat对象,会增加gc的频次,高并发场景下,我们也需要考虑最大限度的降低gc的次数。不推荐使用。

Sync同步锁

既然全局变量在修改的情况下,存在线程不安全,那么我们是否可以对其进行加锁操作,确保在使用的时候只有一个线程修改?继续撸代码:

@Test
public void testSimDateFormatBySync() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    synchronized (sdf) {
                        sdf.parse("2024-01-01");
                    }
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

在调用sdf.parse()时进行synchronized加锁操作,这是多线程处理时,解决线程安全的最基本的操作,看结果:

所有线程格式化日期成功

进程已结束,退出代码 0

可以从结果看出,经过synchronized加锁后,得到了预期的结果:线程安全。但把本来可以多线程并发处理的并行操作,强制变成了串行操作,这对系统的吞吐量是有损害的,同时,也不符合高并发处理的基本原则。不推荐使用。

Lock同步锁

同Synchronize类似,采用另一种加锁的方式。代码如下:

@Test
public void testSimDateFormatByLock() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    Lock lock = new ReentrantLock();
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    lock.lock();
                    sdf.parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }finally{
                    lock.unlock();
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

lock加锁的写法与Synchronize有所不同,但效果是一样的,也存在高并发由并行强制为串行的操作。不推荐。

所有线程格式化日期成功

进程已结束,退出代码 0

线程独占SimpleDateFormat对象

既然使用加锁的方式,不管是Synchronize或者lock方式,均违法高并发处理的初衷,那是否可以换一种思路,即把SimpleDateFormat的对象,在线程中生成对象,使用时,线程间使用不同的SimpleDateFormat对象,那么单个线程修改Calendar对象,不会影响其它线程的SimpleDateFormat对象。嗯,这个思路是正确的。代码如下:

private ThreadLocal<SimpleDateFormat> thDefault = new ThreadLocal<SimpleDateFormat>() {
    @Override
    protected SimpleDateFormat initialValue() {
        return new SimpleDateFormat("yyyy-MM-dd");
    }
};

@Test
public void testSimDateFormatByThreadLocalDefault() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    thDefault.get().parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

ThreadLocal对象thDefault,按线程来创建SimpleDateFormat对象,运行结果如下:

所有线程格式化日期成功

进程已结束,退出代码 0

这种方式是线程安全的,每个线程使用自己的SimpleDateFormat对象,线程之间互不影响,同时也不会产生大量的SimpleDateFormat对象,触发频发的gc。高并发场景下推荐使用。

线程独占SimpleDateFormat对象方式二

与线程独占SimpleDateFormat对象的方式类似,在写法上有所不同,可根据实际情况选择使用。代码如下:

private ThreadLocal<SimpleDateFormat> th = new ThreadLocal<>();
private SimpleDateFormat getSimpleDateFormat(){
    SimpleDateFormat sdf = th.get();
    if(sdf==null){
        sdf = new SimpleDateFormat("yyyy-MM-dd");
        th.set(sdf);
    }
    return sdf;
}

@Test
public void testSimDateFormatByThreadLocal() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    getSimpleDateFormat().parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

使用ThreadLocal线程对象th,通过getSimpleDateFormat()方法获取SimpleDateFormat对象,当本地线程对象中存在SimpleDateFormat时则直接使用,当不存在时,先创建SimpleDateFormat对象,放到本地线程变量中保存,然后再使用。推荐使用。运行结果如下:

所有线程格式化日期成功

进程已结束,退出代码 0

DateTimeFormatter处理日期

DateTimeFormatter是Java 8中引入的一个用于日期和时间格式化的实用类。它提供了一种简便的方法来格式化日期和时间,同时还可以将日期和时间字符串解析为Java对象。DateTimeFormatter提供了预定义的格式化模式,允许开发者根据需要自定义日期和时间的格式,并以字符串的形式输出。

DateTimeFormatter优点

线程安全

与早期的SimpleDateFormat类不同,DateTimeFormatter是线程安全的。这意味着在多线程应用程序中,可以无需担心同步问题而安全地使用DateTimeFormatter。因此,无需为每个线程创建独立的DateTimeFormatter实例,或者将其放入ThreadLocal中,简化了多线程环境下的日期和时间处理。

不可变性

DateTimeFormatter对象一旦创建,其状态是不可修改的。这种不可变性确保了DateTimeFormatter对象在多次使用时的稳定性和一致性,进一步增强了线程安全性。

支持自定义格式

DateTimeFormatter不仅提供了预定义的日期和时间格式,还支持自定义格式。开发者可以根据需要指定日期和时间的格式模式,以满足特定的显示要求。这使得DateTimeFormatter非常灵活,能够适应各种日期和时间格式化的场景。

本地化支持

DateTimeFormatter可以根据不同的语言和国家的习惯来格式化日期和时间。它支持多语言环境,能够自动根据当前的语言环境选择适当的日期和时间格式,使得应用程序更具国际化特性。

扩展性

DateTimeFormatter还提供了DateTimeFormatterBuilder类,用于创建自定义的日期时间格式化器。这使得开发者能够根据自己的需求,构建复杂的日期和时间格式化规则,实现更高级的格式化功能。

DateTimeFormatter使用

栗子

import java.time.format.DateTimeFormatter;

private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");

@Test
public void testDateTimeFormatter() throws InterruptedException{
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    dtf.parse("2024-01-01");
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

定义DateTimeFormatter对象为全局唯一对象,使用方式与常规SimpleDateFormat类似。强烈推荐使用。

结果

所有线程格式化日期成功

进程已结束,退出代码 0

JotTime处理日期

JotTime是一款功能强大的时间管理应用,旨在帮助用户更有效地管理时间和任务。这款应用结合了简洁直观的界面和多种实用的功能,使用户能够轻松地跟踪、组织和优化他们的日程安排。

JotTime优点

直观易用的界面

JotTime的界面设计简洁直观,使用户能够快速上手并熟悉各项功能。无论是添加任务、设置提醒还是查看日程,操作都非常流畅便捷,无需花费大量时间学习。

强大的任务管理功能

JotTime提供了丰富的任务管理功能,包括任务创建、编辑、删除、优先级设置等。用户可以根据需要轻松组织任务,设置截止日期和提醒,确保工作有条不紊地进行。

精准的时间跟踪

JotTime能够精确地跟踪每项任务的耗时情况,帮助用户了解自己的工作效率和习惯。通过收集和分析时间数据,用户可以找出自己的时间管理瓶颈,并进行相应的优化。

灵活的提醒与通知

JotTime支持多种提醒方式和自定义提醒时间,确保用户不会错过任何重要任务。无论是弹窗提醒、震动提醒还是声音提醒,用户都可以根据自己的需求进行设置,提高任务的按时完成率。

强大的同步与协作能力

JotTime支持与多种日历应用同步,使得用户可以在不同平台之间无缝协作。同时,JotTime还支持共享任务和日历,方便团队成员之间的沟通和协作,提高团队效率。

个性化定制与扩展性

JotTime允许用户根据个人喜好定制界面和提醒方式,满足不同用户的需求。此外,JotTime还提供了丰富的扩展功能,如报告与统计、数据导入导出等,进一步提升了其应用价值。

JotTime使用

Maven引入

<dependency>
 <groupId>joda-time</groupId>
 <artifactId>joda-time</artifactId>
 <version>2.9.9</version>
</dependency>

栗子

import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.DateTime;

private static final DateTimeFormatter dtf = DateTimeFormat.forPattern("yyyy-MM-dd");

@Test
public void testJotTime() throws InterruptedException {
    final Semaphore semaphore = new Semaphore(thdNum);
    final CountDownLatch countDownLatch = new CountDownLatch(executeCount);
    ExecutorService executor = Executors.newCachedThreadPool();
    for(int i=0; i< executeCount; i++) {
        executor.submit(() -> {
            try {
                semaphore.acquire();
                try {
                    DateTime.parse("2024-01-01", dtf).toDate();
                } catch (Exception e) {
                    System.out.println("线程:" + Thread.currentThread().getName() + " 格式化日期失败");
                    e.printStackTrace();
                    System.exit(-1);
                }
                semaphore.release();
            } catch (InterruptedException e) {
                System.out.println("信号量发生錯誤");
                e.printStackTrace();
            }
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executor.shutdown();
    System.out.println("所有线程格式化日期成功");
}

JotTime的使用与SimpleDateFormat有多不同,需要声明DateTimeFormatter对象,通过DateTimeFormat.forPattern()实例化,通过DateTime.parse(“日期”, dtf).toDate()调用。

结果

所有线程格式化日期成功

进程已结束,退出代码 0

总结

SimpleDateFormat是常用的日期处理工具,在日常开发中经常会用到,但SimpleDateFormat为线程不安全的,在高并发场景下,如果一定要使用SimpleDateFormat,需考虑线程安全问题,同时需最大限度的减少SimpleDateFormat对象的频发创建,减少gc的负担,可以通过在每个线程中保存SimpleDateFormat对象变量的方式使用。日期处理工具除了SimpleDateFormat外,还有DateTimeFormatter和JotTime,这两个日期处理工具均为线程安全的,同时具有高性能等优势,推荐在高并发场景中使用。


原文始发于微信公众号(扬哥手记):高并发这样用SimpleDateFormat,快醒醒

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/261597.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!