JVM性能优化系列-(4) 编写高效Java程序

释放双眼,带上耳机,听听看~!

目前已经更新完《Java并发编程》,《Docker教程》和《JVM性能优化》,欢迎关注【后端精进之路】,轻松阅读全部文章。

Java并发编程:

  • Java并发编程系列-(1) 并发编程基础
  • Java并发编程系列-(2) 线程的并发工具类
  • Java并发编程系列-(3) 原子操作与CAS
  • Java并发编程系列-(4) 显式锁与AQS
  • Java并发编程系列-(5) Java并发容器
  • Java并发编程系列-(6) Java线程池
  • Java并发编程系列-(7) Java线程安全
  • Java并发编程系列-(8) JMM和底层实现原理
  • Java并发编程系列-(9) JDK 8/9/10中的并发

Docker教程:

  • Docker系列-(1) 原理与基本操作
  • Docker系列-(2) 镜像制作与发布
  • Docker系列-(3) Docker-compose使用与负载均衡

JVM性能优化:

  • JVM性能优化系列-(1) Java内存区域
  • JVM性能优化系列-(2) 垃圾收集器与内存分配策略
  • JVM性能优化系列-(3) 虚拟机执行子系统
  • JVM性能优化系列-(4) 编写高效Java程序
  • JVM性能优化系列-(5) 早期编译优化
  • JVM性能优化系列-(6) 晚期编译优化
  • JVM性能优化系列-(7) 深入了解性能优化

4. 编写高效Java程序

4.1 面向对象

构造器参数太多怎么办?

正常情况下,如果构造器参数过多,可能会考虑重写多个不同参数的构造函数,如下面的例子所示:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
1public class FoodNormal {
2
3    //required
4    private final String foodName;//名称
5    private final int reilang;//热量
6
7    //optional
8    private final int danbz;//蛋白质
9    private final int dianfen;//淀粉
10    private final int zf;//脂肪
11    
12    //全参数
13  public FoodNormal(String foodName, int reilang, int danbz,
14          int dianfen, int zf, int tang, int wss) {
15      super();
16      this.foodName = foodName;
17      this.reilang = reilang;
18      this.danbz = danbz;
19      this.dianfen = dianfen;
20      this.zf = zf;
21  }
22
23  //2个参数
24  public FoodNormal(String foodName, int reilang) {
25      this(foodName,reilang,0,0,0,0,0);
26  }
27 
28  //3....6个参数
29  //
30 
31  public static void main(String[] args) {
32      FoodNormal fn = new FoodNormal("food1",1200,200,0,0,300,100);
33  }
34}
35
36

但是问题很明显:1.可读性很差,特别是参数个数多,并且有多个相同类型的参数时;2.调换参数的顺序,编译器也不会报错。

针对这个两个问题,一种选择是 JavaBeans 模式,在这种模式中,调用一个无参数的构造函数来创建对象,然后调用 setter 方法来设置每个必需的参数和可选参数。这种方法缺陷很明显:排除了让类不可变的可能性,并且需要增加工作以确保线程安全。

推荐的方法是使用builder模式,该模式结合了可伸缩构造方法模式的安全性和JavaBean模式的可读性。

下面的例子中,创建了一个内部类Builder用于接受对应的参数,最后通过Builder类将参数返回。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
1public class FoodBuilder {
2
3    //required
4    private final String foodName;
5    private final int reilang;
6
7    //optional
8    private  int danbz;
9    private  int dianfen;
10    private  int zf;
11    
12    public static class Builder{
13        //required
14        private final String foodName;
15        private final int reilang;
16
17        //optional
18        private  int danbz;
19        private  int dianfen;
20        private  int zf;
21        
22      public Builder(String foodName, int reilang) {
23          super();
24          this.foodName = foodName;
25          this.reilang = reilang;
26      }
27     
28      public Builder danbz(int val) {
29          this.danbz = val;
30          return this;
31      }
32     
33      public Builder dianfen(int val) {
34          this.dianfen = val;
35          return this;
36      }
37     
38      public Builder zf(int val) {
39          this.zf = val;
40          return this;
41      }
42        
43      public FoodBuilder build() {
44          return new FoodBuilder(this);
45      }
46    }
47    
48    private FoodBuilder(Builder builder) {
49      foodName = builder.foodName;
50      reilang = builder.reilang;
51     
52      danbz = builder.danbz;
53        dianfen = builder.danbz;
54        zf = builder.zf;
55    }
56    
57    public static void main(String[] args) {
58      FoodBuilder foodBuilder = new FoodBuilder.Builder("food2", 1000)
59          .danbz(100).dianfen(100).zf(100).build();
60  }
61}
62
63

