一篇就够——Java 8新特性

本文主要介绍了Java 8引入的新特性,结合代码示例,讲解了这些特性存在的意义、使用方法和注意事项。阅读本文,将使你的编码能力更上一层楼。

一、引入

Java每三年会有一个"长期支持版本"(Long Term Support release,简称LTS ),该版本会提供为期三年的支持,目前LTS版本有JDK8JDK11JDK17。Oracle JDK在2020年1月份开始对Java SE 8(8u201/202)之后的版本开始进行商用收费,虽然Java已经发布到Java 18*,但是国内Java编程主流还是使用Java8。今天,我们就一起来看看Java8的新特性。

编程语言都追求更简洁、更清晰、更高效,Java 8新特性也体现着这一点,主要有:

  • Lambda 表达式:允许把函数作为一个方法的参数来传递;
  • 函数式接口:一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口,这种接口可以作为 Lambda 表达式使用;
  • 方法引用:直接引用已有Java类或对象的方法或构造器。
  • 默认方法:在接口中提供方法的一个实现;
  • Stream API:从支持数据处理操作的源生成的元素序列;
  • Optional 类:为"不存在的值"建模,用于取代null;
  • Date Time API:对java.util.Date和Calendar不足的改进,本文将不做介绍。

本文参考了由陆明刚、劳佳翻译的国外著作《Java 8 in Action》

二、Lambda表达式

函数和方法有区别:函数(尤其是静态方法)不能访问共享的可变数据,被称为"纯函数"或"无副作用函数"或"无状态函数"。

没有共享的可变数据,具备将方法和函数即代码传递给其他方法的能力,是函数式编程范式的基石。

lambda表达式,就是通过行为参数化传递代码,本质是极简的匿名内部类可推理即可省略 )。

java 复制代码
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class Example {

  public static void main(String[] args) {
    List<String> list = new ArrayList<>();
    list.add("lia");
    list.add("lae");
    list.add("mie");
    list.add("xie");
    sortList(list);

    // 匿名内部类
    list.sort(new Comparator<String>() {
      @Override
      public int compare(String o1, String o2) {
        return o1.compareTo(o2);
      }
    });

    // lambda
    list.sort((s1, s2) -> s1.compareTo(s2));
    list.sort((s1, s2) -> {
      return s1.compareTo(s2);
    });
    
    // 方法引用
    list.sort(String::compareTo); // 升序 [lae, lia, mie, xie]
    
    // 接口static方法
    list.sort(Comparator.comparing(s -> s.charAt(0)));
  }
}

在Java8之前,我用使用匿名内部类来传递行为。有了lambda,就不需要为只用一次的方法声明定义

  • 可选的参数类型声明:lambda中不需要指定参数类型,因为编译器能够根据上下文进行推断;
  • 可选的参数圆括号:只有一个参数时,无需定义圆括号;多个参数时,需要使用圆括号;
  • 可选的大括号:当主体只包含了一行语句时,无需使用大括号
  • 可选的return:当主体只包含了一行语句时,无需使用return,返回值的类型由编译器推理得出;
  • 变量作用域:Lambda表达式可以引用类成员变量和局部变量,但是局部变量必须显式声明为final,或事实上是final的。 注意:要是Lambda表达式的长度多于几行,变得不够一目了然的话,此时应该声明一个有描述性名称的方法,将比使用匿名的Lambda更有表达力。

三、函数式接口

函数接口指的是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。这种接口可以隐式转换为Lambda表达式。

Java 8提供了一个注解@FunctionalInterface。当你用@FunctionalInterface标注了一个接口,而它却不是函数式接口的话,编译器将返回一个提示原因的错误。

Java 8自带一些常用的函数式接口,在java.util.function包中。

四、方法引用------Lambda的语法糖

语法:目标放在分隔符::前,方法名称放在后面,不需要括号,因为并没有实际调用这个方法。

有3种形式的方法引用:

  • 类成员方法的引用
java 复制代码
Arrays.asList(1, 2, 3, 4).forEach(System.out::println);
  • 构造函数引用:使用构造函数名称和关键字new,即ClassName::new
