【问题标题】:Stack overflows from deep recursion in Java?Java中的深度递归导致堆栈溢出?
【发布时间】:2010-10-26 00:33:33
【问题描述】:

在对函数式语言有一些经验后,我开始在 Java 中更多地使用递归 - 但该语言的调用堆栈似乎相对较浅,约为 1000。

有没有办法让调用堆栈变大?就像在 Erlang 中那样,我可以制作数百万次调用的函数吗?

我在做 Project Euler 问题时越来越多地注意到这一点。

谢谢。

【问题讨论】:

    标签: java functional-programming stack overflow


    【解决方案1】:

    增加堆栈大小只会作为临时绷带。正如其他人所指出的那样,您真正想要的是消除尾调用,而Java由于各种原因没有这个。但是,您可以根据需要作弊。

    手里拿着红色药丸?好的,请这边走。

    您可以通过多种方式将堆栈换成堆。例如,不要在函数中进行递归调用,而是让它返回一个 惰性数据结构,在评估时进行调用。然后,您可以使用 Java 的 for-construct 展开“堆栈”。我将用一个例子来演示。考虑这个 Haskell 代码:

    map :: (a -> b) -> [a] -> [b]
    map _ [] = []
    map f (x:xs) = (f x) : map f xs
    

    请注意,此函数从不计算列表的尾部。所以该函数实际上并不需要进行递归调用。在 Haskell 中,它实际上为尾部返回一个 thunk,如果需要,它就会被调用。我们可以在 Java 中做同样的事情(这使用来自 Functional Java 的类):

    public <B> Stream<B> map(final F<A, B> f, final Stream<A> as)
      {return as.isEmpty()
         ? nil()
         : cons(f.f(as.head()), new P1<Stream<A>>()
             {public Stream<A> _1()
               {return map(f, as.tail);}});}
    

    注意Stream&lt;A&gt;A 类型的值和P1 类型的值组成,这就像一个thunk,它在调用_1() 时返回流的其余部分。虽然它看起来确实像递归,但不会对 map 进行递归调用,而是成为 Stream 数据结构的一部分。

    然后可以使用常规的 for-construct 展开。

    for (Stream<B> b = bs; b.isNotEmpty(); b = b.tail()._1())
      {System.out.println(b.head());}
    

    这是另一个例子,因为你在谈论 Project Euler。这个程序使用相互递归的函数并且不会爆栈,即使是数百万次调用:

    import fj.*; import fj.data.Natural;
    import static fj.data.Enumerator.naturalEnumerator;
    import static fj.data.Natural.*; import static fj.pre.Ord.naturalOrd;
    import fj.data.Stream; import fj.data.vector.V2;
    import static fj.data.Stream.*; import static fj.pre.Show.*;
    
    public class Primes
      {public static Stream<Natural> primes()
        {return cons(natural(2).some(), new P1<Stream<Natural>>()
           {public Stream<Natural> _1()
             {return forever(naturalEnumerator, natural(3).some(), 2)
                     .filter(new F<Natural, Boolean>()
                       {public Boolean f(final Natural n)
                          {return primeFactors(n).length() == 1;}});}});}
    
       public static Stream<Natural> primeFactors(final Natural n)
         {return factor(n, natural(2).some(), primes().tail());}
    
       public static Stream<Natural> factor(final Natural n, final Natural p,
                                            final P1<Stream<Natural>> ps)
         {for (Stream<Natural> ns = cons(p, ps); true; ns = ns.tail()._1())
              {final Natural h = ns.head();
               final P1<Stream<Natural>> t = ns.tail();
               if (naturalOrd.isGreaterThan(h.multiply(h), n))
                  return single(n);
               else {final V2<Natural> dm = n.divmod(h);
                     if (naturalOrd.eq(dm._2(), ZERO))
                        return cons(h, new P1<Stream<Natural>>()
                          {public Stream<Natural> _1()
                            {return factor(dm._1(), h, t);}});}}}
    
       public static void main(final String[] a)
         {streamShow(naturalShow).println(primes().takeWhile
           (naturalOrd.isLessThan(natural(Long.valueOf(a[0])).some())));}}
    

    您可以用栈交换堆的另一件事是使用多线程。这个想法是,不是进行递归调用,您创建一个进行调用的 thunk,将这个 thunk 交给一个新线程并让当前线程退出函数。 这就是事情背后的想法像 Stackless Python。

    以下是 Java 中的示例。抱歉,如果没有 import static 子句,看起来有点不透明:

    public static <A, B> Promise<B> foldRight(final Strategy<Unit> s,
                                              final F<A, F<B, B>> f,
                                              final B b,
                                              final List<A> as)
      {return as.isEmpty()
         ? promise(s, P.p(b))
         : liftM2(f).f
             (promise(s, P.p(as.head()))).f
             (join(s, new P1<Promise<B>>>()
                {public Promise<B> _1()
                  {return foldRight(s, f, b, as.tail());}}));}
    

    Strategy&lt;Unit&gt; s 由一个线程池支持,promise 函数将一个 thunk 传递给线程池,返回一个 Promise,这很像 java.util.concurrent.Future,只是更好。 See here. 重点是上面的方法在 O(1) 堆栈中向右折叠一个右递归数据结构,这通常需要消除尾调用。因此,我们有效地实现了 TCE,以换取一些复杂性。你可以这样调用这个函数:

    Strategy<Unit> s = Strategy.simpleThreadStrategy();
    int x = foldRight(s, Integers.add, List.nil(), range(1, 10000)).claim();
    System.out.println(x); // 49995000
    

    请注意,后一种技术非常适用于非线性递归。也就是说,即使没有尾调用的算法,它也会在常量堆栈中运行。

    您可以做的另一件事是采用一种称为蹦床的技术。蹦床是一种计算,具体化为一种数据结构,可以单步执行。 Functional Java library 包含我编写的 Trampoline 数据类型,它可以有效地将任何函数调用转换为尾调用。例如here is a trampolined foldRightC that folds to the right in constant stack:

    public final <B> Trampoline<B> foldRightC(final F2<A, B, B> f, final B b)
      {return Trampoline.suspend(new P1<Trampoline<B>>()
        {public Trampoline<B> _1()
          {return isEmpty()
             ? Trampoline.pure(b)
             : tail().foldRightC(f, b).map(f.f(head()));}});}
    

    这和使用多线程的原理是一样的,只是我们不是在自己的线程中调用每个步骤,而是在堆上构造每个步骤,很像使用Stream,然后我们在一个带有Trampoline.run 的单循环。

    【讨论】:

    • 这是我见过的一些最疯狂的 Java 代码,+1 以获得非常详细的解释。
    • 是否有使用这些技术的性能基准?
    • @Nik:最大的性能改进是从不工作(StackOverflowError)到工作。
    • 只是想了解使用上述方法递归比迭代的好处。 (除了真的很酷。:))
    • tinyurl 链接已损坏。有谁知道它通向哪里?
    【解决方案2】:

    我遇到了同样的问题,最终将递归重写为一个 for 循环,结果成功了。

    【讨论】:

      【解决方案3】:

      如果你在使用 eclipse,请将 -xss2m 设置为 vm 参数。

      -xss2m 直接在命令行上。

      java -xss2m classname
      

      【讨论】:

        【解决方案4】:
        public static <A, B> Promise<B> foldRight(final Strategy<Unit> s,
                                                  final F<A, F<B, B>> f,
                                                  final B b,
                                                  final List<A> as)
        {
            return as.isEmpty() ? promise(s, P.p(b))
            : liftM2(f).f(promise(s, P.p(as.head())))
              .f(join(s, new F<List<A>, P1<Promise<B>>>()
                {
                     public Promise<B> f(List<A> l)
                     {
                         return foldRight(s, f, b, l);
                     }
                 }.f(as.tail())));
        }
        

        【讨论】:

          【解决方案5】:

          我猜你可以使用这些参数

          -ss Stacksize 增加原生 堆栈大小或

          -oss Stacksize 增加Java 堆栈大小,

          默认的本机堆栈大小为 128k, 最小值为 1000 字节。 默认的java堆栈大小是400k, 最小值为 1000 字节。

          http://edocs.bea.com/wls/docs61/faq/java.html#251197

          编辑:

          在阅读第一条评论(Chuck 的)以及重新阅读问题并阅读其他答案后,我想澄清一下,我将问题解释为“增加堆栈大小”。我并不是要说你可以拥有无​​限的堆栈,例如在函数式编程中(我只是触及其表面的一种编程范式)。

          【讨论】:

          • 这可以为您提供更多关卡,但堆栈大小仍然有限。您将无法像在具有高调用消除的函数式语言中那样无限递归。
          【解决方案6】:

          运行在 Java VM 上的 Clojure 非常想实现尾调用优化,但由于 JVM 字节码的限制(我不知道细节),它不能实现。因此,它只能通过一种特殊的“递归”形式来帮助自己,该形式实现了一些您期望从正确的尾递归中获得的基本功能。

          无论如何,这意味着JVM目前不能支持尾调用优化。我强烈建议不要将递归用作 JVM 上的一般循环结构。我个人的看法是,Java 不是一种足够高级的语言。

          【讨论】:

            【解决方案7】:

            您可以在命令行上设置:

            java -Xss8M 类

            【讨论】:

              【解决方案8】:

              If you have to ask, you're probably doing something wrong.

              现在,虽然您可能可以找到一种方法来增加 java 中的默认堆栈,但让我添加我的 2 美分,因为您确实需要找到另一种方法来做您想做的事情,而不是依赖于增加的堆栈。

              由于 java 规范没有强制要求 JVM 实现尾递归优化技术,解决该问题的唯一方法是减少堆栈压力,或者通过减少需要的局部变量/参数的数量被跟踪,或者理想情况下只是显着降低递归级别,或者只是重写而不使用递归。

              【讨论】:

              • 为什么要检查一种语言是否支持尾调用消除“错误”?
              • 不是,但是 Java 没有强制要求,所以你不能依赖它。如果 Java 强制执行尾递归优化,情况会有所不同,那么我只想说您应该尝试重构递归以始终利用它。既然没有,我就没有。
              • 想指出哪里错了?请注意,我说这是我的 2 美分,换句话说,是一种意见。你可以不同意,但要说它是错误的,那么你真的必须提供更多细节来说明你为什么认为它是错误的。此处的其他 cmets 提供了有关 JVM 为何不实现尾调用递归的更多信息。
              • 您的回答过于基于意见,并且在我看来,考虑到它实际上并没有为讨论提供任何信息点,因此有太多的赞成票。
              【解决方案9】:

              是否使用尾递归取决于 JVM - 我不知道它们中的任何一个是否使用,但你不应该依赖它。特别是,更改堆栈大小非常很少是正确的做法,除非您对实际使用的递归级别有一些硬性限制,并且您确切知道每个级别有多少堆栈空间会占用。非常脆弱。

              基本上,您不应该在不是为它构建的语言中使用无限递归。恐怕您将不得不使用迭代。是的,有时这可能会有点痛苦:(

              【讨论】:

              • 我知道 Sun 的 JVM 不会优化尾递归,我认为其他主要的 JVM 也不会这样做。可能会有一两个实验性的。
              • 认为 IBM 的实力。不过,我听说的是二手或三手的,所以不要引用我的话;P
              • 尾调用优化的工作正在进行中,但它目前在 Java 中不受支持,因为它打破了对堆栈外观的一些期望,这对 Java 的安全模型很重要(以及不太重要的事情,如堆栈跟踪)。 blogs.sun.com/jrose/entry/tail_calls_in_the_vm
              • @Jon:投诉 JVM 不允许 允许优化一般尾调用,因为它违反了安全模型。尾递归应该被允许作为一种特殊情况,但如果许多 JVM 支持它,我会感到惊讶,因为支持这种特殊情况比支持一般情况更难。
              • Norman:在试图找出是否有任何 JVM 这样做时,我也读到了这个说法——而且一些 IBM 研究人员已经设法做到了。请注意,他们可能只支持特殊情况。
              【解决方案10】:

              大多数函数式语言都支持尾递归。但是,大多数 Java 编译器不支持这一点。相反,它会进行另一个函数调用。这意味着您可以进行的递归调用的数量总是有一个上限(因为您最终会用完堆栈空间)。

              使用尾递归,您可以重用正在递归的函数的堆栈帧,因此您对堆栈没有相同的约束。

              【讨论】:

                猜你喜欢
                • 2019-12-21
                • 2020-12-17
                • 2021-01-26
                • 2011-02-26
                • 1970-01-01
                • 2013-04-05
                相关资源
                最近更新 更多