Builder模式更进一步

标准的Builder模式,包含以下4个部分:

  1. 抽象建造者:一般来说是个接口,1)建造方法,建造部件的方法(不止一个);2)返回产品的方法
  2. 具体建造者:继承抽象建造者,并且实现相应的建造方法。
  3. 导演者:调用具体的建造者,创建产品对象。
  4. 产品:需要建造的复杂对象。

下面的例子中,man和woman是产品;personBuilder是抽象建造者;manBuilder和womanBuilder继承了personBuilder并实现了相应方法,是具体建造者;NvWa是导演者,调用建造者方法建造产品。

产品类


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
1public abstract class Person {
2
3    protected String head;
4    protected String body;
5    protected String foot;
6
7    public String getHead() {
8        return head;
9    }
10
11    public void setHead(String head) {
12        this.head = head;
13    }
14
15    public String getBody() {
16        return body;
17    }
18
19    public void setBody(String body) {
20        this.body = body;
21    }
22
23    public String getFoot() {
24        return foot;
25    }
26
27    public void setFoot(String foot) {
28        this.foot = foot;
29    }
30}
31
32// 具体的产品
33public class Man extends Person {
34    public Man() {
35        System.out.println("create a man");
36    }
37
38    @Override
39    public String toString() {
40        return "Man{}";
41    }
42}
43
44public class Woman extends Person {
45
46    public Woman() {
47        System.out.println("create a Woman");
48    }
49
50    @Override
51    public String toString() {
52        return "Woman{}";
53    }
54}
55
56

抽象建造类


1
2
3
4
5
6
7
8
9
10
11
12
1public abstract class PersonBuilder {
2  
3   //建造部件
4   public abstract void buildHead();
5   public abstract void buildBody();
6   public abstract void buildFoot();
7  
8   public abstract Person createPerson();
9
10}
11
12

具体建造者


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
1public class ManBuilder extends PersonBuilder {
2  
3   private Person person;
4  
5   public ManBuilder() {
6       this.person = new Man();
7   }
8
9   @Override
10  public void buildHead() {
11      person.setHead("Brave Head");
12     
13  }
14
15  @Override
16  public void buildBody() {
17      person.setBody("Strong body");
18     
19  }
20
21  @Override
22  public void buildFoot() {
23      person.setFoot("powful foot");
24     
25  }
26
27  @Override
28  public Person createPerson() {
29      return person;
30  }
31}
32
33public class WomanBuilder extends PersonBuilder {
34 
35  private Person person;
36 
37  public WomanBuilder() {
38      this.person = new Woman();
39  }
40
41  @Override
42  public void buildHead() {
43      person.setHead("Pretty Head");
44     
45  }
46
47  @Override
48  public void buildBody() {
49      person.setBody("soft body");
50     
51  }
52
53  @Override
54  public void buildFoot() {
55      person.setFoot("long white foot");
56     
57  }
58
59  @Override
60  public Person createPerson() {
61      return person;
62  }
63}
64
65

导演者


1
2
3
4
5
6
7
8
9
10
11
1public class NvWa {
2  
3   public Person buildPerson(PersonBuilder pb) {
4       pb.buildHead();
5       pb.buildBody();
6       pb.buildFoot();
7       return pb.createPerson();
8   }
9}
10
11