java 复制代码
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Apple {
  // 重量
  private int weight;
  private String color;

  public Apple() {
  }

  public Apple(int weight) {
    this.weight = weight;
  }

  public Apple(int weight, String color) {
    this.weight = weight;
    this.color = color;
  }

  public static boolean isHeavyApple(Apple apple) {
    return apple.getWeight() > 150;
  }

  public static List<Apple> filterHeavyApples(List<Apple> inventory, Predicate<? super Apple> predicate) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
      if (predicate.test(apple)) {
        result.add(apple);
      }
    }
    return result;
  }
}
css 复制代码
List<Apple> apples = Stream.of(100, 120, 160, 180).map(Apple::new).collect(Collectors.toList());
  • 类静态方法的引用
ini 复制代码
// 筛选重量符合的苹果
List<Apple> heavyApples = filterHeavyApples(apples, Apple::isHeavyApple);

五、Stream流

流,就是从支持数据处理操作的源生成的元素序列集合在乎存储、访问元素 ,关注数据;而流的目的在于计算,如过滤、查找、排序等。

5.1流操作的特点

Java 8中提供了java.util.stream库,支持创建流和操作流。流操作有几个重要特点

  • 流水线:很多流操作本身会返回一个流,这样可以将很多操作链接起来,形成一个大的流水线;
  • 内部迭代:集合的迭代器是显式遍历,而流的迭代操作在背后进行。
  • 延迟和短路:中间操作会返回另一个流 ,除非流水线上触发一个终结操作,否则中间操作不会执行任何处理。
java 复制代码
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

// 流操作的延迟性
public class Example1 {

  public static void main(String[] args) {
    // 执行后不会有任何输出
    Stream<String> limit = Stream.of(1, 2, 3, 4, 5, 6)
        .filter(num -> {
          System.out.println("filtering " + num);
          return num > 3;
        })
        .map(num -> {
          System.out.println("mapping " + num);
          return num.toString();
        })
        .limit(2);

    // 有终结操作才会触发中间操作
    List<String> list = limit.collect(Collectors.toList());
  }
}

5.2创建流

流的来源可以是集合,数组,I/O channel,产生器generator 等。对于集合,有两个方法来生成流:

  • stream():为集合创建串行流
  • parallelStream():为集合创建并行流
java 复制代码
// Stream.of
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);

// 数组创建流
String[] num = {"a", "b", "c", "d", "e"};
Stream<String> stream = Arrays.stream(num);

// 元素创建流
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6);

// 集合创建流
List<String> wordList = new ArrayList<>();
// 串行流
Stream<String> stream = wordList.stream();
// 并行流
Stream<String> parallelStream = wordList.parallelStream();

// 创建数值流
Stream<Integer> intStream = IntStream.rangeClosed(2, 100).boxed();

// generate生成流
List<Double> list = Stream.generate(Math::random).limit(5).collect(Collectors.toList());

5.3操作流

流的操作包括筛选、切片、映射、查找、匹配和归约等。

筛选

  • filter():获取满足条件的元素
  • distinct():对流中元素去重,根据equals()
  • limit(n) :返回一个只包含前n个元素的流,可将无限流变成有限流
  • skip(n):返回一个扔掉了前n个元素的流;如果流中元素不足n个,则返回一个空流

映射

  • map():会被应用到每个元素上,并将其映射成一个新的元素(不改变原元素)
  • flatMap:将多个流中内容合并成一个流

查找:allMatch、anyMatch、noneMatch、findFirst和findAny,都是终结操作;

归约:T reduce(T identity, BinaryOperator accumulator);如sum、max、min、avg、count等,最终返回一个结果。

java 复制代码
// 筛选出大于3的元素
Stream<Integer> filter = Stream.of(1, 2, 3, 3, 4, 4, 5, 6).filter(num -> num > 3);
// 去重,根据Integer.equals()
Stream<Integer> distinct = Stream.of(1, 2, 3, 3, 4, 4, 5, 6).distinct();
// 返回前3个
Stream<Integer> limit = Stream.of(1, 2, 3, 3, 4, 4, 5, 6).limit(3);
// 跳过前3个
Stream<Integer> skip = Stream.of(1, 2, 3, 3, 4, 4, 5, 6).skip(3);
// 将元素取平方
Stream<Integer> map = Stream.of(1, 2, 3, 4, 5, 6).map(num -> num * num);
// 获取第一个大于3的元素
Optional<Integer> findFirst = Stream.of(1, 2, 3, 3, 4, 4, 5, 6).filter(num -> num > 3).findFirst();

