Java8实战-笔录
Stream流特点
stream API优势在于语句读起来更像是问题的陈述
- 只计算,不存储
- 不改变源数据
- 具有延迟计算、短路(findAny,findOne可提前终止遍历)
- stream流是一次性消耗品,这里的消耗是指能被终止操作消耗一次。但针对多次中间操作是建立一个流水线,并不是消耗
相比于增强for方式,这种采用的是内部迭代stream流是按需计算(乐观),而集合遍历则是要等待所有的数据计算完成后才能开始操作(悲观)
使用stream流不一定有中间操作,但必须要有终止操作才会执行,而且带终止操作下的数据只会遍历n次,n次取决于源数据的个数(中间操作会返回一个新的stream流对象,终止操作则会返回非stream流对象)
中间操作可以理解为构造处理数据的操作链,等待遇到终端操作时才会执行
下面整个操作一共只遍历5次,而不是对集合数据分别filter与map各5次,共10次
List<Integer> collect = Stream.of(1, 2, 3, 4, 5)
.filter(t -> {
System.out.println("Filter: " + t);
return t > 3;
})
.map(t -> {
System.out.println("Map: " + t);
return t + 1;
})
.collect(Collectors.toList());
Collection 与 Stream区别
Collection 主要是为了存储和访问数据,Stream则主要用于描述对数据的计算
通常的操作是将Collection进行存储,然后使用Straem进行计算,再转换为Collection对象/值
Stream API
使用的是内部迭代方式,显式;
主要用来数据计算
Collection API
使用的是外部迭代方式,隐式;
主要是用来存储和访问数据
常用操作
flatMap扁平化
获取一个集合内的集合元素,使用扁平化处理flagMap,flatMap(Collection::stream)。其一次性只能获取一个集合中的内集合元素,有多层嵌套需要使用多个flatMap
针对简单类型,如List<List
map、flatMap:转换/提取元素
filter:过滤符合条件的数据
peek:遍历
findFirst、findAny:查找
anyMatch、allMatch,noneMatch:匹配
collector收集器常用方法:类型转换(如:map/list)、分组聚合(最小/大,平均数,计数,求和 )、连接字符串
其中toMap = 1:1,而partitioningBy、groupBy = 1:n
异步处理
CompletableFuture与parallelStream并行流默认情况下都使用的ForkJoinPool线程池,但是CompletableFuture允许你自定义线程池适配你现有的数据处理
注意CompletableFuture默认使用的ForkJoinPool线程池:
-
所有的CompletableFuture会共用一个ForkJoinPool线程池,这是因为ForkJoinPool是静态代码块初始化的。所以不同的业务应该使用不同的线程池进行处理。
业务中应避免共用一个线程池时,如果存在IO阻塞时间长的线程,导致线程饥饿拖垮影响系统性能
-
CompletableFuture只有在双核以上的机器内才会使用ForkJoinPool,而且初始化线程数大小是cpu核数-1。在双核及以下的机器中,会为每个任务创建一个新线程,这种等于没有使用线程池,且有资源耗尽的风险。
-
在双核CPU中ForkJoinPool初始化线程数大小是cpu核数-1,适合处理CPU密集型任务
-
并行流中默认使用ForkJoinPool,适合处理cpu密集型任务,但实际情况要看压测的情况
方法:java.util.concurrent.ForkJoinPool#makeCommonPool
由"java.util.concurrent.ForkJoinPool.common.parallelism"指定,Runtime.getRuntime().availableProcessors() - 1 即cpu核数-1
map/flatMap映射
将元素转换成其他形式或提取信息
方法引用
为什么要有方法引用,什么时候适合使用?
- 是lambda是语法糖。引用的是现有实现的方法
1. 类名#静态方法
lambda表达式的参数列表与类中静态方法的参数列表/返回值 【类型相同】
Integer reduce2 = numList.stream().reduce(0, (x, y) -> y + x);
Integer reduce2 = numList.stream().reduce(0, Integer::sum);
Integer::sum 内部实现方法如下
public static int sum(int a, int b) {
return a + b;
}
2. 类名#实例方法:
lambda表达式中第一个参数为方法的调用者,第二个参数为方法中的参数(如果有的话)
int sum = numList.stream().mapToInt(Integer::intValue).sum();
int sum = numList.stream().mapToInt(t->t.intValue()).sum();
3. this::实例方法:什么时候可以使用待研究
怎么使用?
前提:Lambda 表达式与函数式接口数据类型要保证兼容
- 类名::静态方法
- 类名::成员方法
- 对象::成员方法
lambda,函数式接口、stream流
lambda表达式/方法引用作为java在函数式接口的实现,而函数式接口的实践则是steam流
改善代码的可读性
- 重构代码,用 Lambda 表达式取代匿名类;
- 用方法引用重构 Lambda 表达式;
- 用 Stream API 重构命令式的数据处理。
重要内容摘要
-
函数式编程的重要思想:
方法和 Lambda 作为一等值,即让函数(方法,lambda)和参数值一样作为参数或返回值进行传递
-
lambda表达式
作用
lambda表达式是函数式接口的一个实例,该实例是JVM在程序运行时动态生成的,可以用在任何是函数式接口的地方。类似于匿名内部类实现的方式,但lambda使用起来更简洁
Lambda 表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例(Lambda表达式是函数式接口一个具体实现的实例对象
特点
-
不属于任何类,可在任意位置使用:
只要变量(不区分是局部变量还是成员变量)是函数式接口类型,就可以使用lambda表达式 / 方法引用
作用在方法参数,返回值,成员变量,局部变量。可以像使用普通的变量一样随意使用
-
无方法名称
-
简洁明了
术语“函数式编程”意指函数或者方法的行为应该像“数学函数”一样没有任何副作用。对于使用函数式语言的程序员而言,这个术语的范畴更加宽泛,它还意味着函数可以像任何其他值一样随意使用:可以作为参数传递,可以作为返回值,还能存储在数据结构中。
能够像普通变量一样使用的函数称为一等函数(first-class function)。这是Java 8补充的全新内容:通过::操作符,你可以创建一个方法引用,像使用函数值一样使用方法,也能使用Lambda表达式(比如,(int x) -> x + 1)直接表示方法的值。
Java 8中使用下面这样的方法引用将一个方法引用保存到一个变量是合理合法的:
Function<String, Integer> strToInt = Integer::parseInt;
-
-
lambda表达式只能捕获外部局部变量(仅仅是局部变量,不包括静态变量或成员变量)一次,不能对其进行修改。
也就是说只能使用显式/隐式声明为finnal的局部变量。原因有两点:
- 一是因为局部变量仅属于当前线程的,如果捕获允许可改变的局部变量,就可能在使用多线程处理拿到局部变量的值时导致不一致问题
- 二是因为函数式编程不推荐对外部局部变量的修改,并行处理的时候会受阻
public static void main(String[] args) { String a = "100"; Function<String, String> function3 = t -> { // X 此处不可用 return t + a; }; String apply = function3.apply(a); // 对局部变量重新赋值了又 a = "333"; } -
编程语言就像生态系统一样,新的语言会出现,旧语言则被取代,除非它们不断演变。程序员也是一样,没有自学能力的人没有未来
-
软件工程中一个众所周知的问题就是,不管你做什么,用户的需求肯定会变。
-
好的代码应该是一目了然的
-
对数值类型进行求和、最小值、最大值、平均值时推荐使用的做法是:
使用mapToInt,mapToLong,mapToDouble转为数值流,而不是collect(Collectors.summingInt(Integer::intValue)) 或者reduce规约式计算
原因是:后者有装箱,拆箱性能损耗,前者直接使用基本类型处理
注:
对于计算我们有多种方式,应该选择思考其中最适合当下且性能更好的方式,所以选择mapToXXX方式
为避免拆装箱,提供了IntPredicate、IntToLongFunction等函数式接口
-
如有将数值流转为对象流可使用boxed()方法
// 数值流 LongStream longStream = numList.stream().mapToLong(Long::valueOf); // 原始流 Stream<Long> boxed = numList.stream().mapToLong(Long::valueOf).boxed(); -
考虑到集合元素个数可能为0的场景,stream操作结果会返回Optional对象,比如方法findAny,findOne,reduce
-
分区(partitioningBy)是分组(groupingBy)的特殊情况
Map<Boolean, List<Integer>> collect2 = Stream.of(1, 2, 3).collect(Collectors.groupingBy(t -> { if (t > 2) { return true; } else {s return false; } }, Collectors.toList())); Map<Boolean, List<Integer>> collect3 = Stream.of(1, 2, 3).collect(Collectors.partitioningBy(t -> t > 2)); -
虽然并行处理一个流很容易,但是不能保证程序在所有情况下都运行得更快。并行软件的行为和性能有时是违反直觉的,因此一定要测试,确保你并没有把程序拖得更慢。
-
putIfAbsent(key , value)
computeIfAbsent(key,BiFunction)
使用场景:缓存
putIfAbsent:键为空 或 键存在,值为空,则设置键值对。返回是上一次旧值 computeIfAbsent:键为空 或 键存在值为空,则执行计算,设置键值对。返回是新值案例:
HashMap<String,String> map = new HashMap<String,String>(2){{ put("name",null); }}; // key=name不存在 或者 key=name键存在但value值为空,则将key,value设置进去 map.putIfAbsent("name", "张三"); // key=name不存在 或者 key=name键存在但value值为空,则执行函数,并将key,value设置进去 map.computeIfAbsent("name", key->{ System.out.println("key = " + key); return "张三3"; }); System.out.println("map = " + map); -
HashMap#computeIfPresent
键、及对应值都存在,则执行计算,并设置键值对。返回是新值HashMap<String,String> map = new HashMap<String,String>(2){{ put("name","张三"); }}; // key=name并且value不为空时,执行函数 map.computeIfPresent("name",(key,value)->{ System.out.println("key = " + key); System.out.println("value = " + value); return "张三1=10"; }); System.out.println("map = " + map); -
HashMap#compute(key,BiFunction)
不关心键值存不存在,直接计算,并设置key,value。返回是新值HashMap<String,String> map = new HashMap<String,String>(2){{ put("name","张三"); }}; map.compute("name", (key, val) -> { return key + 1; }); System.out.println("map = " + map); -
HashMap如何使用removeIf
使用entrySet(),转为set集合,再调用集合的removeIf方法HashMap<String,String> map = new HashMap<String,String>(2){{ put("name","张三"); }}; // 可根据key/value的值进行remove map.entrySet().removeIf(t->t.getKey().equals("name")); System.out.println("map = " + map); -
调试有问题的代码时,程序员的兵器库里有两大经典武器
查看栈跟踪 输出日志 -
我们编写程序首先是给人阅读的,机器只是偶尔执行一下 ---- Harold Abelson
-
开发最佳实践
代码表达应该是清晰,明确,无二意(修改和阅读也是符合二八原则) --方便程序员开发 代码应该是更容易变更(ETC) --适配需求变化,减少变更带来的风险 代码优先考虑异常场景(程序正常和异常也是符合二八原则) --异常场景远比于正常场景要多 验证优先假设 --编写的代码并不一定按预期运行,所以要自测验证 -
尽量避免向线程池提交可能阻塞(譬如睡眠,或者要等待某个事件)的任务,避免任务长时间等待,造成业务停滞
-
lambda表达式修改外部变量要保证是原子操作
使用单线程操作是不会出现这种问题。但是当使用多线性操作外部变量时,防止出现多个线程并发修改导致不一致的问题。所以统一要求lambda修改外部变量时要求是原子性操作
-
noneMatch()
没有任何元素与给定的谓词匹配 -
并行流 or CompletableFuture
无论是计算密集型还是IO密集型,都不建议使用并行流,原因是所有的并行流 和 cf默认都使用的同一个ForkJoinPool线程池实例(守护线程),线程是大小是cpu核数-1,如果都使用并行流会导致线程资源竞争激烈 而是推荐不同的业务使用不同的线程池隔离操作,只不过针对计算密集型任务的自定义线程池大小是CPU核数大小,IO密集型任务对应的线程池要适当大些 最后还要结合压测,线上实际情况进行调整 -
生成随机数
前闭后闭:IntStream.rangeClosed(0, 100) 前闭后开:IntStream.range(0, 100) -
Function中andThen()方法与compose()方法
andThen() 参数传递是从前往后,执行函数
compose() 参数传递是从后往前,执行函数Function<Integer, Integer> f = x -> x + 1; Function<Integer, Integer> g = x -> x * 2; Function<Integer, Integer> f1 = f.andThen(g); Function<Integer, Integer> f2 = f.compose(g); System.out.println("f1.apply(1) = " + f1.apply(1)); System.out.println("f2.apply(1) = " + f2.apply(1)); -
集合元素为0个,allMatch返回true?
@Test public void test54() { List<Integer> numList = new ArrayList<>(); boolean allMatch = numList.stream().allMatch(t -> t > 100); System.out.println("allMatch = " + allMatch); } 返回结果:allMatch = true原因:
allMatch()关注的是是否有违反条件的元素,当集合中没有元素时自然也并没有违反条件,所以返回的是true最佳实践:对使用allMatch,noneMatch方法,要留意先判断是否有元素
-
当List与Map中不为null但元素为空时,forEach与增强for内部逻辑执行?
结论:不会执行
- 原因是对于增强for来讲是因为底层本质上使用了迭代器,拆掉语法糖后会发现内部会有.hasNext()方法判断是否有元素,没有则不执行
public void test54() { List<Integer> numbers = new ArrayList(); Iterator var2 = numbers.iterator(); while(var2.hasNext()) { Integer number = (Integer)var2.next(); System.out.println("number = " + number); } }- Map来讲,底层也是使用.forEach遍历,原因是
size > 0要保证map容器中有元素,没有元素则会直接跳过
public void forEach(BiConsumer<? super K, ? super V> action) { Node<K,V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (int i = 0; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null; e = e.next) action.accept(e.key, e.value); } if (modCount != mc) throw new ConcurrentModificationException(); } }- List对于使用.forEach方法遍历,原因是
i < size 当没有元素时,则不会执行内部逻辑
public void forEach(Consumer<? super E> action) { Objects.requireNonNull(action); final int expectedModCount = modCount; @SuppressWarnings("unchecked") final E[] elementData = (E[]) this.elementData; final int size = this.size; for (int i=0; modCount == expectedModCount && i < size; i++) { action.accept(elementData[i]); } if (modCount != expectedModCount) { throw new ConcurrentModificationException(); } }
CF同步/异步回调原理与实践
- CompletableFuture 带async与不带async方法区别
同步回调
即没有async的方法,当前置任务执行完后,会由main线程 / 执行前置任务的线程顺带执行
问题1:为什么这样做呢?用意是什么?
减少线程上下文切换的开销,提高性能
问题2:那什么时候会由main线程执行?什么时候让前置任务执行完后顺带执行呢?有以下两种情况
- main线程在注册回调任务B时(执行thenXXX等方法时),如果依赖的A任务已完成,则回调任务B会交由main线程顺带执行。
// 前置任务A
CompletableFuture<Void> cf01 = CompletableFuture.runAsync(() -> {
String name = Thread.currentThread().getName();
System.out.println("runAsync = " + name);
});
// 回调任务
cf01.thenApply(t->{
String name = Thread.currentThread().getName();
System.out.println("thenApply = " + name);
return "ook";
}).get(1,TimeUnit.MINUTES);
- main线程在注册回调任务B时(执行thenXXX方法时),如果前置A任务未完成,则回调任务B会在执行任务A结束后,使用执行任务A的同一个线程被执行。
// 前置任务A
CompletableFuture<Void> cf01 = CompletableFuture.runAsync(() -> {
String name = Thread.currentThread().getName();
System.out.println("runAsync = " + name);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// 回调任务B
cf01.thenApply(t->{
String name = Thread.currentThread().getName();
System.out.println("thenApply = " + name);
return "ook";
}).get(1,TimeUnit.MINUTES);
同步回调整体逻辑:
在main线程执行thenApply逻辑中,发现前置任务A已经执行结束,会顺带让main线程执行回调任务B,但发现前置任务A还未结束,那么会将回调任务B入栈中。接着会再次进行二次判断前置任务A是否已执行完成,若前置任务A已执行完成,则继续让main线程执行回调任务B;否则终止当前操作,main线程至此结束
总之是:在注册回调函数时,main线程会查看前置任务是否完成,完成的话main线程就执行回调函数(其中main线程有double check的作用),未完成让任务入栈后main线程就不管了,回调任务让执行前置任务的线程执行完成后执行
使用场景:
同步回调不能有耗时长的任务,否则可能影响main线程/前置任务的执行,进而影响系统吞吐量
经实验:
同步回调跟上述描述一致,但在执行前置任务时,只设置简单计算,实际同步等待时候会发现时而使用main线程执行回调方法,时而使用执行前置任务的线程执行回调方法。原因是在注册回调函数时main线程是否已经很快执行结束后能顺带执行回调函数,否则就得让执行前置任务的线程执行任务
异步回调
即带async方法,是由指定的线程池中的线程去执行
具体的过程是:
当main方法注册观察着时,即执行thenXXXAsync方法时,会先注册观察者,然后判断前置任务是否已经执行结束,如果已结束那就使用指定线程池执行任务。
如果注册时前置任务没有执行完,那么就当前置任务执行完后再次使用线程池执行回调任务
// 前置任务A
CompletableFuture<Void> cf01 = CompletableFuture.runAsync(() -> {
System.out.println("aaaaa = " + Thread.currentThread().getName());
}, executor);
// 回调任务B
cf01.thenRunAsync(() -> {
System.out.println("bbbbbbb" + Thread.currentThread().getName());
});
try {
TimeUnit.MINUTES.sleep(5);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
原理总结:
同步回调(不带async方法)
1. 注册回调函数(即执行.thenXXX方法)时,会判断前置任务是否完成,如果前置任务已执行完成就让main线程执行回调函数,否则将回调函数添加到栈中,接着会再次判断前置任务是否完成,如果已完成则让main线程执行回调函数,未完成的话就终止步骤1。 2. 上述步骤1结束后,前置任务的线程就开始起作用了,执行前置任务的线程完成当前任务后,会顺带继续执行被压入栈中回调任务 总之结论是: main线程尽最大努力执行回调任务,不能处理就让执行前置任务的线程执行结束后顺带执行回调函数异步回调(带async方法)
当前置任务执行完成后,让指定的线程池负责执行回调函数
同步/异步回调异同点:
1. 同步回调是优先让main线程/执行前置任务的线程执行回调任务,异步回调使用的是指定线程池执行任务。
2. 依赖关系:两者的回调函数都是必须在前置任务执行结束后才能被执行的
3. 同步回调之所以让main/执行前置任务线程执行回调函数,这样能减少线程上下文的开销
异常处理相关问题:
- 异常处理exceptionally()与handle() 在什么位置处理比较合适呢?
在最后cf方法处理。
执行多个cf时,如果中间有执行异常的cf,会一直传递到最后,而且中间的回调任务都会被跳过,不会被执行
当很多的调用链中比如A->thenRunB->thenRunC->thenRunD->exceptionally中,A一旦出现异常会将异常一直传递给exceptionally,B,C,D函数是不会执行的
原因是因为前置任务执行有异常,会将结果赋值给result,在执行thenRun中发现result不是null,就会把该异常结果赋值给回调任务,然后以此类推,A->B->C->D最后到exceptionally中
- 使用同步/异步两种回调方式都在最后的cf中能处理异常吗?
不区分同步回调还是异步回调,都会遇到异常优先处理传递
最佳实践
1. 同步回调:使用main线程/执行前置任务的线程执行回调函数。异步回调:使用指定线程池中线程执行任务
2. 同步回调使用的场景:callback执行轻量、少量计算的任务、避免阻塞、耗时长的任务执行。如有存在阻塞、耗时长的任务则使用异步回调方式
3. 当使用函数回调方式处理任务,只是承诺了完成,但不保证完成的时间,所以要设置超时时间。
4. 同步/异步回调任务的执行都是在前置任务执行结束后才能被执行
5. 异常处理 直接处理的是上一个cf的任务的异常。但当中间任务有异常时,会跳过后边未执行的cf任务,一直传递给最后一个cf。这种处理方式不区分是同步 还是 异步回调
6. 异步处理强制使用自定义线程池,默认线程池是ForkjoinPool线程,最多只有cup核数-1个线程执行任务
private CompletableFuture<Void> uniRunStage(Executor e, Runnable f) {
if (f == null) throw new NullPointerException();
// 负责回调任务执行的cf
CompletableFuture<Void> d = new CompletableFuture<Void>();
// 当时异步回调 或 前置任务未执行完,则压栈,尝试执行
if (e != null || !d.uniRun(this, f, null)) {
UniRun<T> c = new UniRun<T>(e, d, this, f);
push(c);
c.tryFire(SYNC);
}
return d;
}
- 前置任务已经执行完成 等价于 result 不为null
final boolean uniRun(CompletableFuture<?> a, Runnable f, UniRun<?> c) {
Object r; Throwable x;
// 1. 前置任务是否执行完成,未执行完成返回false(a表示前置任务)
if (a == null || (r = a.result) == null || f == null)
return false;
// 2.1 前置任务已经完成,但当前回调函数任务未执行完,优先处理检查前置任务(r)有无异常,有异常则终止,没有异常则继续执行
// 2.2 如果是异步回调,则优先执行异步回调,然后终止。不是异步回调则让main线程执行当前回调任务
if (result == null) {
if (r instanceof AltResult && (x = ((AltResult)r).ex) != null)
completeThrowable(x, r);
else
try {
if (c != null && !c.claim())
return false;
f.run();
completeNull();
} catch (Throwable ex) {
completeThrowable(ex);
}
}
return true;
}
总结
-
同步/异步都是需要关心结果的,只是关注的实时性不同。同步关注的实时性很高,而异步实时性低,可以通过轮训/回调方式
首先澄清一个概念,异步,同步和oneway是三件事。异步,归根结底你还是需要关心结果的,但可能不是当时的时间点关心,可以用轮询或者回调等方式处理结果;同步是需要当时关心 的结果的;而oneway是发出去就不管死活的方式,这种对于某些完全对可靠性没有要求的场景还是适用的,但不是我们重点讨论的范畴。 回归来看,任何的RPC都是存在客户端异步与服务端异步的,而且是可以任意组合的:客户端同步对服务端异步,客户端异步对服务端异步,客户端同步对服务端同步,客户端异步对服务端同步 -
同步/异步 在客户端/服务器是可以任意组合的
客户端同步对服务端异步,客户端异步对服务端异步,客户端同步对服务端同步,客户端异步对服务端同步
-
Java8的一个重要特性就是对多核并行处理任务更友好
-
函数式编程:
- 概念
是一种使用函数进行编程的方式。注重做什么,而不是怎么实现。强调的是执行结果而非执行过程
比如f(x),只要x不变,不论什么时候调用,调用几次,值都是不变的
- 特点:
无副作用,不会对输入源产生影响
- 优先考虑函数式编程思想
请牢记:考虑编程问题时,采用函数式的方法,关注函数的输入参数以及输出结果(即你希望做什么),通常比设计阶段的早期就考虑如何做、修改哪些东西要卓有成效得多。
理由:
这种方法论强调的是从高层次的角度审视、理解问题,确保设计方案符合预期目标后。避免直接陷入细节实现缺陷
- 函数式思想与细节关系是什么?
函数式思想体现的是"做什么?",细节体现的是"怎么做?"
采用函数式编程方法确实可以帮助开发者从宏观层面更好地理解和解决问题,但它并不是忽视细节的理由。相反,它提供了一种思考框架,使得开发者可以在更高层次上构思解决方案,然后再逐步深入到具体实现中去解决那些不可避免的技术挑战。理想的做法是两者结合:先用高层次的概念构建系统的骨架,再根据实际需求精心打磨每一个细节,这样才能开发出既符合业务目标又具备良好性能和可靠性的软件产品。
-
禁止使用带有副作用
如果你需要使用表示计算结果的数据结果,那么请创建它的一个副本而不要直接修改现存的数据结构。这一最佳实践也适用于标准的面向对象程序设计 -
BiFunction,UnaryOperator
Bi表示Binary,二元,代表有两个参数 Unary:一元,代表只有一个参数 -
Optional类的好处?
利用编译器能强制提醒开发者此处可能存在空值,需要显式的处理空值的异常情况
适用场景:用在返回值中,链式取值,对null值做兜底处理,不适用用于参数
-
返回值举例MyBatis:
MyBatis映射器接口支持返回Optional
-

浙公网安备 33010602011771号