Java攻城师

Java攻城师 查看完整档案

深圳编辑江西农业大学  |  软件工程 编辑深圳数据工厂  |  Java工程师 编辑 study.xuezhisijiao.vip/#/login?redirect=%2Fdashboard 编辑
编辑

本人太过于丰富,无法简介

个人动态

Java攻城师 发布了文章 · 今天 14:55

为什么start方法才能启动线程,而run不行?

我们都知道,一个线程直接对应了一个Thread对象,在刚开始学习线程的时候我们也知道启动线程是通过start()方法,而并非run()方法。

那这是为什么呢?

如果你熟悉Thread的代码的话,你应该知道在这个类加载的时候会注册一些native方法

public
class Thread implements Runnable {
  /* Make sure registerNatives is the first thing <clinit> does. */
  private static native void registerNatives();
  static {
      registerNatives();
  }
}
复制代码

一看到native我就想起了JNI,registerNatives()实际上就是java方法和C/C++的函数对应。在首次加载的时候就会注册这些native方法。Thread中有很多native方法,大家有兴趣的可以去看看。

关于JNI方法的命名,我们可以这样测试,我们用java声明一个native方法,然后先使用javac编译源文件(比如javac main.java),然后在使用javah即可生成头文件(javah main),打开这个头文件你就知道方法命名是如何的了

我们在JVM源码中搜索Java_java_lang_Thread_registerNatives可以看到registerNatives方法的具体实现

static JNINativeMethod methods[] = {
    {"start0",           "()V",        (void *)&JVM_StartThread},
    {"stop0",            "(" OBJ ")V", (void *)&JVM_StopThread},
    {"isAlive",          "()Z",        (void *)&JVM_IsThreadAlive},
    {"suspend0",         "()V",        (void *)&JVM_SuspendThread},
    {"resume0",          "()V",        (void *)&JVM_ResumeThread},
    {"setPriority0",     "(I)V",       (void *)&JVM_SetThreadPriority},
    {"yield",            "()V",        (void *)&JVM_Yield},
    {"sleep",            "(J)V",       (void *)&JVM_Sleep},
    {"currentThread",    "()" THD,     (void *)&JVM_CurrentThread},
    {"countStackFrames", "()I",        (void *)&JVM_CountStackFrames},
    {"interrupt0",       "()V",        (void *)&JVM_Interrupt},
    {"isInterrupted",    "(Z)Z",       (void *)&JVM_IsInterrupted},
    {"holdsLock",        "(" OBJ ")Z", (void *)&JVM_HoldsLock},
    {"getThreads",        "()[" THD,   (void *)&JVM_GetAllThreads},
    {"dumpThreads",      "([" THD ")[[" STE, (void *)&JVM_DumpThreads},
    {"setNativeName",    "(" STR ")V", (void *)&JVM_SetNativeThreadName},
};
JNIEXPORT void JNICALL
Java_java_lang_Thread_registerNatives(JNIEnv *env, jclass cls)
{
    (*env)->RegisterNatives(env, cls, methods, ARRAY_LENGTH(methods));
}
复制代码

可以看到,在registerNatives函数中,注册了很多的native方法比如这里的start0()方法。

所有对JNI函数的调用都使用了env指针,该指针是对每一个本地方法的第一个参数。env指针是函数指针表的指针。我们可以在docs.oracle.com/javase/8/do…中找到JNI API

在Thread.start()方法中,实际就是通过调用start0()方法来启动线程的。

public synchronized void start() {
       
  if (threadStatus != 0)
      throw new IllegalThreadStateException();
  group.add(this);
  boolean started = false;
  try {
      // 主要调用了start0()这个native方法来启动线程
      start0();
      started = true;
  } finally {
      try {
          if (!started) {
              group.threadStartFailed(this);
          }
      } catch (Throwable ignore) {
          /* do nothing. If start0 threw a Throwable then
            it will be passed up the call stack */
      }
  }
}
private native void start0();
复制代码

而JNINativeMethod这个数据结构定义如下:


typedef struct {
  char *name;
  char *signature;
  void *fnPtr;
}
复制代码

因此start0()这个方法对应的本地函数是JVM_StartThread

 {"start0", "()V", (void *)&JVM_StartThread}
复制代码

我们接下来看JVM_StartThread的方法实现

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
  JVMWrapper("JVM_StartThread");
  JavaThread *native_thread = NULL;
  bool throw_illegal_thread_state = false;
 
  {
    // Ensure that the C++ Thread and OSThread structures aren't freed before
    // we operate.
    MutexLocker mu(Threads_lock);
    // 从JDK5开始,使用java.lang.Thread threadStatus来防止重新启动一个已经启动的线程
    if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
      throw_illegal_thread_state = true;
    } else {
      
      jlong size =
             java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
      
      NOT_LP64(if (size > SIZE_MAX) size = SIZE_MAX;)
      size_t sz = size > 0 ? (size_t) size : 0;
      // 创建Java线程
      native_thread = new JavaThread(&thread_entry, sz);
     
      if (native_thread->osthread() != NULL) {
        native_thread->prepare(jthread);
      }
    }
  }
  if (throw_illegal_thread_state) {
    THROW(vmSymbols::java_lang_IllegalThreadStateException());
  }
  // 省略了部分代码
  // 将线程状态设置为Runnable,表示可以被运行
  Thread::start(native_thread);
JVM_END
复制代码

上面代码主要做了三件事情

  1. 判断当前线程状态是否合法,不合法抛出IllegalThreadStateException
  2. 创建一个Java线程(我们需要重点关注的)
  3. 将线程状态设置为Runnable

如果面试官以后再问你两次调用start()方法会怎样,你就大胆而坚定的回复说抛出IllegalThreadStateException。

在JavaThread构造函数中实际调用的是os::create_thread方法

bool os::create_thread(Thread* thread, ThreadType thr_type,
                       size_t req_stack_size) {
  
  OSThread* osthread = new OSThread(NULL, NULL);
  if (osthread == NULL) {
    return false;
  }
  osthread->set_thread_type(thr_type);
  osthread->set_state(ALLOCATED);
  thread->set_osthread(osthread);
  // init thread attributes
  pthread_attr_t attr;
  pthread_attr_init(&attr);
  pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
  
  size_t stack_size = os::Posix::get_initial_stack_size(thr_type, req_stack_size);
  int status = pthread_attr_setstacksize(&attr, stack_size);
  ThreadState state;
  {
    pthread_t tid;
    // 创建线程
    int ret = pthread_create(&tid, &attr, (void* (*)(void*)) thread_native_entry, thread);
    
    // 省略其他代码...
  }
  return true;
}
复制代码

pthread_create函数作用是创建一个线程,它的第三个参数是线程运行函数的起始地址,第四个参数是运行函数参数

IEEE标准1003.1c中定义了线程的标准,它定义的线程包叫做Pthread,大部分UNIX系统都支持这个标准。

我们的thread_native_entry实际传入的是JavaThread这个对象,所以最终会调用JavaThread::run()(thread.cpp中)

void JavaThread::run() {
  
  ...
  thread_main_inner();
  ...
}
void JavaThread::thread_main_inner() {
  ...
  this->entry_point()(this,this);
  ...
}
复制代码

thread_main_inner函数中entry_point的返回值实际上是我们在创建JavaThread的时候传入的第一个参数thread_entry。而thread_entry指针指向的函数如下:

static void thread_entry(JavaThread* thread, TRAPS) {
  HandleMark hm(THREAD);
  Handle obj(THREAD, thread->threadObj());
  JavaValue result(T_VOID);
  JavaCalls::call_virtual(&result,
    obj,
    SystemDictionary::Thread_klass(),
    // run方法名称run
    vmSymbols::run_method_name(),
    // 方法签名()V
    vmSymbols::void_method_signature(),
    THREAD);
}
复制代码

这样我们就最终通过JavaCalls调用了run方法。

总结

new Thread只是创建了一个普通的Java对象,只有在调用了start()方法之后才会创建一个真正的线程,在JVM内部会在创建线程之后调用run()方法,执行相应的业务逻辑。

由于Java中线程最终还是和操作系统线程挂钩了的,所以线程资源是一个很重要的资源,为了复用我们一般是通过线程池的方式来使用线程。

参考:《2020最新Java基础精讲视频教程和学习路线!》
链接:https://juejin.cn/post/685805...

查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 今天 14:53

Java中创建线程的几种主流方式

继承Thread类

继承Thread类,并重写它的run方法,就可以创建一个线程了,当然线程是如何真正被启动,可以参考我之前的 为什么start方法才能启动线程,而run不行?

class ThinkThread extends Thread {
  @Override
  public void run() {
      System.out.println("think123");
  }
}

new ThinkThread().start();

复制代码

实现Runnable接口

 new Thread(() -> 
  System.out.println("实现了Runnable接口")
).start();
复制代码