// 求和归约
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Integer sum = numbers.stream().reduce(0, Math::addExact);
Integer sum1 = numbers.stream().reduce(0, Integer::sum);
Optional<Integer> sum2 = numbers.stream().reduce(Integer::sum);

5.4流操作的状态

  • 无状态:如map或filter等操作,从输入流中获取一个元素,不依赖于其他就能得到0或1个结果,这些操作没有内部状态;
  • 有状态:但诸如reduce、sum、max等操作,每处理一个元素,都需要内部状态来累积结果。再比如sort操作,,要求所有元素都放入缓冲区后才能进行排序,存储空间要求是无界的。

5.5数值流------原始类型流

Java 8中提供了IntStream、DoubleStream和LongStream,分别将流中的元素特化为int、long和double,从而避免了暗含的装箱成本,性能更好。

java 复制代码
// 闭区间[2, 100]
IntStream evenNumbers = IntStream.rangeClosed(2, 100);
// 区间[2, 100)
IntStream evenNumbers1 = IntStream.range(2, 100)
    
List<Apple> appleList = Arrays.asList(new Apple(125, "green"),
    new Apple(125, "green"),
    new Apple(120, "green"),
    new Apple(150, "red"),
    new Apple(133, "red"),
    new Apple(147, "red"));
// mapToInt创建数值流,流中元素是int
IntStream intStream = appleList.stream().mapToInt(Apple::getWeight);
// sum时无需拆箱
int sum = intStream.sum();
    
// sum时需要拆箱
Integer sum = appleList.stream().map(Apple::getWeight).reduce(0, Integer::sum);

// 将数值流转为一般流
Stream<Integer> boxed = intStream.boxed();

5.6并行流

在Java 7之前,并行处理数据集合非常麻烦。

  • 第一,你要将数据集合分成若干子部分;
  • 第二,你要给每个子部分分配一个独立的线程;
  • 第三,你需要在恰当的时候对它们进行同步来避免出现竞争条件,等待所有线程都完成,最后把各部分结果合并起来。

现在,Java 8中通过对集合调用parallelStream(),就能创建一个并行流:把内容分成多个数据块,并用不同的线程分别处理每个数据块。

但是,顺序流调用parallel(),它在内部先是设置了一个boolean标志;对并行流调用sequential()就可以又把它变成顺序流。

并行流内部使用了默认的ForkJoinPool(分支/合并框架)

5.6.1并行流的性能

使用并行流就如下面的代码,分别使用for循环、顺序流、并行流计算1到10000000的和。

java 复制代码
import java.util.function.Function;
import java.util.stream.LongStream;
import java.util.stream.Stream;

public class SumPerformanceTest {

  public static void main(String[] args) {
    // 求1到10000000的和
    System.out.println("iterativeSum sum done in:" +
        testSumPerformance(SumPerformanceTest::iterativeSum, 10000000) + " ms")
    System.out.println("Sequential sum done in:" +
        testSumPerformance(SumPerformanceTest::sequentialSum, 10000000) + " ms");
    System.out.println("parallelSum sum done in:" +
        testSumPerformance(SumPerformanceTest::parallelSum, 10000000) + " ms");
    System.out.println("parallelSumNew sum done in:" +
        testSumPerformance(SumPerformanceTest::parallelSum, 10000000) + " ms");
  }

  //  for循环累加
  public static long iterativeSum(long n) {
    long result = 0;
    for (long i = 1L; i <= n; i++) {
      result += i;
    }
    return result;
  }

  // 顺序求1~n的和
  public static long sequentialSum(long n) {
    return Stream.iterate(1L, i -> i + 1).limit(n).reduce(0L, Long::sum);
  }