下面是测试程序:


1
2
3
4
5
6
7
8
9
10
11
1public class Mingyun {
2
3    public static void main(String[] args) {
4       System.out.println("create NvWa");
5       NvWa nvwa =  new NvWa();
6       nvwa.buildPerson(new ManBuilder());
7       nvwa.buildPerson(new WomanBuilder());
8    }
9}
10
11

不需要实例化的类应该构造器私有

工程中的工具类,为了防止实例化,可以将构造器私有化。

不要创建不必要的对象

1. 自动装箱和拆箱等隐式转换。

自动装箱就是Java自动将原始类型值转换成对应的对象,比如将int的变量转换成Integer对象,这个过程叫做装箱,反之将Integer对象转换成int类型值,这个过程叫做拆箱。因为这里的装箱和拆箱是自动进行的非人为转换,所以就称作为自动装箱和拆箱。

自动装箱和拆箱在Java中很常见,比如我们有一个方法,接受一个对象类型的参数,如果我们传递一个原始类型值,那么Java会自动将这个原始类型值转换成与之对应的对象。

自动装箱的弊端:


1
2
3
4
5
6
1Integer sum = 0;
2 for(int i=1000; i<5000; i++){
3   sum+=i;
4}
5
6

上面的例子中,首先sum进行自动拆箱操作,进行数值相加操作,最后发生自动装箱操作转换成Integer对象。上面的循环中会创建将近4000个无用的Integer对象,在这样庞大的循环中,会降低程序的性能并且加重了垃圾回收的工作量。

2. 实例共用,声明为static

多个共用的情况下,声明为static或者采用单例模式,以免生成多个对象影响程序性能。

避免使用终结方法

finalize方法,因为虚拟机不保证这个方法被执行,所以释放资源时,不能保证。

为了合理的释放资源,推荐下面两种方法:

  • try resource

Java 1.7中引入了try-with-resource语法糖来打开资源,而无需自己书写资源来关闭代码。例子如下:


1
2
3
4
5
6
7
8
9
10
11
12
1public class TryWithResource {
2    public static void main(String[] args) {
3        try (Connection conn = new Connection()) {
4            conn.sendData();
5        }
6        catch (Exception e) {
7            e.printStackTrace();
8        }
9    }
10}
11
12

为了能够配合try-with-resource,资源必须实现AutoClosable接口。该接口的实现类需要重写close方法:


1
2
3
4
5
6
7
8
9
10
11
1public class Connection implements AutoCloseable {
2    public void sendData() {
3        System.out.println("正在发送数据");
4    }
5    @Override
6    public void close() throws Exception {
7        System.out.println("正在关闭连接");
8    }
9}
10
11
  • try finally

在finally语句块中释放资源,保证资源永远能够被正常释放。

使类和成员的可访问性最小化

可以有效的解除系统中各个模块的耦合度、实现每个模块的独立开发、使得系统更加的可维护,更加的健壮。

如何最小化类和接口的可访问性?

能将类和接口做成包级私有就一定要做成包级私有的。

如果一个类或者接口,只被另外的一个类应用,那么最好将这个类或者接口做成其内部的私有类或者接口。

如何最小化一个了类中的成员的可访问性?

首先设计出该类需要暴露出来的api,然后将剩下的成员的设计成private类型。然后再其他类需要访问某些private类型的成员时,在删掉private,使其变成包级私有。如果你发现你需要经常这样做,那么就请你重新设计一下这个类的api。

对于protected类型的成员,作用域是整个系统,所以,能用包访问类型的成员的话就尽量不要使用保护行的成员。

不能为了测试而将包中的类或者成员变为public类型的,最多只能设置成包级私有类型。

实例域绝对不能是public类型的.

使可变性最小化

不可变类只是实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期(lifetime)内固定不变。

Java平台类库中包含许多不可变的类,其中有String、基本类型的包装类、BigInteger和BigDecimal。

存在不可变的类有许多理由:不可变的类比可变的类更加易于设计、实现和使用。不容易出错,且更加安全。