构造Thread时传入Runnable类型的参数,也可以创建一个线程

通过线程池创建线程

// 提交 Runnable 任务
Future<?> submit(Runnable task);

// 提交 Callable 任务
<T> Future<T> submit(Callable<T> task);

// 提交 Runnable 任务及结果引用。 future.get()==result
<T> Future<T> submit(Runnable task, T result);
复制代码

Runnable类型的参数和Callable类型的参数不同之处在于Runnable 接口的 run() 方法是没有返回值的,所以 submit(Runnable task)这个方法返回的 Future 仅可以用来断言任务已经结束了,类似于 Thread.join()。 而Callable是一个接口,它有一个call()方法,这个方法是有返回值的,这个可以通过 future.get() l获取任务执行结果

// 建议手动创建线程池,这里只是为了举例
ExecutorService service = Executors.newFixedThreadPool(1);

Future future = service.submit(() -> "think123");

System.out.println(future.get());

复制代码

通过FutureTask创建线程

FutureTask继承了Runnable和Future接口,所以我们可以将FutureTask对象作为任务提交到线程池执行,也可以直接被Thread执行,而且还可以获取到任务执行结果。

FutureTask task = new FutureTask(() -> "666");

Thread t1 = new Thread(task);
t1.start();;

// 阻塞main线程,直到t1执行完成
System.out.println(task.get());
复制代码

FutureTask的源码很简单,当执行run方法时,会将执行的结果保存在内部变量 outcome 中,即便是抛出了异常,此时也会将异常记录到outcome中。

当调用 get 方法时,如果还未执行完成,则会阻塞调用方。执行完成后会将正常的结果返回,如果call方法中抛出了异常,则将其封装成 ExecutionException 抛出。

总结

上面介绍了Java中常用的创建线程执行的方式,可以发现,实际上都是通过 创建Thread来执行的,实际上也是可以算作一种,如果面试官问题,你可以先装一下说之后一种,然后峰回路转,给他说说为什么之后一种。

参考:《2020最新Java基础精讲视频教程和学习路线!》
链接:https://juejin.cn/post/692024...

查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 今天 14:50

讲给应届生的 Java 开源知识项目

一 Java基础知识

  1. 概念常识

1.1 请你谈谈你对 JVM JDK JRE 的认识和理解

1.1.1 JVM(Java Virtual Machine)

JVM 又被称作 Java 虚拟机,用来运行 Java 字节码文件(.class),因为 JVM 对于特定系统(Windows,Linux,macOS)有不同的具体实现,即它屏蔽了具体的操作系统和平台等信息,因此同一字节码文件可以在各种平台中任意运行,且得到同样的结果。

1.1.1.1 什么是字节码?

扩展名为 .class 的文件叫做字节码,是程序的一种低级表示,它不面向任何特定的处理器,只面向虚拟机(JVM),在经过虚拟机的处理后,可以使得程序能在多个平台上运行。

1.1.1.2 采用字节码的好处是什么?

Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,Java程序无须重新编译便可在多种不同的计算机上运行。

为什么一定程度上解决了传统解释型语言执行效率低的问题(参考自思否-scherman ,仅供参考)

首先知道两点,① 因为 Java 字节码是伪机器码,所以会比解析型语言效率高 ②JVM不是解析型语言,是半编译半解析型语言

解析型语言没有编译过程,是直接解析源代码文本的,相当于在执行时进行了一次编译,而 Java 的字节码虽然无法和本地机器码完全一一对应,但可以简单映射到本地机器码,不需要做复杂的语法分析之类的编译处理,当然比纯解析语言快。

1.1.2 JRE(Java Runtime Environment)

JRE 是 Java 运行时环境,它包含了 JVM 和 Java 的一些基础类库,它用来运行已经编译好的 Java 程序(它就是用来运行的,不能创建新程序)

1.1.3 JDK(Java Development Kit)

JDK 是Java开发工具包,是程序员使用 Java 语言开发 Java 程序必备的工具包,它不仅包含 JRE ,同时还包含了编译器(javac)还包含了很多 java 调试,分析,文档相关的工具。使用 JDK 可以创建以及编译程序。

1.1.3.1 只为Java运行环境 JRE,有必要安装 JDK 吗

单纯为了运行一个 Java 程序,JRE 是完全够用的,但话不能这么绝对

有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。(参考自Github-JavaGuide ,仅供参考)

所以,大部分情况安装 JDK 还是有必要的,需要根据具体情况来决定。

1.1.3.2 你能谈一谈 Java 程序从代码到运行的一个过程吗?

过程:编写 -> 编译 -> 解释(这也是 Java编译与解释共存的原因)

首先通过IDE/编辑器编写源代码然后经过 JDK 中的编译器(javac)编译成 Java 字节码文件(.class文件),字节码通过虚拟机执行,虚拟机将每一条要执行的字节码送给解释器,解释器会将其翻译成特定机器上的机器码(及其可执行的二进制机器码)。

1.2 请你比较一下 Java 与JavaScript 、C++

1.2.1 Java 与 JavaScript

  • 用处不同:Java 被广泛用于互联网应用程序的开发。而 JavaScript 主要内嵌于 Web 页面中运行,用来读写 HTML,控制 cookies 等。
  • 定位不同:Java 是基于对象的,简单开发也需要设计并创建类。而 JavaScript 这种脚本语言是基于对象的和事件驱动的,它可以通过大量提供好的内部对象实现各种功能。
  • 代码执行过程不同:Java 源代码会经过编译编程字节码文件,然后 JVM 将字节码文件分发给解释器进行解释处理,因此属于编译与解释共存。而 JavaScript 是一种解释性语言,源代码不需要经过编译,浏览器即可解释执行( JIT (即时编译)可以提高 JavaScript 的运行效率)。
  • 数据类型不同:Java 采用强类型检查,编译前必须声明,而 JavaScript 是弱类型,甚至变量使用前可以不声明,JavaScript 在运行时检查推断其数据类型。

1.2.2 Java 与 C++

  • 指针:Java 语言不显式地向用户提供指针来访问内存,添加了自动内存管理功能,可以避免在 C/C++ 中因操作失误而导致的野指针的问题,使程序更安全(只是不提供,并不是没有指针,虚拟机中内部还是使用了指针只是不向外提供而已)。
  • 继承:Java 的类是单继承的,而 C++ 却可以多重继承,但 Java 可以通过继承/实现多个接口。
  • 内存管理: Java 的 JVM 中有自己的 GC 机制(垃圾回收机制),不需要程序员手动释放没用的内存
  • 运算符重载:Java 不可以运算符重载,而 C++ 则可以。
  1. 基础语法

说明:此部分包括:关键字,标识符,注释,常量,变量 ,运算符,分支,循环的相关内容。

数据类型、方法单独整理为 第 3 大点 和 第 4 大点

2.1 标识符和关键字的区别

标识符和关键字的本质都是一个名字,例如类名,方法名,变量名... ,而关键字,是一种特殊的标识符,它被冠以一种特殊的含义,例如 public return try catch ... ,因为关键字有其特定的位置和用途,所以普通标识符不允许与关键字重名。

2.2 Java 常见的命名规则(非规范)

注:下面所述,均为命名的硬性规则,而非推荐的规范,具体可参考 《阿里巴巴 Java 开发手册》

基本:

  • 包名:全部小写用 . 隔开 eg: com.baidu.www (域名反写)
  • 类名/接口:首字母大写,多个单词组成则使用驼峰命名
  • 方法或变量名:首字母小写,多个单词组成则使用驼峰命名
  • 常量名:全部大写,用 _ 隔开