  // 并行求1~n的和
  public static long parallelSum(long n) {
    return Stream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum);
  }
  
  // 并行求1~n的和
  public static int parallelSumNew(long n) {
    return IntStream.rangeClosed(0, (int) n).parallel().sum();
  }

  // 计算5次,取最小耗时
  public static long testSumPerformance(Function<Long, Long> add, long n) {
    long time = Long.MAX_VALUE;
    for (int i = 0; i < 5; i++) {
      long start = System.nanoTime();
      long sum = add.apply(n);
      long duration = (System.nanoTime() - start) / 1000000;  //毫秒
      if (time > duration) {
        time = duration;
      }
    }
    return time;
  }
}

可以看到,求和的并行版本比顺序版本要慢很多,有这些原因:

  • iterate生成装箱对象,求和时必须拆箱成int;
  • iterate很难分割成多个独立执行的小块,因此每次执行都要依赖前一次执行的结果;
  • 即使使用IntStream数值流时,比使用iterate的更快了,但是还是不及顺序流、for循环速度快。

这个测试提醒我们,并行化并不是没有代价的:对流做递归划分,把每个子流的操作分配到不同线程,在多个内核之间移动数据,最终将各个子流的结果合并。

5.6.2错误使用并行流

java 复制代码
// 累加器
public class Accumulator {

  private volatile long total = 0;

  public void add(long i) {
    total += i;
  }

  public long getTotal() {
    return total;
  }
}
java 复制代码
import java.util.stream.LongStream;

public class ErrorParallel {

  public static void main(String[] args) {
    long sum = LongStream.rangeClosed(1, 10000000).sum();
    System.out.println("正确结果:" + sum);
    for (int i = 0; i < 5; i++) {
      System.out.println("并行结果:" + parallelSum(10000000));
    }
  }

  // 并行时结果有误
  public static long parallelSum(long n) {
    Accumulator accumulator = new Accumulator();
    LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add);
    return accumulator.getTotal();
  }
}

示例中并行流计算结果错误的首要原因,就是改变了Accumulator中共享变量的状态(即使用volatile修饰),导致并发安全问题。为此,我们需注意一些并行流的使用准则:

  • 首先,使用并行流时,结果正确永远是第一位的;
  • 其次,在具体场景下,用适当的基准来检查使用并行流的性能;如果相比于顺序流并没有较大的性能提升,说明该场景并不适合使用并行流计算。
  • 第三,自动装箱和拆箱操作会大大降低性能,可使用原始类型流IntStream等;
  • 第四,对于较小的数据量,并行处理的好处还抵不上并行化造成的额外开销;
  • 第五,limit、findFirst等依赖于元素顺序的操作,在并行流上的性能比顺序流要差;
  • 最后,使用并行流前,要考虑流背后的数据结构是否易于拆分;例如,ArrayList的拆分效率比LinkedList高得多。

5.7分支/合并框架

分支/合并框架的目的,是以递归方式将可以并行的任务拆分成更小的子任务,然后将每个子任务的结果合并起来生成整体结果。

那么,如何创建任务和子任务呢?任务ForkJoinTask是Future的实现类,它有两个抽象子类,有结果返回的RecursiveTask<R>和无结果返回的的RecursiveAction。我们只需继承这两个类,实现compute()方法,定义将任务拆分成子任务的逻辑,以及无法再拆分时处理单个子任务的逻辑。

scss 复制代码
// 伪代码
if (任务足够小或不可分) {
  顺序计算该任务
} else {
  将任务拆分成两个子任务
  递归调用complate()方法,拆分每个子任务
  等待所有子任务完成
  合并每个子任务的结果
}

ForkJoinPool是用来处理任务的线程池,是ExecutorService接口的一个实现,默认最大线程数等于机器的处理器数量(也允许自定义)。

不同之处在于,ForkJoinPool内部采用了工作窃取(work stealing)算法 :每个线程都把分配给它的任务保存到一个双向链式队列中,每完成一个任务,就会从队列头上取出下一个任务开始执行;某些线程的队列为空时,会随机选一个别的线程的队列,从队尾偷走一个任务。

工作窃取算法用于在池中的工作线程之间重新分配和平衡任务,尽可能使所有线程都保持相近的负荷。