为了使类成为不可变,要遵循下面五条规则:

1、不要提供任何会修改对象状态的方法(也称为mutator),即改变对象属性的方法。

2、**保证类不会被扩展。**这样可以防止粗心或者恶意的子类假装对象的状态已经改变,从而破坏该类的不可变行为。为了防止子类化,一般做法是使这个类成为final的。

3、使所有的域都是final的。通过系统的强制方式,这可以清楚地表明你的意图。而且,如果一个指向新创建实例的引用在缺乏同步机制的情况下,从一个线程被传递到另一个 线程,就必须确保正确的行为。

4、使所有的域都成为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final 域,只要这些域包含基本类型的值或者指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法再改变内部的表示法。

5、确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用初始化这样的域,也不要从任何访问方法(accessor)中返回该对象引用。在构造器、访问方法和readObject中请使用保护性拷贝(defensive copy)技术。

优先使用复合

  • 继承:会打破封装性
  • 组合:在内部持有一个父类作为成员变量。

使用继承扩展一个类很危险,父类的具体实现很容易影响子类的正确性。**而复合优先于继承告诉我们,不用扩展现有的类,而是在新类中增加一个私有域,让它引用现有类的一个实例。**这种设计称为复合(Composition)。

只有当子类和超类之间确实存在父子关系时,才可以考虑使用继承。否则都应该用复合,包装类不仅比子类更加健壮,而且功能也更加强大。

接口优于抽象类

接口和抽象类

  • 抽象类允许某些方法的实现,但是接口不允许(JDK 1.8开始已经可以了)
  • 现有类必须成为抽象类的子类,但是只能单继承,接口可以多继承

接口优点

  • 现有类可以很容易被更新,以实现新的接口。
  • 接口允许我们构造非层次结构的类型框架,接口可以多继承。
  • 骨架实现类,下面对骨架类进行详细介绍

假定有Interface A, 可以声明abstarct class B implements A,接着在真正的实现类C中 class C extends B implements A。B就是所谓的骨架类,骨架类中对A中的一些基础通用方法进行了实现,使得C可以直接使用骨架类中的实现,无需再次实现,或者调用骨架类中的实现进行进一步的定制与优化。C只需要实现B中未实现的方法或者添加新的方法。

骨架实现类的优点在于,它们提供抽象类的所有实现的帮助,而不会强加抽象类作为类型定义时的严格约束。对于具有骨架实现类的接口的大多数实现者来说,继承这个类是显而易见的选择,但它不是必需的。如果一个类不能继承骨架的实现,这个类可以直接实现接口。该类仍然受益于接口本身的任何默认方法。此外,骨架实现类仍然可以协助接口的实现。实现接口的类可以将接口方法的调用转发给继承骨架实现的私有内部类的包含实例。这种被称为模拟多重继承的技术,它提供了多重继承的许多好处,同时避免了缺陷。

JDK的实现中,使用了大量的骨架类,按照惯例,骨架实现类被称为AbstractInterface,其中Interface是它们实现的接口的名称。 例如,集合框架( Collections Framework)提供了一个框架实现以配合每个主要集合接口:AbstractCollection,AbstractSet,AbstractList和AbstractMap。

4.2 方法

可变参数要谨慎使用

从Java 1.5开始就增加了可变参数(varargs)方法,又称作variable arity method。可变参数方法接受0个或多个指定类型的参数。它的机制是先创建一个数组,数组的大小为调用位置所传递的参数数量,然后将值传到数组中,最后将数组传递到方法。

例如下面的例子,返回多个参数的和:


1
2
3
4
5
6
7
8
9
1    // Simple use of varargs - Page 197
2    static int sum(int... args) {
3        int sum = 0;
4        for (int arg : args)
5            sum += arg;
6        return sum;
7    }
8
9