标识符:

  • 首字符:字母(A-Z、a-z)、美元符($)、下划线(_
  • 首字符之后:字母(A-Z、a-z)、美元符($)、下划线(_)或者数字的任何字符组合

2.3 注释的种类

注释有三种:① 单行注释(//注释内容)、② 多行注释(/*注释内容*/)、③ 文档注释(/**注释内容*/

2.3.1 谈谈你对注释规范的看法

首先要说注释的作用:① 能准确的反映设计思想和代码逻辑 ② 能够描述业务含义

一份好的注释,可以在较长一段时间后,帮助你快速回忆当时的思路,也可以帮助接收这份代码的新人,快速了解你的想法。

代码的注释不是越详细越好。实际上好的代码本身就是注释,我们要尽量规范和美化自己的代码来减少不必要的注释。若编程语言足够有表达力,就不需要注释,尽量通过代码来阐述。—— 《Clean Code》

2.4 字符常量和字符串常量的区别

  • 形式不同:字符常量是单引号引起的一个字符,而字符串常量是双引号引起的 0 个或若干个字符
  • 含义不同:字符常量相当于一个整型值( ASCII 值),可以参加表达式运算,而字符串常量代表一个地址值(该字符串在内存中存放位置)
  • 占内存大小不同: 字符常量只占 2 个字节( char 在 Java 中占两个字节),字符串常量占若干个字节

2.5 char 型变量能不能存储一个中文汉字?

char 型变量是用来存储 Unicode 编码的字符的,而 Unicode 编码字符集中包含了汉字,所以,char型变量中当然可以存储汉字啦。如果某个特殊的汉字没有被包含在unicode编码字符集中,那么,这个char型变量中就不能存储这个特殊汉字。

补充说明:unicode 编码占用两个字节,所以,char 类型的变量也是占用两个字节

2.6 final 关键字有什么作用

  • final 修饰的类不能被继承,final 类中的成员变量可以根据需要设为 final,但要注意 final 类中的所有成员方法都会被隐式地指定为final方法。
  • final 修饰的方法不能被重写
  • final 修饰的变量叫做常量,如果是基本类型,则数值初始化后就不能改变了,如果是引用类型,则对其初始化后则不能再让其指向到另一个对象了。

2.7 前置或后置自增/自减运算符的区别

++-- 就是对变量进行自增1或者自减1的运算符,前置后置是有区别的:

规则:运算符前置则先加/减,运算符后置则后加/减

int x = 4;
int y = (x++) + (++x) + (x * 10);
System.out.println(y);
复制代码

首先 (x++) 中 x 后置 ++ 所以后加减,即x 运算时取 4 然后自增为 5

其次 (++x) 中 x 前置 ++ 所以先加减, x = 6

接着 x 10 = 6 10 = 60

最后执行赋值语句,即:y = 4 + 6 + 60 = 70

2.8 & 和 && 的区别

& 运算符有两种用法:① 按位与 ② 逻辑与(这里只讨论)

&& 运算符是短路与运算

逻辑与跟短路与都要求运算符左右两端的布尔值都是 true 整个表达式的值才是 true

&& 具有短路作用,如果&&左边的表达式的值是false,右边的表达式会被直接短路掉,不会进行运算,因此效率更高

一般更推荐使用 &&,例如在验证用户登录时判定用户名不是null而且不是空字符串,应当写为:username != null &&!username.equals(""),二者的顺序不能交换,更不能用&运算符,因为第一个条件如果不成立,根本不能进行字符串的equals比较,否则会产生NullPointerException异常。

注意:逻辑或运算符(|)和短路或运算符(||)的差别也是如此。

补充:& 还可以当做位运算符,当 & 两边操作数或表达式的结果不是布尔类型的时候,& 即按照位于运算符操作

2.9 交换两个整型数的值你有几种方法?

方式1:使用一个中间值传递(因其可读性高,所以开发中也常用这种方式)

方式2:用位异或实现

  • ^ 位异或运算符的特点:一个数据对另一个数据位异或两次,该数本身不变
a = a ^ b;
b = a ^ b; // 将 a 带入,即: b = a ^ b ^ b 
a = a ^ b; // a 还是 a ^ b , b 变成了 a 即: a = a ^ b ^ a = b
复制代码

方式3:用变量相加的方法

a = a + b;
b = a - b;
a = a - b; 
复制代码

方式4:一句话的事

b = (a + b) - (a = b);
复制代码

此处方式 1 2 4 都好理解,顺便回顾一下原反补码,以及各种位运算

2.9.1 简单讲一下原码,补码,反码

在计算机内,有符号数有三种表示方法,源码、反码、和补码。而所有的数据运算都是采用补码进行的

  • 原码:二进制点表示法,最高位为符号位,“0”表示正,“1”表示负,其余位置表示数值大小,可直观反映出数据的大小。

    • 正数的原码最高位是 0 ,负数的原码最高位是 1 ,其他的是数值位
  • 反码:解决负数加法运算问题,将减法运算转换为加法运算,从而简化运算规则。

    • 正数的反码与原码相同,负数的反码与源码符号位相同,数值位取反 1 -> 0 、 0 -> 1
  • 补码:解决负数加法运算正负零问题,弥补了反码的不足。

    • 正数的补码与原码相同,负数的补码是在反码的基础上+1

2.9.2 介绍一下几种位运算

位运算需要将数据转换成二进制,用 0 补齐位数

& 位与运算符:有 0 则 0

| 位或运算符:有 1 则 1

^ 位异或运算符:相同则 0,不同则1

~ 按位取反运算符:0 变 1,1 变 0(拿到的是补码,要转换为原码)

<< 按位左移运算符:左边最高位丢弃,右边补齐

  • 快速计算:把 << 左边的数据 乘以 2 的移动次幂:例如 3 << 2 即:3 * 2 ^ 2 = 12

>> 按位右移运算符:最高位为 0,左边补齐 0,最高位是 1,左边补齐 1

  • 快速计算:把 >> 左边的数据 除以 2 的移动次幂:例如 -24 >> 2 即:-24 / 2 ^ 2 = -6

>>> 按位右移补零操作符:无论最高位是 0 还是 1 ,左边补齐 0

分别演示 ^>> 两个典型运算符

  • 3 ^ 4
// 3 的二进制: 11 补齐位数
00000000 00000000 00000000 00000011
// 4 的二进制: 100 补齐位数
00000000 00000000 00000000 00000100
// 位异或运算符: 相同则 0, 不同则1
00000000 00000000 00000000 00000011
00000000 00000000 00000000 00000100
-----------------------------------
00000000 00000000 00000000 00000111
// 得到的为补码,因为符号位为 0 即为正数,所以原反补码一致,所以结果(原码)就是二进制的111,即十进制的 7
复制代码
  • -24 >> 2
// -24 的二进制: 11000 负数符号位为 1, 补齐位数
原码: 10000000 00000000 00000000 00011000
反码: 11111111 11111111 11111111 11100111
补码: 11111111 11111111 11111111 11101000
// 右移 2 位 最高位是 1,左边补齐 1
11111111 11111111 11111111 11101000
1111111111 11111111 11111111 111010(00)
// 拿到的结果为补码,按8位划分开
补码: 11111111 11111111 11111111 11111010
反码: 11111111 11111111 11111111 11111001
原码: 10000000 00000000 00000000 00000110
// 结果是二进制的 110, 即十进制的 -6
复制代码

2.10 用最有效率的方法算出2乘以8等于多少

使用位运算来实现效率最高。位运算符是对操作数以二进制比特位为单位进行操作和运算,操作数和结果都是整型数。

对于位运算符 << , 是将一个数左移 n 位,就相当于乘以了 2 的 n 次方,那么,一个数乘以 8 只要将其左移 3 位即可,位运算是 cpu 直接支持的,效率最高。

所以,2乘以8等于几的最效率的方法是 2 << 3

2.11 break、continue、return 的区别?

break、continue 均属于跳出循环的关键字,return 属于跳出方法的关键字

  • break:完全跳出一个循环体,执行该循环体下接着的语句
  • continue:跳过本次循环,执行下一次循环
  • return:结束方法的运行,有两种用法

    • return;:用于没有返回值的方法(可不写)
    • retu
    • rn value:用于返回一个特定的值
  1. 基本数据类型

3.1 讲一讲 Java 中的几种基本数据类型

首先Java是一种强类型的语言,针对每一种数据都定义了明确的数据类型(就是将一些值的范围做了约束,从而为不同类型的值在内存中分配不同的内存空间)

Name

Size(字节|位数)

Range

byte

1byte | 8bit

-128~127 之间

short

2bytes | 16bit

-32768~32767 之间,最大数据存储量是65536

int

4bytes | 32bit

-2^31 ~ 2^31-1 之间

long

8bytes | 64bit

-2^63 ~ 2^63-1

float

4bytes | 32bit

3.4e-45~1.4e38,直接赋值时必须在数字后加上 f 或 F

double

8bytes | 64bit

4.9e-324~1.8e308,赋值时可以加 d 或 D 也可以不加

boolean

只有 true 和 false 两个取值

char

2bytes

存储Unicode码,用单引号赋值

注意:对于 boolean,官方文档未明确定义,它依赖于 JVM 厂商的具体实现。逻辑上理解是占用 1 位,但是实际中会考虑计算机高效存储因素。

3.2 谈谈数据类型转换时的精度处理问题

一般来说,我们在运算的时候,要求参与运算的数值类型必须一致,针对类型不一致的时候,有两种将不同数据类型统一的方式,即:默认自动转换(从小到大的转换)和 强制转换。

  • 默认自动转换:即从 byte,short, char 三者都会被默认的转到更高的精度类型,精度等级顺序如下 ( ==> int ==> long ==> float ==> double )
疑惑:为什么 float(4个字节)在 long(8个字节)后面

A: 它们底层的存储结构不同

B: float表示的数据范围比long范围要大

long:2^63-1

float:3.4_10^38 > 2_10^38 > 2_8^38 > 2_2^3^38

= 2*2^144 > 2^63 -1

默认类型转换示例:

// 例如低精度byte到高精度int 会根据默认转换,自动转换类型
// 可以正常执行
public static void main(String[] args) {
    byte a = 2;
    int b = 3;
    int c = a + b
    System.out.println(c);
}

// 高精度int到低精度byte 可能会损失精度
// 直接报错,不兼容的类型:从int转换到byte可能会有损失
public static void main(String[] args) {
    byte a = 3;
    int b = 4;
    byte c = a + b
    System.out.println(c);
}
复制代码
  • 强制类型转换

    • 格式:目标数据类型 变量 = (目标数据类型)(被转换的数据)

注意:不要随便的去用强制转化,因为它隐含了精度损失的问题,把容量大的类型转换为容量小的类型时必须使用强制类型转换。

int i = 128;   
// 因为byte类型是8位,最大值为127,所以当int强制转换为byte类型的时候,值128就会导致溢出
byte b = (byte)i;
复制代码

3.2.1 变量相加和常量相加类型转换时有什么区别

变量相加,会首先看类型问题,最终把结果赋值也会考虑类型问题

常量相加,首先做加法,然后看结果是否在赋值的数据类型范围内,如果不是,才报错

3.2.2 Java背后是如何强制转换 byte 类型溢出错误问题的

public static void main(String[] args) {
    // byte 的范围是: -128到127,所以报错
    byte a = 130;
    // 使用强制类型转换
    byte b = (byte)130;
    System.out.println(b);
}
复制代码

我们想要知道结果是什么,就应该知道是如何计算的,而我们又知道计算机中数据的运算都是补码进行的,得到补码,首先要计算出数据的二进制

// 求出130的二进制 10000010
// 130 是一个整数 所以补齐4个字节 (一个字节8位)
0000000  00000000  00000000  10000010

// 做截取操作,截成byte类型(1个字节,8位)
10000010

// 上述结果是补码,求其原码
补码: 10000010
反码: 10000001
原码: 11111110
// 11111110 转换为十进制为 -126
复制代码

3.3 Java 中基础类型对应的包装类型是什么,自动装箱与拆箱又是什么?

Java中有 8 种基本数据类型,分别为:byte、short、int、long、float、double、char、boolean。

对应的包装类分别为:Byte、Short、Integer、Long、Float、Double、Character、Boolean

将基本数据类型封装成对象的的好处在于可以在对象中定义更多的功能方法操作该数据,比如 String 和 int 类型的相互转换。同时简化了基本数据类型和相对应对象的转化步骤。

  • 自动装箱:将基本类型用它们对应的引用类型包装起来
  • 自动拆箱:将包装类型转换为基本数据类型

而在我们想要使用包装类的一些方法的时候,可以通过基本类型包装类的构造方法将值传入,但是 JDK5 后的新特性就为我们大大的简化了一些麻烦的步骤。

// 定义一个 包装类型 Integer 接收一个基本类型 int 整数 1, 这就是一个自动装箱。
Integer a = 1;
// 如果没有自动装箱的话,需要使用构造函数
Integer a = new Integer(1)
// 继续用 int 类型 b 接收一个 上面的包装类型 Integer a, 这就是一个自动拆箱
int b = a;
// 如果没有自动拆箱的话,需要使用方法
int b = a.intValue()
复制代码

3.4 几种包装类类型的常量池(缓冲区)问题

在 JDK 5 以后,几种包装类对象在内部实现中通过使用相同的对象引用实 现了缓存和重用。例如:Integer类型对于-128-127之间的数字是在缓冲区取的,所以对于在这个范围内的数值用双等号(==)比较是一致的,因为对应的内存地址是相同的。但对于不在这区间的数字是在堆中 new 出来的,所以地址空间不一样,也就不相等。

  • Byte、Short、Integer、Long 缓存范围:[-128,127]
  • Character 缓存范围:[0,127]
  • Boolean 直接返回 True Or False

注:浮点数类型的包装类 Float 和 Double 并没有实现常量池技术

Boolean 源码节选:

// 一开始就定义 TRUE FALSE 两个常量
public static final Boolean TRUE = new Boolean(true);
public static final Boolean FALSE = new Boolean(false);

// 很少使用此构造函数, 非必须时推荐使用静态工厂
public Boolean(boolean value) {
    this.value = value;
}

// valueOf 是一个更好的选择,它能产生更好的时间和空间性能
public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}
复制代码

Character 源码节选:

// 此方法通常优先于构造函数, 原因也是产生更好的时间和空间性能
public static Character valueOf(char c) {
    if (c <= 127) { // must cache
        return CharacterCache.cache[(int)c];
    }
    return new Character(c);
}

// 具体的逻辑在此
private static class CharacterCache {
    private CharacterCache(){}

    static final Character cache[] = new Character[127 + 1];

    static {
        for (int i = 0; i < cache.length; i++)
            cache[i] = new Character((char)i);
    }
}
复制代码

Integer 源码节选:

// 该方法缓存的值总是在-128到127之间,并可能缓存该范围之外的其他值
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

// IntegerCache 具体逻辑可自行研究
复制代码
  1. 方法

4.1 Java 中方法参数传递为值传递还是引用传递

Java 中方法参数传递方式是按值传递

  • 如果参数是基本类型,传递的是基本类型的字面量值的拷贝。形式参数的改变对实际参数没有影响
  • 如果参数是引用类型,传递的是该参量所引用的对象在堆中地址值的拷贝。形式参数的改变直接影响实际参数

下面我们对以上结论进行简单分析:

示例 1:

public static void main(String[] args) {
    // 基本类型
    int a = 100;
    int b = 200;
    System.out.println("main 调用 modify 前: " + "a: " + a + ", b: " + b);
    modify(a, b);
    System.out.println("main 调用 modify 后: " + "a: " + a + ", b: " + b);
}

/**
* 参数为基本类型
* @param a
* @param b
*/
public static void modify(int a, int b) {
    System.out.println("modify 接收到参数: " + "a: " + a + ", b: " + b);
    a = 300;
    b = 400;
    System.out.println("modify 修改参数后: " + "a: " + a + ", b: " + b);
}
复制代码

运行结果:

main 调用 modify 前: a: 100, b: 200
modify 接收到参数: a: 100, b: 200
modify 修改参数后: a: 300, b: 400
main 调用 modify 后: a: 100, b: 200
复制代码

示例 2:

public static void main(String[] args) {
    // 引用类型
    int[] arr = {1, 2, 3, 4, 5};
    System.out.println("main 调用 modify 前: " + "arr[0]: " + arr[0]);
    modify(arr);
    System.out.println("main 调用 modify 后: " + "arr[0]: " + arr[0]);
}

/**
* 参数为引用类型
* @param arr
*/
public static void modify(int[] arr) {
    System.out.println("modify 接收到参数(以arr[0]举例): " + "arr[0]: " + arr[0]);
    arr[0] = 100;
    System.out.println("modify 修改参数后(以arr[0]举例): " + "arr[0]: " + arr[0]);
}
复制代码

运行结果:

main 调用 modify 前: arr[0]: 1
modify 接收到参数(以arr[0]举例): arr[0]: 1
modify 修改参数后(以arr[0]举例): arr[0]: 100
main 调用 modify 后: arr[0]: 100
复制代码

上述代码的结果,即:以基本类型作为方法参数,方法内对形参的修改,不会影响到实际参数。以引用类型作为方法参数,方法内对形参的修改,会直接影响到实际参数。画一张图简单分析一下:

对于基本类型,a 和 b ,在 modify(int, int) 方法中进行修改不会影响原先的值,这是因为 modify 方法中的参数 a 和 b 是从原先的 a 和 b 复制过来的一个副本。无论如何修改 a 和 b 的值,都不会影响到原先的值。

对于引用类型,arr 数组初始化后,指向到了一个具体的地址中,而将其作为方法参数传递,modify 方法中的 arr 也就指向到了同一个地址去,所以方法内的修改,会直接反映在所对应的对象上。

4.2 说一说方法重载和重写的区别

方法重载:在一个类中,同名的方法如果有不同的参数列表(参数类型、个数甚至顺序不同)则叫做重载

  • 规则:在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同,方法返回值和访问修饰符可以不同。
  • 表现形式:方法名,返回值,访问修饰符,相同的方法,根据不同的数据列表,做出不同的逻辑处理。

方法重写:是子类对父类的允许访问的方法的实现过程进行重新编写

  • 规则

    • 方法名、参数列表、返回类型都相同的情况,对方法体进行修改或者重写。
    • 访问修饰符的限制一定要大于被重写方法的访问修饰符(public > protected > default > private)。
    • 重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常
  • 表现形式:重写就是当子类继承自父类的相同方法,输入一样的数据,你就要覆盖父类方法,使得方法能做出不同的响应

4.2.1 如何理解方法的重载和重写都是实现多态的方式

方法的重载和重写都是实现多态的方式,区别在于重载实现的是编译时的多态性,而重写实现的是运行时的多态

性。这里的多态可以理解为一个方法的调用,或者函数入口参数的不同,而造成的方法行为不同。

两种不同时期的多态:

  • 编译时期多态:其又被称为静态多态,编译时期的多态是靠重载实现的,根据参数个数,类型和顺序决定的(必须在同一个类中)

    • 在方法调用之前,编译器就已经确定了所要调用的方法,这称为“早绑定”或“静态绑定” ;
  • 运行时的多态:运行时期的多态是靠方法的重写实现的,在编译期间被视作相同方法,但是运行期间根据对象的不同调用不同的方法

    • 只有等到方法调用的那一刻, 解释运行器才会确定所要调用的具体方法,这称为“晚绑定”或“动态绑定” 。
    • 这也就是我们说的,编译看左边,运行看右边(会在面向对象篇设涉及)

4.2.1 为什么函数不能根据返回类型来区分重载?

Java 允许重载任何方法,而不只是构造器方法。因此要完整的指出方法名以及参数类型。这叫做方法的签名(signature)。例如 String 类有 4 个称为 indexOf 的公有方法。它们的签名是:

indexOf(int)
indexOf(int, int)
indexOf(String)
indexOf(String, int)
复制代码

返回值类型不是方法签名的一部分。也就是说,不能有两个名字相同、参数类型也相同却返回不同类型值的方法。

同时函数的返回值只是作为函数运行之后的一个“状态”,他是保持方法的调用者与被调用者进行通信的关键。并不能作为某个方法的“标识”。

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/692043...

查看原文

赞 1 收藏 1 评论 0

Java攻城师 发布了文章 · 1月21日

垃圾代码书写准则(有意思)

💩 以一种容易造成代码混淆的方式命名变量

命名越短,就需要越多的时间去思考代码逻辑等问题。

Good 👍🏻

int a = 42;
复制代码

Bad 👎🏻

int age = 42;
复制代码

💩 变量/方法命名风格不统一

为风格不统一干杯。

Good 👍🏻

int wWidth = 640;
int w_height = 480;
复制代码

Bad 👎🏻

int windowWidth = 640;
int windowHeight = 480;
复制代码

💩 不写注释

反正没人能读懂你的代码。

Good 👍🏻

int cdr = 700;
复制代码

Bad 👎🏻

注释应该包含一些“为什么”,而不是一些“是什么”。如果代码连是“什么”都表达不清楚,那代码也太烂了。

// 700ms 的数量是从 UX A/B 测试结果中得到的一个经验值。
// @查看: <详细解释 700 的一个链接>
int callbackDebounceRate = 700;
复制代码

💩 使用母语写注释

如果你的母语是英语,那么请忽略这条准则。

Good 👍🏻

// Закриваємо модальне віконечко при виникненні помилки.
toggleModal(false);
复制代码

Bad 👎🏻

// 隐藏错误弹窗
toggleModal(false);
复制代码

PS:如果英语书写能力不是很强的话,建议还是用母语吧。毕竟说清楚总比说不清楚要强。

💩 声明变量的风格不统一

再次为风格不统一干杯。

Good 👍🏻

String [] i1 = {"沉", "默", "王", "二"};
String i2 [] = {"沉", "默", "王", "三"};
复制代码

Bad 👎🏻

String [] wanger = {"沉", "默", "王", "二"};
String wangsan [] = {"沉", "默", "王", "三"};
复制代码

💩 尽可能把代码写成一行

Good 👍🏻

IntStream.range(1, 5).boxed().map(i -> { System.out.print("Happy Birthday "); if (i == 3) return "dear NAME"; else return "to You"; }).forEach(System.out::println);
复制代码

Bad 👎🏻

for (int i = 1; i < 5; i++) {
    System.out.println("Happy Birthday " + (i == 3 ? "dear NAME" : "to you"));
}
复制代码

💩 对错误信息不管不顾

无论什么时候发现错误,都没有必要让其他人知道。

Good 👍🏻

try {
  // 意料之外的情况。
} catch (error) {
  // tss... 🤫
}
复制代码

Bad 👎🏻

try {
  // 意料之外的情况。
} catch (error) {
  // and/or
  logError(error);
}
复制代码

💩 使用大量的全局变量

全球化的原则。

Good 👍🏻

int x = 5;

void multi() {
  x = x * 2;
}

multi(); // 现在 x 是 10
复制代码

Bad 👎🏻

int x = 5;

int multi(int num) {
  return num * 2;
}

x = multi(x); // 现在 x 是 10
复制代码

💩 声明根本不会使用的变量

万一以后用了呢?以备不时之需。

Good 👍🏻

int sum(int a, int b, int c) {
  int timeout = 1300;
  int result = a + b;
  return a + b;
}
复制代码

Bad 👎🏻

int sum(int a, int b) {
  return a + b;
}
复制代码

💩 如果条件允许的话,从不指定类型。

Good 👍🏻

// 享受便捷的快乐
List list = new ArrayList();
list.add("沉默王二");
list.add(18);
复制代码

Bad 👎🏻

List<String> nameList = new ArrayList<String>();

// 编译出错
nameList.add(18);
复制代码

💩 没鸟用的代码

看起来更严谨,其实很多余。

Good 👍🏻

Integer multi(Object num) {
    if (!(num instanceof Integer)) {
        return null;
    } else if (num != null) {
        return (Integer) num * 2;
    }
    return null;
}
复制代码

Bad 👎🏻

Integer multi(Object num) {
    if (num instanceof Integer) {
        return (Integer) num * 2;
    }
    return null;
}
复制代码

💩 大量的 if-else 嵌套

Good 👍🏻

void someMethod(int a, int b, int c) {
    if (a > 0) {
        if (b > 0) {
            if (c > 0) {
               int result = a / b / c;
            }
        }
    }
}
复制代码

Bad 👎🏻

void someMethod1(int a, int b, int c) {
    if (a < 0 || b < 0 || c < 0) {
        return;
    }
    int result = a / b / c;
}
复制代码

💩 参差不齐地缩进

参差不齐乃幸福本源。

Good 👍🏻

String [] wanger = {"沉", 
        "默", "王", "二"};
String [] wangsan = {"沉", "默", "王", "三"};
Arrays.asList(wanger).stream().
        forEach(System.out::println);
Arrays.asList(wangsan).
        stream().
                forEach(System.out::println);
复制代码

Bad 👎🏻

String [] wanger = {"沉", "默", "王", "二"};
String [] wangsan = {"沉", "默", "王", "三"};
Arrays.asList(wanger)
        .stream()
        .forEach(System.out::println);
Arrays.asList(wangsan)
        .stream()
        .forEach(System.out::println);
复制代码

💩 代码行数多的方法的比少的好

不要把代码逻辑分成可读的部分。

  • 一个类中的代码行数超过 10000 行。
  • 一个方法中的代码行数超过 1000 行。
  • 一个方法里既做减法处理又做加法处理,还做乘除的处理。

💩 不要测试你的代码

代码测试是测试工程师的事,关我屁事。

💩 避免代码风格统一

随心所欲地编写代码,特别是在一个团队中有多个开发人员的情况下,我崇尚“自由”。

💩 不要写文档

从一开始就不要。

💩 不要删除废弃掉的代码

代码尽管已经废弃了,注释掉就行了,没必要删掉。

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/692002...

查看原文

赞 1 收藏 0 评论 0

Java攻城师 发布了文章 · 1月21日

Spring Boot 快速迁移至 Quarkus

Quarkus 是一个目前非常火的 Java 应用开发框架,定位是轻量级的微服务框架。,Quarkus 提供了优秀的容器化整合能力,相较于传统开发框架(Spring Boot)有着更快的启动速度、更小的内存消耗、更短的服务响应。

Quarkus 性能对比图

Quarkus 性能对比图

本文将演示将 SpringBoot 迁移至 Quarkus

Spring Boot 示例程序

使用 JPA 完成 数据库的增删改查操作,基础代码如下

  • maven 依赖

`<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
复制代码`

  • jpa crud

`public interface DemoUserDao extends CrudRepository<DemoUser, Long> {
}
复制代码`

迁移至 Quarkus

  • quarkus-bom 管理了全部 quarkus 插件 maven 依赖的版本信息,引入后所有依赖不需要再定义版本。

    `<dependencyManagement>

  <dependencies>
   <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-bom</artifactId>
    <version>1.10.5.Final</version>
    <type>pom</type>
    <scope>import</scope>
   </dependency>
  </dependencies>
 </dependencyManagement>
复制代码`

  • 迁移 spring-web 、spring-jpa 至 quarkus 技术栈。

`<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-spring-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-spring-web</artifactId>
</dependency>
复制代码`

  • 配置文件调整 (还是在 application.yml)

`quarkus.datasource.db-kind=mysql
quarkus.datasource.jdbc.driver=com.mysql.cj.jdbc.Driver
quarkus.datasource.username=root
quarkus.datasource.password=root
quarkus.datasource.jdbc.url=jdbc:mysql://localhost:3306/pig_demo?useUnicode=true&characterEncoding=utf8&autoReconnect=true&rewriteBatchedStatements=TRUE
复制代码`

  • Main 方法调整为 实现 QuarkusApplication ,且需要通过 Quarkus.waitForExit() 保持服务运行。

`@QuarkusMain
public class SimpleApplication implements QuarkusApplication {
 public static void main(String[] args) {
  Quarkus.run(SimpleApplication.class,args);
 }
 @Override
 public int run(String... args) {
  Quarkus.waitForExit();
  return 0;
 }
}
复制代码`

启动运行

main 方法启动, 输出 Quarkus banner

`__  ____  __  _____   ___  __ ____  ______
 --/ __ / / / / _ | / _ / //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ / 
--________/_/ |_/_/|_/_/|_|____/___/
2021-01-12 22:31:46,341 INFO  [io.qua.arc.pro.BeanProcessor] (build-21) Found unrecommended usage of private members (use package-private instead) in application beans:
 - @Inject field com.example.simple.controller.DemoController#userDao
2021-01-12 22:31:48,702 INFO  [io.quarkus] (Quarkus Main Thread) Quarkus 1.10.5.Final on JVM started in 4.613s. Listening on: http://localhost:8080
2021-01-12 22:31:48,703 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2021-01-12 22:31:48,703 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, mutiny, narayana-jta, resteasy, resteasy-jackson, smallrye-context-propagation, spring-data-jpa, spring-di, spring-web]
复制代码`

非常重要的是输出了当前已经安装的功能

`Installed features: [agroal, cdi, hibernate-orm, hibernate-orm-panache, mutiny, narayana-jta, resteasy, resteasy-jackson, smallrye-context-propagation, spring-data-jpa, spring-di, spring-web]
复制代码`

【扩展】 actuator 监控迁移

  • 添加以下依赖

`<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-smallrye-health</artifactId>
</dependency>
复制代码`

  • 指定访问监控断点路径

`quarkus.smallrye-health.root-path=/actuator/health
复制代码`

{
    "status": "UP",
    "checks": [
        {
            "name": "Database connections health check",
            "status": "UP"
        }
    ]
}⏎
复制代码`

【扩展】Flyway 迁移

  • 添加 quarkus flyway 插件

`<dependency>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-flyway</artifactId>
</dependency>
复制代码`

  • 指定插件启动策略即可

quarkus.flyway.migrate-at-start=true

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/692003...

查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 1月21日

一个成熟的Java项目如何优雅地处理异常

(一)概述

异常处理是一个系统最重要的环节,当一个项目变得很大的时候,异常处理和日志系统能让你快速定位到问题。对于用户或者接口调用者而言,优雅的异常处理可以让调用者快速知道问题所在。本文将介绍如何优雅地处理异常。

(二)使用通用的返回体

我们希望所有的错误都以Json的方式返回给客户,因此拿出上次写的通用返回体,新建一个类CommonResult记录返回体。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult {
    private int code;
    private String message;
    private Object data;
}
复制代码

新建一个枚举类ResponseCode集成code和message。

public enum ResponseCode {

    // 系统模块
    SUCCESS(0, "操作成功"),
    ERROR(1, "操作失败"),
    SERVER_ERROR(500, "服务器异常"),

    // 通用模块 1xxxx
    ILLEGAL_ARGUMENT(10000, "参数不合法"),
    REPETITIVE_OPERATION(10001, "请勿重复操作"),
    ACCESS_LIMIT(10002, "请求太频繁, 请稍后再试"),
    MAIL_SEND_SUCCESS(10003, "邮件发送成功"),

    // 用户模块 2xxxx
    NEED_LOGIN(20001, "登录失效"),
    USERNAME_OR_PASSWORD_EMPTY(20002, "用户名或密码不能为空"),
    USERNAME_OR_PASSWORD_WRONG(20003, "用户名或密码错误"),
    USER_NOT_EXISTS(20004, "用户不存在"),
    WRONG_PASSWORD(20005, "密码错误"),
    ;

    ResponseCode(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    private Integer code;
    private String msg;
    public Integer getCode() {
        return code;
    }
    public void setCode(Integer code) {
        this.code = code;
    }
    public String getMsg() {
        return msg;
    }
    public void setMsg(String msg) {
        this.msg = msg;
    }
}
复制代码

(三)自定义运行时异常

自定义一个运行时异常类,构造方法传入异常参数即可。

public class MyException extends RuntimeException{
    private String msg;

    public MyException(String msg) {
        super(msg);
    }
}
复制代码

(四)编写一个统一的异常处理类

异常处理类是整个异常处理核心,SpringBoot中提供了ControllerAdvice注解来拦截异常,使用RestControllerAdvice注解保证了返回Json格式。

如果拦截到的异常属于MyException,则按Json格式返回错误结果。

@RestControllerAdvice
public class ExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = Exception.class)
    public CommonResult exceptionHandler(Exception e){
        //如果抛出的异常属于自定义异常,就以JSON格式返回
        if (e instanceof MyException){
            return new CommonResult(ResponseCode.ERROR.getCode(),ResponseCode.ERROR.getMsg(),"自定义的错误为:"+e.getMessage());
        }
        //如果都不是就打印出异常的信息
        return new CommonResult(ResponseCode.ERROR.getCode(),ResponseCode.ERROR.getMsg(),"错误的信息为:"+e.getMessage());
    }
}
复制代码

(五)测试

为了看初效果,这里手动抛出一个异常来测试,新建IndexController,手动抛出异常

@RestController
public class IndexController {

    @RequestMapping(value = "/index",method = RequestMethod.GET)
    public String index(){
        throw new MyException("测试");
    }
}
复制代码

查看调用结果:

在这里插入图片描述

(六)对实体类的校验

有这样一个场景,登陆注册时用户名和密码有长度限制,手机号有格式限制,如果不满足要求就无法注册。这个功能前端可以限制,但是对于后端接口而言,也需要进行限制,万一前端没有限制住呢。

导入两个校验依赖包:

<!--校验-->
<!-- https://mvnrepository.com/artifact/javax.validation/validation-api -->
<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.hibernate/hibernate-validator -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.0.Final</version>
</dependency>
复制代码

编写实体类,在每个属性上加上校验包的验证参数。

@Data
public class Register {

    @Length(max = 20,min = 4,message = "用户名长度需要在4到20个字符之间")
    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "手机号不能为空")
    @Pattern(regexp = "^1[3|4|5|8][0-9]d{8}$",message = "电话号码格式不正确")
    private String phone;

    @Length(max = 20,min = 4,message = "密码长度需要在4到20个字符之间")
    @NotBlank(message = "密码不能为空")
    private String password;
}
复制代码

我们在需要使用的方法中增加@Valid注解进行校验,比如这个post请求中我要校验。

@PostMapping("/register")
public CommonResult register(@Valid @RequestBody Register register){
    //一连串注册的业务
    userService.registerUser(register);
    return new CommonResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMsg(),"");
}
复制代码

@Valid在校验失败的情况下会报出参数不合法的异常,还是在统一的异常处理类中捕获异常,如果是MethodArgumentNotValidException,就取出对应的message数据。

@RestControllerAdvice
public class ExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = Exception.class)
    public CommonResult exceptionHandler(Exception e){
        //如果属于参数校验异常,就抛出校验的错误
        if (e instanceof MethodArgumentNotValidException){
            MethodArgumentNotValidException methodArgumentNotValidException= (MethodArgumentNotValidException) e;
            return new CommonResult(ResponseCode.ERROR.getCode(),ResponseCode.ERROR.getMsg(),
                    "校验错误:"+methodArgumentNotValidException.getBindingResult().getFieldError().getDefaultMessage());
        }//如果是自定义的异常,就给出具体的异常原因
        else if (e instanceof MyException){
            return new CommonResult(ResponseCode.ERROR.getCode(),ResponseCode.ERROR.getMsg(),"自定义的错误为:"+e.getMessage());
        }
        //如果都不是就打印出异常的信息
        return new CommonResult(ResponseCode.ERROR.getCode(),ResponseCode.ERROR.getMsg(),"错误的信息为:"+e.getMessage());
    }
}
复制代码

(七)测试校验

接下来就可以测试校验的功能了,通过postman访问

在这里插入图片描述

如果输入参数不满足之前的设置,就会给出具体的错误信息。而不是抛出让人无法接收的报错:

在这里插入图片描述

(八)总结

许多人写代码时最不考虑的就是异常处理,简单地实现需求就好了,所以才会导致许多不可预估的bug出现。

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/691981...

查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 1月20日

0.1 + 0.2 != 0.3?

Floating Point Math

Your language isn’t broken, it’s doing floating point math. Computers can only natively store integers, so they need some way of representing decimal numbers. This representation is not perfectly accurate. This is why, more often than not, 0.1 + 0.2 != 0.3.

Why does this happen?

It’s actually rather interesting. When you have a base-10 system (like ours), it can only express fractions that use a prime factor of the base. The prime factors of 10 are 2 and 5. So 1/2, 1/4, 1/5, 1/8, and 1/10 can all be expressed cleanly because the denominators all use prime factors of 10. In contrast, 1/3, 1/6, 1/7 and 1/9 are all repeating decimals because their denominators use a prime factor of 3 or 7.

In binary (or base-2), the only prime factor is 2, so you can only cleanly express fractions whose denominator has only 2 as a prime factor. In binary, 1/2, 1/4, 1/8 would all be expressed cleanly as decimals, while 1/5 or 1/10 would be repeating decimals. So 0.1 and 0.2 (1/10 and 1/5), while clean decimals in a base-10 system, are repeating decimals in the base-2 system the computer uses. When you perform math on these repeating decimals, you end up with leftovers which carry over when you convert the computer’s base-2 (binary) number into a more human-readable base-10 representation.

Below are some examples of sending .1 + .2 to standard output in a variety of languages.

翻译如下:

浮点数学

您的语言没有坏,正在做浮点运算。计算机只能本地存储整数,因此它们需要某种表示十进制数字的方式。此表示并不完全准确。这就是为什么(通常不是)的原因 0.1 + 0.2 != 0.3

为什么会这样?

实际上,这很有趣。当您有一个以10为底的系统(如我们的系统)时,它只能表示使用该底数的质数的分数。10的素数是2和5。因此,由于分母都使用10的素数,所以1 / 2、1 / 4、1 / 5、1 / 8和1/10都可以清楚地表示。 / 3、1 / 6、1 / 7和1/9都是重复的小数,因为它们的分母使用3或7的质数。

在二进制(或以2为基数)中,唯一的质数是2,因此您只能清楚地表达分母只有2作为质数的分数。以二进制形式,1 / 2、1 / 4、1 / 8都将干净地表示为小数,而1/5或1/10将重复小数。因此,在以10为基数的系统中使用干净的小数时,0.1和0.2(1/10和1/5)在计算机使用的以2为基数的系统中重复小数。当您对这些重复的小数进行数学运算时,最终会剩下余数,当您将计算机的以2为底的(二进制)数字转换为更易于理解的以10为底的表示形式时,这些余数就会保留下来。

以下是使用.1 + .2多种语言发送到标准输出的一些示例。

在Java中的情况:Java使用BigDecimal类内置了对任意精度数字的支持。

所有语言中的情况:

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:Floating Point Math
查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 1月20日

推荐几款顶级好用的IDEA插件

前言

“工欲善其事必先利其器” 在实际的开发过程中,灵活的使用好开发工具,将让我们的工作事半功倍。今天给大家推荐几款好用的IDEA插件,写代码也可以“飞起来”

美化插件

Material Theme UI

相亲第一眼也得看眼缘,所以今天推荐的第一款是主题插件,可以让你的idea图标、配置搭配很到位,也可以切换不用的颜色,默认提供了很多的主题供选择,每一种都是狂拽酷炫;当前端小姐姐或者测试小姐姐看到了你这么炫酷的界面,她肯定会觉得原来男孩子也会这么精致呀,形象陡然上升~

就问你,这么绚丽多彩的颜色,哪个小姐姐不为你着迷~

Extra Icons

这也是一款美化插件,为一些文件类型提供官方没有的图标

Background Image Plus

设置idea背景图片的插件,不但可以设置固体的图片,还可以设置一段时间后随机变化背景图片,以及设置图片的透明度等等;接下来我设置一张女神的照片,看着女神照片撸代码,整天心情美滋滋

实用插件

Translation

像我这样英文很菜的人来说,这款插件就是神器,在看各种框架源码的时候十分有用; 选择右键就可以翻译,对于方法或者类上面的注释,只要按下F1就自动被翻译成中文

Maven Helper

依赖包冲突的问题,我相信大家都遇到过,一旦出现了冲突,启动或运行过程总是会出一些莫名其妙的错误,查找冲突过程十分痛苦,但如果你安装了这个插件,那这些都不是事,分分钟搞定

Code Glance

Sublime Text右侧的预览区相信很多人都用过吧, 此插件就实现了代码编辑区迷你缩放功能, 达到代码全局预览

MyBatis Log Plugin

Mybaits在运行的时候会把SQL打印出来,但是打印的是带占位符的SQL,如果遇到SQL复杂的话,那么要手动拼接出这个SQL还是比较麻烦的,好在这个插件帮我们搞定

菜单栏 -> Tools -> MyBatis Log Plugin

Free Mybatis plugin

可以在Mybatis的Mapper接口和xml文件之间很方便的来回切换,像是查看接口实现类一样简单,无需到xml中去搜索。

Lombok

神器级别的插件,可以让实体类更加简化,不在需要写getter/setter方法,通过注解就可以实现builder模式以及链式调用;在充血模型中可以不需要在和getter/setter方法混在一起

项目还需要添加maven依赖

<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <version>1.16.10</version>
</dependency>
复制代码

Key promoter X

回想刚开始从eclipse切换到idea那段时间实在是很痛苦,就是因为快捷键不熟悉,熟悉开发工具的快捷键能够很好的提高我们的开发效率,这款工具的目的就是为了帮助用户记住快捷键,操作窗口之后就会在右下角给出快捷键的提示,提醒多了自然你就记住了。

Grep Console

在开发的过程中,idea的控制台通常会打印出一大推的日志,想要快速找到自己关心的日志比较困难,通过这个插件可以给不同级别的日志设置不同的展示样式,帮助快速定位日志

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/691964...

查看原文

赞 1 收藏 1 评论 0

Java攻城师 发布了文章 · 1月20日

完整的对象实例化过程

对象的实例化过程需要做哪些工作呢?首先Java是一门面向对象的语言,类是对所属于一类的所有对象的抽象,对象的所有结构化信息都定义在了类中,因此对象的创建需要根据类中定义的类型信息,也就是类所对应的class二进制字节流,所以这就涉及到了类的加载与初始化。其次,对象大多存储在堆内存中,这就涉及到内存的分配。除此之外,还有变量的初始化零值,对象头的设置,在栈中创建对象的引用等等,本文我们来一起详细的分析一下对象的完整实例化过程。

1 整体流程

从整天上来看对象的整个实例化过程如下图所示:

为了故事的顺利发展,这里我们定义一个Demo,并据此详细讨论一下dc对象是如何创建并实例化出来的。

public class Demo {

    public static void main(String[] args) {
        DemoClass dc=new DemoClass();
    }
}
class DemoClass {
    private static final int a=1;
    private static int b=2;
    private static int c;
    private int d=4;
    private int e;
    static
    {
        c=3;
    }
    public DemoClass() {
        e=5;
    }

}
复制代码

2 类初始化检查

​ 这里我们使用new 关键字创建对象,Java中创建对象的方式还有好多种,比如反射,克隆,序列化与反序列化等等。这些方式不一而同,但是经过编译器编译之后,对应到Java虚拟机中其实就是一条new(这里的new指令与前面提到的new关键字不同,这是虚拟机级别的指令)指令。当Java虚拟机碰到一条new指令时,会首先根据这条指令所对应的参数去常量池中查找是否有该类所对应的符号引用,并判断该类是否已经被加载、解析、初始化过,也就是到方法区中检查是否有该类的类型信息,如果没有,首先要进行类加载与初始化。如果类已经加载和初始化,那么继续后续的操作。

​ 这里假设DemoClass类还没有被加载与初始化,也就是方法区中还没有DemoClass的类型信息,这时需要进行DemoClass类的加载与初始化。

3 类加载过程

类加载过程总的可分为7个步骤:加载、验证、准备、解析、初始化、使用、卸载。这里我们看一下前六个阶段。

加载

加载阶段主要干了三件事:

  1. 根据类的全限定名获取类的二进制字节流。
  2. 将二进制字节流所代表的静态存储结构转化为方法区中运行时数据结构。
  3. 在内存中创建一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

​ 具体到这里就是首先根据package.DemoClass全限定名定位DemoClass.class二进制文件,然后将该.class文件加载到内存进行解析,将解析之后的结果存储在方法区中,最后在堆内存中创建一个Java.lang.Class的对象,用来访问方法区中加载的这些类信息。

验证

​ 验证阶段完成的任务主要是确保class文件中字节流中包含的信息符合Java虚拟机的规范,虽然说得很简单,但是Java虚拟机进行了很多复杂的验证工作,总的来说可分为四个方面:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

    具体到这里就是对于加载进内存的DemoClass.class中存储的信息进行虚拟机级别的校验,以确保DemoClass.class中存储的信息不会危害到Java虚拟机的运行。

准备

​ 准备阶段完成的工作就是为类变量(也就是静态变量)分配内存并赋予初始值,通常情况下是变量所对应的数据类型的零值。但是在这个阶段,被final修饰的变量也就是常量会在这个阶段准确的被赋值。

​ 具体到这里,在这个阶段DemoClass中的a会被赋值为1,b与c均被赋值为0。

解析

这个阶段主要的任务是将常量池中的符号引用替换为直接引用。

初始化

​ 在之前的阶段中,除了加载阶段通过自定义的类加载器可以干预虚拟机的加载过程外,其他的阶段都是虚拟机完全主导,而在初始化阶段才开始根据程序员的意愿执行类的初始化,这个阶段主要完成的工作是执行类构造器方法(),同时虚拟机会保证执行该类的类构造器方法时,其父类的类构造器方法已经被正确的执行,同时,由于类的初始化只进行一次,当多个线程并发的进行初始化时,虚拟机可以确保多个线程只有一个可以完成类的初始化工作, 保证线程安全工作。

具体到DemoClass类,在这个阶段会将b赋值为2,c赋值为3。

4 分配内存

当类加载过程完成后,或者类本身之前已经被加载过,下一步就是虚拟机要为新生对象分配内存。对象所需要的内存空间在类加载过程完成后就可以完全确定下来,为对象分配内存空间就相当于从堆内存中划分出一块合适的内存来,分配内存的主要方式有两种:指针碰撞和空闲列表。

  • 指针碰撞:这种方式将堆内存分为空闲空间与已分配空间,使用一个指针来作为二者之间的分界线,当要为新生对象分配内存空间的时候,相当于将指针向着空闲空间的方向移动一段与对象大小相等的距离,可见这种分配方式Java堆内存必须是规整的,所有空闲空间在一边,已分配空间在另外一边。

  • 空闲列表:在虚拟机中维护一个列表,用来记录堆中哪一块内存是空闲可用的,在为新生对象分配内存时,从列表中寻找一块合适大小的可用内存块,分配完成后更新空闲列表,这种方式下堆内存的空闲空间与分配空间可以交错存在。

  <img data-original="images/空闲列表.png" style="zoom:80%;" />

从上面来看,选择采用指针碰撞还是空闲列表法分配内存,主要由Java堆内存是否规整决定的,而Java堆内存是否规整又取决于所采用的垃圾收集算法,这就涉及到垃圾回收机制(可见知识都是相通的,程序员就是活到老学到死啊!),GC之后是否具有压缩或者整理的动作等等。

同时,由于创建对象的动作是十分频繁的,多线程可能存在多个线程同时申请为对象分配内存空间,这个时候如果不采取一定的同步机制,就有可能导致一个线程还未来得及修改指针,另一个线程就使用了原来的指针分配内存空间,因此衍生出来了两种解决方案:CAS配上失败重试、TLAB方式。

第一种方式很好理解,多个线程使用CAS的方式更新指针,多线程下只有一个线程可以更新完成,其他线程通过不断重试完成内存指针的重新移动。

第二种方式是每个线程提前分配一块内存空间,这个内存空间就是线程本地缓冲TLAB,这样线程每次要分配内存时,先去TLAB中获取,当TLAB中内存空间不足的时候才采用同步机制继续申请一块TLAB空间,这样就降低了同步锁的申请次数。

具体到这个阶段,是在堆内存中为DemoClass对象,也就是dc对象实例开辟了一块内存空间。

5 初始化零值

在为对象分配内存完成之后,虚拟机会将分配到的这块内存初始化为零值,这样也就使得Java中的对象的实例变量可以在不赋初值的情况下使用,因为代码所访问当的就是虚拟机为这块内存分配的零值。

具体到这里,就是Java虚拟机将上面分配的内存空间初始化为零值,这一步使得现在DemoClass中的d与e均被赋值为0。

6 设置对象头

对象头就像我们人的身份证一样,存放了一些标识对象的数据,也就是对象的一些元数据,我们首先看一下对象的构成。

在初始化了零值之后,怎么知道对象是哪个类的实例,就需要设置指向方法区中类型信息的指针,对象Mark Word中相关信息的设置,就在这个阶段完成。

7 实例对象初始化

这一步虚拟机将调用实例构造器方法(), 根据我们程序员的意愿初始化对象,在这一步会调用构造函数,完成实例对象的初始化。

具体到这里就是DemoClass的d被赋值为4,e被赋值为5。

8 创建引用,入栈

执行到这一步,堆内存中已经存在被完成创建完成的对象,但是我们知道,在Java中使用对象是通过虚拟机栈中的引用来获取对象属性,调用对象的方法,因此这一步将创建对象的引用,并压如虚拟机栈中,最终返回引用供我们使用。

在这里就是讲对象的引入入栈,并返回赋值给dc,至此,一个对象被创建完成。

对象实例化的完整流程

根据上面的讨论,我们再来回顾一下对象实例化的整个流程:

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/691969...

查看原文

赞 0 收藏 0 评论 0

Java攻城师 发布了文章 · 1月19日

生成订单后一段时间不支付订单会自动关闭的功能该如何实现?

业务场景


我们以订单功能为例说明下:生成订单后一段时间不支付订单会自动关闭。最简单的想法是设置定时任务轮询,但是每个订单的创建时间不一样,定时任务的规则无法设定,如果将定时任务执行的间隔设置的过短,太影响效率。还有一种想法,在用户进入订单界面的时候,判断时间执行相关操作。方式可能有很多,在这里介绍一种监听 Redis 键值对过期时间来实现订单自动关闭

实现思路


在生成订单时,向 Redis 中增加一个 KV 键值对,K 为订单号,保证通过 K 能定位到数据库中的某个订单即可,V 可为任意值。假设,生成订单时向 Redis 中存放 K 为订单号,V 也为订单号的键值对,并设置过期时间为 30 分钟,如果该键值对在 30 分钟过期后能够发送给程序一个通知,或者执行一个方法,那么即可解决订单关闭问题。实现:通过监听 Redis 提供的过期队列来实现,监听过期队列后,如果 Redis 中某一个 KV 键值对过期了,那么将向监听者发送消息,监听者可以获取到该键值对的 K,注意,是获取不到 V 的,因为已经过期了,这就是上面所提到的,为什么要保证能通过 K 来定位到订单,而 V 为任意值即可。拿到 K 后,通过 K 定位订单,并判断其状态,如果是未支付,更新为关闭,或者取消状态即可。

开启 Redis key 过期提醒


修改 redis 相关事件配置。找到 redis 配置文件 redis.conf,查看 notify-keyspace-events 配置项,如果没有,添加 notify-keyspace-events Ex,如果有值,则追加 Ex,相关参数说明如下:

  • K:keyspace 事件,事件以 keyspace@ 为前缀进行发布
  • E:keyevent 事件,事件以 keyevent@ 为前缀进行发布
  • g:一般性的,非特定类型的命令,比如del,expire,rename等
  • $:字符串特定命令
  • l:列表特定命令
  • s:集合特定命令
  • h:哈希特定命令
  • z:有序集合特定命令
  • x:过期事件,当某个键过期并删除时会产生该事件
  • e:驱逐事件,当某个键因 maxmemore 策略而被删除时,产生该事件
  • A:g$lshzxe的别名,因此”AKE”意味着所有事件

引入依赖


在 pom.xml 中添加 org.springframework.boot:spring-boot-starter-data-redis 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
复制代码

相关配置


定义配置 RedisListenerConfig 实现监听 Redis key 过期时间

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisListenerConfig {

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}
复制代码

定义监听器 RedisKeyExpirationListener,实现KeyExpirationEventMessageListener 接口,查看源码发现,该接口监听所有 db 的过期事件 keyevent@*:expired"

import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.listener.KeyExpirationEventMessageListener;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;

/**
 * 监听所有db的过期事件__keyevent@*__:expired"
 */
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }

    /**
     * 针对 redis 数据失效事件,进行数据处理
     * @param message
     * @param pattern
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {

        // 获取到失效的 key,进行取消订单业务处理
        String expiredKey = message.toString();
        System.out.println(expiredKey);
    }
}

参考:《2020最新Java基础精讲视频教程和学习路线!》

链接:https://juejin.cn/post/691909...

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 74 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

注册于 2020-11-09
个人主页被 2.3k 人浏览