示例------数字求和

我们来使用ForkJoin框架计算1到1000000的和,并与顺序计算方式进行比较。

java 复制代码
import java.util.concurrent.RecursiveTask;

public class ForkJoinSumCalculator extends RecursiveTask<Long> {
  /**
   * 拆分后的一段数据
   */
  private final long[] array;
  private final int start;
  private final int end;
  /**
   * 任务拆分阈值
   */
  private static final int THRESHOLD = 1000;


  public ForkJoinSumCalculator(long[] array) {
    this(array, 0, array.length - 1);
  }

  public ForkJoinSumCalculator(long[] array, int start, int end) {
    this.array = array;
    this.start = start;
    this.end = end;
  }

  @Override
  protected Long compute() {
    int length = start - end + 1;
    // 任务足够小时
    if (length < THRESHOLD) {
      return computeSequentially(array, start, end);
    }

    ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(array, start, start + length / 2);
    // 先fork左半部分任务
    leftTask.fork();
    ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(array, start + length / 2 + 1, end);
    Long rightResult = rightTask.compute();
    // 在rightTask提交计算后调用,阻塞等待左半部分计算完成
    Long leftResult = leftTask.join();
    // 合并结果
    return leftResult + rightResult;
  }

  // 顺序求和
  private Long computeSequentially(long[] array, int start, int end) {
    long sum = 0;
    for (int i = start; i <= end; i++) {
      sum += array[i];
    }
    return sum;
  }
}
java 复制代码
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.stream.LongStream;

public class ForkJoinTest {
  /**
   * 定义成静态成员,单例即可
   */
  private static final ForkJoinPool FORK_JOIN_POOL = new ForkJoinPool();

  public static void main(String[] args) {
    iterativeSum(10000000);
    
    long[] array = LongStream.rangeClosed(1, 10000000).toArray();
    forkJoinSum(array);
  }

  // 顺序求和
  public static void iterativeSum(long n) {
    long start = System.nanoTime();
    long sum = 0;
    for (long i = 1L; i <= n; i++) {
      sum += i;
    }
    long duration = (System.nanoTime() - start) / 1000000; //毫秒
    System.out.println("sum: " + sum + ", 耗时: " + duration);
  }

  // ForkJoin框架求和
  public static void forkJoinSum(long[] array) {
    long start = System.nanoTime();
    ForkJoinTask<Long> task = new ForkJoinSumCalculator(array);
    Long sum = FORK_JOIN_POOL.invoke(task);
    long duration = (System.nanoTime() - start) / 1000000; //毫秒
    System.out.println("sum: " + sum + ", 耗时: " + duration);
  }
}

运行结果如下,ForkJoin框架耗时反而比顺序计算更久,因为任务拆分、结果合并会有额外损耗。可见,对于简单的计算任务,并行处理未必比串行有性能优势。

注意:一个任务调用join方法后会阻塞调用方。因此需要在两个子任务的计算都开始后再调用它。

六、接口默认方法

Java中接口是将相关方法按照约定组合到一起的方式。Java 8允许在接口内声明静态方法、默认方法。这种机制可以用于平滑地进行接口的优化和演进。 如果一个类从多个地方(如另一个类或接口)继承了同一签名的方法,那么使用该方法时,各版本的优先级为:

  • 本类中的方法优先级最高,高于父类实现或接口中的默认方法
  • 子接口中默认方法优先级高于父接口的;
  • 如果还是无法判断,就必须通过显式复写和显式调用该方法。
java 复制代码
public interface A {
  default void hello() {
    System.out.println("Hello from A");
  }
}

public interface B {
  default void hello() {
    System.out.println("Hello from B");
  }
}

// 从A、 B接口继承了同一签名的default方法
public class C implements A, B {
  public static void main(String[] args) {
    new C().hello();
  }

  //不重写会报错
  @Override
  public void hello() {
    // 显式调用,将输出:Hello from B
    B.super.hello();
    System.out.println("Hello from C");
  }
}

七、用Optional取代null