但是这种方法也接受0个参数,所以一般需要对参数进行检查。通常为了规避这种情况,就是声明该方法有两个参数,一个是指定类型的正常参数,另一个是这种类型的varargs参数。这个方法弥补了上面的不足(不需要再检查参数的数量了,因为至少要传递一个参数,否则不能通过编译):


1
2
3
4
5
6
7
8
9
1    static int min(int firstArg, int... remainingArgs) {
2        int min = firstArg;
3        for (int arg : remainingArgs)
4            if (arg < min)
5                min = arg;
6        return min;
7    }
8
9

需要注意的是,在重视性能的情况下,使用可变参数机制要特别小心。可变参数方法每次调用都会导致进行一次数组分配和初始化。

返回零长度的数组或集合,不要返回null

要求调用方单独处理null的情况。对于list的情况,可以直接返回jdk内置的Collections.emptyList()。

优先使用标准的异常

  • 可读性。
  • 追求代码的重用。
  • 在类装载的性能上考虑,也提倡使用标准异常。

常用异常:

illegalArgumentException — 调用者传递的参数不合适
illegalStateException — 接收状态异常
NullPointException — 空指针
UnSupportOperationException — 操作不支持

4.3 通用程序设计

用枚举代替int常量

在枚举类型出现之前,一般都常常使用int常量或者String常量表示列举相关事物。如:


1
2
3
4
5
6
7
8
9
1public static final int APPLE_FUJI = 0;
2public static final int APPLE_PIPPIN = 1;
3public static final int APPLE_GRANNY_SMITH = 2;
4
5public static final int ORANGE_NAVEL = 0;
6public static final int ORANGE_TEMPLE = 1;
7public static final int ORANGE_BLOOD = 2;
8
9

针对int常量以下不足:

  1. 在类型安全方面,如果你想使用的是ORANGE_NAVEL,但是传递的是APPLE_FUJI,编译器并不能检测出错误;
  2. 因为int常量是编译时常量,被编译到使用它们的客户端中。若与枚举常量关联的int发生了变化,客户端需重新编译,否则它们的行为就不确定;
  3. 没有便利方法将int常量翻译成可打印的字符串。这里的意思应该是比如你想调用的是ORANGE_NAVEL,debug的时候显示的是0,但你不能确定是APPLE_FUJI还是ORANGE_NAVEL

1. 默认枚举

上面的例子可以使用下面的enum重写:


1
2
3
4
5
6
7
1public enum Apple {
2    APPLE_FUJI,
3    APPLE_PIPPIN,
4    APPLE_GRANNY_SMITH;
5}
6
7

在调用的时候,直接使用enum类型,在编译的时候可以直接指定类型,否则编译不通过;并且debug的时候,显示的是enum中的常量(APPLE_FUJI这样的),可以一眼看出是否用错;最后由于枚举导出的常量域(APPLE_FUJI等)与客户端之间是通过枚举来引用的,再增加或者重排序枚举类型中的常量后,并不需要重新编译客户端代码。

2. 带行为的枚举

首先必须明白,java里的枚举就是一个类,枚举中的每个对象,是这个枚举类的一个实例。

因此我们可以编写下面的枚举类,并且提供相应的计算方法。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
1public enum DepotEnum {
2   UNPAY(0,"未支付"),PAID(1,"已支付"),TIMOUT(-1,"超时");
3  
4   private int status;
5   private String desc;
6   private String dbInfo;//其他属性
7  
8   private DepotEnum(int status, String desc) {
9       this.status = status;
10      this.desc = desc;
11  }
12
13  public int getStatus() {
14      return status;
15  }
16
17  public String getDesc() {
18      return desc;
19  }
20
21  public String getDbInfo() {
22      return dbInfo;
23  }
24 
25  public int calcStatus(int params) {
26      return status+params;
27  }
28 
29  public static void main(String[] args) {
30      for(DepotEnum e:DepotEnum.values()) {
31          System.out.println(e+":"+e.calcStatus(14));
32      }
33  }
34}
35
36