null代表了"不存在的值",虽然使用简单便捷,但是有这些问题

  • 它是NullPointerException的错误之源;
  • 它会使代码膨胀,不得不做null检查;
  • 它会提示你Java中指针的存在;
  • 它不属于任何类型,可以赋值给任意引用类型,你将无法获知null变量最初的类型。 我们来看个例子:一个人Person可能有汽车Car,汽车可能买了保险Insurance。
java 复制代码
import java.util.Optional;

public class Person {
  private Car car;

  public Car getCar() {
    return car;
  }

  public void setCar(Car car) {
    this.car = car;
  }

  // 有了Optional后
  public Optional<Car> getCarAsOptional() {
    return Optional.ofNullable(car);
  }
}
java 复制代码
import java.util.Optional;

public class Car {
  private Insurance insurance;

  public Insurance getInsurance() {
    return insurance;
  }

  public void setInsurance(Insurance insurance) {
    this.insurance = insurance;
  }

  // 有了Optional后
  public Optional<Insurance> getInsuranceAsOptional() {
    return Optional.ofNullable(insurance);
  }
}
java 复制代码
// 保险
public class Insurance {
  private String name;

  public Insurance(String name) {
    this.name = name;
  }

  public String getName() {
    return name;
  }
}

当我们想要获取一个人的某辆车的保险名时,可能会有下面这些写法。

java 复制代码
  // 忘记做null检查
  public String getCarInsuranceName1(Person person) {
    return person.getCar().getInsurance().getName();
  }

  // 深层质疑
  public String getCarInsuranceName2(Person person) {
    if (person != null) {
      Car car = person.getCar();
      if (car != null) {
        Insurance insurance = car.getInsurance();
        if (insurance != null) {
          return insurance.getName();
        }
      }
    }
    return "Unknown";
  }

  // 过多的退出语句
  public String getCarInsuranceName3(Person person) {
    if (person == null) {
      return "Unknown";
    }
    Car car = person.getCar();
    if (car == null) {
      return "Unknown";
    }
    Insurance insurance = car.getInsurance();
    if (insurance == null) {
      return "Unknown";
    }
    return insurance.getName();
  }

如果使用Java 8的Optional,你将发现代码变得如此简洁。

java 复制代码
  public String getCarInsuranceName4(Person person) {
    return person.getCarAsOptional()
        .flatMap(Car::getInsuranceAsOptional)
        .map(Insurance::getName)
        .orElse("Unknown");
  }

使用Optional对可能不存在的值建模时,将迫使你注意值缺失问题,最终避免代码中不期而至的空指针异常。

因此,当一个方法可能返回null时,不如将返回值类型定义为Optional,对调用方也是个极好的提示。

可使用静态方法Optional.empty、Optional.of、Optional.ofNullable来创建Optional对象。Optional类中提供了很多方法。感兴趣的小伙伴可以直接阅读源码。

相关推荐
秋落风声40 分钟前
【数据结构】---图
java·数据结构··graph
2401_8576226643 分钟前
Spring Boot新闻推荐系统:性能优化策略
java·spring boot·后端
qinzechen1 小时前
分享几个做题网站------学习网------工具网;
java·c语言·c++·python·c#
hakesashou1 小时前
python交互式命令时如何清除
java·前端·python
攒了一袋星辰1 小时前
今日指数项目项目集成RabbitMQ与CaffienCatch
java·分布式·rabbitmq
wrx繁星点点1 小时前
事务的四大特性(ACID)
java·开发语言·数据库
IT学长编程1 小时前
计算机毕业设计 Java酷听音乐系统的设计与实现 Java实战项目 附源码+文档+视频讲解
java·spring boot·毕业设计·课程设计·毕业论文·音乐系统·计算机毕业设计选题
AskHarries1 小时前
如何优雅的处理NPE问题?
java·spring boot·后端
IT学长编程1 小时前
计算机毕业设计 基于协同过滤算法的个性化音乐推荐系统的设计与实现 Java实战项目 附源码+文档+视频讲解
java·spring boot·毕业设计·毕业论文·协同过滤算法·计算机毕业设计选题·个性化音乐推荐系统
小小娥子2 小时前
Redis的基础认识与在ubuntu上的安装教程
java·数据库·redis·缓存