下面是比较复杂的枚举,这里在类里面定义了枚举BetterActive枚举类,进行计算加减乘除的操作,为了保证每增加一个枚举类后,都增加对应的计算方法,这里将计算方法oper定义为抽象方法,保证了在增加枚举变量时,一定增加对应的oper方法。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
1public class ActiveEnum {
2  
3   public enum BetterActive{
4       PLUS {
5           @Override
6           double oper(double x, double y) {
7               return x+y;
8           }
9       },MINUS {
10          @Override
11          double oper(double x, double y) {
12              return x-y;
13          }
14      },MUL {
15          @Override
16          double oper(double x, double y) {
17              return x*y;
18          }
19      },DIV {
20          @Override
21          double oper(double x, double y) {
22              return x/y;
23          }
24      };
25     
26      abstract double oper(double x,double y);   
27  }
28
29  public static void main(String[] args) {
30      System.out.println(BetterActive.PLUS.oper(0.1, 0.2));
31  }
32}
33
34

3. 策略枚举

主要是为了优化在多个枚举变量的情况下,尽量减少重复代码。下面以不同的日期,薪水的支付方式不同为例,进行说明,当增加了一个新的日期后,我们只需要在外层枚举类中进行修改,无需修改其他计算方法。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
1public enum BetterPayDay {
2   MONDAY(PayType.WORK), TUESDAY(PayType.WORK), WEDNESDAY(
3           PayType.WORK), THURSDAY(PayType.WORK), FRIDAY(PayType.WORK),
4   SATURDAY(PayType.REST), SUNDAY(PayType.REST),WUYI(PayType.REST);
5
6   private final PayType payType;//成员变量
7
8   BetterPayDay(PayType payType) {
9       this.payType = payType;
10  }
11
12  double pay(double hoursOvertime) {
13      return payType.pay(hoursOvertime);
14  }
15
16  //策略枚举
17  private enum PayType {
18      WORK {
19          double pay(double hoursOvertime) {
20              return hoursOvertime*HOURS_WORK;
21          }
22      },
23      REST {
24          double pay(double hoursOvertime) {
25              return hoursOvertime*HOURS_REST;
26          }
27      };
28     
29      private static final int HOURS_WORK = 2;
30      private static final int HOURS_REST = 3;
31
32      abstract double pay(double hoursOvertime);//抽象计算加班费的方法
33  }
34 
35  public static void main(String[] args) {
36      System.out.println(BetterPayDay.MONDAY.pay(7.5));
37  }
38}
39
40

将局部变量的作用域最小化

  • 要使局部变量的作用域最小化,最有力的方法就是在第一次使用它的地方声明。
  • **几乎每个局部变量的声明都应该包含一个初始化表达式。**如果没有足够信息来对一个变量进行有意义的初始化,就应该推迟这个声明,直到可以初始化为止。
  • 尽量保证方法小而集中。
  • 仅在某一代码块中使用的局部变量,那么就在该代码块中声明。

精确计算,避免使用float和double

float和double类型不能用于精确计算,其主要目的是为了科学计算和工程计算,它们执行二进制浮点运算。

转成int或者long,推荐使用bigDecimal。

当心字符串连接的性能

String是不可变的,每一次拼接都会产生字符串的复制。

StringBuilder和StringBuffer

  • 都是可变的类。
  • StringBuffer线程安全,可以在多线程下使用;StrngBuilder非线程安全,速度比StringBuffer快。

控制方法的大小

这个好理解,主要是从解耦和可维护性角度考虑。

在Unix philosophy中也提到,编写代码时注意Do One Thing and Do It Well。


本文由『后端精进之路』原创,首发于博客 http://teckee.github.io/ , 转载请注明出处

搜索『后端精进之路』关注公众号,立刻获取最新文章和价值2000元的BATJ精品面试课程

给TA打赏
共{{data.count}}人
人已打赏
安全技术

Bootstrap 4 Flex(弹性)布局

2021-12-21 16:36:11

安全技术

从零搭建自己的SpringBoot后台框架(二十三)

2022-1-12 12:36:11

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索